Render Pipeline
How Scrivr moves from a user keypress to pixels on the canvas.
Scrivr's rendering model is fundamentally different from a DOM-based editor. There is no
contenteditable region, no DOM diffing, and no CSS layout. Instead, every glyph, cursor,
and selection highlight is drawn directly onto an HTML5 <canvas> element by a custom layout
and rendering engine.
This page walks you through the four-stage pipeline that runs on every user interaction.
The Four Stages
┌─────────────────────────────────────────────────────────┐
│ 1. INPUT User types → hidden <textarea> fires │
│ an input / keydown event │
├─────────────────────────────────────────────────────────┤
│ 2. TRANSACTION Event → ProseMirror Command → dispatch │
│ Produces a new immutable EditorState │
├─────────────────────────────────────────────────────────┤
│ 3. LAYOUT layoutDocument() traverses the doc tree │
│ Breaks text into lines; assigns each │
│ character an (x, y, page) coordinate │
├─────────────────────────────────────────────────────────┤
│ 4. PAINT ViewManager repaints affected canvases │
│ PageRenderer draws text runs │
│ OverlayRenderer draws cursor + selection │
└─────────────────────────────────────────────────────────┘Stage 1 — Input Bridge
Scrivr does not use contenteditable. Instead, Editor.mount() creates a hidden <textarea>
and positions it at the cursor's pixel location. All keyboard events are captured from this element.
The textarea serves three purposes:
- Keyboard events —
keydownfires for special keys (arrows, backspace, shortcuts). The raw event is converted to a ProseMirror key string (e.g."Mod-b","Enter") and dispatched to the keymap. - Text composition —
inputevents handle regular character input and are guarded bycompositionstart/compositionendfor CJK IME support. - Scroll anchoring —
syncInputBridge()repositions the textarea to the cursor's world coordinates after every state change. This prevents the browser from scrolling the viewport back totop: 0when the focused element is out of view.
Stage 2 — ProseMirror Transaction
When an event maps to an editor action, a ProseMirror Transaction is created and dispatched via
editor.dispatch(tr). The transaction is applied to the current (immutable) EditorState, producing
a new immutable EditorState.
All state changes — typing, undo, remote collaboration updates — go through this single path.
Stage 3 — Layout Engine
After dispatch, Editor sets a dirty flag and calls ensureLayout():
// Inside Editor.dispatch():
this.state = this.state.apply(tr);
this.dirty = true;
this.ensureLayout(); // re-runs only when dirtyensureLayout() calls layoutDocument() which traverses the ProseMirror doc tree and:
- Measures every text run using
TextMeasurer(which wrapsCanvasRenderingContext2D.measureText) - Breaks lines at the content width defined by
pageConfig - Paginates blocks across fixed-height pages
- Returns a
DocumentLayout— an array of pages, each with positioned blocks and lines
After layout, populateCharMap() populates the CharacterMap — a spatial index that records the
(x, y, height, page) coordinate for every character position in the document.
The layout engine uses a block-level cache — only blocks whose content or position has changed are re-measured and re-paginated. Unaffected blocks are reused from the previous layout pass, keeping layout fast even for large documents.
Stage 3 — Layout & CharacterMap
After dispatch, Editor.dispatch() calls lc.invalidate() and schedules a single RAF flush via
scheduleFlush(). Multiple dispatches within the same frame coalesce — only one layout + one paint
occur per frame.
When the RAF fires:
lc.ensureLayout()re-runs the layout pipeline and clears theCharacterMap.lc.ensurePagePopulated(cursorPage ± 1)immediately re-populates glyph entries and float objectRects for the cursor page and its neighbours.scrollCursorIntoView()settles scroll position.notifyListeners()fires — floating menus and other subscribers receive up-to-dategetViewportRect/getNodeViewportRectresults.
Stage 4 — Canvas Paint (TileManager)
TileManager subscribes to editor.subscribe(). When notifyListeners fires it schedules its own
RAF (scheduleUpdate), which runs one frame after the layout flush:
- Tile pool management — a fixed pool of
<canvas>elements is recycled; each visible page slot is assigned a tile. - Content canvas —
PageRenderer.renderPage()draws text runs, block backgrounds, floating images, and mark decorations. It also re-stamps glyph entries and float objectRects into theCharacterMapas a side-effect of rendering. - Overlay canvas —
OverlayRendererdraws the cursor (a blinking line fromCursorManager) and selection highlights. Extension-registered overlay handlers (e.g. collaboration cursors) run here viaeditor.runOverlayHandlers(). - Skip guard — a tile skips repaint when both
lastPaintedVersion === layout.versionandlastRenderGeneration === editor.renderGeneration. Callingeditor.redraw()(e.g. after an image loads) incrementsrenderGenerationand forces a repaint without a layout change.
RAF Timing
Understanding the two-frame sequence matters when building floating menus:
Frame N dispatch()
│
├─ lc.invalidate()
└─ scheduleFlush() → RAF-A
Frame N+1 (RAF-A) Editor flush
│
├─ ensureLayout() ← layout runs, charMap cleared + re-populated
├─ scrollCursorIntoView()
└─ notifyListeners() ← BubbleMenu / ImageMenu read getViewportRect HERE
Frame N+2 (RAF-B) TileManager paint
│
└─ renderPage() ← pixels drawn, charMap also re-stampedFloating menus receive their rect at RAF-A (before pixels are drawn). Because ensurePagePopulated
re-stamps float objectRects synchronously inside the layout flush, getNodeViewportRect returns
correct coordinates even before the paint.
Summary
| Stage | Key class / function | Output |
|---|---|---|
| Input | Editor + hidden <textarea> | DOM event → ProseMirror key string |
| Transaction | EditorState.apply(tr) | New immutable EditorState |
| Layout flush (RAF-A) | ensureLayout() + ensurePagePopulated() | DocumentLayout + CharacterMap |
| Paint (RAF-B) | TileManager → PageRenderer + OverlayRenderer | Pixels on <canvas> |