code-sample-editor
Advanced tools
Comparing version
127
CHANGELOG.md
@@ -20,14 +20,135 @@ # Change Log | ||
## [0.1.0-pre.4] - 2020-10-22 | ||
### Changed | ||
- [**BREAKING**] Major refactor of elements to allow them to be more easily used | ||
independently. The new elements are: | ||
- `<code-sample>`: A single editor with file-selection bar and preview in | ||
side-by-side layout. If a different layout is required, the editor and | ||
preview elements can instead be used directly, along with a project element. | ||
New equivalent of what used to be `<code-sample-editor>`. | ||
- `<code-sample-project>`: New purely abstract element that coordinates | ||
between the service worker, editor elements, and preview elements. | ||
- `<code-sample-editor>`: An editor with file-selection bar, tied to a project | ||
element. New equivalent to the left-hand-side side of what used to be | ||
`<code-sample-editor>`. | ||
- `<code-sample-preview>`: A rendered HTML preview window, tied to a project | ||
element. New equivalent to the right-hand-side of what used to be | ||
`<code-sample-editor-preview>`. | ||
- `<codemirror-editor>`: A pure CodeMirror editor, mostly unchanged from | ||
previous version. | ||
Example usage without `<code-sample>`: | ||
```html | ||
<code-sample-project | ||
id="myProject" | ||
project-src="/demo/typescript/project.json" | ||
></code-sample-project> | ||
<code-sample-editor project="myProject"></code-sample-editor> | ||
<code-sample-preview project="myProject"></code-sample-preview> | ||
``` | ||
The `project` property can either be an ID in the host scope (as shown above) | ||
or a direct reference to a `<code-sample-project>` element (which would allow | ||
the elements to live in different scopes). | ||
- Downgraded from CodeMirror v6 to v5 in order to gain support for nested | ||
highlighting of HTML and CSS inside JS/TS. See | ||
https://github.com/lezer-parser/javascript/issues/3. Will upgrade back to 6 | ||
once support is ready. | ||
- The caret is now only displayed when an editor is on focus (previously it was | ||
always displayed). | ||
- The `<code-sample>` side-by-side layout is now consistently 70%/30% (widths can be | ||
changed using the `editor` and `preview` CSS shadow parts). | ||
### Added | ||
- Add syntax highlighting of TypeScript files. | ||
- Add syntax highlighting of nested HTML and CSS inside JS/TS. | ||
- Add `filename` property/attribute to `<code-sample-editor>` which allows | ||
getting and setting the currently selected file. | ||
- Add `noFilePicker` property (`no-file-picker` attribute) to | ||
`<code-sample-editor>` which disables the top file selection tab-bar. | ||
- Add `lineNumbers` property (`line-numbers` attribute) to `<code-sample>`, | ||
`<code-sample-editor>`, and `<codemirror-editor>` which enables the | ||
left-hand-side gutter with line numbers. Off by default. | ||
- Add a `<slot>` to `<code-sample-editor>` which will be displayed until the | ||
file is loaded. This facilitates pre-rendering syntax-highlighted code before | ||
both the element has upgraded, and before the project file has been fetched. | ||
- Add a `<slot>` to `<code-sample-preview>` which will be displayed until the | ||
preview iframe has loaded for the first time. This facilitates pre-rendering | ||
preview HTML both before both the element has upgraded, and before the live | ||
preview first renders. | ||
- Add `label` property and attribute to project files. When set, the file picker | ||
will display this label instead of the filename. | ||
- An animated progress bar now displays when a preview is loading. | ||
- Added CSS Shadow Parts: | ||
- `<code-sample-editor>`: `file-picker` | ||
- `<code-sample-preview>`: `preview-toolbar`, `preview-location`, `preview-reload-button`, `preview-loading-indicator` | ||
- `<code-sample>`: `editor`, `preview`, `file-picker`, `preview-toolbar`, `preview-location`, `preview-reload-button`, `preview-loading-indicator` | ||
- Added CSS Custom Properties: | ||
- `--playground-code-font-family` | ||
- `--playground-code-font-size` | ||
- `--playground-editor-background-color` | ||
- `--playground-file-picker-background-color` | ||
- `--playground-file-picker-foreground-color` | ||
- `--playground-preview-toolbar-background-color` | ||
- `--playground-preview-toolbar-foreground-color` | ||
- `--playground-border` | ||
- `--playground-highlight-color` | ||
- `--playground-bar-height` | ||
- Added `theme` property to `<code-sample>`, `<code-sample-editor>`, and | ||
`<codemirror-editor>`, which sets the theme (currently only `default`, | ||
`monokai`, `ambiance`, `ayu-mirage` are available, but a way to load other | ||
themes will follow). | ||
- Previews will now automatically reload on changes (0.5 second debounce). | ||
- Added `readonly` property/attribute to `<codemirror-editor>` which disables | ||
the ability to edit. | ||
### Fixed | ||
- Fix absent CSS syntax highlighting. | ||
- Fix various styling/layout glitches. | ||
- Fix service worker and TypeScript worker URLs, which reached up too many | ||
parent directories. | ||
## [0.1.0-pre.3] - 2020-10-05 | ||
### Fixed | ||
- Fixed missing CodeMirror styles on Firefox and Safari. | ||
- Fixed Safari crashes in `<mwc-tab>` code. | ||
- Fix missing CodeMirror styles on Firefox and Safari. | ||
- Fix Safari crashes in `<mwc-tab>` code. | ||
## [0.1.0-pre.2] - 2020-09-12 | ||
### Fixed | ||
- Fix extra/missing files. | ||
## [0.1.0-pre.1] - 2020-09-12 | ||
- Initial release. | ||
- Initial release. |
@@ -17,52 +17,8 @@ /** | ||
import '@material/mwc-tab'; | ||
import '@material/mwc-button'; | ||
import { SampleFile } from '../shared/worker-api.js'; | ||
import { CodeSampleProjectElement } from './code-sample-project'; | ||
import './codemirror-editor.js'; | ||
import '@material/mwc-icon-button'; | ||
import './codemirror-editor.js'; | ||
import './code-sample-editor-preview.js'; | ||
declare global { | ||
interface ImportMeta { | ||
url: string; | ||
} | ||
} | ||
/** | ||
* A multi-file code editor component with live preview that works without a | ||
* server. | ||
* | ||
* <code-sample-editor> loads a project configuration file and the set of source | ||
* files it describes from the network. The source files can be edited locally. | ||
* To serve the locally edited files to the live preview, <code-sample-editor> | ||
* registers a service worker to serve files to the preview from the main UI | ||
* thread directly, without a network roundtrip. | ||
* | ||
* The project manifest is a JSON file with a "files" property. "files" is an | ||
* object with properties for each file. The key is the filename, relative to | ||
* the project manifest. | ||
* | ||
* Eample project manifest: | ||
* ```json | ||
* { | ||
* "files": { | ||
* "./index.html": {}, | ||
* "./my-element.js": {}, | ||
* } | ||
* } | ||
* ``` | ||
* | ||
* Files can also be given as <script> tag children of <code-sample-editor>. | ||
* The type attribute must start with "sample/" and then the type of the file, | ||
* one of: "js", "ts", "html", or "css". The <script> must also have a | ||
* "filename" attribute. | ||
* | ||
* Example inline files: | ||
* ```html | ||
* <code-sample-editor> | ||
* <script type="sample/html" filename="index.html"> | ||
* <script type="module" src="index.js"><script> | ||
* <h1>Hello World</h1> | ||
* </script> | ||
* <script type="sample/js" filename="index.js"> | ||
* document.body.append('<h2>Hello from JS</h2>'); | ||
* </script> | ||
* </code-sample-editor> | ||
* ``` | ||
* A text editor associated with a <code-sample-project>. | ||
*/ | ||
@@ -72,11 +28,2 @@ export declare class CodeSampleEditor extends LitElement { | ||
/** | ||
* A document-relative path to a project configuration file. | ||
*/ | ||
projectSrc?: string; | ||
/** | ||
* The service worker scope to register on | ||
*/ | ||
sandboxScope: string; | ||
_scopeUrl: string; | ||
/** | ||
* Whether to show the "Add File" button on the UI that allows | ||
@@ -86,33 +33,43 @@ * users to add a new blank file to the project. | ||
enableAddFile: boolean; | ||
private _preview; | ||
private _tabBar; | ||
private _editor; | ||
files?: SampleFile[]; | ||
/** | ||
* A unique identifier for this instance so the service worker can keep an | ||
* independent cache of files for it. | ||
* The CodeMirror theme to load. | ||
*/ | ||
private readonly _sessionId; | ||
private _files?; | ||
theme: string; | ||
/** | ||
* The name of the project file that is currently being displayed. Set when | ||
* changing tabs. Does not reflect to attribute. | ||
*/ | ||
filename?: string; | ||
/** | ||
* If true, don't display the top file-picker. Default: false (visible). | ||
*/ | ||
noFilePicker: boolean; | ||
/** | ||
* If true, display a left-hand-side gutter with line numbers. Default false | ||
* (hidden). | ||
*/ | ||
lineNumbers: boolean; | ||
private _currentFileIndex?; | ||
private get _currentFile(); | ||
private _serviceWorkerAPI?; | ||
private _typescriptWorkerAPI?; | ||
private _compiledFilesPromise; | ||
private _compiledFiles?; | ||
private _slot; | ||
private get _previewSrc(); | ||
update(changedProperties: PropertyValues): void; | ||
/** | ||
* The project that this editor is associated with. Either the | ||
* `<code-sample-project>` node itself, or its `id` in the host scope. | ||
*/ | ||
project: CodeSampleProjectElement | string | undefined; | ||
private _project; | ||
type: 'js' | 'ts' | 'html' | 'css' | undefined; | ||
update(changedProperties: PropertyValues): Promise<void>; | ||
render(): import("lit-element").TemplateResult; | ||
private _slotChange; | ||
private _tabActivated; | ||
private _fetchProject; | ||
private _startWorkers; | ||
private _startTypeScriptWorker; | ||
private _installServiceWorker; | ||
private _connectServiceWorker; | ||
private _getFile; | ||
private _findProjectAndRegister; | ||
private _onEdit; | ||
private _compileProject; | ||
private _onSave; | ||
} | ||
declare global { | ||
interface HTMLElementTagNameMap { | ||
'code-sample-editor': CodeSampleEditor; | ||
} | ||
} | ||
//# sourceMappingURL=code-sample-editor.d.ts.map |
@@ -14,3 +14,28 @@ /** | ||
*/ | ||
import { LitElement, PropertyValues } from 'lit-element'; | ||
import { LitElement } from 'lit-element'; | ||
import '../_codemirror/codemirror-bundle.js'; | ||
declare function CodeMirror(callback: (host: HTMLElement) => void, options?: CodeMirrorConfiguration): { | ||
getValue(): string; | ||
setValue(content: string): void; | ||
setSize(width?: string | number, height?: string | number): void; | ||
setOption<K extends keyof CodeMirrorConfiguration>(option: K, value: CodeMirrorConfiguration[K]): void; | ||
on(eventName: 'change', handler: () => void): void; | ||
}; | ||
interface CodeMirrorConfiguration { | ||
value?: string; | ||
mode?: string | null; | ||
lineNumbers?: boolean; | ||
theme?: string; | ||
readOnly?: boolean | 'nocursor'; | ||
} | ||
interface TypedMap<T> extends Map<keyof T, unknown> { | ||
get<K extends keyof T>(key: K): T[K]; | ||
set<K extends keyof T>(key: K, value: T[K]): this; | ||
delete<K extends keyof T>(key: K): boolean; | ||
keys(): IterableIterator<keyof T>; | ||
values(): IterableIterator<T[keyof T]>; | ||
entries(): IterableIterator<{ | ||
[K in keyof T]: [K, T[K]]; | ||
}[keyof T]>; | ||
} | ||
/** | ||
@@ -20,6 +45,5 @@ * A basic text editor with syntax highlighting for HTML, CSS, and JavaScript. | ||
export declare class CodeMirrorEditorElement extends LitElement { | ||
static styles: import("lit-element").CSSResult; | ||
private _editorView; | ||
static styles: import("lit-element").CSSResult[]; | ||
protected _codemirror?: ReturnType<typeof CodeMirror>; | ||
private _value?; | ||
private _capturedCodeMirrorStyles?; | ||
get value(): string | undefined; | ||
@@ -32,7 +56,44 @@ set value(v: string | undefined); | ||
type: 'js' | 'ts' | 'html' | 'css' | undefined; | ||
render(): import("lit-element").TemplateResult; | ||
update(changedProperties: PropertyValues): void; | ||
/** | ||
* If true, display a left-hand-side gutter with line numbers. Default false | ||
* (hidden). | ||
*/ | ||
lineNumbers: boolean; | ||
/** | ||
* If true, this editor is not editable. | ||
*/ | ||
readonly: boolean; | ||
/** | ||
* The CodeMirror theme to load. | ||
*/ | ||
theme: string; | ||
private _resizeObserver?; | ||
private _valueChangingFromOutside; | ||
update(changedProperties: TypedMap<Omit<CodeMirrorEditorElement, keyof LitElement | 'update'>>): void; | ||
connectedCallback(): void; | ||
disconnectedCallback(): void; | ||
private _createView; | ||
private _getLanguagePlugins; | ||
/** | ||
* We want the CodeMirror theme's background color to win if | ||
* "--playground-editor-background-color" is unset. | ||
* | ||
* However, there are no values we can use as the default for that property | ||
* that allow for this. "revert" seems like it should work, but it doesn't. | ||
* "initial" and "unset" also don't work. | ||
* | ||
* So we instead maintain a private CSS property called | ||
* "--playground-editor-theme-background-color" that is always set to the | ||
* theme's background-color, and we use that as the default. We detect this by | ||
* momentarily disabling the rule that applies | ||
* "--playground-editor-background-color" whenever the theme changes. | ||
*/ | ||
private _setBackgroundColor; | ||
private _getLanguageMode; | ||
} | ||
declare global { | ||
interface HTMLElementTagNameMap { | ||
'codemirror-editor': CodeMirrorEditorElement; | ||
} | ||
} | ||
export {}; | ||
//# sourceMappingURL=codemirror-editor.d.ts.map |
@@ -20,18 +20,15 @@ /** | ||
}; | ||
import { LitElement, customElement, css, html, property, internalProperty, } from 'lit-element'; | ||
import { EditorView, keymap, highlightSpecialChars, multipleSelections, } from '@codemirror/next/view'; | ||
import { EditorState } from '@codemirror/next/state'; | ||
import { history, historyKeymap } from '@codemirror/next/history'; | ||
import { defaultKeymap } from '@codemirror/next/commands'; | ||
import { lineNumbers } from '@codemirror/next/gutter'; | ||
import { closeBrackets } from '@codemirror/next/closebrackets'; | ||
import { searchKeymap } from '@codemirror/next/search'; | ||
import { commentKeymap } from '@codemirror/next/comment'; | ||
// TODO(justinfagnani): devise a way to load languages outside of the element, | ||
// possible into a shared registry keyed by name, so they can be selected with | ||
// an attribute. | ||
import { html as htmlLang } from '@codemirror/next/lang-html'; | ||
import { css as cssLang } from '@codemirror/next/lang-css'; | ||
import { javascript as javascriptLang } from '@codemirror/next/lang-javascript'; | ||
import { defaultHighlighter } from '@codemirror/next/highlight'; | ||
import { LitElement, customElement, css, property } from 'lit-element'; | ||
// TODO(aomarks) We use CodeMirror v5 instead of v6 only because we want support | ||
// for nested highlighting of HTML and CSS inside JS/TS. Upgrade back to v6 once | ||
// support is available. See | ||
// https://github.com/lezer-parser/javascript/issues/3. This module sets a | ||
// `CodeMirror` global. | ||
import '../_codemirror/codemirror-bundle.js'; | ||
// TODO(aomarks) Provide an API for loading these themes dynamically. We can | ||
// include a bunch of standard themes, but we don't want them to all be included | ||
// here if they aren't being used. | ||
import codemirrorStyles from '../_codemirror/codemirror-styles.js'; | ||
import monokaiTheme from '../_codemirror/themes/monokai.css.js'; | ||
const unreachable = (n) => n; | ||
/** | ||
@@ -41,2 +38,19 @@ * A basic text editor with syntax highlighting for HTML, CSS, and JavaScript. | ||
let CodeMirrorEditorElement = class CodeMirrorEditorElement extends LitElement { | ||
constructor() { | ||
super(...arguments); | ||
/** | ||
* If true, display a left-hand-side gutter with line numbers. Default false | ||
* (hidden). | ||
*/ | ||
this.lineNumbers = false; | ||
/** | ||
* If true, this editor is not editable. | ||
*/ | ||
this.readonly = false; | ||
/** | ||
* The CodeMirror theme to load. | ||
*/ | ||
this.theme = 'default'; | ||
this._valueChangingFromOutside = false; | ||
} | ||
get value() { | ||
@@ -50,82 +64,154 @@ return this._value; | ||
} | ||
render() { | ||
return html ` ${this._editorView?.dom} ${this._capturedCodeMirrorStyles} `; | ||
} | ||
update(changedProperties) { | ||
if (changedProperties.has('value') || changedProperties.has('src')) { | ||
const cm = this._codemirror; | ||
if (cm === undefined) { | ||
this._createView(); | ||
} | ||
else { | ||
for (const prop of changedProperties.keys()) { | ||
switch (prop) { | ||
case 'value': | ||
this._valueChangingFromOutside = true; | ||
cm.setValue(this.value ?? ''); | ||
this._valueChangingFromOutside = false; | ||
break; | ||
case 'lineNumbers': | ||
cm.setOption('lineNumbers', this.lineNumbers); | ||
break; | ||
case 'type': | ||
cm.setOption('mode', this._getLanguageMode()); | ||
break; | ||
case 'theme': | ||
cm.setOption('theme', this.theme); | ||
this._setBackgroundColor(); | ||
break; | ||
case 'readonly': | ||
cm.setOption('readOnly', this.readonly); | ||
break; | ||
default: | ||
unreachable(prop); | ||
} | ||
} | ||
} | ||
super.update(changedProperties); | ||
} | ||
connectedCallback() { | ||
// CodeMirror uses JavaScript to control whether scrollbars are visible. It | ||
// does so automatically on interaction, but won't notice container size | ||
// changes. If the browser doesn't have ResizeObserver, scrollbars will | ||
// sometimes be missing, but typing in the editor will fix it. | ||
if (typeof ResizeObserver === 'function') { | ||
this._resizeObserver = new ResizeObserver(() => { | ||
this._codemirror?.setSize(); | ||
}); | ||
this._resizeObserver.observe(this); | ||
} | ||
super.connectedCallback(); | ||
} | ||
disconnectedCallback() { | ||
this._resizeObserver?.disconnect(); | ||
super.disconnectedCallback(); | ||
} | ||
_createView() { | ||
// This is called every time the value property is set externally, so that | ||
// we set up a fresh document with new state. | ||
const view = new EditorView({ | ||
dispatch: (t) => { | ||
view.update([t]); | ||
if (t.docChanged) { | ||
this._value = t.state.doc.toString(); | ||
this.dispatchEvent(new Event('change')); | ||
} | ||
this.requestUpdate(); | ||
}, | ||
state: EditorState.create({ | ||
doc: this.value, | ||
extensions: [ | ||
lineNumbers(), | ||
highlightSpecialChars(), | ||
history(), | ||
multipleSelections(), | ||
...this._getLanguagePlugins(), | ||
defaultHighlighter, | ||
closeBrackets(), | ||
keymap([ | ||
...defaultKeymap, | ||
...searchKeymap, | ||
...historyKeymap, | ||
...commentKeymap, | ||
]), | ||
], | ||
}), | ||
root: this.shadowRoot, | ||
const cm = CodeMirror((dom) => { | ||
this.shadowRoot.innerHTML = ''; | ||
this.shadowRoot.appendChild(dom); | ||
}, { | ||
value: this.value ?? '', | ||
lineNumbers: this.lineNumbers, | ||
mode: this._getLanguageMode(), | ||
theme: this.theme, | ||
readOnly: this.readonly, | ||
}); | ||
// EditorView writes a <style> directly into the given root on construction | ||
// (unless adopted stylesheets are available, in which case it uses that). | ||
// But then lit renders and blows it away. So, we'll just snatch any new | ||
// styles before this can happen, and then have lit put them back again. | ||
// Note that EditorView re-uses the same <style> element across instances, | ||
// so our list of styles does not grow every time we reset the view. | ||
this._capturedCodeMirrorStyles = this.shadowRoot.querySelectorAll('style'); | ||
this._editorView = view; | ||
this.requestUpdate(); | ||
cm.on('change', () => { | ||
this._value = cm.getValue(); | ||
// Only notify changes from user interaction. External changes are usually | ||
// things like the editor switching which file it is displaying. | ||
if (!this._valueChangingFromOutside) { | ||
this.dispatchEvent(new Event('change')); | ||
} | ||
}); | ||
this._codemirror = cm; | ||
this._setBackgroundColor(); | ||
} | ||
_getLanguagePlugins() { | ||
/** | ||
* We want the CodeMirror theme's background color to win if | ||
* "--playground-editor-background-color" is unset. | ||
* | ||
* However, there are no values we can use as the default for that property | ||
* that allow for this. "revert" seems like it should work, but it doesn't. | ||
* "initial" and "unset" also don't work. | ||
* | ||
* So we instead maintain a private CSS property called | ||
* "--playground-editor-theme-background-color" that is always set to the | ||
* theme's background-color, and we use that as the default. We detect this by | ||
* momentarily disabling the rule that applies | ||
* "--playground-editor-background-color" whenever the theme changes. | ||
*/ | ||
_setBackgroundColor() { | ||
this.setAttribute('probing-codemirror-theme', ''); | ||
const codeMirrorRootElement = this.shadowRoot.querySelector('.CodeMirror'); | ||
const themeBgColor = window.getComputedStyle(codeMirrorRootElement) | ||
.backgroundColor; | ||
this.style.setProperty('--playground-editor-theme-background-color', themeBgColor); | ||
this.removeAttribute('probing-codemirror-theme'); | ||
} | ||
_getLanguageMode() { | ||
switch (this.type) { | ||
case 'ts': | ||
return 'google-typescript'; | ||
case 'js': | ||
return [javascriptLang()]; | ||
return 'google-javascript'; | ||
case 'html': | ||
return [htmlLang()]; | ||
return 'google-html'; | ||
case 'css': | ||
[cssLang()]; | ||
return 'css'; | ||
} | ||
return []; | ||
return null; | ||
} | ||
}; | ||
CodeMirrorEditorElement.styles = css ` | ||
:host { | ||
display: block; | ||
overflow: hidden; | ||
box-sizing: border-box; | ||
} | ||
CodeMirrorEditorElement.styles = [ | ||
css ` | ||
:host { | ||
display: block; | ||
font-family: var(--playground-code-font-family, monospace); | ||
font-size: var(--playground-code-font-size, unset); | ||
} | ||
.cm-wrap { | ||
height: 100%; | ||
} | ||
`; | ||
:host(:not([probing-codemirror-theme])) { | ||
background-color: var( | ||
--playground-editor-background-color, | ||
var(--playground-editor-theme-background-color) | ||
); | ||
} | ||
:host(:not([probing-codemirror-theme])) .CodeMirror { | ||
background-color: inherit !important; | ||
} | ||
.CodeMirror { | ||
height: 100% !important; | ||
font-family: inherit !important; | ||
border-radius: inherit; | ||
} | ||
.CodeMirror-scroll { | ||
padding-left: 5px; | ||
} | ||
`, | ||
codemirrorStyles, | ||
monokaiTheme, | ||
]; | ||
__decorate([ | ||
internalProperty() | ||
], CodeMirrorEditorElement.prototype, "_editorView", void 0); | ||
property() | ||
], CodeMirrorEditorElement.prototype, "type", void 0); | ||
__decorate([ | ||
property({ type: Boolean, attribute: 'line-numbers', reflect: true }) | ||
], CodeMirrorEditorElement.prototype, "lineNumbers", void 0); | ||
__decorate([ | ||
property({ type: Boolean, reflect: true }) | ||
], CodeMirrorEditorElement.prototype, "readonly", void 0); | ||
__decorate([ | ||
property() | ||
], CodeMirrorEditorElement.prototype, "type", void 0); | ||
], CodeMirrorEditorElement.prototype, "theme", void 0); | ||
CodeMirrorEditorElement = __decorate([ | ||
@@ -132,0 +218,0 @@ customElement('codemirror-editor') |
{ | ||
"name": "code-sample-editor", | ||
"version": "0.1.0-pre.3", | ||
"version": "0.1.0-pre.4", | ||
"description": "A multi-file code editor component with live preview", | ||
@@ -11,6 +11,7 @@ "type": "module", | ||
"build:lib": "tsc --build", | ||
"build:configurator": "rollup -c rollup.config.configurator.js", | ||
"bundle": "rollup -c rollup.config.js", | ||
"test": "wtr", | ||
"watch": "npm run build:lib -- --watch & rollup -c rollup.config.js -w", | ||
"serve": "es-dev-server --node-resolve --event-stream=false", | ||
"dev": "npm run watch & npm run serve -- --open=demo/", | ||
"serve": "web-dev-server --node-resolve --watch --open=configurator/", | ||
"format": "prettier src/**/*.ts --write", | ||
@@ -22,18 +23,28 @@ "prepublishOnly": "npm run build" | ||
"devDependencies": { | ||
"@esm-bundle/chai": "^4.1.5", | ||
"@rollup/plugin-commonjs": "^15.1.0", | ||
"@rollup/plugin-node-resolve": "^9.0.0", | ||
"es-dev-server": "^1.57.4", | ||
"@rollup/plugin-replace": "^2.3.3", | ||
"@types/resize-observer-browser": "^0.1.4", | ||
"@web/dev-server": "0.0.15", | ||
"@web/test-runner": "^0.9.4", | ||
"@web/test-runner-playwright": "^0.6.0", | ||
"codemirror": "^5.58.1", | ||
"codemirror-grammar-mode": "^0.1.10", | ||
"google_modes": "git+https://github.com/codemirror/google-modes.git#57b26bb0e76ca5d3b83b12faf13ce1054d34bddf", | ||
"prettier": "^2.0.5", | ||
"rollup": "^2.21.0", | ||
"rollup-plugin-copy": "^3.3.0", | ||
"rollup-plugin-lit-css": "^2.0.5", | ||
"rollup-plugin-node-polyfills": "^0.2.1", | ||
"rollup-plugin-summary": "^1.2.3", | ||
"rollup-plugin-terser": "^7.0.2", | ||
"tsc-watch": "^4.2.3", | ||
"typescript": "^4.0.3" | ||
"typescript": "^4.1.0-beta" | ||
}, | ||
"dependencies": { | ||
"@codemirror/next": "^0.12.0", | ||
"@material/mwc-button": "=0.19.0-canary.615d861d.0", | ||
"@material/mwc-icon-button": "=0.19.0-canary.615d861d.0", | ||
"@material/mwc-linear-progress": "^0.19.1", | ||
"@material/mwc-tab": "=0.19.0-canary.615d861d.0", | ||
"@material/mwc-tab-bar": "=0.19.0-canary.615d861d.0", | ||
"@material/mwc-textfield": "=0.19.0-canary.615d861d.0", | ||
"comlink": "^4.3.0", | ||
@@ -40,0 +51,0 @@ "lit-element": "^2.3.1", |
@@ -21,34 +21,16 @@ /** | ||
property, | ||
internalProperty, | ||
query, | ||
PropertyValues, | ||
internalProperty, | ||
} from 'lit-element'; | ||
import {wrap, Remote, proxy} from 'comlink'; | ||
import '@material/mwc-tab-bar'; | ||
import {TabBar} from '@material/mwc-tab-bar'; | ||
import '@material/mwc-tab'; | ||
import '@material/mwc-button'; | ||
import '@material/mwc-icon-button'; | ||
import { | ||
SampleFile, | ||
ServiceWorkerAPI, | ||
ProjectManifest, | ||
ESTABLISH_HANDSHAKE, | ||
HANDSHAKE_RECEIVED, | ||
TypeScriptWorkerAPI, | ||
} from '../shared/worker-api.js'; | ||
import {getRandomString, endWithSlash} from '../shared/util.js'; | ||
import {CodeSampleEditorPreviewElement} from './code-sample-editor-preview.js'; | ||
import {SampleFile} from '../shared/worker-api.js'; | ||
import {CodeSampleProjectElement} from './code-sample-project'; | ||
import './codemirror-editor.js'; | ||
import {CodeMirrorEditorElement} from './codemirror-editor.js'; | ||
import './code-sample-editor-preview.js'; | ||
import {nothing} from 'lit-html'; | ||
import '@material/mwc-icon-button'; | ||
declare global { | ||
interface ImportMeta { | ||
url: string; | ||
} | ||
} | ||
// Hack to workaround Safari crashing and reloading the entire browser tab | ||
@@ -68,64 +50,4 @@ // whenever an <mwc-tab> is clicked to switch files, because of a bug relating | ||
// Each <code-sample-editor> has a unique session ID used to scope requests | ||
// from the preview iframes. | ||
const sessions = new Set<string>(); | ||
const generateUniqueSessionId = (): string => { | ||
let sessionId; | ||
do { | ||
sessionId = getRandomString(); | ||
} while (sessions.has(sessionId)); | ||
sessions.add(sessionId); | ||
return sessionId; | ||
}; | ||
const serviceWorkerScriptUrl = new URL( | ||
'../../service-worker.js', | ||
import.meta.url | ||
); | ||
const typescriptWorkerScriptUrl = new URL( | ||
'../../typescript-worker.js', | ||
import.meta.url | ||
); | ||
/** | ||
* A multi-file code editor component with live preview that works without a | ||
* server. | ||
* | ||
* <code-sample-editor> loads a project configuration file and the set of source | ||
* files it describes from the network. The source files can be edited locally. | ||
* To serve the locally edited files to the live preview, <code-sample-editor> | ||
* registers a service worker to serve files to the preview from the main UI | ||
* thread directly, without a network roundtrip. | ||
* | ||
* The project manifest is a JSON file with a "files" property. "files" is an | ||
* object with properties for each file. The key is the filename, relative to | ||
* the project manifest. | ||
* | ||
* Eample project manifest: | ||
* ```json | ||
* { | ||
* "files": { | ||
* "./index.html": {}, | ||
* "./my-element.js": {}, | ||
* } | ||
* } | ||
* ``` | ||
* | ||
* Files can also be given as <script> tag children of <code-sample-editor>. | ||
* The type attribute must start with "sample/" and then the type of the file, | ||
* one of: "js", "ts", "html", or "css". The <script> must also have a | ||
* "filename" attribute. | ||
* | ||
* Example inline files: | ||
* ```html | ||
* <code-sample-editor> | ||
* <script type="sample/html" filename="index.html"> | ||
* <script type="module" src="index.js"><script> | ||
* <h1>Hello World</h1> | ||
* </script> | ||
* <script type="sample/js" filename="index.js"> | ||
* document.body.append('<h2>Hello from JS</h2>'); | ||
* </script> | ||
* </code-sample-editor> | ||
* ``` | ||
* A text editor associated with a <code-sample-project>. | ||
*/ | ||
@@ -136,20 +58,16 @@ @customElement('code-sample-editor') | ||
:host { | ||
display: flex; | ||
display: block; | ||
/* Prevents scrollbars from changing container size and shifting layout | ||
slightly. */ | ||
box-sizing: border-box; | ||
height: 350px; | ||
border: solid 1px #ddd; | ||
} | ||
* { | ||
box-sizing: border-box; | ||
} | ||
#editor { | ||
display: flex; | ||
flex-direction: column; | ||
flex: 0 0 50%; | ||
border-right: solid 1px #ddd; | ||
} | ||
#editor > mwc-tab-bar { | ||
--mdc-tab-height: 35px; | ||
mwc-tab-bar { | ||
--mdc-tab-height: var(--playground-bar-height, 35px); | ||
/* The tab bar doesn't hold its height unless there are tabs inside it. | ||
Also setting height here prevents a resize flashes after the project file | ||
manifest loads. */ | ||
height: var(--mdc-tab-height); | ||
color: blue; | ||
--mdc-typography-button-text-transform: none; | ||
@@ -161,42 +79,45 @@ --mdc-typography-button-font-weight: normal; | ||
--mdc-icon-size: 18px; | ||
--mdc-theme-primary: var(--playground-highlight-color, #6200ee); | ||
--mdc-tab-text-label-color-default: var( | ||
--playground-file-picker-foreground-color, | ||
black | ||
); | ||
color: #444; | ||
border-bottom: 1px solid #ddd; | ||
flex: 0 0 36px; | ||
border-bottom: var(--playground-border, solid 1px #ddd); | ||
background-color: var(--playground-file-picker-background-color, white); | ||
border-radius: inherit; | ||
border-bottom-left-radius: 0; | ||
border-bottom-right-radius: 0; | ||
} | ||
#editor mwc-tab { | ||
mwc-tab { | ||
flex: 0; | ||
} | ||
#editor > codemirror-editor { | ||
flex: 1; | ||
slot { | ||
display: block; | ||
} | ||
code-sample-editor-preview { | ||
flex: 0 0 50%; | ||
height: 100%; | ||
codemirror-editor, | ||
slot { | ||
height: calc(100% - var(--playground-bar-height, 35px)); | ||
} | ||
codemirror-editor { | ||
border-radius: inherit; | ||
border-top-left-radius: 0; | ||
border-top-right-radius: 0; | ||
} | ||
slot { | ||
display: none; | ||
background-color: var(--playground-code-background-color, unset); | ||
} | ||
:host([no-file-picker]) codemirror-editor, | ||
slot { | ||
height: calc(100%); | ||
} | ||
`; | ||
/** | ||
* A document-relative path to a project configuration file. | ||
*/ | ||
@property({attribute: 'project-src'}) | ||
projectSrc?: string; | ||
/** | ||
* The service worker scope to register on | ||
*/ | ||
// TODO: generate this? | ||
@property({attribute: 'sandbox-scope'}) | ||
sandboxScope = 'code-sample-editor-projects'; | ||
// computed from this.sandboxScope | ||
_scopeUrl!: string; | ||
/** | ||
* Whether to show the "Add File" button on the UI that allows | ||
@@ -208,5 +129,2 @@ * users to add a new blank file to the project. | ||
@query('code-sample-editor-preview') | ||
private _preview!: CodeSampleEditorPreviewElement; | ||
@query('mwc-tab-bar') | ||
@@ -218,13 +136,32 @@ private _tabBar!: TabBar; | ||
@property({attribute: false}) | ||
files?: SampleFile[]; | ||
/** | ||
* A unique identifier for this instance so the service worker can keep an | ||
* independent cache of files for it. | ||
* The CodeMirror theme to load. | ||
*/ | ||
private readonly _sessionId: string = generateUniqueSessionId(); | ||
@property() | ||
theme = 'default'; | ||
/** | ||
* The name of the project file that is currently being displayed. Set when | ||
* changing tabs. Does not reflect to attribute. | ||
*/ | ||
@property() | ||
filename?: string; | ||
/** | ||
* If true, don't display the top file-picker. Default: false (visible). | ||
*/ | ||
@property({type: Boolean, attribute: 'no-file-picker'}) | ||
noFilePicker = false; | ||
/** | ||
* If true, display a left-hand-side gutter with line numbers. Default false | ||
* (hidden). | ||
*/ | ||
@property({type: Boolean, attribute: 'line-numbers'}) | ||
lineNumbers = false; | ||
@internalProperty() | ||
private _files?: SampleFile[]; | ||
// TODO: make a public property/method to select a file | ||
@property({attribute: false}) | ||
private _currentFileIndex?: number; | ||
@@ -235,39 +172,36 @@ | ||
? undefined | ||
: this._files?.[this._currentFileIndex]; | ||
: this.files?.[this._currentFileIndex]; | ||
} | ||
@internalProperty() | ||
private _serviceWorkerAPI?: Remote<ServiceWorkerAPI>; | ||
private _typescriptWorkerAPI?: Remote<TypeScriptWorkerAPI>; | ||
private _compiledFilesPromise = Promise.resolve< | ||
Map<string, string> | undefined | ||
>(undefined); | ||
private _compiledFiles?: Map<string, string>; | ||
/** | ||
* The project that this editor is associated with. Either the | ||
* `<code-sample-project>` node itself, or its `id` in the host scope. | ||
*/ | ||
@property() | ||
project: CodeSampleProjectElement | string | undefined = undefined; | ||
@query('slot') | ||
private _slot!: HTMLSlotElement; | ||
private _project: CodeSampleProjectElement | undefined = undefined; | ||
private get _previewSrc() { | ||
// Make sure that we've connected to the Service Worker and loaded the | ||
// project files before generating the preview URL. This ensures that there | ||
// are files to load when the iframe navigates to the URL. | ||
if (this._serviceWorkerAPI === undefined || this._files === undefined) { | ||
return undefined; | ||
} | ||
// TODO (justinfagnani): lookup URL to show from project config | ||
const indexUrl = new URL(`./${this._sessionId}/index.html`, this._scopeUrl); | ||
return indexUrl.href; | ||
} | ||
/* | ||
* The type of the file being edited, as represented by its usual file | ||
* extension. | ||
*/ | ||
@property() | ||
type: 'js' | 'ts' | 'html' | 'css' | undefined; | ||
update(changedProperties: PropertyValues) { | ||
if (changedProperties.has('sandboxScope')) { | ||
// Ensure scope is relative to this module and always ends in a slash | ||
this._scopeUrl = new URL( | ||
'./' + endWithSlash(this.sandboxScope), | ||
import.meta.url | ||
).href; | ||
this._startWorkers(); | ||
async update(changedProperties: PropertyValues) { | ||
if (changedProperties.has('project')) { | ||
this._findProjectAndRegister(); | ||
} | ||
if (changedProperties.has('projectSrc')) { | ||
this._fetchProject(); | ||
if (changedProperties.has('files') || changedProperties.has('filename')) { | ||
this._currentFileIndex = | ||
this.files && this.filename | ||
? this.files.map((f) => f.name).indexOf(this.filename) | ||
: 0; | ||
// TODO(justinfagnani): whyyyy? | ||
if (this._tabBar) { | ||
await this._tabBar.updateComplete; | ||
this._tabBar.activeIndex = -1; | ||
this._tabBar.activeIndex = this._currentFileIndex; | ||
} | ||
} | ||
@@ -279,182 +213,68 @@ super.update(changedProperties); | ||
return html` | ||
<slot @slotchange=${this._slotChange}></slot> | ||
<div id="editor"> | ||
<mwc-tab-bar | ||
.activeIndex=${this._currentFileIndex || 0} | ||
@MDCTabBar:activated=${this._tabActivated} | ||
> | ||
${this._files?.map((file) => { | ||
const label = file.name.substring(file.name.lastIndexOf('/') + 1); | ||
return html`<mwc-tab label=${label}></mwc-tab>`; | ||
})} | ||
${this.enableAddFile | ||
? html`<mwc-icon-button icon="add"></mwc-icon-button>` | ||
: nothing} | ||
</mwc-tab-bar> | ||
<codemirror-editor | ||
.value=${this._currentFile?.content ?? ''} | ||
@change=${this._onEdit} | ||
.type=${mimeTypeToTypeEnum(this._currentFile?.contentType)} | ||
></codemirror-editor> | ||
</div> | ||
<code-sample-editor-preview | ||
.src=${this._previewSrc} | ||
location="index.html" | ||
@reload=${this._onSave} | ||
> | ||
</code-sample-editor-preview> | ||
${this.noFilePicker | ||
? nothing | ||
: html` <mwc-tab-bar | ||
part="file-picker" | ||
.activeIndex=${this._currentFileIndex ?? 0} | ||
@MDCTabBar:activated=${this._tabActivated} | ||
> | ||
${this.files?.map((file) => { | ||
const label = | ||
file.label || | ||
file.name.substring(file.name.lastIndexOf('/') + 1); | ||
return html`<mwc-tab | ||
.isFadingIndicator=${true} | ||
label=${label} | ||
></mwc-tab>`; | ||
})} | ||
${this.enableAddFile | ||
? html`<mwc-icon-button icon="add"></mwc-icon-button>` | ||
: nothing} | ||
</mwc-tab-bar>`} | ||
${this._currentFile | ||
? html` | ||
<codemirror-editor | ||
.value=${this._currentFile.content} | ||
.type=${this._currentFile | ||
? mimeTypeToTypeEnum(this._currentFile.contentType) | ||
: undefined} | ||
.lineNumbers=${this.lineNumbers} | ||
.theme=${this.theme} | ||
@change=${this._onEdit} | ||
> | ||
</codemirror-editor> | ||
` | ||
: html`<slot></slot>`} | ||
`; | ||
} | ||
private _slotChange(_e: Event) { | ||
const elements = this._slot.assignedElements({flatten: true}); | ||
const sampleScripts = elements.filter((e) => | ||
e.matches('script[type^=sample][filename]') | ||
); | ||
// TODO (justinfagnani): detect both inline samples and a manifest | ||
// and give an warning. | ||
this._files = sampleScripts.map((s) => { | ||
const typeAttr = s.getAttribute('type'); | ||
const fileType = typeAttr!.substring('sample/'.length); | ||
const name = s.getAttribute('filename')!; | ||
// TODO (justinfagnani): better entity unescaping | ||
const content = s.textContent!.trim().replace('<', '<'); | ||
const contentType = typeEnumToMimeType(fileType); | ||
return { | ||
name, | ||
content, | ||
contentType, | ||
}; | ||
}); | ||
this._compileProject(); | ||
} | ||
private _tabActivated(e: CustomEvent<{index: number}>) { | ||
this._currentFileIndex = e.detail.index; | ||
this.filename = this.files?.[this._currentFileIndex].name; | ||
} | ||
private async _fetchProject() { | ||
if (!this.projectSrc) { | ||
return; | ||
} | ||
const projectUrl = new URL(this.projectSrc, document.baseURI); | ||
const manifestFetched = await fetch(this.projectSrc); | ||
const manifest = (await manifestFetched.json()) as ProjectManifest; | ||
const filenames = Object.keys(manifest.files || []); | ||
this._files = await Promise.all( | ||
filenames.map(async (filename) => { | ||
const fileUrl = new URL(filename, projectUrl); | ||
const response = await fetch(fileUrl.href); | ||
if (response.status === 404) { | ||
throw new Error(`Could not find file ${filename}`); | ||
} | ||
// Remember the mime type so that the service worker can set it | ||
const contentType = response.headers.get('Content-Type') || undefined; | ||
return { | ||
name: filename, | ||
content: await response.text(), | ||
contentType, | ||
}; | ||
}) | ||
); | ||
this._compileProject(); | ||
this._currentFileIndex = 0; | ||
// TODO(justinfagnani): whyyyy? | ||
await this._tabBar.updateComplete; | ||
this._tabBar.activeIndex = -1; | ||
this._tabBar.activeIndex = 0; | ||
} | ||
private async _startWorkers() { | ||
await Promise.all([ | ||
this._startTypeScriptWorker(), | ||
this._installServiceWorker(), | ||
]); | ||
} | ||
private async _startTypeScriptWorker() { | ||
if (this._typescriptWorkerAPI === undefined) { | ||
const worker = new Worker(typescriptWorkerScriptUrl); | ||
this._typescriptWorkerAPI = wrap<TypeScriptWorkerAPI>(worker); | ||
private _findProjectAndRegister() { | ||
const prevProject = this._project; | ||
if (this.project instanceof HTMLElement) { | ||
this._project = this.project; | ||
} else if (typeof this.project === 'string') { | ||
this._project = | ||
(((this.getRootNode() as unknown) as | ||
| Document | ||
| ShadowRoot).getElementById( | ||
this.project | ||
) as CodeSampleProjectElement | null) || undefined; | ||
} else { | ||
console.debug('typescript-worker already started'); | ||
this._project = undefined; | ||
} | ||
} | ||
private async _installServiceWorker() { | ||
if (!('serviceWorker' in navigator)) { | ||
// TODO: show this in the UI | ||
console.warn('ServiceWorker support required for <code-sample-editor>'); | ||
return; | ||
} | ||
const registration = await navigator.serviceWorker.register( | ||
serviceWorkerScriptUrl.href, | ||
{scope: this._scopeUrl} | ||
); | ||
registration.addEventListener('updatefound', () => { | ||
// We can get a new service worker at any time, so we need to listen for | ||
// updates and connect to new workers on demand. | ||
const newWorker = registration.installing; | ||
if (newWorker) { | ||
this._connectServiceWorker(newWorker); | ||
if (prevProject !== this._project) { | ||
if (prevProject) { | ||
prevProject._unregisterEditor(this); | ||
} | ||
}); | ||
if (registration.active) { | ||
this._connectServiceWorker(registration.active); | ||
} else { | ||
console.warn('unhandled service worker registration state', registration); | ||
if (this._project) { | ||
this._project._registerEditor(this); | ||
} | ||
} | ||
} | ||
private async _connectServiceWorker(worker: ServiceWorker) { | ||
return new Promise((resolve) => { | ||
const {port1, port2} = new MessageChannel(); | ||
const onMessage = (e: MessageEvent) => { | ||
if (e.data.initComlink === HANDSHAKE_RECEIVED) { | ||
port1.removeEventListener('message', onMessage); | ||
this._serviceWorkerAPI = wrap<ServiceWorkerAPI>(port1); | ||
this._serviceWorkerAPI.setFileAPI( | ||
proxy({ | ||
getFile: (name: string) => this._getFile(name), | ||
}), | ||
this._sessionId | ||
); | ||
resolve(); | ||
} | ||
}; | ||
port1.addEventListener('message', onMessage); | ||
port1.start(); | ||
worker.postMessage( | ||
{ | ||
initComlink: ESTABLISH_HANDSHAKE, | ||
port: port2, | ||
}, | ||
[port2] | ||
); | ||
// TODO: timeout | ||
}); | ||
} | ||
private async _getFile(name: string): Promise<SampleFile | undefined> { | ||
await this._compiledFilesPromise; | ||
const compiledUrl = new URL(name, window.origin).href; | ||
const compiledContent = this._compiledFiles?.get(compiledUrl); | ||
if (compiledContent !== undefined) { | ||
return { | ||
name, | ||
content: compiledContent, | ||
contentType: 'application/javascript', | ||
}; | ||
} else { | ||
return this._files?.find((f) => f.name === name); | ||
} | ||
} | ||
private _onEdit() { | ||
@@ -464,21 +284,11 @@ const value = this._editor.value; | ||
this._currentFile.content = value!; | ||
// TODO: send to worker? | ||
this._project?.saveDebounced(); | ||
} | ||
} | ||
} | ||
private async _compileProject() { | ||
if (this._files === undefined) { | ||
return; | ||
} | ||
this._compiledFilesPromise = (this._typescriptWorkerAPI!.compileProject( | ||
this._files | ||
) as any) as Promise<Map<string, string>>; | ||
this._compiledFiles = undefined; | ||
this._compiledFiles = await this._compiledFilesPromise; | ||
declare global { | ||
interface HTMLElementTagNameMap { | ||
'code-sample-editor': CodeSampleEditor; | ||
} | ||
private async _onSave() { | ||
await this._compileProject(); | ||
this._preview.reload(); | ||
} | ||
} | ||
@@ -511,20 +321,1 @@ | ||
}; | ||
const typeEnumToMimeType = (type?: string) => { | ||
// TODO: infer type based on extension too | ||
if (type === undefined) { | ||
return; | ||
} | ||
switch (type) { | ||
// TypeScript | ||
case 'ts': | ||
return 'video/mp2t'; | ||
case 'js': | ||
return 'application/javascript; charset=utf-8'; | ||
case 'html': | ||
return 'text/html; charset=utf-8'; | ||
case 'css': | ||
return 'text/css; charset=utf-8'; | ||
} | ||
return undefined; | ||
}; |
@@ -15,33 +15,60 @@ /** | ||
import { | ||
LitElement, | ||
customElement, | ||
css, | ||
html, | ||
property, | ||
PropertyValues, | ||
internalProperty, | ||
} from 'lit-element'; | ||
import { | ||
EditorView, | ||
keymap, | ||
highlightSpecialChars, | ||
multipleSelections, | ||
} from '@codemirror/next/view'; | ||
import {EditorState, Transaction} from '@codemirror/next/state'; | ||
import {history, historyKeymap} from '@codemirror/next/history'; | ||
import {defaultKeymap} from '@codemirror/next/commands'; | ||
import {lineNumbers} from '@codemirror/next/gutter'; | ||
import {closeBrackets} from '@codemirror/next/closebrackets'; | ||
import {searchKeymap} from '@codemirror/next/search'; | ||
import {commentKeymap} from '@codemirror/next/comment'; | ||
import {LitElement, customElement, css, property} from 'lit-element'; | ||
// TODO(justinfagnani): devise a way to load languages outside of the element, | ||
// possible into a shared registry keyed by name, so they can be selected with | ||
// an attribute. | ||
import {html as htmlLang} from '@codemirror/next/lang-html'; | ||
import {css as cssLang} from '@codemirror/next/lang-css'; | ||
import {javascript as javascriptLang} from '@codemirror/next/lang-javascript'; | ||
import {defaultHighlighter} from '@codemirror/next/highlight'; | ||
// TODO(aomarks) We use CodeMirror v5 instead of v6 only because we want support | ||
// for nested highlighting of HTML and CSS inside JS/TS. Upgrade back to v6 once | ||
// support is available. See | ||
// https://github.com/lezer-parser/javascript/issues/3. This module sets a | ||
// `CodeMirror` global. | ||
import '../_codemirror/codemirror-bundle.js'; | ||
// TODO(aomarks) Provide an API for loading these themes dynamically. We can | ||
// include a bunch of standard themes, but we don't want them to all be included | ||
// here if they aren't being used. | ||
import codemirrorStyles from '../_codemirror/codemirror-styles.js'; | ||
import monokaiTheme from '../_codemirror/themes/monokai.css.js'; | ||
// TODO(aomarks) @types/codemirror exists, but installing it and referencing | ||
// global `CodeMirror` errors with: | ||
// | ||
// 'CodeMirror' refers to a UMD global, but the current file is a module. | ||
// Consider adding an import instead | ||
// | ||
// Maybe there's a way to get this working. See | ||
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/codemirror/index.d.ts | ||
declare function CodeMirror( | ||
callback: (host: HTMLElement) => void, | ||
options?: CodeMirrorConfiguration | ||
): { | ||
getValue(): string; | ||
setValue(content: string): void; | ||
setSize(width?: string | number, height?: string | number): void; | ||
setOption<K extends keyof CodeMirrorConfiguration>( | ||
option: K, | ||
value: CodeMirrorConfiguration[K] | ||
): void; | ||
on(eventName: 'change', handler: () => void): void; | ||
}; | ||
interface CodeMirrorConfiguration { | ||
value?: string; | ||
mode?: string | null; | ||
lineNumbers?: boolean; | ||
theme?: string; | ||
readOnly?: boolean | 'nocursor'; | ||
} | ||
// TODO(aomarks) Could we upstream this to lit-element? It adds much stricter | ||
// types to the ChangedProperties type. | ||
interface TypedMap<T> extends Map<keyof T, unknown> { | ||
get<K extends keyof T>(key: K): T[K]; | ||
set<K extends keyof T>(key: K, value: T[K]): this; | ||
delete<K extends keyof T>(key: K): boolean; | ||
keys(): IterableIterator<keyof T>; | ||
values(): IterableIterator<T[keyof T]>; | ||
entries(): IterableIterator<{[K in keyof T]: [K, T[K]]}[keyof T]>; | ||
} | ||
const unreachable = (n: never) => n; | ||
/** | ||
@@ -52,17 +79,38 @@ * A basic text editor with syntax highlighting for HTML, CSS, and JavaScript. | ||
export class CodeMirrorEditorElement extends LitElement { | ||
static styles = css` | ||
:host { | ||
display: block; | ||
overflow: hidden; | ||
box-sizing: border-box; | ||
} | ||
static styles = [ | ||
css` | ||
:host { | ||
display: block; | ||
font-family: var(--playground-code-font-family, monospace); | ||
font-size: var(--playground-code-font-size, unset); | ||
} | ||
.cm-wrap { | ||
height: 100%; | ||
} | ||
`; | ||
:host(:not([probing-codemirror-theme])) { | ||
background-color: var( | ||
--playground-editor-background-color, | ||
var(--playground-editor-theme-background-color) | ||
); | ||
} | ||
@internalProperty() | ||
private _editorView!: EditorView; | ||
:host(:not([probing-codemirror-theme])) .CodeMirror { | ||
background-color: inherit !important; | ||
} | ||
.CodeMirror { | ||
height: 100% !important; | ||
font-family: inherit !important; | ||
border-radius: inherit; | ||
} | ||
.CodeMirror-scroll { | ||
padding-left: 5px; | ||
} | ||
`, | ||
codemirrorStyles, | ||
monokaiTheme, | ||
]; | ||
// Used by tests. | ||
protected _codemirror?: ReturnType<typeof CodeMirror>; | ||
// We store _value ourselves, rather than using a public reactive property, so | ||
@@ -72,4 +120,2 @@ // that we can set this value internally without triggering an update. | ||
private _capturedCodeMirrorStyles?: NodeListOf<HTMLStyleElement>; | ||
get value() { | ||
@@ -92,9 +138,57 @@ return this._value; | ||
render() { | ||
return html` ${this._editorView?.dom} ${this._capturedCodeMirrorStyles} `; | ||
} | ||
/** | ||
* If true, display a left-hand-side gutter with line numbers. Default false | ||
* (hidden). | ||
*/ | ||
@property({type: Boolean, attribute: 'line-numbers', reflect: true}) | ||
lineNumbers = false; | ||
update(changedProperties: PropertyValues) { | ||
if (changedProperties.has('value') || changedProperties.has('src')) { | ||
/** | ||
* If true, this editor is not editable. | ||
*/ | ||
@property({type: Boolean, reflect: true}) | ||
readonly = false; | ||
/** | ||
* The CodeMirror theme to load. | ||
*/ | ||
@property() | ||
theme = 'default'; | ||
private _resizeObserver?: ResizeObserver; | ||
private _valueChangingFromOutside = false; | ||
update( | ||
changedProperties: TypedMap< | ||
Omit<CodeMirrorEditorElement, keyof LitElement | 'update'> | ||
> | ||
) { | ||
const cm = this._codemirror; | ||
if (cm === undefined) { | ||
this._createView(); | ||
} else { | ||
for (const prop of changedProperties.keys()) { | ||
switch (prop) { | ||
case 'value': | ||
this._valueChangingFromOutside = true; | ||
cm.setValue(this.value ?? ''); | ||
this._valueChangingFromOutside = false; | ||
break; | ||
case 'lineNumbers': | ||
cm.setOption('lineNumbers', this.lineNumbers); | ||
break; | ||
case 'type': | ||
cm.setOption('mode', this._getLanguageMode()); | ||
break; | ||
case 'theme': | ||
cm.setOption('theme', this.theme); | ||
this._setBackgroundColor(); | ||
break; | ||
case 'readonly': | ||
cm.setOption('readOnly', this.readonly); | ||
break; | ||
default: | ||
unreachable(prop); | ||
} | ||
} | ||
} | ||
@@ -104,56 +198,94 @@ super.update(changedProperties); | ||
connectedCallback() { | ||
// CodeMirror uses JavaScript to control whether scrollbars are visible. It | ||
// does so automatically on interaction, but won't notice container size | ||
// changes. If the browser doesn't have ResizeObserver, scrollbars will | ||
// sometimes be missing, but typing in the editor will fix it. | ||
if (typeof ResizeObserver === 'function') { | ||
this._resizeObserver = new ResizeObserver(() => { | ||
this._codemirror?.setSize(); | ||
}); | ||
this._resizeObserver.observe(this); | ||
} | ||
super.connectedCallback(); | ||
} | ||
disconnectedCallback() { | ||
this._resizeObserver?.disconnect(); | ||
super.disconnectedCallback(); | ||
} | ||
private _createView() { | ||
// This is called every time the value property is set externally, so that | ||
// we set up a fresh document with new state. | ||
const view = new EditorView({ | ||
dispatch: (t: Transaction) => { | ||
view.update([t]); | ||
if (t.docChanged) { | ||
this._value = t.state.doc.toString(); | ||
this.dispatchEvent(new Event('change')); | ||
} | ||
this.requestUpdate(); | ||
const cm = CodeMirror( | ||
(dom) => { | ||
this.shadowRoot!.innerHTML = ''; | ||
this.shadowRoot!.appendChild(dom); | ||
}, | ||
state: EditorState.create({ | ||
doc: this.value, | ||
extensions: [ | ||
lineNumbers(), | ||
highlightSpecialChars(), | ||
history(), | ||
multipleSelections(), | ||
...this._getLanguagePlugins(), | ||
defaultHighlighter, | ||
closeBrackets(), | ||
keymap([ | ||
...defaultKeymap, | ||
...searchKeymap, | ||
...historyKeymap, | ||
...commentKeymap, | ||
]), | ||
], | ||
}), | ||
root: this.shadowRoot!, | ||
{ | ||
value: this.value ?? '', | ||
lineNumbers: this.lineNumbers, | ||
mode: this._getLanguageMode(), | ||
theme: this.theme, | ||
readOnly: this.readonly, | ||
} | ||
); | ||
cm.on('change', () => { | ||
this._value = cm.getValue(); | ||
// Only notify changes from user interaction. External changes are usually | ||
// things like the editor switching which file it is displaying. | ||
if (!this._valueChangingFromOutside) { | ||
this.dispatchEvent(new Event('change')); | ||
} | ||
}); | ||
// EditorView writes a <style> directly into the given root on construction | ||
// (unless adopted stylesheets are available, in which case it uses that). | ||
// But then lit renders and blows it away. So, we'll just snatch any new | ||
// styles before this can happen, and then have lit put them back again. | ||
// Note that EditorView re-uses the same <style> element across instances, | ||
// so our list of styles does not grow every time we reset the view. | ||
this._capturedCodeMirrorStyles = this.shadowRoot!.querySelectorAll('style'); | ||
this._editorView = view; | ||
this.requestUpdate(); | ||
this._codemirror = cm; | ||
this._setBackgroundColor(); | ||
} | ||
private _getLanguagePlugins() { | ||
/** | ||
* We want the CodeMirror theme's background color to win if | ||
* "--playground-editor-background-color" is unset. | ||
* | ||
* However, there are no values we can use as the default for that property | ||
* that allow for this. "revert" seems like it should work, but it doesn't. | ||
* "initial" and "unset" also don't work. | ||
* | ||
* So we instead maintain a private CSS property called | ||
* "--playground-editor-theme-background-color" that is always set to the | ||
* theme's background-color, and we use that as the default. We detect this by | ||
* momentarily disabling the rule that applies | ||
* "--playground-editor-background-color" whenever the theme changes. | ||
*/ | ||
private _setBackgroundColor() { | ||
this.setAttribute('probing-codemirror-theme', ''); | ||
const codeMirrorRootElement = this.shadowRoot!.querySelector( | ||
'.CodeMirror' | ||
)!; | ||
const themeBgColor = window.getComputedStyle(codeMirrorRootElement) | ||
.backgroundColor; | ||
this.style.setProperty( | ||
'--playground-editor-theme-background-color', | ||
themeBgColor | ||
); | ||
this.removeAttribute('probing-codemirror-theme'); | ||
} | ||
private _getLanguageMode() { | ||
switch (this.type) { | ||
case 'ts': | ||
return 'google-typescript'; | ||
case 'js': | ||
return [javascriptLang()]; | ||
return 'google-javascript'; | ||
case 'html': | ||
return [htmlLang()]; | ||
return 'google-html'; | ||
case 'css': | ||
[cssLang()]; | ||
return 'css'; | ||
} | ||
return []; | ||
return null; | ||
} | ||
} | ||
declare global { | ||
interface HTMLElementTagNameMap { | ||
'codemirror-editor': CodeMirrorEditorElement; | ||
} | ||
} |
@@ -31,4 +31,9 @@ /** | ||
export interface SampleFile { | ||
/** Filename. */ | ||
name: string; | ||
/** Optional display label. */ | ||
label?: string; | ||
/** File contents. */ | ||
content: string; | ||
/** MIME type. */ | ||
contentType?: string; | ||
@@ -41,2 +46,4 @@ } | ||
isTemplate?: boolean; | ||
/** Optional display label. */ | ||
label?: string; | ||
} | ||
@@ -43,0 +50,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Mixed license
License(Experimental) Package contains multiple licenses.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
10161570
14929.46%7
-22.22%113
222.86%7054
310.59%1
-50%2
-50%20
150%1
Infinity%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed