@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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 (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,288 @@
1
+ import {
2
+ Editor,
3
+ TLComment,
4
+ TLCommentId,
5
+ TLCommentReactionId,
6
+ TLCommentThread,
7
+ TLCommentThreadId,
8
+ TLHistoryBatchOptions,
9
+ TLRecord,
10
+ TLRichText,
11
+ } from 'tldraw'
12
+ import { getCommentRecord, getLiveComments, type TLCommentRecord } from './comment-store'
13
+ import { getCommentingOptions, type CommentingOptions } from './options'
14
+ import { openThreadId } from './state'
15
+
16
+ /**
17
+ * Every write to a comment record, and the undo/redo policy governing them.
18
+ *
19
+ * This file layers bottom-up: {@link commitCommentMutation} resolves the history mode,
20
+ * {@link putCommentRecords} and {@link removeCommentRecords} are the typed writes that run under
21
+ * it, and the verbs below are those writes plus the one rule each carries — a timestamp to stamp, a
22
+ * shape to put the `resolved` field in, or the soft-delete protocol. The built-in thread view calls
23
+ * exactly these verbs, so a UI of your own behaves the same as the one in the box.
24
+ *
25
+ * Every verb takes the record it acts on, but treats it as the identity of what to change rather
26
+ * than as the value to write back — see {@link readLatest}.
27
+ *
28
+ * Posting carries no such rule, so it isn't a verb here: build the records with
29
+ * `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.
30
+ */
31
+
32
+ /**
33
+ * Which history policy a comment write follows:
34
+ *
35
+ * - `mutation` — {@link CommentingOptions.history}: posts, replies, edits, resolves.
36
+ * - `drag` — {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region
37
+ * re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.
38
+ * - `delete` — always `'ignore'`, whatever the options say. A soft-delete flag is write-once
39
+ * server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.
40
+ *
41
+ * @internal
42
+ */
43
+ export type CommentMutationKind = 'delete' | 'drag' | 'mutation'
44
+
45
+ /** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */
46
+ function historyModeFor(
47
+ options: CommentingOptions,
48
+ kind: CommentMutationKind
49
+ ): TLHistoryBatchOptions['history'] {
50
+ switch (kind) {
51
+ case 'delete':
52
+ return 'ignore'
53
+ case 'drag':
54
+ return options.dragHistory ?? options.history
55
+ case 'mutation':
56
+ return options.history
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Commit a comment mutation with the configured undo/redo behavior, so the
62
+ * {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to
63
+ * `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.
64
+ * @internal
65
+ */
66
+ export function commitCommentMutation<T>(
67
+ editor: Editor,
68
+ fn: () => T,
69
+ kind: CommentMutationKind = 'mutation'
70
+ ): T {
71
+ const history = historyModeFor(getCommentingOptions(editor), kind)
72
+ let result: T
73
+ editor.run(
74
+ () => {
75
+ result = fn()
76
+ },
77
+ { history }
78
+ )
79
+ return result!
80
+ }
81
+
82
+ /**
83
+ * Write records without opening a commit, for call sites that already sit inside one.
84
+ *
85
+ * `editor.run`'s history option isn't additive — a nested run overwrites the enclosing mode for its
86
+ * own scope — so a {@link putCommentRecords} call inside a commit would discard the mode its caller
87
+ * chose. A pin drag committed as `drag` under `dragHistory: 'record'` would land back on
88
+ * `history: 'ignore'` and quietly stop being undoable. Inside a commit, write with this.
89
+ *
90
+ * @internal
91
+ */
92
+ export function putRecordsInCommit(editor: Editor, records: TLCommentRecord[]): void {
93
+ editor.store.put(records as unknown as TLRecord[])
94
+ }
95
+
96
+ /** {@link putRecordsInCommit}'s counterpart for removals. @internal */
97
+ export function removeRecordsInCommit(
98
+ editor: Editor,
99
+ ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
100
+ ): void {
101
+ editor.store.remove(ids as unknown as TLRecord['id'][])
102
+ }
103
+
104
+ /**
105
+ * Write comment records to the store, under the configured
106
+ * {@link CommentingOptions.history} behavior — so a record you write lands on the undo stack (or
107
+ * doesn't) exactly like one the built-in UI writes. Defaults to `'ignore'`.
108
+ *
109
+ * Use it to seed or import threads, and to save an edit. To delete, prefer
110
+ * {@link deleteComment} and {@link deleteThread} over {@link removeCommentRecords}: comments are
111
+ * soft-deleted, and a synced server rejects the hard delete.
112
+ *
113
+ * @public
114
+ */
115
+ export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
116
+ commitCommentMutation(editor, () => putRecordsInCommit(editor, records))
117
+ }
118
+
119
+ /**
120
+ * Remove comment records from the store by id, under the configured
121
+ * {@link CommentingOptions.history} behavior.
122
+ *
123
+ * This is a hard delete, which is rarely what you want for a comment or a thread: the built-in UI
124
+ * soft-deletes them ({@link deleteComment}, {@link deleteThread}) so the server can prune the
125
+ * records — including reactions, which belong to whoever left them rather than to the deleter. A
126
+ * server that enforces per-record permissions will veto a hard delete outright. Reach for this on
127
+ * a local, unsynced comment store, or to drop a reaction (which is a hard delete — see
128
+ * {@link toggleCommentReaction}).
129
+ *
130
+ * @public
131
+ */
132
+ export function removeCommentRecords(
133
+ editor: Editor,
134
+ ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
135
+ ): void {
136
+ commitCommentMutation(editor, () => removeRecordsInCommit(editor, ids))
137
+ }
138
+
139
+ /**
140
+ * The record as the store currently holds it, or `undefined` if it isn't there any more.
141
+ *
142
+ * A verb is handed a record, but that record is a snapshot of whenever its caller got hold of one,
143
+ * and a comment record moves underneath it. A thread's `anchor` and `pageId` are rewritten without
144
+ * anyone touching the thread: deleting a pinned shape converts the anchor to a point, reparenting
145
+ * one rehomes the thread to another page, a pin drag re-anchors it. Writing the caller's snapshot
146
+ * back would put those fields as they were and sync the revert out to everyone.
147
+ *
148
+ * `put` is also an upsert, so a record a remote delete has already removed would come back — with
149
+ * `isDeleted: false` on an edit or a resolve. That's the multiplayer surprise the schema warns
150
+ * about (see `TLComment`), reached by a route the history option doesn't cover.
151
+ *
152
+ * So a verb reads what it's changing rather than trusting what it was given, and a record that's
153
+ * gone is a no-op: whatever the change was, there's nothing left for it to apply to.
154
+ */
155
+ function readLatest<T extends TLComment | TLCommentThread>(
156
+ editor: Editor,
157
+ record: T
158
+ ): T | undefined {
159
+ const current = getCommentRecord(editor, record.id)
160
+ // Record ids carry their type, so a matching `typeName` means a record of exactly T.
161
+ return current?.typeName === record.typeName ? (current as T) : undefined
162
+ }
163
+
164
+ /**
165
+ * Replace a comment's body and stamp it as edited, which is what renders the "(edited)" marker on
166
+ * its byline.
167
+ *
168
+ * Editing is the author's to do. The built-in UI only offers it on your own comments, and a server
169
+ * that enforces per-record permissions rejects anyone else's edit.
170
+ *
171
+ * The `comment` you pass says which comment to edit; the body lands on the version the store
172
+ * currently holds, so a copy you've held on to can't revert a change made since it, and can't
173
+ * re-create a comment that's already been removed — editing one of those does nothing.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * editComment(editor, comment, toRichText('Actually, make it dashed'))
178
+ * ```
179
+ *
180
+ * @public
181
+ */
182
+ export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {
183
+ commitCommentMutation(editor, () => {
184
+ const current = readLatest(editor, comment)
185
+ if (!current) return
186
+ putRecordsInCommit(editor, [{ ...current, body, editedAt: Date.now() }])
187
+ })
188
+ }
189
+
190
+ /**
191
+ * Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a
192
+ * checked one) and are hidden from the sidebar until its "show resolved" filter is on.
193
+ *
194
+ * Only the resolution is written: the rest of the thread is read fresh, so resolving with a stale
195
+ * copy in hand won't drag a pin back to where it used to be. A no-op on a thread that's gone.
196
+ *
197
+ * @public
198
+ */
199
+ export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {
200
+ commitCommentMutation(editor, () => {
201
+ const current = readLatest(editor, thread)
202
+ if (!current) return
203
+ putRecordsInCommit(editor, [{ ...current, resolved: { at: Date.now(), by: userId } }])
204
+ })
205
+ }
206
+
207
+ /**
208
+ * Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and
209
+ * on one that's gone. Like {@link resolveThread}, it touches only the resolution.
210
+ *
211
+ * @public
212
+ */
213
+ export function reopenThread(editor: Editor, thread: TLCommentThread): void {
214
+ commitCommentMutation(editor, () => {
215
+ const current = readLatest(editor, thread)
216
+ if (!current) return
217
+ putRecordsInCommit(editor, [{ ...current, resolved: null }])
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Delete a comment.
223
+ *
224
+ * This is a soft delete: it sets the record's `isDeleted` flag rather than removing it, and the
225
+ * server prunes the comment and its reactions once the flag is persisted. That way no client ever
226
+ * removes records it doesn't own — a reaction belongs to whoever left it — and a server enforcing
227
+ * per-record permissions has a write it can check rather than a deletion it can only refuse.
228
+ * Deleting is the author's to do; the built-in UI only offers it on your own comments.
229
+ *
230
+ * The write is never undoable, whatever {@link CommentingOptions.history} says: the flag is
231
+ * write-once server-side, so an undo clearing it would be vetoed and rebased rather than bring the
232
+ * comment back.
233
+ *
234
+ * Deleting a thread's last comment leaves the thread with no surface — an empty thread renders
235
+ * nothing — and closes it if it's open. The thread record is left for the server to prune, since
236
+ * the deleter may not be its creator.
237
+ *
238
+ * A comment that's already deleted, or already pruned, is a no-op.
239
+ *
240
+ * @public
241
+ */
242
+ export function deleteComment(editor: Editor, comment: TLComment): void {
243
+ commitCommentMutation(
244
+ editor,
245
+ () => {
246
+ const current = readLatest(editor, comment)
247
+ // Deleting twice — a double activation, a handler firing on a copy taken before the first
248
+ // delete — is nothing to do rather than something to redo. The check below counts the
249
+ // comment being deleted among the live ones, so it only reads as "the last one" while
250
+ // this delete is the one taking it away.
251
+ if (!current || current.isDeleted) return
252
+ const isLastInThread =
253
+ getLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1
254
+ if (isLastInThread && openThreadId.get(editor) === current.threadId) {
255
+ openThreadId.set(editor, null)
256
+ }
257
+ putRecordsInCommit(editor, [{ ...current, isDeleted: true }])
258
+ },
259
+ 'delete'
260
+ )
261
+ }
262
+
263
+ /**
264
+ * Delete a thread and, with it, the whole conversation.
265
+ *
266
+ * A soft delete on the same model as {@link deleteComment}: the flag goes on the thread record and
267
+ * the server prunes the thread, its comments, and their reactions once it's persisted. Deleting a
268
+ * thread is its creator's to do — a server enforcing per-record permissions vetoes anyone else —
269
+ * and the write is never undoable.
270
+ *
271
+ * Closes the thread if it's the open one. A thread that's already pruned is a no-op.
272
+ *
273
+ * @public
274
+ */
275
+ export function deleteThread(editor: Editor, thread: TLCommentThread): void {
276
+ commitCommentMutation(
277
+ editor,
278
+ () => {
279
+ const current = readLatest(editor, thread)
280
+ if (!current) return
281
+ if (openThreadId.get(editor) === current.id) {
282
+ openThreadId.set(editor, null)
283
+ }
284
+ putRecordsInCommit(editor, [{ ...current, isDeleted: true }])
285
+ },
286
+ 'delete'
287
+ )
288
+ }
@@ -0,0 +1,132 @@
1
+ import { TLComment, TLCommentId, TLCommentReaction, createCommentReactionId } from 'tldraw'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { summarizeReactions } from './comment-reactions'
4
+
5
+ const COMMENT_A = 'comment:a' as TLCommentId
6
+ const COMMENT_B = 'comment:b' as TLCommentId
7
+
8
+ function reaction(userId: string, emoji: string, createdAt: number): TLCommentReaction {
9
+ return {
10
+ id: createCommentReactionId(COMMENT_A, userId, emoji),
11
+ typeName: 'comment-reaction',
12
+ commentId: COMMENT_A,
13
+ threadId: 'comment-thread:t' as TLCommentReaction['threadId'],
14
+ pageId: 'page:page' as TLCommentReaction['pageId'],
15
+ userId,
16
+ emoji,
17
+ createdAt,
18
+ meta: {},
19
+ }
20
+ }
21
+
22
+ describe('summarizeReactions', () => {
23
+ it('returns nothing when there are no reactions', () => {
24
+ expect(summarizeReactions([], 'user1')).toEqual([])
25
+ })
26
+
27
+ it('counts each emoji, marks the current user’s active, and lists reactors', () => {
28
+ expect(
29
+ summarizeReactions(
30
+ [reaction('user1', '👍', 100), reaction('user2', '👍', 200), reaction('user3', '🎉', 300)],
31
+ 'user1'
32
+ )
33
+ ).toEqual([
34
+ {
35
+ emoji: '👍',
36
+ count: 2,
37
+ active: true,
38
+ // no resolveName here, so names fall back to the generic 'Someone'
39
+ reactors: [
40
+ { name: 'Someone', you: true },
41
+ { name: 'Someone', you: false },
42
+ ],
43
+ },
44
+ { emoji: '🎉', count: 1, active: false, reactors: [{ name: 'Someone', you: false }] },
45
+ ])
46
+ })
47
+
48
+ // the row shouldn't reshuffle as later reactions land, so groups sort by first use
49
+ it('orders emoji by when each was first used', () => {
50
+ expect(
51
+ summarizeReactions(
52
+ [reaction('user1', '🎉', 300), reaction('user2', '👍', 100), reaction('user3', '👍', 200)],
53
+ undefined
54
+ ).map((r) => r.emoji)
55
+ ).toEqual(['👍', '🎉'])
56
+ })
57
+
58
+ it('marks nothing active when there is no current user', () => {
59
+ expect(summarizeReactions([reaction('user1', '👍', 100)], undefined)[0].active).toBe(false)
60
+ })
61
+
62
+ it('names reactors via resolveName, falling back to a generic name', () => {
63
+ const resolveName = (id: string) => (id === 'user1' ? 'Ada' : undefined)
64
+ expect(
65
+ summarizeReactions(
66
+ [reaction('user1', '👍', 100), reaction('user2', '👍', 200)],
67
+ 'user1',
68
+ resolveName
69
+ )[0].reactors
70
+ ).toEqual([
71
+ { name: 'Ada', you: true },
72
+ // user2 can't be resolved — a generic name, never the raw id
73
+ { name: 'Someone', you: false },
74
+ ])
75
+ })
76
+
77
+ // reactors keep the order they're passed in (the caller sorts by reaction time), so the hover
78
+ // list is stable
79
+ it('lists reactors in input order', () => {
80
+ expect(
81
+ summarizeReactions(
82
+ [reaction('user2', '👍', 100), reaction('user1', '👍', 200)],
83
+ undefined,
84
+ (id) => id.toUpperCase()
85
+ )[0].reactors.map((r) => r.name)
86
+ ).toEqual(['USER2', 'USER1'])
87
+ })
88
+ })
89
+
90
+ describe('createCommentReactionId', () => {
91
+ // the id is what makes reaction identity structural: re-picking the same emoji addresses the
92
+ // same record (toggle off), while a different emoji is its own record (independent)
93
+ it('is stable for the same comment, user, and emoji', () => {
94
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).toBe(
95
+ createCommentReactionId(COMMENT_A, 'user1', '👍')
96
+ )
97
+ })
98
+
99
+ it('differs per user, per comment, and per emoji', () => {
100
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
101
+ createCommentReactionId(COMMENT_A, 'user2', '👍')
102
+ )
103
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
104
+ createCommentReactionId(COMMENT_B, 'user1', '👍')
105
+ )
106
+ // same user + comment, different emoji → a distinct record (this is what enables multi-react)
107
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
108
+ createCommentReactionId(COMMENT_A, 'user1', '🎉')
109
+ )
110
+ })
111
+
112
+ it('is a comment-reaction id, not a comment id', () => {
113
+ const id: string = createCommentReactionId(COMMENT_A, 'user1', '👍')
114
+ expect(id.startsWith('comment-reaction:')).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('reaction records', () => {
119
+ // a comment's reactions are found by commentId, so a reaction to another comment must not
120
+ // leak into this one's tally
121
+ it('are scoped to one comment by commentId', () => {
122
+ const forOtherComment: TLCommentReaction = {
123
+ ...reaction('user9', '💩', 400),
124
+ commentId: COMMENT_B,
125
+ }
126
+ const all = [reaction('user1', '👍', 100), forOtherComment]
127
+ const mine = all.filter((r) => r.commentId === (COMMENT_A as TLComment['id']))
128
+ expect(summarizeReactions(mine, 'user1')).toEqual([
129
+ { emoji: '👍', count: 1, active: true, reactors: [{ name: 'Someone', you: true }] },
130
+ ])
131
+ })
132
+ })
@@ -0,0 +1,245 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ Editor,
4
+ TLComment,
5
+ TLCommentReaction,
6
+ createCommentReaction,
7
+ createCommentReactionId,
8
+ useEditor,
9
+ useValue,
10
+ } from 'tldraw'
11
+ import { RenderReaction } from '../ui/reaction'
12
+ import { ReactionPicker } from '../ui/reaction-picker'
13
+ import { Reactions, ReactionSummary } from '../ui/reactions'
14
+ import {
15
+ commitCommentMutation,
16
+ putRecordsInCommit,
17
+ removeRecordsInCommit,
18
+ } from './comment-mutations'
19
+ import { UNKNOWN_AUTHOR } from './comment-render'
20
+ import { getCommentReactions } from './comment-store'
21
+ import { getCommentingOptions, useCommentingOptions } from './options'
22
+
23
+ /**
24
+ * One comment's reactions, oldest first, reactively.
25
+ *
26
+ * @public
27
+ */
28
+ export function useCommentReactions(
29
+ editor: Editor,
30
+ commentId: TLComment['id']
31
+ ): TLCommentReaction[] {
32
+ return useValue(
33
+ 'comment reactions',
34
+ () =>
35
+ getCommentReactions(editor)
36
+ .filter((reaction) => reaction.commentId === commentId)
37
+ .sort((a, b) => a.createdAt - b.createdAt),
38
+ [editor, commentId]
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used
44
+ * so the row stays stable as later reactions arrive. `active` marks the emoji the current user
45
+ * reacted with (the pills render those highlighted), and `reactors` lists who reacted with it, in
46
+ * reaction order, for the hover list. `resolveName` names each reactor; an id it can't name falls
47
+ * back to the id itself.
48
+ *
49
+ * @public
50
+ */
51
+ export function summarizeReactions(
52
+ reactions: TLCommentReaction[],
53
+ currentUserId?: string | null,
54
+ resolveName?: (userId: string) => string | undefined
55
+ ): ReactionSummary[] {
56
+ const groups = new Map<
57
+ string,
58
+ { count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] }
59
+ >()
60
+ for (const reaction of reactions) {
61
+ const mine = currentUserId != null && reaction.userId === currentUserId
62
+ // Fall back to a generic name, never the raw user id, when the id can't be resolved.
63
+ const reactor = { name: resolveName?.(reaction.userId) ?? UNKNOWN_AUTHOR, you: mine }
64
+ const group = groups.get(reaction.emoji)
65
+ if (group) {
66
+ group.count++
67
+ group.active ||= mine
68
+ group.firstAt = Math.min(group.firstAt, reaction.createdAt)
69
+ group.reactors.push(reactor)
70
+ } else {
71
+ groups.set(reaction.emoji, {
72
+ count: 1,
73
+ active: mine,
74
+ firstAt: reaction.createdAt,
75
+ reactors: [reactor],
76
+ })
77
+ }
78
+ }
79
+ return [...groups]
80
+ .sort(([, a], [, b]) => a.firstAt - b.firstAt)
81
+ .map(([emoji, group]) => ({
82
+ emoji,
83
+ count: group.count,
84
+ active: group.active,
85
+ reactors: group.reactors,
86
+ }))
87
+ }
88
+
89
+ /**
90
+ * Toggle one user's reaction with a given emoji on a comment.
91
+ *
92
+ * Each reaction is its own record keyed by (comment, user, emoji), so this only ever touches that
93
+ * one user's own records — two people reacting at once never conflict. Behaviour depends on the
94
+ * `allowMultipleReactions` option:
95
+ *
96
+ * - **multiple** (default): the emoji toggles independently — add it if absent, remove it if
97
+ * present, leaving the user's other reactions alone.
98
+ * - **single**: picking a new emoji first removes the user's existing reaction(s) on the comment,
99
+ * then adds this one (a replace); picking the one they already have removes it.
100
+ *
101
+ * @public
102
+ */
103
+ export function toggleCommentReaction(
104
+ editor: Editor,
105
+ comment: TLComment,
106
+ userId: string,
107
+ emoji: string,
108
+ now = Date.now()
109
+ ): void {
110
+ const { allowMultipleReactions, isAllowedReaction } = getCommentingOptions(editor)
111
+ const targetId = createCommentReactionId(comment.id, userId, emoji)
112
+ const mine = getCommentReactions(editor).filter(
113
+ (reaction) => reaction.commentId === comment.id && reaction.userId === userId
114
+ )
115
+ const removing = mine.some((reaction) => reaction.id === targetId)
116
+ // Only a token the configured palette allows may be added. Removals always go through — if a
117
+ // reaction somehow carries an off-palette token, the user must still be able to clear it.
118
+ if (!removing && !isAllowedReaction(emoji)) return
119
+ commitCommentMutation(editor, () => {
120
+ if (removing) {
121
+ removeRecordsInCommit(editor, [targetId])
122
+ return
123
+ }
124
+ // Single-select: a new emoji replaces the user's existing reaction(s) on this comment.
125
+ if (!allowMultipleReactions && mine.length > 0) {
126
+ removeRecordsInCommit(
127
+ editor,
128
+ mine.map((reaction) => reaction.id)
129
+ )
130
+ }
131
+ putRecordsInCommit(editor, [
132
+ createCommentReaction({
133
+ commentId: comment.id,
134
+ threadId: comment.threadId,
135
+ pageId: comment.pageId,
136
+ userId,
137
+ emoji,
138
+ now,
139
+ }),
140
+ ])
141
+ })
142
+ }
143
+
144
+ /** @public */
145
+ export interface CommentReactionsProps {
146
+ comment: TLComment
147
+ /** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the
148
+ * pills don't toggle. */
149
+ currentUserId?: string | null
150
+ /** Names a reactor id for the hover list. Ids it can't name fall back to the id. */
151
+ resolveName?(userId: string): string | undefined
152
+ }
153
+
154
+ /**
155
+ * Adapts the `ReactionContent` component override (if any) into a `renderReaction` function for the
156
+ * presentational reaction components. Returns undefined when no override is set, so they fall back
157
+ * to the default (the token string, drawn by the OS emoji font).
158
+ */
159
+ function useReactionRenderer(): RenderReaction | undefined {
160
+ const { components } = useCommentingOptions()
161
+ const ReactionContent = components.ReactionContent
162
+ return useMemo(
163
+ () => (ReactionContent ? (token: string) => <ReactionContent token={token} /> : undefined),
164
+ [ReactionContent]
165
+ )
166
+ }
167
+
168
+ /**
169
+ * The tallied reaction row under one comment. Pair with `CommentReactionPicker`, which is what
170
+ * adds a reaction.
171
+ * @public @react
172
+ */
173
+ export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps) {
174
+ const editor = useEditor()
175
+ const renderReaction = useReactionRenderer()
176
+ const { components } = useCommentingOptions()
177
+ const reactions = useCommentReactions(editor, comment.id)
178
+ const summaries = useMemo(
179
+ () => summarizeReactions(reactions, currentUserId, resolveName),
180
+ [reactions, currentUserId, resolveName]
181
+ )
182
+ // Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it
183
+ // doesn't compete with the menu the user is actually working in. Edit mode already hides the
184
+ // pills entirely (the card becomes a composer), so it needs no special case here.
185
+ const anyMenuOpen = useValue('any menu open', () => editor.menus.getOpenMenus().length > 0, [
186
+ editor,
187
+ ])
188
+ return (
189
+ <Reactions
190
+ reactions={summaries}
191
+ canReact={currentUserId != null}
192
+ enableHoverList={!anyMenuOpen}
193
+ renderReaction={renderReaction}
194
+ ReactionTooltip={components.ReactionTooltip}
195
+ onToggle={(value) => {
196
+ if (currentUserId == null) return
197
+ toggleCommentReaction(editor, comment, currentUserId, value)
198
+ }}
199
+ />
200
+ )
201
+ }
202
+
203
+ /** @public */
204
+ export interface CommentReactionPickerProps {
205
+ comment: TLComment
206
+ /** The reacting user. Null/omitted renders nothing — there's nobody to react as. */
207
+ currentUserId?: string | null
208
+ /** The emoji the picker offers. Defaults to `DEFAULT_REACTION_EMOJI`. */
209
+ emoji?: string[]
210
+ }
211
+
212
+ /**
213
+ * The add-reaction button for one comment. Belongs with the comment card's hover actions (under
214
+ * the ⋯ button) rather than in the reaction row, so opening it doesn't chase the row as reactions
215
+ * are added.
216
+ * @public @react
217
+ */
218
+ export function CommentReactionPicker({
219
+ comment,
220
+ currentUserId,
221
+ emoji,
222
+ }: CommentReactionPickerProps) {
223
+ const editor = useEditor()
224
+ const { components } = useCommentingOptions()
225
+ const renderReaction = useReactionRenderer()
226
+ const reactions = useCommentReactions(editor, comment.id)
227
+ const selected = useMemo(
228
+ () =>
229
+ reactions
230
+ .filter((reaction) => currentUserId != null && reaction.userId === currentUserId)
231
+ .map((reaction) => reaction.emoji),
232
+ [reactions, currentUserId]
233
+ )
234
+ if (currentUserId == null) return null
235
+ return (
236
+ <ReactionPicker
237
+ emoji={emoji}
238
+ selected={selected}
239
+ renderReaction={renderReaction}
240
+ palette={components.ReactionPalette}
241
+ menuId={`comment-reactions-${comment.id}`}
242
+ onSelect={(value) => toggleCommentReaction(editor, comment, currentUserId, value)}
243
+ />
244
+ )
245
+ }
@@ -38,7 +38,7 @@ describe('renderCommentHtml', () => {
38
38
  it('renders a mention as a pill and resolves its id to the current name', () => {
39
39
  const body = doc(para(text('hey '), mention('u1', 'Ada')))
40
40
  const html = renderCommentHtml(body, (id) => (id === 'u1' ? 'Ada Lovelace' : '?'))
41
- expect(html).toContain('cmt-mention')
41
+ expect(html).toContain('tlui-cmt-mention')
42
42
  // the live name from the resolver, not the label stored at insert time
43
43
  expect(html).toContain('@Ada Lovelace')
44
44
  expect(html).not.toContain('@Ada<')