| 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 | |
| 12 | import { EditorState, Compartment } from "@codemirror/state"; |
| 13 | import { EditorView, keymap, highlightActiveLine, lineNumbers } from "@codemirror/view"; |
| 14 | import { indentWithTab } from "@codemirror/commands"; |
| 15 | import { basicSetup } from "codemirror"; |
| 16 | import { StreamLanguage } from "@codemirror/language"; |
| 17 | |
| 18 | import { rust } from "@codemirror/lang-rust"; |
| 19 | import { python } from "@codemirror/lang-python"; |
| 20 | import { javascript } from "@codemirror/lang-javascript"; |
| 21 | import { json } from "@codemirror/lang-json"; |
| 22 | import { markdown } from "@codemirror/lang-markdown"; |
| 23 | import { html } from "@codemirror/lang-html"; |
| 24 | import { css } from "@codemirror/lang-css"; |
| 25 | import { java } from "@codemirror/lang-java"; |
| 26 | import { cpp } from "@codemirror/lang-cpp"; |
| 27 | import { php } from "@codemirror/lang-php"; |
| 28 | import { sql } from "@codemirror/lang-sql"; |
| 29 | import { xml } from "@codemirror/lang-xml"; |
| 30 | import { yaml } from "@codemirror/lang-yaml"; |
| 31 | import { shell } from "@codemirror/legacy-modes/mode/shell"; |
| 32 | import { toml } from "@codemirror/legacy-modes/mode/toml"; |
| 33 | import { go } from "@codemirror/legacy-modes/mode/go"; |
| 34 | import { 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. |
| 39 | const 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. |
| 65 | const 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 | |
| 94 | function 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=""]`); |
| 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 | |
| 158 | for (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