1
21import type { Editor } from "@tiptap/react";
22import type { BlockOffset } from "./presence-cursor";
23
24type Resolver = (offset: BlockOffset) => { x: number; y: number } | null;
25
26const views = new Map<string, Resolver>();
27
28
29function clampPos(editor: Editor, offset: BlockOffset): number {
30 const size = editor.state.doc.content.size;
31 if (offset === "end") return size;
32 return Math.max(0, Math.min(offset, size));
33}
34
35
36function pointAt(editor: Editor, offset: BlockOffset): { x: number; y: number } | null {
37 if (typeof document === "undefined") return null;
38 const pos = clampPos(editor, offset);
39 const { node, offset: off } = editor.view.domAtPos(pos);
40 const range = document.createRange();
41 try {
42 if (node.nodeType === 3) {
43 range.setStart(node, Math.min(off, (node as CharacterData).length));
44 } else {
45 range.setStart(node, Math.min(off, node.childNodes.length));
46 }
47 range.collapse(true);
48 } catch {
49 return null;
50 }
51 const rect = range.getBoundingClientRect();
52 if (rect.width === 0 && rect.height === 0 && rect.left === 0 && rect.top === 0) return null;
53 return { x: rect.left, y: rect.top + rect.height / 2 };
54}
55
56
61export function registerPresenceEditor(blockId: string, editor: Editor): () => void {
62 views.set(blockId, (offset) => pointAt(editor, offset));
63 return () => {
64 if (views.get(blockId)) views.delete(blockId);
65 };
66}
67
68
69export function resolvePresencePoint(
70 blockId: string,
71 offset: BlockOffset,
72): { x: number; y: number } | null {
73 const resolve = views.get(blockId);
74 return resolve ? resolve(offset) : null;
75}
76
77
78export function registeredPresenceBlocks(): string[] {
79 return [...views.keys()];
80}
81