@tldraw/commenting 0.0.0-bootstrap

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/README.md +11 -0
  2. package/commenting.css +832 -0
  3. package/dist-cjs/canvas/cluster-input.js +40 -0
  4. package/dist-cjs/canvas/cluster-input.js.map +7 -0
  5. package/dist-cjs/canvas/comment-body.js +31 -0
  6. package/dist-cjs/canvas/comment-body.js.map +7 -0
  7. package/dist-cjs/canvas/comment-render.js +72 -0
  8. package/dist-cjs/canvas/comment-render.js.map +7 -0
  9. package/dist-cjs/canvas/comment-store.js +48 -0
  10. package/dist-cjs/canvas/comment-store.js.map +7 -0
  11. package/dist-cjs/canvas/comment-tool.js +160 -0
  12. package/dist-cjs/canvas/comment-tool.js.map +7 -0
  13. package/dist-cjs/canvas/comments-filter-menu.js +109 -0
  14. package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
  15. package/dist-cjs/canvas/comments-menu-item.js +42 -0
  16. package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
  17. package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
  18. package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
  19. package/dist-cjs/canvas/comments-overlay.js +1081 -0
  20. package/dist-cjs/canvas/comments-overlay.js.map +7 -0
  21. package/dist-cjs/canvas/comments-sidebar.js +134 -0
  22. package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
  23. package/dist-cjs/canvas/hooks.js +45 -0
  24. package/dist-cjs/canvas/hooks.js.map +7 -0
  25. package/dist-cjs/canvas/license.js +28 -0
  26. package/dist-cjs/canvas/license.js.map +7 -0
  27. package/dist-cjs/canvas/options.js +45 -0
  28. package/dist-cjs/canvas/options.js.map +7 -0
  29. package/dist-cjs/canvas/region-options.js +49 -0
  30. package/dist-cjs/canvas/region-options.js.map +7 -0
  31. package/dist-cjs/canvas/rich-text.js +28 -0
  32. package/dist-cjs/canvas/rich-text.js.map +7 -0
  33. package/dist-cjs/canvas/sidebar-filters.js +30 -0
  34. package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
  35. package/dist-cjs/canvas/state.js +76 -0
  36. package/dist-cjs/canvas/state.js.map +7 -0
  37. package/dist-cjs/canvas/thread-state.js +81 -0
  38. package/dist-cjs/canvas/thread-state.js.map +7 -0
  39. package/dist-cjs/clustering/computeClusterTable.js +83 -0
  40. package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
  41. package/dist-cjs/clustering/mst.js +117 -0
  42. package/dist-cjs/clustering/mst.js.map +7 -0
  43. package/dist-cjs/clustering/replay.js +275 -0
  44. package/dist-cjs/clustering/replay.js.map +7 -0
  45. package/dist-cjs/clustering/runtime.js +134 -0
  46. package/dist-cjs/clustering/runtime.js.map +7 -0
  47. package/dist-cjs/clustering/schedule.js +170 -0
  48. package/dist-cjs/clustering/schedule.js.map +7 -0
  49. package/dist-cjs/clustering/types.js +17 -0
  50. package/dist-cjs/clustering/types.js.map +7 -0
  51. package/dist-cjs/index.d.ts +868 -0
  52. package/dist-cjs/index.js +126 -0
  53. package/dist-cjs/index.js.map +7 -0
  54. package/dist-cjs/ui/avatar.js +43 -0
  55. package/dist-cjs/ui/avatar.js.map +7 -0
  56. package/dist-cjs/ui/byline.js +35 -0
  57. package/dist-cjs/ui/byline.js.map +7 -0
  58. package/dist-cjs/ui/comment-card.js +37 -0
  59. package/dist-cjs/ui/comment-card.js.map +7 -0
  60. package/dist-cjs/ui/comment-composer.js +136 -0
  61. package/dist-cjs/ui/comment-composer.js.map +7 -0
  62. package/dist-cjs/ui/comment-extensions.js +37 -0
  63. package/dist-cjs/ui/comment-extensions.js.map +7 -0
  64. package/dist-cjs/ui/comment-mention.js +42 -0
  65. package/dist-cjs/ui/comment-mention.js.map +7 -0
  66. package/dist-cjs/ui/comment-pin.js +29 -0
  67. package/dist-cjs/ui/comment-pin.js.map +7 -0
  68. package/dist-cjs/ui/comment-text.js +29 -0
  69. package/dist-cjs/ui/comment-text.js.map +7 -0
  70. package/dist-cjs/ui/comment-thread.js +45 -0
  71. package/dist-cjs/ui/comment-thread.js.map +7 -0
  72. package/dist-cjs/ui/comments-list.js +106 -0
  73. package/dist-cjs/ui/comments-list.js.map +7 -0
  74. package/dist-cjs/ui/count-badge.js +28 -0
  75. package/dist-cjs/ui/count-badge.js.map +7 -0
  76. package/dist-cjs/ui/empty-state.js +31 -0
  77. package/dist-cjs/ui/empty-state.js.map +7 -0
  78. package/dist-cjs/ui/format-time.js +46 -0
  79. package/dist-cjs/ui/format-time.js.map +7 -0
  80. package/dist-cjs/ui/mention-list.js +70 -0
  81. package/dist-cjs/ui/mention-list.js.map +7 -0
  82. package/dist-cjs/ui/mention-suggestion.js +206 -0
  83. package/dist-cjs/ui/mention-suggestion.js.map +7 -0
  84. package/dist-cjs/ui/mention.js +31 -0
  85. package/dist-cjs/ui/mention.js.map +7 -0
  86. package/dist-cjs/ui/reaction.js +31 -0
  87. package/dist-cjs/ui/reaction.js.map +7 -0
  88. package/dist-cjs/ui/reactions.js +34 -0
  89. package/dist-cjs/ui/reactions.js.map +7 -0
  90. package/dist-cjs/ui/render-markdown.js +63 -0
  91. package/dist-cjs/ui/render-markdown.js.map +7 -0
  92. package/dist-cjs/ui/send-button.js +28 -0
  93. package/dist-cjs/ui/send-button.js.map +7 -0
  94. package/dist-esm/canvas/cluster-input.mjs +20 -0
  95. package/dist-esm/canvas/cluster-input.mjs.map +7 -0
  96. package/dist-esm/canvas/comment-body.mjs +11 -0
  97. package/dist-esm/canvas/comment-body.mjs.map +7 -0
  98. package/dist-esm/canvas/comment-render.mjs +52 -0
  99. package/dist-esm/canvas/comment-render.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-store.mjs +28 -0
  101. package/dist-esm/canvas/comment-store.mjs.map +7 -0
  102. package/dist-esm/canvas/comment-tool.mjs +142 -0
  103. package/dist-esm/canvas/comment-tool.mjs.map +7 -0
  104. package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
  105. package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
  106. package/dist-esm/canvas/comments-menu-item.mjs +22 -0
  107. package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
  108. package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
  109. package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
  110. package/dist-esm/canvas/comments-overlay.mjs +1094 -0
  111. package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
  112. package/dist-esm/canvas/comments-sidebar.mjs +120 -0
  113. package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
  114. package/dist-esm/canvas/hooks.mjs +25 -0
  115. package/dist-esm/canvas/hooks.mjs.map +7 -0
  116. package/dist-esm/canvas/license.mjs +8 -0
  117. package/dist-esm/canvas/license.mjs.map +7 -0
  118. package/dist-esm/canvas/options.mjs +27 -0
  119. package/dist-esm/canvas/options.mjs.map +7 -0
  120. package/dist-esm/canvas/region-options.mjs +29 -0
  121. package/dist-esm/canvas/region-options.mjs.map +7 -0
  122. package/dist-esm/canvas/rich-text.mjs +8 -0
  123. package/dist-esm/canvas/rich-text.mjs.map +7 -0
  124. package/dist-esm/canvas/sidebar-filters.mjs +10 -0
  125. package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
  126. package/dist-esm/canvas/state.mjs +60 -0
  127. package/dist-esm/canvas/state.mjs.map +7 -0
  128. package/dist-esm/canvas/thread-state.mjs +61 -0
  129. package/dist-esm/canvas/thread-state.mjs.map +7 -0
  130. package/dist-esm/clustering/computeClusterTable.mjs +63 -0
  131. package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
  132. package/dist-esm/clustering/mst.mjs +97 -0
  133. package/dist-esm/clustering/mst.mjs.map +7 -0
  134. package/dist-esm/clustering/replay.mjs +255 -0
  135. package/dist-esm/clustering/replay.mjs.map +7 -0
  136. package/dist-esm/clustering/runtime.mjs +114 -0
  137. package/dist-esm/clustering/runtime.mjs.map +7 -0
  138. package/dist-esm/clustering/schedule.mjs +150 -0
  139. package/dist-esm/clustering/schedule.mjs.map +7 -0
  140. package/dist-esm/clustering/types.mjs +1 -0
  141. package/dist-esm/clustering/types.mjs.map +7 -0
  142. package/dist-esm/index.d.mts +868 -0
  143. package/dist-esm/index.mjs +139 -0
  144. package/dist-esm/index.mjs.map +7 -0
  145. package/dist-esm/ui/avatar.mjs +23 -0
  146. package/dist-esm/ui/avatar.mjs.map +7 -0
  147. package/dist-esm/ui/byline.mjs +15 -0
  148. package/dist-esm/ui/byline.mjs.map +7 -0
  149. package/dist-esm/ui/comment-card.mjs +17 -0
  150. package/dist-esm/ui/comment-card.mjs.map +7 -0
  151. package/dist-esm/ui/comment-composer.mjs +116 -0
  152. package/dist-esm/ui/comment-composer.mjs.map +7 -0
  153. package/dist-esm/ui/comment-extensions.mjs +17 -0
  154. package/dist-esm/ui/comment-extensions.mjs.map +7 -0
  155. package/dist-esm/ui/comment-mention.mjs +22 -0
  156. package/dist-esm/ui/comment-mention.mjs.map +7 -0
  157. package/dist-esm/ui/comment-pin.mjs +9 -0
  158. package/dist-esm/ui/comment-pin.mjs.map +7 -0
  159. package/dist-esm/ui/comment-text.mjs +9 -0
  160. package/dist-esm/ui/comment-text.mjs.map +7 -0
  161. package/dist-esm/ui/comment-thread.mjs +25 -0
  162. package/dist-esm/ui/comment-thread.mjs.map +7 -0
  163. package/dist-esm/ui/comments-list.mjs +86 -0
  164. package/dist-esm/ui/comments-list.mjs.map +7 -0
  165. package/dist-esm/ui/count-badge.mjs +8 -0
  166. package/dist-esm/ui/count-badge.mjs.map +7 -0
  167. package/dist-esm/ui/empty-state.mjs +11 -0
  168. package/dist-esm/ui/empty-state.mjs.map +7 -0
  169. package/dist-esm/ui/format-time.mjs +26 -0
  170. package/dist-esm/ui/format-time.mjs.map +7 -0
  171. package/dist-esm/ui/mention-list.mjs +50 -0
  172. package/dist-esm/ui/mention-list.mjs.map +7 -0
  173. package/dist-esm/ui/mention-suggestion.mjs +186 -0
  174. package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
  175. package/dist-esm/ui/mention.mjs +11 -0
  176. package/dist-esm/ui/mention.mjs.map +7 -0
  177. package/dist-esm/ui/reaction.mjs +11 -0
  178. package/dist-esm/ui/reaction.mjs.map +7 -0
  179. package/dist-esm/ui/reactions.mjs +14 -0
  180. package/dist-esm/ui/reactions.mjs.map +7 -0
  181. package/dist-esm/ui/render-markdown.mjs +45 -0
  182. package/dist-esm/ui/render-markdown.mjs.map +7 -0
  183. package/dist-esm/ui/send-button.mjs +8 -0
  184. package/dist-esm/ui/send-button.mjs.map +7 -0
  185. package/package.json +71 -0
  186. package/src/canvas/canvas.css +148 -0
  187. package/src/canvas/cluster-input.test.ts +223 -0
  188. package/src/canvas/cluster-input.ts +29 -0
  189. package/src/canvas/comment-body.tsx +21 -0
  190. package/src/canvas/comment-render.test.ts +90 -0
  191. package/src/canvas/comment-render.ts +92 -0
  192. package/src/canvas/comment-store.ts +55 -0
  193. package/src/canvas/comment-tool.tsx +200 -0
  194. package/src/canvas/comments-filter-menu.tsx +104 -0
  195. package/src/canvas/comments-menu-item.tsx +24 -0
  196. package/src/canvas/comments-overflow-menu.tsx +62 -0
  197. package/src/canvas/comments-overlay.tsx +1460 -0
  198. package/src/canvas/comments-sidebar.tsx +184 -0
  199. package/src/canvas/hooks.ts +28 -0
  200. package/src/canvas/license.ts +15 -0
  201. package/src/canvas/options.test.ts +127 -0
  202. package/src/canvas/options.ts +111 -0
  203. package/src/canvas/region-options.ts +57 -0
  204. package/src/canvas/rich-text.ts +15 -0
  205. package/src/canvas/sidebar-filters.ts +25 -0
  206. package/src/canvas/state.ts +102 -0
  207. package/src/canvas/thread-state.ts +91 -0
  208. package/src/clustering/computeClusterTable.test.ts +299 -0
  209. package/src/clustering/computeClusterTable.ts +82 -0
  210. package/src/clustering/locality.test.ts +189 -0
  211. package/src/clustering/mst.test.ts +383 -0
  212. package/src/clustering/mst.ts +134 -0
  213. package/src/clustering/replay.test.ts +547 -0
  214. package/src/clustering/replay.ts +326 -0
  215. package/src/clustering/runtime.test.ts +596 -0
  216. package/src/clustering/runtime.ts +153 -0
  217. package/src/clustering/schedule.test.ts +514 -0
  218. package/src/clustering/schedule.ts +184 -0
  219. package/src/clustering/types.ts +102 -0
  220. package/src/index.ts +96 -0
  221. package/src/ui/avatar.tsx +31 -0
  222. package/src/ui/byline.tsx +23 -0
  223. package/src/ui/comment-card.tsx +32 -0
  224. package/src/ui/comment-composer.tsx +179 -0
  225. package/src/ui/comment-extensions.ts +25 -0
  226. package/src/ui/comment-mention.ts +47 -0
  227. package/src/ui/comment-pin.tsx +21 -0
  228. package/src/ui/comment-text.tsx +12 -0
  229. package/src/ui/comment-thread.tsx +56 -0
  230. package/src/ui/comments-list.tsx +139 -0
  231. package/src/ui/comments.css +678 -0
  232. package/src/ui/count-badge.tsx +9 -0
  233. package/src/ui/empty-state.tsx +16 -0
  234. package/src/ui/format-time.ts +29 -0
  235. package/src/ui/mention-list.tsx +106 -0
  236. package/src/ui/mention-suggestion.test.ts +18 -0
  237. package/src/ui/mention-suggestion.tsx +285 -0
  238. package/src/ui/mention.tsx +9 -0
  239. package/src/ui/reaction.tsx +16 -0
  240. package/src/ui/reactions.tsx +15 -0
  241. package/src/ui/render-markdown.tsx +72 -0
  242. package/src/ui/send-button.tsx +15 -0
