Skip to content

Commit 275a8a7

Browse files
Codemirror component (#215)
Available via `<os-code-editor>` Fixes #189 # Notes Source code can be reset via `source` attribute. Language highlighting is toggled via mime type on `lang` prop/attr. Supported languages: - css: "text/css" - html: "text/html" - javascript: "text/javascript" - jsx: "text/x.jsx" - typescript: "text/x.typescript" - json: "application/json" - markdown: "text/markdown" # Example ```html <os-code-editor lang="text/javascript" source="console.log('hello world');"> </os-code-editor> ``` ```html <os-code-editor lang="text/x.typescript" source="function hello(text: string) {}"> </os-code-editor> ``` ```html <os-code-editor lang="text/css" source=".foo { font-weight: bold; }"> </os-code-editor> ```
1 parent 7582916 commit 275a8a7

File tree

12 files changed

+485
-463
lines changed

12 files changed

+485
-463
lines changed

typescript/package-lock.json

Lines changed: 268 additions & 424 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

typescript/packages/common-os-ui/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<os-icon-button slot="toolbar-end" icon="info"></os-icon-button>
3939
<os-icon-button slot="toolbar-end" icon="palette"></os-icon-button>
4040
<os-sidebar-close-button slot="toolbar-end"></os-sidebar-close-button>
41+
<os-code-editor></os-code-editor>
4142
<os-charm-row-group>
4243
<os-charm-row icon="mail" text="Mail"></os-charm-row>
4344
<os-charm-row icon="calendar_month" text="Calendar"> </os-charm-row>

typescript/packages/common-os-ui/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,17 @@
2525
"./lib/index.js"
2626
],
2727
"dependencies": {
28+
"@codemirror/lang-css": "^6.3.0",
29+
"@codemirror/lang-html": "^6.4.9",
30+
"@codemirror/lang-javascript": "^6.2.2",
31+
"@codemirror/lang-json": "^6.0.1",
32+
"@codemirror/lang-markdown": "^6.3.0",
33+
"@codemirror/search": "^6.5.6",
34+
"@codemirror/state": "^6.4.1",
35+
"@codemirror/theme-one-dark": "^6.1.2",
36+
"@commontools/common-os-ui": "file:",
2837
"@floating-ui/dom": "^1.6.11",
38+
"codemirror": "^6.0.1",
2939
"lit": "^3.2.0",
3040
"prosemirror-commands": "^1.6.0",
3141
"prosemirror-history": "^1.4.1",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { EditorState } from "@codemirror/state";
2+
import { EditorView } from "codemirror";
3+
4+
export const replaceSource = (state: EditorState, value: string) =>
5+
state.update({
6+
changes: {
7+
from: 0,
8+
to: state.doc.length,
9+
insert: value,
10+
},
11+
});
12+
13+
/** Replace the source in this editor view, but only if it's different */
14+
export const replaceSourceIfNeeded = (view: EditorView, value: string) => {
15+
if (view.state.doc.toString() === value) return;
16+
view.update([
17+
view.state.update({
18+
changes: {
19+
from: 0,
20+
to: view.state.doc.length,
21+
insert: value,
22+
},
23+
}),
24+
]);
25+
};
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { css, html, render, ReactiveElement, PropertyValues } from "lit";
2+
import { customElement, property } from "lit/decorators.js";
3+
import { basicSetup, EditorView } from "codemirror";
4+
import { EditorState, Compartment, Extension } from "@codemirror/state";
5+
import { LanguageSupport } from "@codemirror/language";
6+
import { javascript as createJavaScript } from "@codemirror/lang-javascript";
7+
import { markdown as createMarkdown } from "@codemirror/lang-markdown";
8+
import { css as createCss } from "@codemirror/lang-css";
9+
import { html as creatHtml } from "@codemirror/lang-html";
10+
import { json as createJson } from "@codemirror/lang-json";
11+
import { oneDark } from "@codemirror/theme-one-dark";
12+
import { replaceSourceIfNeeded } from "./codemirror/utils.js";
13+
import { createCancelGroup } from "../../shared/cancel.js";
14+
15+
const freeze = Object.freeze;
16+
17+
export const MimeType = freeze({
18+
css: "text/css",
19+
html: "text/html",
20+
javascript: "text/javascript",
21+
jsx: "text/x.jsx",
22+
typescript: "text/x.typescript",
23+
json: "application/json",
24+
markdown: "text/markdown",
25+
} as const);
26+
27+
export type MimeType = (typeof MimeType)[keyof typeof MimeType];
28+
29+
export const langRegistry = new Map<MimeType, LanguageSupport>();
30+
const markdownLang = createMarkdown({
31+
defaultCodeLanguage: createJavaScript({ jsx: true }),
32+
});
33+
const defaultLang = markdownLang;
34+
35+
langRegistry.set(MimeType.javascript, createJavaScript());
36+
langRegistry.set(
37+
MimeType.jsx,
38+
createJavaScript({
39+
jsx: true,
40+
}),
41+
);
42+
langRegistry.set(
43+
MimeType.typescript,
44+
createJavaScript({
45+
jsx: true,
46+
typescript: true,
47+
}),
48+
);
49+
langRegistry.set(MimeType.css, createCss());
50+
langRegistry.set(MimeType.html, creatHtml());
51+
langRegistry.set(MimeType.markdown, markdownLang);
52+
langRegistry.set(MimeType.json, createJson());
53+
54+
export const getLangExtFromMimeType = (mime: MimeType) => {
55+
return langRegistry.get(mime) ?? defaultLang;
56+
};
57+
58+
export const createEditor = ({
59+
element,
60+
extensions = [],
61+
}: {
62+
element: HTMLElement;
63+
extensions?: Array<Extension>;
64+
}) => {
65+
const state = EditorState.create({
66+
extensions: [basicSetup, oneDark, ...extensions],
67+
});
68+
69+
return new EditorView({
70+
state,
71+
parent: element,
72+
});
73+
};
74+
75+
@customElement("os-code-editor")
76+
export class OsCodeEditor extends ReactiveElement {
77+
static styles = [
78+
css`
79+
:host {
80+
display: block;
81+
}
82+
83+
.code-editor {
84+
display: block;
85+
}
86+
87+
.cm-editor.cm-focused {
88+
outline: none;
89+
}
90+
`,
91+
];
92+
93+
#editorView: EditorView | undefined = undefined;
94+
#lang = new Compartment();
95+
#tabSize = new Compartment();
96+
97+
destroy = createCancelGroup();
98+
99+
@property({ type: String })
100+
source = "";
101+
102+
@property({ type: String })
103+
lang = MimeType.markdown;
104+
105+
get editor(): EditorState | undefined {
106+
return this.#editorView?.state;
107+
}
108+
109+
set editor(state: EditorState) {
110+
this.#editorView?.setState(state);
111+
}
112+
113+
protected firstUpdated(changedProperties: PropertyValues): void {
114+
super.firstUpdated(changedProperties);
115+
// Set up skeleton
116+
// - #editor is managed by ProseMirror
117+
// - #reactive is rendered via Lit templates and driven by store updates
118+
render(html`<div id="editor" class="code-editor"></div>`, this.renderRoot);
119+
const editorRoot = this.renderRoot.querySelector("#editor") as HTMLElement;
120+
121+
this.#editorView = createEditor({
122+
element: editorRoot,
123+
extensions: [
124+
this.#lang.of(defaultLang),
125+
this.#tabSize.of(EditorState.tabSize.of(4)),
126+
],
127+
});
128+
this.destroy.add(() => this.#editorView?.destroy());
129+
}
130+
131+
protected updated(changedProperties: PropertyValues): void {
132+
if (changedProperties.has("source")) {
133+
replaceSourceIfNeeded(this.#editorView!, this.source);
134+
}
135+
if (changedProperties.has("lang")) {
136+
const lang = getLangExtFromMimeType(this.lang);
137+
this.#editorView?.dispatch({
138+
effects: this.#lang.reconfigure(lang),
139+
});
140+
}
141+
}
142+
}

