import * as Y from 'yjs' // Comment threads live inside the shared Yjs doc, so they sync in real time and persist // with the deck. Each thread is anchored to a text range via Yjs *relative positions*, // which survive concurrent edits. export interface ReplySnap { id: string author: string color: string text: string ts: number } export interface ThreadSnap { id: string from: number | null // absolute position in the current doc (null if the anchor was deleted) to: number | null quote: string resolved: boolean replies: ReplySnap[] } export interface CommentRange { id: string from: number | null to: number | null resolved: boolean } export interface ReplyInput { author: string color: string text: string } const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36) function commentsArray(doc: Y.Doc): Y.Array> { return doc.getArray>('comments') } function makeReply(input: ReplyInput): Y.Map { const r = new Y.Map() r.set('id', uid()) r.set('author', input.author) r.set('color', input.color) r.set('text', input.text) r.set('ts', Date.now()) return r } export function createThread( doc: Y.Doc, ytext: Y.Text, from: number, to: number, quote: string, reply: ReplyInput, ): void { const anchor = Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(ytext, from)) const anchorEnd = Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(ytext, to)) doc.transact(() => { const t = new Y.Map() t.set('id', uid()) t.set('anchor', JSON.stringify(anchor)) t.set('anchorEnd', JSON.stringify(anchorEnd)) t.set('quote', quote.slice(0, 240)) t.set('resolved', false) const replies = new Y.Array>() replies.push([makeReply(reply)]) t.set('replies', replies) commentsArray(doc).push([t]) }) } export function addReply(doc: Y.Doc, threadId: string, reply: ReplyInput): void { for (const t of commentsArray(doc)) { if (t.get('id') === threadId) { ;(t.get('replies') as Y.Array>).push([makeReply(reply)]) return } } } export function setResolved(doc: Y.Doc, threadId: string, resolved: boolean): void { for (const t of commentsArray(doc)) { if (t.get('id') === threadId) { t.set('resolved', resolved) return } } } export function deleteThread(doc: Y.Doc, threadId: string): void { const arr = commentsArray(doc) for (let i = 0; i < arr.length; i++) { if (arr.get(i).get('id') === threadId) { arr.delete(i, 1) return } } } function absFromJSON(doc: Y.Doc, json: unknown): number | null { try { const rel = Y.createRelativePositionFromJSON(JSON.parse(json as string)) const abs = Y.createAbsolutePositionFromRelativePosition(rel, doc) return abs ? abs.index : null } catch { return null } } export function snapshotThreads(doc: Y.Doc, _ytext: Y.Text): ThreadSnap[] { const out: ThreadSnap[] = [] for (const t of commentsArray(doc)) { const replies = (t.get('replies') as Y.Array>).map((r) => ({ id: r.get('id') as string, author: r.get('author') as string, color: r.get('color') as string, text: r.get('text') as string, ts: r.get('ts') as number, })) out.push({ id: t.get('id') as string, from: absFromJSON(doc, t.get('anchor')), to: absFromJSON(doc, t.get('anchorEnd')), quote: t.get('quote') as string, resolved: Boolean(t.get('resolved')), replies, }) } return out }