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.9 KiB
JavaScript
103 lines
3.9 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, rows = 0 } = {}) {
|
||
const langCompartment = new Compartment();
|
||
const themeCompartment = new Compartment();
|
||
const autocompleteCompartment = new Compartment();
|
||
|
||
const lang = pickLanguage(language);
|
||
const mm = window.matchMedia("(prefers-color-scheme: dark)");
|
||
|
||
// Fix the editor's rendered height to match the textarea's `rows`
|
||
// attribute via a small EditorView.theme — keeps the wrapper's
|
||
// pre-reserved space (editor.css `.editor-mount`) and the actual
|
||
// cm6 height in lockstep, eliminating the mount-time layout shift.
|
||
// Empirical per-row metric on this build: 1.84rem content + 1.125rem
|
||
// chrome (8px top + 8px bottom padding + 1px×2 border).
|
||
const heightThemes = rows > 0 ? [
|
||
EditorView.theme({
|
||
"&": { height: `calc(${rows} * 1.84rem + 1.125rem)` },
|
||
".cm-scroller": { overflow: "auto" },
|
||
}),
|
||
] : [];
|
||
|
||
const extensions = [
|
||
...heightThemes,
|
||
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 };
|