import { EditorState, Compartment } from "@codemirror/state"; import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { StreamLanguage, indentOnInput, bracketMatching, defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { closeBrackets, closeBracketsKeymap, completionKeymap, acceptCompletion } from "@codemirror/autocomplete"; import { shell as shellLegacy } from "@codemirror/legacy-modes/mode/shell"; import { srccfgLanguage } from "./srccfg-mode.js"; import { editorLightTheme, editorDarkTheme, editorHighlighting } from "./themes.js"; import { autocompleteExtension } from "./autocomplete.js"; const bashLanguage = StreamLanguage.define(shellLegacy); function pickLanguage(name) { if (name === "srccfg") return srccfgLanguage; if (name === "bash") return bashLanguage; return null; // "plain" / unknown → no language extension } function pickThemeForMatchMedia(mm) { return mm.matches ? editorDarkTheme : editorLightTheme; } function mount(textarea, { language = "plain", vocab = null, rows = 0 } = {}) { const langCompartment = new Compartment(); const themeCompartment = new Compartment(); const autocompleteCompartment = new Compartment(); const lang = pickLanguage(language); const mm = window.matchMedia("(prefers-color-scheme: dark)"); // Fix the editor's rendered height to match the textarea's `rows` // attribute via a small EditorView.theme — keeps the wrapper's // pre-reserved space (editor.css `.editor-mount`) and the actual // cm6 height in lockstep, eliminating the mount-time layout shift. // Empirical per-row metric on this build: 1.84rem content + 1.125rem // chrome (8px top + 8px bottom padding + 1px×2 border). const heightThemes = rows > 0 ? [ EditorView.theme({ "&": { height: `calc(${rows} * 1.84rem + 1.125rem)` }, ".cm-scroller": { overflow: "auto" }, }), ] : []; const extensions = [ ...heightThemes, history(), lineNumbers(), highlightActiveLine(), bracketMatching(), closeBrackets(), indentOnInput(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), editorHighlighting, themeCompartment.of(pickThemeForMatchMedia(mm)), langCompartment.of(lang ? [lang] : []), autocompleteCompartment.of(vocab ? [autocompleteExtension(vocab)] : []), keymap.of([ // Tab → acceptCompletion when the popup is open; falls through // to indentWithTab when no popup. `run` returning false means // cm6 keeps walking the keymap list, so indentWithTab still // works as a fallback indent on Tab when typing normally. { key: "Tab", run: acceptCompletion }, ...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, ...completionKeymap, indentWithTab, ]), ]; const state = EditorState.create({ doc: textarea.value, extensions }); const view = new EditorView({ state, parent: textarea.parentElement }); // Insert the editor right before the textarea, then hide the textarea. textarea.parentElement.insertBefore(view.dom, textarea); textarea.style.display = "none"; // OS-level theme swap const onThemeChange = () => view.dispatch({ effects: themeCompartment.reconfigure(pickThemeForMatchMedia(mm)), }); mm.addEventListener("change", onThemeChange); const controller = { getValue: () => view.state.doc.toString(), setContent: (text) => { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }); }, setLanguage: (name) => { const next = pickLanguage(name); view.dispatch({ effects: langCompartment.reconfigure(next ? [next] : []) }); }, destroy: () => { mm.removeEventListener("change", onThemeChange); view.destroy(); textarea.style.display = ""; }, }; return controller; } window.__editor = { mount };