Scrivr
Concepts

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 | null

coordsAtPos 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. populateCharMap uses isLastLineOfBlock = isLastLine && !block.continuesOnNextPage to decide whether to suppress space stretching, matching what TextBlockStrategy.render draws. 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. ensurePagePopulated overwrites this with the real (x, y, width, height) from FloatLayout so that getNodeViewportRect works 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

On this page