CLS verified zero (0.00000) on /blueprints/1 and /overlays/1 via
PerformanceObserver({type: 'layout-shift', buffered: true}) on a
real browser session — previously CLS=0.00859 from a 253 px shift
when cm6 mounted into a display:none slot.
Mechanism:
- editor-entry.js: mount() accepts `rows`. When provided, prepends
an EditorView.theme that pins
.cm-editor { height: calc(rows * 1.84rem + 1.125rem) }
and sets .cm-scroller overflow:auto. cm6 renders at a fixed,
predictable height; long content scrolls internally (same UX the
raw <textarea rows="N"> used to give).
- editor.js: reads textarea.rows attribute and passes it to mount().
- editor.css: new .editor-mount wrapper uses the same calc on
min-height keyed off an inline --editor-rows CSS custom property,
so the slot is pre-reserved BEFORE cm6 mounts. Wrapper and cm6
match exactly (browser-measured 254 / 254 px for rows=8 and
607 / 607 px for rows=20).
- Templates: each editor textarea wrapped in
<div class="editor-mount" style="--editor-rows: N">. Single source
of truth on N (only the rows attribute + the inline custom prop
vary per call site).
Per-row metric 1.84 rem derived empirically: 253 px for rows=8 minus
1.125 rem chrome = 235 px content, ÷ 8 ≈ 29.4 px = 1.84 rem.
Fast suite + e2e suite still green (3 + 2 pass, 0 fail).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.7 KiB
JavaScript
103 lines
3.7 KiB
JavaScript
// Un-bundled. Driven by data-editor-language attrs on <textarea>.
|
|
// Mounts cm6 (from editor.bundle.js exporting window.__editor),
|
|
// installs one capture-phase submit handler per <form>, and exposes
|
|
// a named alias for the files-editor modal.
|
|
(function () {
|
|
"use strict";
|
|
|
|
// editor.css pre-hides every textarea[data-editor-language] so the
|
|
// page never paints the raw textarea before cm6 takes over. Both
|
|
// failure paths below restore the textarea by clearing the CSS rule
|
|
// with an inline display:revert.
|
|
function unhideTextarea(ta) {
|
|
ta.style.display = "revert";
|
|
}
|
|
|
|
if (!window.__editor || typeof window.__editor.mount !== "function") {
|
|
// Bundle didn't load — un-hide every editor textarea so the form
|
|
// is still usable. Mirrors the <noscript> override for the
|
|
// JS-disabled case.
|
|
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
|
|
unhideTextarea(ta);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let vocabPromise = null;
|
|
function loadSrccfgVocab() {
|
|
if (!vocabPromise) {
|
|
vocabPromise = fetch("/static/data/srccfg-vocab.json", { credentials: "same-origin" })
|
|
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
|
|
.catch(err => { console.warn("[editor] vocab load failed", err); return null; });
|
|
}
|
|
return vocabPromise;
|
|
}
|
|
|
|
function resolveAutoLanguage(filenameInput) {
|
|
const name = (filenameInput && filenameInput.value || "").toLowerCase();
|
|
if (name.endsWith(".cfg")) return "srccfg";
|
|
if (name.endsWith(".sh")) return "bash";
|
|
return "plain";
|
|
}
|
|
|
|
async function mountOne(textarea) {
|
|
let lang = textarea.getAttribute("data-editor-language") || "plain";
|
|
let filenameInput = null;
|
|
let dropdown = null;
|
|
if (lang === "auto") {
|
|
const modal = textarea.closest("#files-editor-modal") || document;
|
|
filenameInput = modal.querySelector("[data-editor-filename]");
|
|
dropdown = modal.querySelector("[data-editor-language-select]");
|
|
lang = resolveAutoLanguage(filenameInput);
|
|
}
|
|
|
|
const vocab = (lang === "srccfg") ? await loadSrccfgVocab() : null;
|
|
const rows = parseInt(textarea.getAttribute("rows") || "0", 10) || 0;
|
|
const controller = window.__editor.mount(textarea, { language: lang, vocab, rows });
|
|
|
|
// Submit-time copy bridge
|
|
const form = textarea.closest("form");
|
|
if (form && !form.__editorSubmitBound) {
|
|
form.__editorSubmitBound = true;
|
|
form.addEventListener("submit", () => {
|
|
for (const ta of form.querySelectorAll("textarea[data-editor-language]")) {
|
|
if (ta.__editorController) ta.value = ta.__editorController.getValue();
|
|
}
|
|
}, true /* capture phase */);
|
|
}
|
|
textarea.__editorController = controller;
|
|
|
|
// Files-modal hooks
|
|
if (textarea.classList.contains("files-editor-content")) {
|
|
window.__filesEditor = controller;
|
|
if (dropdown) {
|
|
dropdown.addEventListener("change", () => {
|
|
const v = dropdown.value;
|
|
controller.setLanguage(v === "auto" ? resolveAutoLanguage(filenameInput) : v);
|
|
});
|
|
}
|
|
if (filenameInput) {
|
|
filenameInput.addEventListener("input", () => {
|
|
if (!dropdown || dropdown.value === "auto") {
|
|
controller.setLanguage(resolveAutoLanguage(filenameInput));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
|
|
mountOne(ta).catch(err => {
|
|
console.error("[editor] mount failed", err);
|
|
unhideTextarea(ta); // restore the form-usable raw textarea
|
|
});
|
|
}
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|