/** * presence-cursor (TASK-0093, spec R4) — the PURE geometry resolution for * remote cursors: which peers get a caret, where, and which selection * highlights to draw. * * The web editor is per-block Tiptap (no shared ProseMirror document), so a * remote cursor's `{blockId, offset}` is resolved against THAT block's own * view. The browser glue (the editor-view registry + `coordsAtPos`) is the * injectable `lookup` — this module is DOM-free and unit-testable * (AGENTS rules 23 + 25). * * Rules (spec R4 + the stale-cursor contract): * - a peer with `cursor: null` gets nothing (cleared, or the 50-peer * avatars-only cap — the avatar bar still shows them); * - a peer with `viewing: false` gets NO CARET (a background tab's cursor * is noise — the avatar stays); * - a cursor whose `blockId` is not in the doc (deleted/moved by a * concurrent edit) is DROPPED SILENTLY (`dropped` counts it) — the peer's * next caret move re-places it; no crash, no orphan node; * - the name label is MANDATORY (the a11y rule: color is never the sole * identifier) — the component renders it, here we only carry the peer. * * Selections (spec R4): a peer's `selection` decomposes into per-block * ranges (single block → 1 range; multi-block → head partial + full middle * blocks + tail partial). Each range is requested from `lookup` at its two * endpoints; the renderer takes their bounding box (a wrapped multi-line * selection is approximated by the endpoint bbox — recorded). A range * endpoint in a missing block drops just that range (the caret, if valid, * stays). */ import type { Presence } from "./types"; export interface CursorPoint { x: number; y: number; } /** A bounding box (min/max corners), for a selection highlight. */ export interface SelectionRect { x1: number; y1: number; x2: number; y2: number; } /** An endpoint inside one block: a text offset, or "end" of the block doc. */ export type BlockOffset = number | "end"; /** * Resolve a point for one block at an offset. Returns null when the block * is gone, hidden, or the offset is out of range (stale — the cursor/hide * is dropped silently). The browser glue maps `offset`/`"end"` through the * block's Tiptap view (`coordsAtPos`), relative to the page root. */ export type CursorLookup = (blockId: string, offset: BlockOffset) => CursorPoint | null; export interface ResolvedCursor { peer: Presence; caret: CursorPoint; /** The peer's selection as bounding boxes (empty when no/invalid selection). */ rects: SelectionRect[]; } export interface CursorResolution { /** One entry per peer with a resolvable caret (viewing + block present). */ cursors: ResolvedCursor[]; /** Peers whose cursor was dropped as stale (block not found / offset OOB). */ dropped: number; } /** Decompose a selection into per-block ranges (pure). */ export function selectionRanges( blockOrder: readonly string[], sel: { fromBlockId: string; fromOffset: number; toBlockId: string; toOffset: number }, ): { blockId: string; from: BlockOffset; to: BlockOffset }[] { const fi = blockOrder.indexOf(sel.fromBlockId); const ti = blockOrder.indexOf(sel.toBlockId); if (fi === -1 || ti === -1) return []; // stale — a selected block is gone if (fi === ti) { return [ { blockId: sel.fromBlockId, from: Math.min(sel.fromOffset, sel.toOffset), to: Math.max(sel.fromOffset, sel.toOffset), }, ]; } const [lo, hi] = fi < ti ? [fi, ti] : [ti, fi]; const out: { blockId: string; from: BlockOffset; to: BlockOffset }[] = []; for (let i = lo; i <= hi; i += 1) { const blockId = blockOrder[i]!; // lo..hi is in-range (fi/ti are indexOf hits) if (i === lo) out.push({ blockId, from: fi < ti ? sel.fromOffset : sel.toOffset, to: i === hi ? (fi < ti ? sel.toOffset : sel.fromOffset) : "end", }); else if (i === hi) out.push({ blockId, from: 0, to: fi < ti ? sel.toOffset : sel.fromOffset }); else out.push({ blockId, from: 0, to: "end" }); } return out; } function bbox(a: CursorPoint, b: CursorPoint): SelectionRect { return { x1: Math.min(a.x, b.x), y1: Math.min(a.y, b.y), x2: Math.max(a.x, b.x), y2: Math.max(a.y, b.y), }; } export function resolvePresenceCursors( peers: readonly Presence[], blockOrder: readonly string[], lookup: CursorLookup, ): CursorResolution { const cursors: ResolvedCursor[] = []; let dropped = 0; for (const peer of peers) { const cursor = peer.cursor; if (cursor === null || cursor === undefined) continue; // cleared / avatars-only cap if (peer.viewing === false) continue; // background tab — avatar stays, caret hides const caret = lookup(cursor.blockId, cursor.offset); if (caret === null) { dropped += 1; // stale block — silent drop, re-placed on the next move continue; } const rects: SelectionRect[] = []; if (cursor.selection) { for (const range of selectionRanges(blockOrder, cursor.selection)) { const p1 = lookup(range.blockId, range.from); const p2 = lookup(range.blockId, range.to); if (p1 !== null && p2 !== null) rects.push(bbox(p1, p2)); // A missing endpoint drops just that range (the caret stays). } } cursors.push({ peer, caret, rects }); } return { cursors, dropped }; }