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:
parent
cdcb7e4853
commit
e29eaf3254
1 changed files with 147 additions and 0 deletions
147
l4d2web/l4d2web/static/js/editor.js
Normal file
147
l4d2web/l4d2web/static/js/editor.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<");
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue