diff --git a/l4d2web/scripts/editor-src/editor-entry.js b/l4d2web/scripts/editor-src/editor-entry.js index 4be7802..444ecdb 100644 --- a/l4d2web/scripts/editor-src/editor-entry.js +++ b/l4d2web/scripts/editor-src/editor-entry.js @@ -1,2 +1,84 @@ -// Façade entry point. Populated in Task 4. -window.__editor = { mount: () => { throw new Error('editor bundle not yet implemented'); } }; +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 } 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 } = {}) { + const langCompartment = new Compartment(); + const themeCompartment = new Compartment(); + const autocompleteCompartment = new Compartment(); + + const lang = pickLanguage(language); + const mm = window.matchMedia("(prefers-color-scheme: dark)"); + + const extensions = [ + 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([ + ...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 };