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 eventskeydown 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 compositioninput events handle regular character input and are guarded by compositionstart / compositionend for CJK IME support.
  3. Scroll anchoringsyncInputBridge() 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 canvasPageRenderer.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 canvasOverlayRenderer 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)TileManagerPageRenderer + OverlayRendererPixels on <canvas>

On this page