plan(textarea-editor): consolidate Task 4 editor.js into one block

The plan had Step 1 (initial widget) + Step 2 (setLanguage patch); the
implementation merges them into one final file. Update the plan to
show the final file verbatim so a future regeneration produces the
same output. Step 2 in the plan is renumbered to 'Manual verification
note' (just the deferred-to-Task-6 sentence) for completeness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-16 19:30:22 +02:00
parent e29eaf3254
commit e058b45ff2
No known key found for this signature in database

View file

@ -387,12 +387,19 @@ variables where present so the editor matches the site palette."
**Files:** **Files:**
- Create: `l4d2web/l4d2web/static/js/editor.js` - Create: `l4d2web/l4d2web/static/js/editor.js`
The widget mounts on every `<textarea data-editor-language>`, hides the textarea, creates a sibling contenteditable, mounts CodeJar with Prism highlighting, and pipes content back to the textarea on every input. Autocomplete is added in Task 8; this task lands a working "highlight as you type" experience. The widget mounts on every `<textarea data-editor-language>`, hides the textarea, creates a sibling contenteditable, mounts CodeJar with Prism highlighting, and pipes content back to the textarea on every input. Autocomplete is added in Task 9; this task lands a working "highlight as you type" experience.
- [ ] **Step 1: Write the widget skeleton** - [x] **Step 1: Write `editor.js` (consolidated final version)**
Create `l4d2web/l4d2web/static/js/editor.js`: Create `l4d2web/l4d2web/static/js/editor.js`:
Key design decisions baked into the final file (both the initial skeleton and the `setLanguage` fix merged into one):
- `findFilenameInput` is hoisted above `mount` so the initial `language` resolution can reference it without a forward-reference hazard.
- `attachOnUpdate(jarInstance)` is a small helper called at construction time AND inside `setLanguage`'s remount, avoiding a duplicated callback body.
- All method bodies in `instance` use `instance.jar` (not the captured `jar` variable) so `setValue`/`getValue`/`destroy` always operate on the *current* jar after a `setLanguage` swap.
- `setLanguage` uses tear-down-and-remount (not `updateOptions`/`code.__highlighter`) because CodeJar captures its highlight callback by closure at construction time and provides no API to swap it on a live instance.
```js ```js
// Code editor widget. Mounts on any <textarea data-editor-language>. // Code editor widget. Mounts on any <textarea data-editor-language>.
// The textarea stays in the DOM (display:none) and the widget mirrors // The textarea stays in the DOM (display:none) and the widget mirrors
@ -433,6 +440,14 @@ Create `l4d2web/l4d2web/static/js/editor.js`:
}; };
} }
// For "auto" language: look for a filename input near the textarea
// (the files-editor modal). Returns the <input> or null.
function findFilenameInput(textarea) {
const modal = textarea.closest("dialog, .modal, body");
if (!modal) return null;
return modal.querySelector(".files-editor-filename");
}
function mount(textarea) { function mount(textarea) {
if (textarea._codeEditor) return textarea._codeEditor; if (textarea._codeEditor) return textarea._codeEditor;
@ -458,12 +473,16 @@ Create `l4d2web/l4d2web/static/js/editor.js`:
// on each input while preserving caret position. // on each input while preserving caret position.
const jar = window.CodeJar(code, highlightFor(language), { tab: " " }); const jar = window.CodeJar(code, highlightFor(language), { tab: " " });
jar.onUpdate(function (value) { function attachOnUpdate(jarInstance) {
jarInstance.onUpdate(function (value) {
// Mirror back to the underlying textarea so form POST and any // Mirror back to the underlying textarea so form POST and any
// .value readers see the current content. // .value readers see the current content.
textarea.value = value; textarea.value = value;
textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.dispatchEvent(new Event("input", { bubbles: true }));
}); });
}
attachOnUpdate(jar);
const instance = { const instance = {
textarea, textarea,
@ -472,11 +491,11 @@ Create `l4d2web/l4d2web/static/js/editor.js`:
jar, jar,
language, language,
setValue: function (text) { setValue: function (text) {
jar.updateCode(text); instance.jar.updateCode(text);
textarea.value = text; textarea.value = text;
}, },
getValue: function () { getValue: function () {
return jar.toString(); return instance.jar.toString();
}, },
setLanguage: function (name) { setLanguage: function (name) {
const next = const next =
@ -484,17 +503,21 @@ Create `l4d2web/l4d2web/static/js/editor.js`:
? resolveAutoLanguage(findFilenameInput(textarea)?.value) ? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name; : name;
if (next === instance.language) return; if (next === instance.language) return;
// CodeJar captures its highlight callback by closure at
// construction time — there is no API to swap it on a live
// instance. Tear down and remount with the new highlighter.
// Caret position is lost on switch; acceptable since this is
// triggered by the user clicking the language dropdown.
const currentText = instance.jar.toString();
instance.jar.destroy();
instance.language = next; instance.language = next;
code.className = "editor-code language-" + next; code.className = "editor-code language-" + next;
jar.updateOptions({}); code.textContent = currentText;
// Replace the highlighter by recreating it via updateCode (CodeJar instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
// doesn't expose setHighlight directly). attachOnUpdate(instance.jar);
// Trick: stash the new highlighter on the closure and rerun.
code.__highlighter = highlightFor(next);
code.__highlighter(code);
}, },
destroy: function () { destroy: function () {
jar.destroy(); instance.jar.destroy();
shell.remove(); shell.remove();
textarea.style.display = ""; textarea.style.display = "";
delete textarea._codeEditor; delete textarea._codeEditor;
@ -505,14 +528,6 @@ Create `l4d2web/l4d2web/static/js/editor.js`:
return instance; return instance;
} }
// For "auto" language: look for a filename input near the textarea
// (the files-editor modal). Returns the <input> or null.
function findFilenameInput(textarea) {
const modal = textarea.closest("dialog, .modal, body");
if (!modal) return null;
return modal.querySelector(".files-editor-filename");
}
function mountAll(root) { function mountAll(root) {
const scope = root || document; const scope = root || document;
scope.querySelectorAll("textarea[data-editor-language]").forEach(mount); scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
@ -526,52 +541,16 @@ Create `l4d2web/l4d2web/static/js/editor.js`:
mountAll(document); mountAll(document);
} }
// Re-export for callers that need to mount editors created later (e.g. // Re-export for callers that need to mount editors created later
// the files-editor modal which is in the static DOM but only used after // (the files-editor modal exists in the static DOM but is only
// user interaction — the initial mount is still correct, but exposing // shown after user interaction — initial mount is correct, but
// this hook lets future code mount dynamically-inserted editors). // exposing this hook lets future code mount dynamically-inserted
// editors if needed).
window.l4d2Editor = { mount, mountAll }; window.l4d2Editor = { mount, mountAll };
})(); })();
``` ```
- [ ] **Step 2: Patch the `setLanguage` hot-path** - **Manual verification note:** No smoke test in this task — first live render happens in Task 6 when the blueprint config textarea gets `data-editor-language="srccfg"` and the asset partial is included.
CodeJar's `updateOptions` API doesn't replace the highlighter function — the constructor captured it by closure. Reading the source: CodeJar stores the highlight callback as `highlight` in its options, then calls it on every input. We need a workaround:
Replace the `setLanguage` body with a tear-down-and-remount strategy that preserves caret if possible (for our use case — switching from srccfg to bash in the files-editor — losing caret is acceptable since the user just clicked a dropdown):
```js
setLanguage: function (name) {
const next =
name === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name;
if (next === instance.language) return;
const currentText = jar.toString();
jar.destroy();
instance.language = next;
code.className = "editor-code language-" + next;
code.textContent = currentText;
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
instance.jar.onUpdate(function (value) {
textarea.value = value;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
},
```
- [ ] **Step 3: Commit (no test yet — manual smoke runs after Task 6)**
```bash
git add l4d2web/l4d2web/static/js/editor.js
git commit -m "feat(editor): widget core — mount, sync, language switch
Mounts on <textarea data-editor-language>, hides the textarea, renders
content in a contenteditable sibling with Prism highlighting via
CodeJar. Mirrors content back to textarea.value on every input so form
POST and existing JS readers keep working unchanged. Exposes
setValue/setLanguage/getValue on textarea._codeEditor for callers."
```
--- ---