Scrivr
Concepts

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:

  1. Keyboard events — keydown fires 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.
  2. Text composition — input events handle regular character input and are guarded by compositionstart / compositionend for CJK IME support.
  3. 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 to top: 0 when 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 dirty

ensureLayout() calls layoutDocument() which traverses the ProseMirror doc tree and:

  1. Measures every text run using TextMeasurer (which wraps CanvasRenderingContext2D.measureText)
  2. Breaks lines at the content width defined by pageConfig
  3. Paginates blocks across fixed-height pages
  4. 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:

  1. lc.ensureLayout() re-runs the layout pipeline and clears the CharacterMap.
  2. lc.ensurePagePopulated(cursorPage ± 1) immediately re-populates glyph entries and float objectRects for the cursor page and its neighbours.
  3. scrollCursorIntoView() settles scroll position.
  4. notifyListeners() fires — floating menus and other subscribers receive up-to-date getViewportRect / getNodeViewportRect results.

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:

  1. Tile pool management — a fixed pool of <canvas> elements is recycled; each visible page slot is assigned a tile.
  2. Content canvas — PageRenderer.renderPage() draws text runs, block backgrounds, floating images, and mark decorations. It also re-stamps glyph entries and float objectRects into the CharacterMap as a side-effect of rendering.
  3. Overlay canvas — OverlayRenderer draws the cursor (a blinking line from CursorManager) and selection highlights. Extension-registered overlay handlers (e.g. collaboration cursors) run here via editor.runOverlayHandlers().
  4. Skip guard — a tile skips repaint when both lastPaintedVersion === layout.version and lastRenderGeneration === editor.renderGeneration. Calling editor.redraw() (e.g. after an image loads) increments renderGeneration and 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-stamped

Floating 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

StageKey class / functionOutput
InputEditor + hidden <textarea>DOM event → ProseMirror key string
TransactionEditorState.apply(tr)New immutable EditorState
Layout flush (RAF-A)ensureLayout() + ensurePagePopulated()DocumentLayout + CharacterMap
Paint (RAF-B)TileManager → PageRenderer + OverlayRendererPixels on <canvas>

On this page