Jump to…
snowinitial commitqoxwzsukwmkx1mo
1// In-browser file editing (CodeMirror 6).
2//
3// **Enhancement only.** The page ships a working `<textarea>` inside an
4// ordinary form. This script replaces it with an editor and copies the content
5// back on submit. If the bundle fails to load, is blocked, or throws, the
6// textarea is what remains and saving still works — which is the same
7// progressive-enhancement rule the rest of the product follows (spec §7).
8//
9// The bundle is served from our own origin and executed under a per-request
10// nonce, because the CSP has no `unsafe-inline` and no external hosts.
11
12import { EditorState, Compartment } from "@codemirror/state";
13import { EditorView, keymap, highlightActiveLine, lineNumbers } from "@codemirror/view";
14import { indentWithTab } from "@codemirror/commands";
15import { basicSetup } from "codemirror";
16import { StreamLanguage } from "@codemirror/language";
17
18import { rust } from "@codemirror/lang-rust";
19import { python } from "@codemirror/lang-python";
20import { javascript } from "@codemirror/lang-javascript";
21import { json } from "@codemirror/lang-json";
22import { markdown } from "@codemirror/lang-markdown";
23import { html } from "@codemirror/lang-html";
24import { css } from "@codemirror/lang-css";
25import { java } from "@codemirror/lang-java";
26import { cpp } from "@codemirror/lang-cpp";
27import { php } from "@codemirror/lang-php";
28import { sql } from "@codemirror/lang-sql";
29import { xml } from "@codemirror/lang-xml";
30import { yaml } from "@codemirror/lang-yaml";
31import { shell } from "@codemirror/legacy-modes/mode/shell";
32import { toml } from "@codemirror/legacy-modes/mode/toml";
33import { go } from "@codemirror/legacy-modes/mode/go";
34import { ruby } from "@codemirror/legacy-modes/mode/ruby";
35
36// Deliberately the same set the server-side highlighter covers, so a file that
37// is coloured when you read it is coloured when you edit it. Keys match the
38// grammar names df-render uses.
39const LANGUAGES = {
40 rust: () => rust(),
41 python: () => python(),
42 javascript: () => javascript(),
43 typescript: () => javascript({ typescript: true }),
44 tsx: () => javascript({ typescript: true, jsx: true }),
45 json: () => json(),
46 markdown: () => markdown(),
47 html: () => html(),
48 css: () => css(),
49 java: () => java(),
50 c: () => cpp(),
51 cpp: () => cpp(),
52 php: () => php(),
53 sql: () => sql(),
54 xml: () => xml(),
55 yaml: () => yaml(),
56 bash: () => StreamLanguage.define(shell),
57 toml: () => StreamLanguage.define(toml),
58 go: () => StreamLanguage.define(go),
59 ruby: () => StreamLanguage.define(ruby),
60};
61
62// The editor takes its colours from the page's CSS custom properties rather
63// than shipping a theme of its own. That way it matches whichever theme the
64// reader is in, including one added later, with no toggle to keep in sync.
65const theme = EditorView.theme({
66 "&": {
67 color: "var(--text)",
68 backgroundColor: "var(--bg)",
69 border: "1px solid var(--border)",
70 borderRadius: "var(--radius)",
71 fontSize: "13px",
72 },
73 ".cm-content": {
74 fontFamily: "var(--font-mono)",
75 caretColor: "var(--identity)",
76 },
77 ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--identity)" },
78 "&.cm-focused": { outline: "2px solid var(--action)", outlineOffset: "-1px" },
79 "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
80 backgroundColor: "color-mix(in srgb, var(--action) 30%, transparent)",
81 },
82 ".cm-gutters": {
83 backgroundColor: "var(--surface)",
84 color: "var(--text-faint)",
85 borderRight: "1px solid var(--border)",
86 },
87 ".cm-activeLine": {
88 backgroundColor: "color-mix(in srgb, var(--identity) 8%, transparent)",
89 },
90 ".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--text-dim)" },
91 ".cm-scroller": { overflow: "auto", maxHeight: "70vh" },
92});
93
94function enhance(form) {
95 const textarea = form.querySelector("textarea[data-editor]");
96 if (!textarea) return;
97
98 const language = textarea.dataset.language || "";
99 const factory = LANGUAGES[language];
100
101 const extensions = [
102 basicSetup,
103 theme,
104 // Tab indents rather than moving focus. Bound explicitly because taking
105 // Tab away from keyboard navigation is only acceptable when it is doing
106 // something — Escape then Tab still leaves the editor.
107 keymap.of([indentWithTab]),
108 EditorView.lineWrapping,
109 ];
110 if (factory) {
111 try {
112 extensions.push(factory());
113 } catch (e) {
114 // A grammar that fails to construct must not cost the user their editor.
115 console.warn("dogfood: language mode failed to load", language, e);
116 }
117 }
118
119 const view = new EditorView({
120 state: EditorState.create({ doc: textarea.value, extensions }),
121 parent: textarea.parentElement,
122 });
123
124 // The textarea remains the thing that is submitted; it is hidden rather than
125 // removed so the form keeps working exactly as it did.
126 textarea.style.display = "none";
127 textarea.setAttribute("aria-hidden", "true");
128 textarea.tabIndex = -1;
129
130 // CodeMirror's editable area is not a labelled control, so name it from the
131 // same label the textarea used.
132 const content = view.contentDOM;
133 content.setAttribute("role", "textbox");
134 content.setAttribute("aria-multiline", "true");
135 const label = form.querySelector(`label[for="${textarea.id}"]`);
136 if (label) content.setAttribute("aria-label", label.textContent.trim());
137
138 form.addEventListener("submit", () => {
139 textarea.value = view.state.doc.toString();
140 });
141
142 // Warn before navigating away from unsaved work. Registered only once the
143 // editor is actually up, so the no-JavaScript path never gets a stray prompt.
144 const initial = textarea.value;
145 let saving = false;
146 form.addEventListener("submit", () => {
147 saving = true;
148 });
149 window.addEventListener("beforeunload", (e) => {
150 if (saving) return;
151 if (view.state.doc.toString() !== initial) {
152 e.preventDefault();
153 e.returnValue = "";
154 }
155 });
156}
157
158for (const form of document.querySelectorAll("form[data-editor-form]")) {
159 try {
160 enhance(form);
161 } catch (e) {
162 // Leaves the plain textarea in place, which is a working editor.
163 console.error("dogfood: could not start the editor", e);
164 }
165}

165 lines · JavaScript