marp-share / client /src /comments /model.ts
Nipun's picture
Deploy marp-share (M1–M5): realtime + persistence + HF auth + comments + export
43370b3 verified
Raw
History Blame Contribute Delete
3.62 kB
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
}