Editor Notes
Patterns and gotchas for the CodeMirror editor in apps/desktop/src/components/editor-area/. Each rule earned its place by costing real time. Apply them when extending or reviewing editor code.
Use the layout model, not the rendered DOM, for positions
Prefer:
view.lineBlockAt(pos)→BlockInfowithtop/bottom/heightin document coordinates.view.documentTop→ screen y of the first line.
Over:
view.coordsAtPos(pos)→ can returnnullfor positions outside the rendered viewport. CodeMirror only measures lines that are currently virtualized into the DOM; matches further down the document have noRectuntil they scroll into view.view.contentDOM.getBoundingClientRect()→ affected by virtualization padding and async layout.
Match screen position, valid for any document position:
const block = view.lineBlockAt(pos);
const matchScreenY = view.documentTop + block.top;
coordsAtPos returning null is a silent failure: a scrollHandler that returns false falls back to CodeMirror's default scroll, which doesn't know about app-level fades, masks, or other ancestor overlays. If you only test in-viewport cases, the bug ships.
Choose the right scroll API for who owns the scroll container
CodeMirror's built-in scroll APIs assume the editor owns its scroll container (view.scrollDOM, by default .cm-scroller):
search()config'sscrollToMatch— customize the scroll effect for findNext/findPrevious.EditorView.scrollMarginsfacet — declare top/bottom/left/right regions of the scroll container that should be treated as off-screen (e.g. for a fixed gutter or fade).
These are correct when view.scrollDOM is the actual scrolling element.
In Writer's editor, .cm-scroller has overflow: visible !important (see prosemark-theme.css) and the surrounding EditorScrollContainer is the real scroller. CodeMirror's default scroll walks up to scroll ancestors generically, but scrollMargins only applies to view.scrollDOM's computation — so the match can still land under the outer container's fade.
When the scrollable element is an ancestor:
- Use
EditorView.scrollHandler.of(...)to take over scrolling. - Find the ancestor scroller by walking
view.dom.parentElementfor the first element withoverflowY: auto | scroll. - Scroll it yourself with
scroller.scrollTo({ top, behavior: "auto" }).behavior: "smooth"is async and gets interrupted by rapid keystrokes (e.g. Cmd+G held down). - Account for
clientTopif the ancestor has a border (Writer's container has a 12px transparent border-top to give the mask gradient room).
Reference: EditorView.scrollHandler.of((view, range) => …) in apps/desktop/src/components/editor-area/use-prosemark-editor.ts.
Block widgets: pick the decoration shape
Common shapes for widgets that own a block region:
- Replace-only. Always
Decoration.replace. Use when the widget doesn't need to expose source for editing and interaction lives inside the widget itself. - Conditional replace ↔ widget.
Decoration.replaceover[node.from, node.to]when the selection doesn't overlap the fence;Decoration.widget(...).range(node.to)(block, anchored at the end) when it does. The source becomes editable above the canvas. Canonical example:mermaid-decorations.ts. Driven byselectionTouchesRange, the third arg passed tofoldableSyntaxFacet'sbuildDecorations. - Conditional replace ↔ source-line styling.
Decoration.replacewhen the selection is outside the block; line decorations when selection touches the block and the source should stay editable in the main editor. Canonical example:table-decorations.ts, which renders a folded table preview with safe inline markdown inside cells, then unfolds a touched table into codeblock-styled markdown source lines rather than a nested editor.
Don't invent a parallel "edit mode" flag that isn't wired through selectionTouchesRange. The fold extension already manages that state — duplicating it produces drift between the two sources of truth.
Enter edit mode by range-selecting the fence, not by placing a caret
selectionTouchesRange from @prosemark/core is overlap-based with inclusive bounds (a.from <= b.to && b.from <= a.to, see node_modules/@prosemark/core/dist/main.js:30). A range selection covering the whole fence reliably flips it true regardless of where the head lands.
view.dispatch({
selection: EditorSelection.single(fenceTo, fenceFrom), // reverse-anchor convention
effects: view.scrollSnapshot(),
});
Caret-placement at a single point inside the fence is fragile: empty fences, boundary positions, multi-line content, and stale offsets all break it. Mirror what selectAllDecorationsOnSelectExtension (@prosemark/core) does — it's the canonical pattern.
Don't store document positions on widget instances
Widget identity is its visual state — source, editMode, etc. — never positions. Positions are derived state owned by the syntax tree.
- Don't capture
node.from/node.toon the widget at construction. Above-fence edits shift them and the widget lives across rebuilds. - Don't try to keep them current via an
eq()side-effect (mutating the kept instance fromother). It looks like it works in isolation and silently fails across multi-fence diffs and decoration-shape transitions. - Look up positions live at click time:
const pos = view.posAtDOM(host);
const tree = syntaxTree(view.state);
let node = tree.resolveInner(pos, side);
while (node.name !== "FencedCode" && node.parent) node = node.parent;
eq() should compare only the visual identity. Let CM rebuild when that changes; don't mutate kept instances to compensate.
posAtDOM boundaries: try resolveInner with both sides
A Decoration.widget(...).range(node.to) returns posAtDOM(host) === node.to. resolveInner(node.to, 1) resolves to the node starting at node.to — the next sibling, not the FencedCode that ends there. The walk up never finds the fence.
For widgets anchored at boundary positions, try side = -1 first (prefers the node ending at the boundary), fall back to side = 1:
for (const side of [-1, 1] as const) {
let node = tree.resolveInner(pos, side);
while (node.name !== target && node.parent) node = node.parent;
if (node.name === target) return node;
}
return null;
Buttons inside widgets: mousedown.preventDefault()
A button inside the widget that dispatches a transaction will race the editor's focus state. Default browser behavior on mousedown:
- Browser focuses the button → editor blurs.
- Click handler runs →
view.dispatch(...). - Decoration rebuilds → button DOM destroyed → focus reverts to body.
- Our
view.contentDOM.focus({preventScroll: true})runs.
Steps 1–4 race with CM's own focus tracking; the visible result is the caret landing at click coordinates instead of the dispatched selection, or focus landing nowhere useful.
Add to every in-widget button:
b.addEventListener("mousedown", (e) => {
e.preventDefault(); // keep the editor focused
e.stopPropagation(); // keep CM's pointerdown handlers from competing
});
Combine with ignoreEvent: true on the widget so CM skips its own pointer/click handling for events inside the widget DOM.
Gotcha: mousedown.stopPropagation does not stop pointerdown. They're separate event types — the browser dispatches both for a click, and stopping one doesn't filter the other. If you wire an editor-level handler on pointerdown (e.g., a drag-selection gate that listens on view.contentDOM), the in-widget button's mousedown stop won't suppress it. Filter inside the editor-level handler instead — typically event.target instanceof Element && event.target.closest('.cm-your-widget'). See mermaid-decorations.ts's shouldStartDragGate for the canonical filter.
Heightmap-shifting transitions: include view.scrollSnapshot()
Any decoration switch that changes block heights (replace ↔ widget, fold/unfold, widget appearing/disappearing) shifts the heightmap. Without compensation the viewport jumps.
view.dispatch({
selection: ...,
effects: view.scrollSnapshot(),
});
scrollSnapshot captures the viewport-top doc anchor and its screen offset; CM applies the resulting StateEffect after the heightmap rebuild and re-scrolls so the same anchor lands at the same screen Y. Don't roll your own coordsAtPos-delta scroll math — it depends on layout being flushed and is brittle.
Synchronous render in toDOM beats IntersectionObserver-deferred
If your renderer is sync and cache-backed (or cheap to call), paint in toDOM. The async-deferred path adds a "Loading…" gap users see, can re-fire after a toggle (producing a visible flash), and has no real benefit when the cache makes repeat renders O(map lookup). CM only calls toDOM for widgets in its viewport buffer anyway.
Reference: mermaid-decorations.ts mounts the canvas synchronously in toDOM; the SVG cache is bounded LRU and the output is sanitised before reaching innerHTML.
Test the dispatch path, not just the helpers
Pure-helper tests (computeToggleSelection-style) catch math bugs but not focus races, posAtDOM boundary errors, or cross-widget interference. The actual contract is "click does the right thing in CM," which only an integration test can verify.
When a widget has a click → dispatch → mode-change cycle, mount a real EditorView with two instances and simulate clicks. Assert against view.state.selection.main and view.state.field(foldExtension), not against helper outputs.
File map
mermaid-decorations.ts— canonical conditional replace ↔ widget. Reference for the click → dispatch → mode-change pattern, range-selection toggle, and live position lookup.table-decorations.ts— canonical replace-only widget; usesselectAllDecorationsOnSelectExtensionfor click-to-select.use-prosemark-editor.ts—EditorView.scrollHandlersetup for the ancestor-scroller case.node_modules/@prosemark/core/dist/main.js:30—selectionTouchesRangesemantics.