Coordinate System
World-space versus screen-space coordinates, and how Scrivr maps between them.
Because Scrivr bypasses the browser's layout engine, it manages its own coordinate system. Understanding the two coordinate spaces — world-space and screen-space — is essential when building custom menus, overlays, or hit-testing logic.
Two Coordinate Spaces
World-Space (page-relative)
World-space coordinates are relative to the top-left corner of a page canvas. They are computed by
the layout engine during layoutDocument() and stored in the CharacterMap.
- Unit: pixels at 96 DPI (same unit as
pageConfig.pageWidth/pageConfig.pageHeight) - Origin: top-left of the page element (not the scroll container)
- Who computes them:
layoutDocument()→ensurePagePopulated()→populateCharMap()
Screen-Space (viewport-relative)
Screen-space coordinates are relative to the browser viewport. They are the coordinates you need
to position a floating menu (BubbleMenu, FloatingMenu) using position: fixed.
- Unit: CSS pixels
- Origin: top-left of the browser viewport (same as
Element.getBoundingClientRect()) - Who computes them:
editor.getViewportRect(from, to)
The CharacterMap
The CharacterMap is the spatial index that makes both directions of lookup fast:
// World-space coords for a doc position (used by the renderer)
const coords = editor.charMap.coordsAtPos(docPosition);
// Returns: { x, y, height, page } | null
// Doc position from world-space coords (used by mouse click handling)
const pos = editor.charMap.posAtCoords(x, y, pageNumber);
// Returns: number | nullcoordsAtPos is used internally by syncInputBridge(), scrollCursorIntoView(), and the overlay
renderer to place the cursor. posAtCoords is called by ViewManager to resolve a mouse click to a
doc position.
Converting to Screen-Space
To position a DOM element (like a tooltip or bubble menu) relative to the cursor or selection, use
editor.getViewportRect(from, to):
const snapshot = editor.getSelectionSnapshot();
const rect = editor.getViewportRect(snapshot.from, snapshot.to);
if (rect) {
// rect is a standard DOMRect — use it with position: fixed
floatingEl.style.top = `${rect.bottom + 8}px`;
floatingEl.style.left = `${rect.left}px`;
}getViewportRect internally calls coordsAtPos for world-space, then adds the page element's
getBoundingClientRect() offset to convert to screen-space.
Glyph Registration Accuracy
Every character in the document has exactly one GlyphEntry in the CharacterMap. A few details
are worth knowing if you write custom hit-testing or layout strategies:
-
Justify space bonus — for a paragraph that spans multiple pages, the last rendered line on page N is not the last line of the whole block.
populateCharMapusesisLastLineOfBlock = isLastLine && !block.continuesOnNextPageto decide whether to suppress space stretching, matching whatTextBlockStrategy.renderdraws. Without this, justified text's character x-positions would diverge between the charMap and the canvas, causing click misses for the final characters on the page. -
Float objectRects — floating images register a zero-width anchor entry in the inline text flow.
ensurePagePopulatedoverwrites this with the real(x, y, width, height)fromFloatLayoutso thatgetNodeViewportRectworks correctly even before the TileManager paint has run.
Page Numbering
Pages are 1-based. The document begins on page 1. CharacterMap entries record which page a
character belongs to:
const entry = editor.charMap.coordsAtPos(pos);
entry?.page; // 1, 2, 3, ...The TileManager registers a pageTopLookup callback with the editor via
editor.setPageTopLookup(). This callback computes the live screen-space origin of any page by
reading tilesContainer.getBoundingClientRect() and adding the page's visual Y offset — no
sentinel DOM nodes required.
Coordinate Flow at a Glance
Mouse click (clientX, clientY)
│
▼
TileManager.hitTest() → { page, docX, docY }
│
▼
CharacterMap.posAtCoords(docX, docY, page) ← world-space
│
▼
Doc position (integer)
│
▼
editor.moveCursorTo(pos) or editor.setSelection(anchor, head)Selection change (anchor, head integers)
│
▼
CharacterMap.coordsAtPos(head) ← world-space
│
▼
+ editor.setPageTopLookup(page) ← screen-space offset (TileManager)
│
▼
DOMRect → position BubbleMenu / FloatingMenu