Spaces:
Sleeping
Sleeping
| 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<Y.Map<unknown>> { | |
| return doc.getArray<Y.Map<unknown>>('comments') | |
| } | |
| function makeReply(input: ReplyInput): Y.Map<unknown> { | |
| const r = new Y.Map<unknown>() | |
| 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<unknown>() | |
| 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<Y.Map<unknown>>() | |
| 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<Y.Map<unknown>>).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<Y.Map<unknown>>).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 | |
| } | |