typescript/packages/common-os-ui/src/components/editor/os-rich-text-editor.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
Store,
2323
ValueMsg,
2424
} from "../../shared/store.js";
25-
import { createCleanupGroup } from "../../shared/cleanup.js";
25+
import { createCancelGroup } from "../../shared/cancel.js";
2626
import { TemplateResult } from "lit";
2727
import { classes, on } from "../../shared/dom.js";
2828
import { ClickCompletion } from "../os-floating-completions.js";
@@ -308,7 +308,7 @@ export class OsRichTextEditor extends HTMLElement {
308308
`,
309309
];
310310

311-
#destroy = createCleanupGroup();
311+
destroy = createCancelGroup();
312312
#store: Store<Model, Msg>;
313313
#editorView: EditorView;
314314
#reactiveRoot: HTMLElement;
@@ -343,7 +343,7 @@ export class OsRichTextEditor extends HTMLElement {
343343
element: editorRoot,
344344
send: (msg: Msg) => this.#store.send(msg),
345345
});
346-
this.#destroy.add(() => {
346+
this.destroy.add(() => {
347347
this.#editorView.destroy();
348348
});
349349

@@ -352,7 +352,7 @@ export class OsRichTextEditor extends HTMLElement {
352352
const event = new EditorStateChangeEvent(this.#editorView.state);
353353
this.dispatchEvent(event);
354354
});
355-
this.#destroy.add(offInput);
355+
this.destroy.add(offInput);
356356

357357
// Create fx driver
358358
const fx = createFx({
@@ -368,11 +368,11 @@ export class OsRichTextEditor extends HTMLElement {
368368
});
369369

370370
// Drive #reactive renders via store changes
371-
const cleanupRender = this.#store.sink(() => {
371+
const cancelRender = this.#store.sink(() => {
372372
// Wire up reactive rendering
373373
render(this.render(), this.#reactiveRoot);
374374
});
375-
this.#destroy.add(cleanupRender);
375+
this.destroy.add(cancelRender);
376376
}
377377

378378
get editor() {

typescript/packages/common-os-ui/src/components/os-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class OsContainer extends LitElement {
1616
.container {
1717
max-width: var(--container-width);
1818
margin: 0 auto;
19-
padding: var(--pad);
19+
padding: 0 var(--pad);
2020
}
2121
`,
2222
];

