🚀 Big News: Socket Acquires Coana to Bring Reachability Analysis to Every Appsec Team.Learn more
Socket
Sign inDemoInstall
Socket

code-sample-editor

Package Overview
Dependencies
Maintainers
3
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

code-sample-editor - npm Package Compare versions

Comparing version

to
0.1.0-pre.4

_codemirror/codemirror-bundle.js

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.

113

lib/code-sample-editor.d.ts

@@ -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">&lt;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">&lt;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('&lt;', '<');
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