File size: 3,615 Bytes
43370b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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
}