@@ -0,0 +1,184 @@
1
+ import type { ClusterNode, ContractedEvent, MergeEvent, RawMergeEvent } from './types'
2
+
3
+ export function contract(raw: readonly RawMergeEvent[], eps: number): ContractedEvent[] {
4
+ if (!Number.isFinite(eps) || eps < 0) {
5
+ throw new Error('eps must be finite and greater than or equal to 0')
6
+ }
7
+
8
+ const out: ContractedEvent[] = []
9
+ let i = 0
10
+ while (i < raw.length) {
11
+ const anchor = raw[i].z
12
+ let j = i
13
+ while (j + 1 < raw.length && raw[j + 1].z >= anchor / (1 + eps)) {
14
+ j++
15
+ }
16
+
17
+ const chains = contractWindow(raw.slice(i, j + 1), anchor)
18
+ out.push(...chains)
19
+ i = j + 1
20
+ }
21
+
22
+ return out
23
+ }
24
+
25
+ export function finalize(
26
+ events: readonly ContractedEvent[],
27
+ opts: { Tc: number; Tu: number; minZoom: number; maxZoom: number; maxSplitZoom: number }
28
+ ): MergeEvent[] {
29
+ validateFinalizeOptions(opts)
30
+
31
+ const r = opts.Tu / opts.Tc
32
+ // Every cluster must have split by maxSplitZoom, however close (or coincident) its members.
33
+ // Capping zMerge at maxSplitZoom / r keeps the whole band below the cap, preserving
34
+ // zSplit > zMerge and the table's sort order (min with a constant is order-preserving).
35
+ const zMergeCap = opts.maxSplitZoom / r
36
+ const out: MergeEvent[] = []
37
+ for (const event of events) {
38
+ const zMerge = Math.min(event.zMerge, zMergeCap)
39
+ if (zMerge < opts.minZoom) break
40
+ let zSplit = zMerge * r
41
+ if (zMerge < opts.maxZoom) {
42
+ zSplit = Math.min(zSplit, opts.maxZoom)
43
+ }
44
+ out.push({
45
+ zMerge,
46
+ zSplit,
47
+ children: event.children,
48
+ result: event.result,
49
+ })
50
+ }
51
+ return out
52
+ }
53
+
54
+ function contractWindow(events: readonly RawMergeEvent[], zMerge: number): ContractedEvent[] {
55
+ const uf = new ComponentUnionFind(events.length)
56
+ const resultToIndex = new Map<string, number>()
57
+ for (let i = 0; i < events.length; i++) {
58
+ resultToIndex.set(events[i].result.id, i)
59
+ }
60
+
61
+ for (let i = 0; i < events.length; i++) {
62
+ for (const child of events[i].children) {
63
+ const producer = resultToIndex.get(child.id)
64
+ if (producer !== undefined) {
65
+ uf.union(i, producer)
66
+ }
67
+ }
68
+ }
69
+
70
+ const byRoot = new Map<number, number[]>()
71
+ for (let i = 0; i < events.length; i++) {
72
+ const root = uf.find(i)
73
+ const list = byRoot.get(root)
74
+ if (list) {
75
+ list.push(i)
76
+ } else {
77
+ byRoot.set(root, [i])
78
+ }
79
+ }
80
+
81
+ return Array.from(byRoot.values())
82
+ .map((indices) => contractChain(events, indices, zMerge))
83
+ .sort((a, b) => compareNodesByMinMember(a.result, b.result))
84
+ }
85
+
86
+ function contractChain(
87
+ events: readonly RawMergeEvent[],
88
+ indices: readonly number[],
89
+ zMerge: number
90
+ ): ContractedEvent {
91
+ const produced = new Set<string>()
92
+ const consumedProduced = new Set<string>()
93
+ for (const index of indices) {
94
+ produced.add(events[index].result.id)
95
+ }
96
+ for (const index of indices) {
97
+ for (const child of events[index].children) {
98
+ if (produced.has(child.id)) consumedProduced.add(child.id)
99
+ }
100
+ }
101
+
102
+ const childrenById = new Map<string, ClusterNode>()
103
+ for (const index of indices) {
104
+ for (const child of events[index].children) {
105
+ if (!produced.has(child.id)) childrenById.set(child.id, child)
106
+ }
107
+ }
108
+
109
+ let result: ClusterNode | undefined
110
+ for (const index of indices) {
111
+ const candidate = events[index].result
112
+ if (!consumedProduced.has(candidate.id)) {
113
+ if (!result || compareNodesByMinMember(candidate, result) < 0) result = candidate
114
+ }
115
+ }
116
+
117
+ return {
118
+ zMerge,
119
+ children: Array.from(childrenById.values()).sort(compareNodesByMinMember),
120
+ result: result!,
121
+ }
122
+ }
123
+
124
+ function validateFinalizeOptions(opts: {
125
+ Tc: number
126
+ Tu: number
127
+ minZoom: number
128
+ maxZoom: number
129
+ maxSplitZoom: number
130
+ }) {
131
+ if (!Number.isFinite(opts.maxSplitZoom) || opts.maxSplitZoom <= 0) {
132
+ throw new Error('maxSplitZoom must be finite and greater than 0')
133
+ }
134
+ if (!Number.isFinite(opts.Tc) || opts.Tc <= 0) {
135
+ throw new Error('Tc must be finite and greater than 0')
136
+ }
137
+ if (!Number.isFinite(opts.Tu) || opts.Tu <= opts.Tc) {
138
+ throw new Error('Tu must be finite and greater than Tc')
139
+ }
140
+ if (!Number.isFinite(opts.minZoom) || opts.minZoom <= 0) {
141
+ throw new Error('minZoom must be finite and greater than 0')
142
+ }
143
+ if (!Number.isFinite(opts.maxZoom) || opts.maxZoom <= opts.minZoom) {
144
+ throw new Error('maxZoom must be finite and greater than minZoom')
145
+ }
146
+ }
147
+
148
+ function compareNodesByMinMember(a: ClusterNode, b: ClusterNode): number {
149
+ const aMin = a.members[0]
150
+ const bMin = b.members[0]
151
+ if (aMin < bMin) return -1
152
+ if (aMin > bMin) return 1
153
+ return 0
154
+ }
155
+
156
+ class ComponentUnionFind {
157
+ private readonly parent: Int32Array
158
+
159
+ constructor(n: number) {
160
+ this.parent = new Int32Array(n)
161
+ for (let i = 0; i < n; i++) {
162
+ this.parent[i] = i
163
+ }
164
+ }
165
+
166
+ find(index: number): number {
167
+ let root = index
168
+ while (this.parent[root] !== root) {
169
+ root = this.parent[root]
170
+ }
171
+ while (this.parent[index] !== index) {
172
+ const next = this.parent[index]
173
+ this.parent[index] = root
174
+ index = next
175
+ }
176
+ return root
177
+ }
178
+
179
+ union(a: number, b: number) {
180
+ const rootA = this.find(a)
181
+ const rootB = this.find(b)
182
+ if (rootA !== rootB) this.parent[rootB] = rootA
183
+ }
184
+ }
@@ -0,0 +1,102 @@
1
+ import type { VecLike } from 'tldraw'
2
+
3
+ /**
4
+ * One comment thread's pin, already resolved to a page-space anchor point.
5
+ * @public
6
+ */
7
+ export interface LeafInput {
8
+ /** Unique. Thread id. Uniqueness is a precondition — throw on duplicates. */
9
+ id: string
10
+ /** Page-space coordinates. Must be finite — throw on NaN/Infinity. */
11
+ point: VecLike
12
+ }
13
+
14
+ /** An edge of the Euclidean MST over the leaf anchor points. */
15
+ export interface MstEdge {
16
+ /** Index into the input leaves array. Normalized: leaves[a].id < leaves[b].id (lexicographic). */
17
+ a: number
18
+ /** Index into the input leaves array. */
19
+ b: number
20
+ /** Exact Euclidean page-space distance between the two anchors. May be 0 (coincident). */
21
+ d: number
22
+ }
23
+
24
+ /**
25
+ * A cluster in the merge tree: a leaf (one thread) or a merged group.
26
+ * @public
27
+ */
28
+ export interface ClusterNode {
29
+ /** Leaves: the thread id verbatim. Merged nodes: `cluster:${count}:${minMemberId}`. */
30
+ id: string
31
+ /** Page space; count-weighted mean of all member leaf anchors. */
32
+ centroid: VecLike
33
+ /** Number of member leaves. Leaves = 1. */
34
+ count: number
35
+ /** All member thread ids, sorted lexicographically ascending. */
36
+ members: string[]
37
+ }
38
+
39
+ /** One merge produced by the capped replay, before contraction. */
40
+ export interface RawMergeEvent {
41
+ /** Effective merge threshold zEff = min(Tc/d, Dmax/unionBboxDiag). +Infinity for coincident anchors. */
42
+ z: number
43
+ /** The two clusters consumed, ordered by ascending min-member id. */
44
+ children: [ClusterNode, ClusterNode]
45
+ /** The cluster produced. */
46
+ result: ClusterNode
47
+ }
48
+
49
+ /** A merge event after contraction: possibly multi-way, before hysteresis. */
50
+ export interface ContractedEvent {
51
+ /** Fires (merges) when zoom <= zMerge. May be +Infinity. */
52
+ zMerge: number
53
+ /** Clusters consumed — 2 or more, ordered by ascending min-member id. */
54
+ children: ClusterNode[]
55
+ /** Cluster produced. */
56
+ result: ClusterNode
57
+ }
58
+
59
+ /**
60
+ * A finalized merge event, ready for the runtime.
61
+ * @public
62
+ */
63
+ export interface MergeEvent {
64
+ zMerge: number
65
+ /** Reverses (splits) when zoom \>= zSplit. Always \> zMerge. May be +Infinity. */
66
+ zSplit: number
67
+ children: ClusterNode[]
68
+ result: ClusterNode
69
+ }
70
+
71
+ /**
72
+ * The precomputed clustering schedule for one page's comments.
73
+ * @public
74
+ */
75
+ export interface ClusterTable {
76
+ /** Sorted non-increasing by zMerge; satisfies the invariants of CLUSTERING.md §7.6. */
77
+ events: readonly MergeEvent[]
78
+ /** One node per input leaf, in input order. */
79
+ leaves: readonly ClusterNode[]
80
+ }
81
+
82
+ /** @public */
83
+ export interface ClusterOptions {
84
+ /** Cluster (merge) distance, screen px. Default 40. */
85
+ Tc?: number
86
+ /** Uncluster (split) distance, screen px. Must be \> Tc. Default 1.5 · Tc. */
87
+ Tu?: number
88
+ /** Contraction window ratio. Default 0.12. */
89
+ eps?: number
90
+ /** Max cluster screen extent at birth, screen px. Must be \>= Tc. Default 3 · Tc. */
91
+ Dmax?: number
92
+ /** Camera zoom bounds — pass the editor's camera constraints. Required. */
93
+ minZoom: number
94
+ maxZoom: number
95
+ /**
96
+ * Zoom by which every cluster has split, no matter how close its members are — including
97
+ * coincident anchors, which otherwise never split. Merge thresholds are capped at
98
+ * `maxSplitZoom / (Tu/Tc)` so the hysteresis band keeps its shape below the cap.
99
+ * Default 6 (600%).
100
+ */
101
+ maxSplitZoom?: number
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { registerTldrawLibraryVersion } from '@tldraw/utils'
2
+
3
+ // Presentational commenting components. These are tldraw-independent and can be used to build
4
+ // custom commenting UI.
5
+ export { Avatar, type AvatarProps } from './ui/avatar'
6
+ export { Byline, type BylineProps } from './ui/byline'
7
+ export { CommentCard, type CommentCardProps } from './ui/comment-card'
8
+ export { CommentComposer, type CommentComposerProps } from './ui/comment-composer'
9
+ export { CountBadge, type CountBadgeProps } from './ui/count-badge'
10
+ export { CommentPin, type CommentPinProps } from './ui/comment-pin'
11
+ export { CommentText, type CommentTextProps } from './ui/comment-text'
12
+ export { CommentThread, type CommentThreadProps } from './ui/comment-thread'
13
+ export { CommentsList, type CommentListItemProps, type CommentsListProps } from './ui/comments-list'
14
+ export { EmptyState, type EmptyStateProps } from './ui/empty-state'
15
+ export { formatRelativeTime } from './ui/format-time'
16
+ export { Mention, type MentionProps } from './ui/mention'
17
+ export { MentionList, type MentionListProps, type MentionMember } from './ui/mention-list'
18
+ export {
19
+ createMentionSuggestion,
20
+ filterMentionMembers,
21
+ type MentionSuggestionOptions,
22
+ } from './ui/mention-suggestion'
23
+ export { Reaction, type ReactionProps } from './ui/reaction'
24
+ export { Reactions } from './ui/reactions'
25
+ export { renderMarkdown } from './ui/render-markdown'
26
+ export { SendButton, type SendButtonProps } from './ui/send-button'
27
+
28
+ // The tldraw-coupled commenting layer: the comment tool, reactive hooks over the comment
29
+ // records, a rich-text body renderer, and a batteries-included <CanvasComments> overlay. Pairs
30
+ // with the presentational components above.
31
+ export { CommentBody, type CommentBodyProps } from './canvas/comment-body'
32
+ export {
33
+ CommentTool,
34
+ commentToolOverrides,
35
+ commentTools,
36
+ type PendingComment,
37
+ } from './canvas/comment-tool'
38
+ export { collectClusterLeaves } from './canvas/cluster-input'
39
+ export { computeClusterTable } from './clustering/computeClusterTable'
40
+ export {
41
+ getCommentRecord,
42
+ getComments,
43
+ getCommentThreads,
44
+ putCommentRecords,
45
+ removeCommentRecords,
46
+ type TLCommentRecord,
47
+ } from './canvas/comment-store'
48
+ export { createClusterRuntime, type ClusterRuntime } from './clustering/runtime'
49
+ export type {
50
+ ClusterNode,
51
+ ClusterOptions,
52
+ ClusterTable,
53
+ LeafInput,
54
+ MergeEvent,
55
+ } from './clustering/types'
56
+ export { CommentsFilterMenu, type CommentsFilterMenuProps } from './canvas/comments-filter-menu'
57
+ export { CommentsMenuItem } from './canvas/comments-menu-item'
58
+ export { CanvasComments, type CanvasCommentsProps } from './canvas/comments-overlay'
59
+ export {
60
+ type CommentingComponents,
61
+ type CommentingOptions,
62
+ defaultCommentingOptions,
63
+ getCommentingOptions,
64
+ useCommentingOptions,
65
+ } from './canvas/options'
66
+ export { CommentsOverflowMenu } from './canvas/comments-overflow-menu'
67
+ export { CanvasCommentsSidebar, type CanvasCommentsSidebarProps } from './canvas/comments-sidebar'
68
+ export { useComments, useCommentThreads, useThreadComments } from './canvas/hooks'
69
+ export { useCommentingEnabled } from './canvas/license'
70
+ export { DEFAULT_REGION_COMMENT_OPTIONS, type RegionCommentOptions } from './canvas/region-options'
71
+ export { richTextToPlaintext } from './canvas/rich-text'
72
+ export { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './canvas/sidebar-filters'
73
+ export {
74
+ commentsHidden,
75
+ commitCommentMutation,
76
+ openThreadId,
77
+ pendingComment,
78
+ sidebarFilters,
79
+ toggleCommentsHidden,
80
+ useCommentsHidden,
81
+ useOpenThreadId,
82
+ usePendingComment,
83
+ useSidebarFilters,
84
+ } from './canvas/state'
85
+ export {
86
+ anchorPagePoint,
87
+ DEFAULT_IMPRECISE_SHAPE_ANCHOR,
88
+ focusThread,
89
+ shapeAnchorAt,
90
+ } from './canvas/thread-state'
91
+
92
+ registerTldrawLibraryVersion(
93
+ (globalThis as any).TLDRAW_LIBRARY_NAME,
94
+ (globalThis as any).TLDRAW_LIBRARY_VERSION,
95
+ (globalThis as any).TLDRAW_LIBRARY_MODULES
96
+ )
@@ -0,0 +1,31 @@
1
+ import { getFirstCharacter } from 'tldraw'
2
+
3
+ /** @public */
4
+ export interface AvatarProps {
5
+ name: string
6
+ /** Background colour, used when there's no `image`. Falls back to the default avatar tint. */
7
+ color?: string
8
+ /** Avatar image URL. When set, shows the image instead of the coloured initial. */
9
+ image?: string
10
+ }
11
+
12
+ function initial(name: string) {
13
+ return (getFirstCharacter(name.trim()) || '?').toUpperCase()
14
+ }
15
+
16
+ /** A commenter's avatar — their image if provided, otherwise a single-initial coloured circle.
17
+ * @public @react */
18
+ export function Avatar({ name, color, image }: AvatarProps) {
19
+ if (image) {
20
+ return <img className="cmt-avatar cmt-avatar--image" src={image} alt="" aria-hidden="true" />
21
+ }
22
+ return (
23
+ <div
24
+ className="cmt-avatar"
25
+ aria-hidden="true"
26
+ style={color ? { backgroundColor: color } : undefined}
27
+ >
28
+ {initial(name)}
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,23 @@
1
+ import { formatRelativeTime } from './format-time'
2
+
3
+ /** @public */
4
+ export interface BylineProps {
5
+ author: string
6
+ /** ISO datetime; formatted to relative time by the component. */
7
+ date: string
8
+ /** Shows an "edited" marker when the comment has been edited. */
9
+ edited?: boolean
10
+ }
11
+
12
+ /** A comment's metadata line: author name, relative time, and an edited marker. @public @react */
13
+ export function Byline({ author, date, edited }: BylineProps) {
14
+ return (
15
+ <div className="cmt-head">
16
+ <span className="cmt-author">{author}</span>
17
+ <span className="cmt-time">
18
+ {formatRelativeTime(date)}
19
+ {edited && <span className="cmt-edited"> · edited</span>}
20
+ </span>
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,32 @@
1
+ import { ReactNode } from 'react'
2
+ import { Avatar } from './avatar'
3
+ import { Byline } from './byline'
4
+
5
+ /** @public */
6
+ export interface CommentCardProps {
7
+ author: string
8
+ /** The rendered comment body. The card doesn't dictate a format — pass a `<CommentText>`
9
+ * for markdown, a rich-text render, or any node. */
10
+ body: ReactNode
11
+ /** ISO datetime; formatted to relative time by the component. */
12
+ date: string
13
+ you: boolean
14
+ /** Whether the comment has been edited (shows an "edited" marker). */
15
+ edited?: boolean
16
+ /** Hover-revealed controls at the card's top-right (e.g. an edit affordance). */
17
+ actions?: ReactNode
18
+ }
19
+
20
+ /** A single comment: Avatar, Byline, and a body slot the consumer renders. @public @react */
21
+ export function CommentCard({ author, body, date, you, edited, actions }: CommentCardProps) {
22
+ return (
23
+ <div className={you ? 'cmt-card cmt-card--you' : 'cmt-card'}>
24
+ <Avatar name={author} />
25
+ <div className="cmt-body">
26
+ <Byline author={author} date={date} edited={edited} />
27
+ {body}
28
+ </div>
29
+ {actions !== undefined && <div className="cmt-card__actions">{actions}</div>}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,179 @@
1
+ import { Extension, JSONContent } from '@tiptap/core'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { isEqual, TLRichText, useMaybeEditor } from 'tldraw'
5
+ import { Avatar } from './avatar'
6
+ import { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'
7
+ import { commentMention } from './comment-mention'
8
+ import { MentionMember } from './mention-list'
9
+ import { createMentionSuggestion, isMentionPickerOpen } from './mention-suggestion'
10
+ import { SendButton } from './send-button'
11
+
12
+ /** @public */
13
+ export interface CommentComposerProps {
14
+ author: string
15
+ placeholder: string
16
+ /** Controlled rich-text value. Omit for the presentational (display-only) composer. */
17
+ value?: TLRichText
18
+ onChange?(value: TLRichText): void
19
+ /** Called on Send click or Enter. When set, the composer is interactive. */
20
+ onSubmit?(): void
21
+ sendLabel?: string
22
+ disabled?: boolean
23
+ autoFocus?: boolean
24
+ /** The leading element before the field. Defaults to the author's avatar. */
25
+ leading?: ReactNode
26
+ /** Resolve the members matching an `@`-query (sync or async). Provide to enable mentions. */
27
+ getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
28
+ /** Override a picker row's content. Defaults to avatar + name (+ secondary). */
29
+ renderMentionSuggestion?(member: MentionMember): ReactNode
30
+ }
31
+
32
+ /**
33
+ * The input for writing a comment: a TipTap rich-text editor restricted to the comment extension
34
+ * set (bold, italic, lists, links, code, highlight — no headings), with a Send button. Formatting
35
+ * is applied through markdown and keyboard shortcuts (e.g. `**bold**`, `- `, Cmd+B); there's no
36
+ * floating toolbar. Presentational by default; pass value/onChange/onSubmit to drive it as a form.
37
+ * @public @react
38
+ */
39
+ export function CommentComposer({
40
+ author,
41
+ placeholder,
42
+ value,
43
+ onChange,
44
+ onSubmit,
45
+ sendLabel = 'Send',
46
+ disabled,
47
+ autoFocus,
48
+ leading,
49
+ getMentionSuggestions,
50
+ renderMentionSuggestion,
51
+ }: CommentComposerProps) {
52
+ const interactive = !!onChange || !!onSubmit
53
+ // The canvas editor the composer lives in, if any — lets the mention popup track the camera. Null
54
+ // when the composer is used outside a tldraw editor (e.g. an isolated demo).
55
+ const tlEditor = useMaybeEditor()
56
+
57
+ // Callbacks are read through refs so the editor instance doesn't need to be recreated when they
58
+ // change identity between renders.
59
+ const onChangeRef = useRef(onChange)
60
+ onChangeRef.current = onChange
61
+ const onSubmitRef = useRef(onSubmit)
62
+ onSubmitRef.current = onSubmit
63
+ const disabledRef = useRef(disabled)
64
+ disabledRef.current = disabled
65
+ const getMentionSuggestionsRef = useRef(getMentionSuggestions)
66
+ getMentionSuggestionsRef.current = getMentionSuggestions
67
+ const renderMentionSuggestionRef = useRef(renderMentionSuggestion)
68
+ renderMentionSuggestionRef.current = renderMentionSuggestion
69
+
70
+ const [isEmpty, setIsEmpty] = useState(() => !value || isCommentEmpty(value))
71
+
72
+ // Enter submits the comment. Shift+Enter (and Cmd/Ctrl+Enter) keep their default behavior — a
73
+ // new line — for the occasional multi-line comment.
74
+ const submitExtension = useMemo(
75
+ () =>
76
+ Extension.create({
77
+ name: 'commentComposerSubmit',
78
+ priority: 1000,
79
+ addKeyboardShortcuts() {
80
+ return {
81
+ Enter: () => {
82
+ // While the @-mention picker is open, Enter selects the highlighted member.
83
+ // This extension outranks the mention plugin (priority 1000), so defer to the
84
+ // picker here or Enter would submit before the member could be inserted.
85
+ if (isMentionPickerOpen()) return false
86
+ if (!disabledRef.current) onSubmitRef.current?.()
87
+ return true
88
+ },
89
+ }
90
+ },
91
+ }),
92
+ []
93
+ )
94
+
95
+ // The suggestion plugin is built once (the editor is recreated only on `interactive`) and runs
96
+ // outside React, so it must read the mention callbacks through refs — like onChange/onSubmit —
97
+ // or it queries the roster present at mount forever, never seeing a member who loads or joins
98
+ // later. Whether mentions (and a custom picker row) are wired at all is fixed at mount; only the
99
+ // callbacks themselves are live.
100
+ const mentionsEnabled = !!getMentionSuggestions
101
+ const hasCustomRow = !!renderMentionSuggestion
102
+ const extensions = useMemo(() => {
103
+ const list = [...commentTipTapExtensions, submitExtension]
104
+ // Always register the mention node so an existing body that contains a mention keeps it on
105
+ // edit (an unregistered node would be stripped by ProseMirror when the content loads). The `@`
106
+ // picker itself only turns on when the host provides a resolver; otherwise the node is present
107
+ // but its trigger is disabled.
108
+ if (mentionsEnabled) {
109
+ const resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []
110
+ const renderRow = hasCustomRow
111
+ ? (member: MentionMember) => renderMentionSuggestionRef.current?.(member)
112
+ : undefined
113
+ list.push(
114
+ commentMention({
115
+ suggestion: createMentionSuggestion(resolveSuggestions, {
116
+ renderMember: renderRow,
117
+ editor: tlEditor,
118
+ }),
119
+ })
120
+ )
121
+ } else {
122
+ list.push(commentMention({ suggestion: { char: '@', allow: () => false } }))
123
+ }
124
+ return list
125
+ }, [submitExtension, mentionsEnabled, hasCustomRow, tlEditor])
126
+
127
+ const editor = useEditor(
128
+ {
129
+ extensions,
130
+ content: (value ?? EMPTY_COMMENT) as JSONContent,
131
+ editable: interactive,
132
+ // tldraw's default extensions add their own TextDirection extension (so it can be
133
+ // overridden), so disable TipTap's core one to avoid a duplicate-extension warning —
134
+ // mirrors RichTextArea's setup.
135
+ enableCoreExtensions: { textDirection: false },
136
+ textDirection: 'auto',
137
+ editorProps: { attributes: { class: 'cmt-input' } },
138
+ onUpdate: ({ editor }) => {
139
+ setIsEmpty(editor.isEmpty)
140
+ onChangeRef.current?.(editor.getJSON() as TLRichText)
141
+ },
142
+ },
143
+ [interactive]
144
+ )
145
+
146
+ // Sync controlled resets (e.g. the parent clearing to EMPTY_COMMENT after posting) into the
147
+ // editor without echoing back the value the editor itself just emitted.
148
+ useEffect(() => {
149
+ if (!editor || value === undefined) return
150
+ if (isEqual(editor.getJSON(), value)) return
151
+ editor.commands.setContent(value as JSONContent)
152
+ setIsEmpty(editor.isEmpty)
153
+ }, [editor, value])
154
+
155
+ // Focus on the next frame rather than via TipTap's autofocus: the composer often mounts from a
156
+ // canvas pointer event whose default focus handling would otherwise steal it back.
157
+ useEffect(() => {
158
+ if (!autoFocus || !editor) return
159
+ const raf = requestAnimationFrame(() => editor.commands.focus('end'))
160
+ return () => cancelAnimationFrame(raf)
161
+ }, [autoFocus, editor])
162
+
163
+ return (
164
+ <div className="cmt-composer">
165
+ {leading ?? <Avatar name={author} />}
166
+ <div className="cmt-composer__field">
167
+ <div className="cmt-composer__input-wrap">
168
+ <EditorContent editor={editor} />
169
+ {isEmpty && (
170
+ <div className="cmt-input__placeholder" aria-hidden="true">
171
+ {placeholder}
172
+ </div>
173
+ )}
174
+ </div>
175
+ {interactive && <SendButton label={sendLabel} onClick={onSubmit} disabled={disabled} />}
176
+ </div>
177
+ </div>
178
+ )
179
+ }
@@ -0,0 +1,25 @@
1
+ import { getTipTapDefaultExtensions, TLRichText, toRichText } from 'tldraw'
2
+
3
+ /**
4
+ * tldraw's default rich-text extension set, minus headings — the deliberately limited set used for
5
+ * both the comment composer and comment display. Comments support paragraphs, bold, italic, lists,
6
+ * links, code, and highlight, but not headings. Built from tldraw's shared factory so the config
7
+ * stays in lockstep with the text shape's defaults rather than drifting from a copy.
8
+ */
9
+ export const commentTipTapExtensions = getTipTapDefaultExtensions({ heading: false })
10
+
11
+ /** An empty comment document — the seed value for a fresh composer and its post-submit reset. */
12
+ export const EMPTY_COMMENT: TLRichText = toRichText('')
13
+
14
+ /**
15
+ * Whether a rich-text comment body has no text. Mirrors tldraw's private `isEmptyRichText`: an
16
+ * empty doc can be encoded as an empty `content` array or a single empty paragraph.
17
+ */
18
+ export function isCommentEmpty(richText: TLRichText): boolean {
19
+ if (richText.content.length === 0) return true
20
+ if (richText.content.length === 1) {
21
+ const node = richText.content[0] as any
22
+ if (!node.content || node.content.length === 0) return true
23
+ }
24
+ return false
25
+ }