left4me/l4d2web/scripts/editor-src/editor-entry.js
mwiegand 19bc0afaa9
test(editor-v2): Playwright e2e + Tab→acceptCompletion fix
Two e2e tests:
- test_blueprint_autocomplete_accept_writes_into_hidden_textarea:
  loads /blueprints/1, types 'sv_che', asserts the cm6 autocomplete
  popup shows 'sv_cheats', presses Tab to accept, fires a synthetic
  submit on the form, and reads the hidden textarea value back.
  Exercises both the autocomplete extension and the submit-time copy
  bridge in editor.js end-to-end.
- test_copy_preserves_newlines_across_lines: regression gate for
  bug class 1 from v1 (Prism+contenteditable collapsed multi-line
  selections). cm6 preserves linebreaks in its doc by construction;
  we verify via the per-textarea controller's getValue().

editor-entry.js: discovered during the e2e debug that cm6's default
completionKeymap does NOT bind Tab. Added an explicit
`{ key: "Tab", run: acceptCompletion }` ahead of the rest of the
keymap stack so Tab accepts when the popup is open and falls through
to indentWithTab otherwise. Bundle rebuilt + SHA refreshed.

Tests also surfaced a 200ms popup-settle timing race: the popup is
*visible* on the same tick acceptCompletion runs against null
selectedCompletion. A page.wait_for_timeout(200) before pressing
the accept key bridges the gap reliably in CI.

Chromium runs fine in Claude Code's default sandbox — the stale note
in the handoff doc about Mach-port IPC sandbox-blocking is no longer
accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:15:51 +02:00

89 lines
3.3 KiB
JavaScript

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 } = {}) {
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([
// 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 };