/** * presence-editor-views (TASK-0093, spec R4) — the browser glue that maps a * remote cursor's `{blockId, offset}` to VIEWPORT coordinates. * * The web editor is per-block Tiptap: each text block owns a ProseMirror * `EditorView`, and a remote offset is only meaningful inside THAT block's * own document. This registry lets the CursorLayer (an overlay sibling of * the block list) resolve a remote point without prop-threading through the * recursive BlockRenderer — the focused/known editors self-register here, * exactly like the presence caret store (stores/presence-store). * * The resolver returns **viewport** coordinates (from a `Range.getBoundingClientRect` * at the block's `domAtPos`). The CursorLayer converts them to its own * container's coordinate space (and re-resolves on scroll/resize). A * `null` return means the block is gone / hidden / the offset is out of * range — the pure resolver (lib/presence-cursor) drops the cursor silently. * * Browser-only (guarded by `document`); the module is imported by * "use client" components only. */ import type { Editor } from "@tiptap/react"; import type { BlockOffset } from "./presence-cursor"; type Resolver = (offset: BlockOffset) => { x: number; y: number } | null; const views = new Map(); /** Clamp a doc position into the block's content range. */ function clampPos(editor: Editor, offset: BlockOffset): number { const size = editor.state.doc.content.size; if (offset === "end") return size; return Math.max(0, Math.min(offset, size)); } /** Viewport point at a block position (caret: the top-left of the caret box). */ function pointAt(editor: Editor, offset: BlockOffset): { x: number; y: number } | null { if (typeof document === "undefined") return null; const pos = clampPos(editor, offset); const { node, offset: off } = editor.view.domAtPos(pos); const range = document.createRange(); try { if (node.nodeType === 3) { range.setStart(node, Math.min(off, (node as CharacterData).length)); } else { range.setStart(node, Math.min(off, node.childNodes.length)); } range.collapse(true); } catch { return null; // the node detached mid-resolve (concurrent edit) — stale. } const rect = range.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0 && rect.left === 0 && rect.top === 0) return null; return { x: rect.left, y: rect.top + rect.height / 2 }; } /** * Register a block's editor for cursor resolution. The resolver closes over * the editor; re-registering (editor recreated) replaces the prior entry. * Returns an unregister fn (call it on unmount / editor destruction). */ export function registerPresenceEditor(blockId: string, editor: Editor): () => void { views.set(blockId, (offset) => pointAt(editor, offset)); return () => { if (views.get(blockId)) views.delete(blockId); }; } /** Resolve a viewport point, or null when the block is unknown/stale. */ export function resolvePresencePoint( blockId: string, offset: BlockOffset, ): { x: number; y: number } | null { const resolve = views.get(blockId); return resolve ? resolve(offset) : null; } /** The block ids currently resolvable (a test/diagnostic view). */ export function registeredPresenceBlocks(): string[] { return [...views.keys()]; }