typescript/packages/common-os-ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ export * as charmRow from "./components/os-charm-row.js";
1717
export * as charmChip from "./components/os-charm-chip.js";
1818
export * as dialog from "./components/os-dialog.js";
1919
export * as richTextEditor from "./components/editor/os-rich-text-editor.js";
20+
export * as codeEditor from "./components/code-editor/os-code-editor.js";
2021
export * as floatingCompletions from "./components/os-floating-completions.js";
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { createCleanupGroup } from "./cleanup.js";
1+
import { createCancelGroup } from "./cancel.js";
22
import * as assert from "node:assert/strict";
33

44
describe("cleanupGroup", () => {
55
it("should create a cleanup group with add and cleanup methods", () => {
6-
const group = createCleanupGroup();
6+
const group = createCancelGroup();
77
assert.equal(typeof group.add, "function");
8-
assert.equal(typeof group.cleanup, "function");
8+
assert.equal(typeof group, "function");
99
});
1010

1111
it("should execute added cleanup functions when cleanup is called", () => {
12-
const group = createCleanupGroup();
12+
const group = createCancelGroup();
1313
let count = 0;
1414

1515
group.add(() => {
@@ -19,13 +19,13 @@ describe("cleanupGroup", () => {
1919
count++;
2020
});
2121

22-
group.cleanup();
22+
group();
2323

2424
assert.equal(count, 2);
2525
});
2626

2727
it("should not execute cleanup functions more than once", () => {
28-
const group = createCleanupGroup();
28+
const group = createCancelGroup();
2929
let count = 0;
3030

3131
group.add(() => {
@@ -35,31 +35,31 @@ describe("cleanupGroup", () => {
3535
count++;
3636
});
3737

38-
group.cleanup();
39-
group.cleanup();
38+
group();
39+
group();
4040

4141
assert.equal(count, 2);
4242
});
4343

4444
it("should allow adding cleanup functions after cleanup has been called", () => {
45-
const group = createCleanupGroup();
45+
const group = createCancelGroup();
4646
let count = 0;
4747

4848
group.add(() => {
4949
count++;
5050
});
51-
group.cleanup();
51+
group();
5252

5353
group.add(() => {
5454
count++;
5555
});
56-
group.cleanup();
56+
group();
5757

5858
assert.equal(count, 2);
5959
});
6060

6161
it("should handle empty cleanup group", () => {
62-
const group = createCleanupGroup();
63-
assert.doesNotThrow(() => group.cleanup());
62+
const group = createCancelGroup();
63+
assert.doesNotThrow(() => group());
6464
});
6565
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type Cancel = () => void;
2+
3+
export const createCancelGroup = () => {
4+
const cancels: Set<Cancel> = new Set();
5+
6+
const cancel = () => {
7+
for (const cancel of cancels) {
8+
cancel();
9+
}
10+
cancels.clear();
11+
};
12+
13+
cancel.add = (cancel: Cancel) => {
14+
cancels.add(cancel);
15+
};
16+
17+
return cancel;
18+
};

0 commit comments

Comments
 (0)