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.

Language switch uses tear-down-and-remount because CodeJar captures
its highlighter by closure at construction time and has no API to
swap it on a live instance.

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

View file

@ -0,0 +1,147 @@
// Code editor widget. Mounts on any <textarea data-editor-language>.
// The textarea stays in the DOM (display:none) and the widget mirrors
// content back into it on every input — form submission and JS code
// that reads textarea.value (e.g. files-overlay.js) keep working.
//
// Public per-instance API (attached to the textarea as ._codeEditor):
// - setValue(text)
// - setLanguage(name) // "srccfg" | "bash" | "plain" | "auto"
// - getValue()
// - destroy()
(function () {
"use strict";
const LANG_BY_EXT = {
cfg: "srccfg",
sh: "bash",
bash: "bash",
};
function resolveAutoLanguage(filename) {
if (!filename) return "plain";
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
if (!m) return "plain";
return LANG_BY_EXT[m[1].toLowerCase()] || "plain";
}
function highlightFor(lang) {
return function (editorEl) {
if (lang === "plain" || !window.Prism || !window.Prism.languages[lang]) {
// Plain mode or grammar missing: leave textContent alone.
editorEl.innerHTML = editorEl.textContent
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;");
return;
}
window.Prism.highlightElement(editorEl);
};
}
// 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) {
if (textarea._codeEditor) return textarea._codeEditor;
const requested = textarea.dataset.editorLanguage || "plain";
let language =
requested === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: requested;
// Build the visible editor.
const shell = document.createElement("div");
shell.className = "editor-shell";
const code = document.createElement("code");
code.className = "editor-code language-" + language;
code.setAttribute("contenteditable", "true");
code.setAttribute("spellcheck", "false");
code.textContent = textarea.value;
shell.appendChild(code);
textarea.parentNode.insertBefore(shell, textarea);
textarea.style.display = "none";
// CodeJar mounts on the contenteditable and re-runs the highlighter
// on each input while preserving caret position.
const jar = window.CodeJar(code, highlightFor(language), { tab: " " });
function attachOnUpdate(jarInstance) {
jarInstance.onUpdate(function (value) {
// Mirror back to the underlying textarea so form POST and any
// .value readers see the current content.
textarea.value = value;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
attachOnUpdate(jar);
const instance = {
textarea,
shell,
code,
jar,
language,
setValue: function (text) {
instance.jar.updateCode(text);
textarea.value = text;
},
getValue: function () {
return instance.jar.toString();
},
setLanguage: function (name) {
const next =
name === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name;
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;
code.className = "editor-code language-" + next;
code.textContent = currentText;
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
attachOnUpdate(instance.jar);
},
destroy: function () {
instance.jar.destroy();
shell.remove();
textarea.style.display = "";
delete textarea._codeEditor;
},
};
textarea._codeEditor = instance;
return instance;
}
function mountAll(root) {
const scope = root || document;
scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
mountAll(document);
});
} else {
mountAll(document);
}
// Re-export for callers that need to mount editors created later
// (the files-editor modal exists in the static DOM but is only
// shown after user interaction — initial mount is correct, but
// exposing this hook lets future code mount dynamically-inserted
// editors if needed).
window.l4d2Editor = { mount, mountAll };
})();