@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d

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 (338) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1152 -350
  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/{ui/avatar.js → canvas/canvas-events.js} +14 -22
  6. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  7. package/dist-cjs/canvas/cluster-badge.js +121 -0
  8. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  9. package/dist-cjs/canvas/cluster-fade.js +85 -0
  10. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  11. package/dist-cjs/canvas/cluster-input.js +53 -3
  12. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  13. package/dist-cjs/canvas/cluster-model.js +252 -0
  14. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  15. package/dist-cjs/canvas/comment-body.js +1 -1
  16. package/dist-cjs/canvas/comment-body.js.map +2 -2
  17. package/dist-cjs/canvas/comment-drafts.js +54 -0
  18. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  19. package/dist-cjs/canvas/comment-mutations.js +152 -0
  20. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  21. package/dist-cjs/canvas/comment-reactions.js +160 -0
  22. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  23. package/dist-cjs/canvas/comment-render.js +9 -6
  24. package/dist-cjs/canvas/comment-render.js.map +2 -2
  25. package/dist-cjs/canvas/comment-store.js +19 -9
  26. package/dist-cjs/canvas/comment-store.js.map +2 -2
  27. package/dist-cjs/canvas/comment-tool.js +63 -14
  28. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  29. package/dist-cjs/canvas/comments-filter-menu.js +17 -37
  30. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  31. package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
  32. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  33. package/dist-cjs/canvas/comments-overlay.js +151 -989
  34. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  35. package/dist-cjs/canvas/comments-sidebar.js +49 -41
  36. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  37. package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
  38. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  39. package/dist-cjs/canvas/context.js +17 -0
  40. package/dist-cjs/canvas/context.js.map +7 -0
  41. package/dist-cjs/canvas/hooks.js +28 -3
  42. package/dist-cjs/canvas/hooks.js.map +2 -2
  43. package/dist-cjs/canvas/license.js.map +1 -1
  44. package/dist-cjs/canvas/mobile-placement.js +91 -0
  45. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  46. package/dist-cjs/canvas/options.js +57 -2
  47. package/dist-cjs/canvas/options.js.map +2 -2
  48. package/dist-cjs/canvas/pending-composer.js +134 -0
  49. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  50. package/dist-cjs/canvas/pin-stacking.js +81 -0
  51. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  52. package/dist-cjs/canvas/region-box.js +113 -0
  53. package/dist-cjs/canvas/region-box.js.map +7 -0
  54. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  55. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  56. package/dist-cjs/canvas/state.js +28 -14
  57. package/dist-cjs/canvas/state.js.map +2 -2
  58. package/dist-cjs/canvas/thread-pin.js +304 -0
  59. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  60. package/dist-cjs/canvas/thread-preview.js +177 -0
  61. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  62. package/dist-cjs/canvas/thread-stack.js +199 -0
  63. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  64. package/dist-cjs/canvas/thread-state.js +82 -21
  65. package/dist-cjs/canvas/thread-state.js.map +2 -2
  66. package/dist-cjs/canvas/thread-view.js +450 -0
  67. package/dist-cjs/canvas/thread-view.js.map +7 -0
  68. package/dist-cjs/clustering/computeClusterTable.js +6 -6
  69. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  70. package/dist-cjs/clustering/replay.js +89 -22
  71. package/dist-cjs/clustering/replay.js.map +3 -3
  72. package/dist-cjs/clustering/runtime.js +126 -7
  73. package/dist-cjs/clustering/runtime.js.map +2 -2
  74. package/dist-cjs/clustering/types.js.map +1 -1
  75. package/dist-cjs/index.d.ts +904 -353
  76. package/dist-cjs/index.js +52 -28
  77. package/dist-cjs/index.js.map +2 -2
  78. package/dist-cjs/ui/byline.js +10 -6
  79. package/dist-cjs/ui/byline.js.map +2 -2
  80. package/dist-cjs/ui/comment-card.js +15 -8
  81. package/dist-cjs/ui/comment-card.js.map +2 -2
  82. package/dist-cjs/ui/comment-composer.js +124 -39
  83. package/dist-cjs/ui/comment-composer.js.map +2 -2
  84. package/dist-cjs/ui/comment-pin.js +11 -2
  85. package/dist-cjs/ui/comment-pin.js.map +2 -2
  86. package/dist-cjs/ui/comment-thread.js +9 -7
  87. package/dist-cjs/ui/comment-thread.js.map +2 -2
  88. package/dist-cjs/ui/comments-list.js +39 -40
  89. package/dist-cjs/ui/comments-list.js.map +2 -2
  90. package/dist-cjs/ui/count-badge.js +3 -2
  91. package/dist-cjs/ui/count-badge.js.map +2 -2
  92. package/dist-cjs/ui/emoji-picker.js +54 -0
  93. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  94. package/dist-cjs/ui/empty-state.js +3 -3
  95. package/dist-cjs/ui/empty-state.js.map +2 -2
  96. package/dist-cjs/ui/format-time.js +16 -1
  97. package/dist-cjs/ui/format-time.js.map +2 -2
  98. package/dist-cjs/ui/icons.js +115 -0
  99. package/dist-cjs/ui/icons.js.map +7 -0
  100. package/dist-cjs/ui/reaction-picker.js +71 -0
  101. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  102. package/dist-cjs/ui/reaction.js +58 -6
  103. package/dist-cjs/ui/reaction.js.map +2 -2
  104. package/dist-cjs/ui/reactions.js +20 -7
  105. package/dist-cjs/ui/reactions.js.map +2 -2
  106. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  107. package/dist-cjs/ui/reply-count.js.map +7 -0
  108. package/dist-cjs/ui/send-button.js +13 -1
  109. package/dist-cjs/ui/send-button.js.map +2 -2
  110. package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
  111. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  112. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  113. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  114. package/dist-esm/canvas/canvas-events.mjs +15 -0
  115. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  116. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  117. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  118. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  119. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  120. package/dist-esm/canvas/cluster-input.mjs +54 -4
  121. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  122. package/dist-esm/canvas/cluster-model.mjs +236 -0
  123. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  124. package/dist-esm/canvas/comment-body.mjs +1 -1
  125. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  126. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  127. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  128. package/dist-esm/canvas/comment-mutations.mjs +132 -0
  129. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  130. package/dist-esm/canvas/comment-reactions.mjs +145 -0
  131. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  132. package/dist-esm/canvas/comment-render.mjs +10 -7
  133. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  134. package/dist-esm/canvas/comment-store.mjs +19 -9
  135. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  136. package/dist-esm/canvas/comment-tool.mjs +66 -17
  137. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  138. package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
  139. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  140. package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
  141. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  142. package/dist-esm/canvas/comments-overlay.mjs +157 -1010
  143. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  144. package/dist-esm/canvas/comments-sidebar.mjs +53 -46
  145. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  146. package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
  147. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  148. package/dist-esm/canvas/context.mjs +1 -0
  149. package/dist-esm/canvas/context.mjs.map +7 -0
  150. package/dist-esm/canvas/hooks.mjs +35 -5
  151. package/dist-esm/canvas/hooks.mjs.map +2 -2
  152. package/dist-esm/canvas/license.mjs.map +1 -1
  153. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  154. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  155. package/dist-esm/canvas/options.mjs +59 -3
  156. package/dist-esm/canvas/options.mjs.map +2 -2
  157. package/dist-esm/canvas/pending-composer.mjs +126 -0
  158. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  159. package/dist-esm/canvas/pin-stacking.mjs +61 -0
  160. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  161. package/dist-esm/canvas/region-box.mjs +93 -0
  162. package/dist-esm/canvas/region-box.mjs.map +7 -0
  163. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  164. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  165. package/dist-esm/canvas/state.mjs +29 -19
  166. package/dist-esm/canvas/state.mjs.map +2 -2
  167. package/dist-esm/canvas/thread-pin.mjs +310 -0
  168. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  169. package/dist-esm/canvas/thread-preview.mjs +163 -0
  170. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  171. package/dist-esm/canvas/thread-stack.mjs +189 -0
  172. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  173. package/dist-esm/canvas/thread-state.mjs +86 -22
  174. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  175. package/dist-esm/canvas/thread-view.mjs +461 -0
  176. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  177. package/dist-esm/clustering/computeClusterTable.mjs +6 -6
  178. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  179. package/dist-esm/clustering/replay.mjs +89 -22
  180. package/dist-esm/clustering/replay.mjs.map +3 -3
  181. package/dist-esm/clustering/runtime.mjs +126 -7
  182. package/dist-esm/clustering/runtime.mjs.map +2 -2
  183. package/dist-esm/index.d.mts +904 -353
  184. package/dist-esm/index.mjs +102 -43
  185. package/dist-esm/index.mjs.map +2 -2
  186. package/dist-esm/ui/byline.mjs +11 -7
  187. package/dist-esm/ui/byline.mjs.map +2 -2
  188. package/dist-esm/ui/comment-card.mjs +15 -8
  189. package/dist-esm/ui/comment-card.mjs.map +2 -2
  190. package/dist-esm/ui/comment-composer.mjs +135 -39
  191. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  192. package/dist-esm/ui/comment-pin.mjs +11 -2
  193. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  194. package/dist-esm/ui/comment-thread.mjs +9 -7
  195. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  196. package/dist-esm/ui/comments-list.mjs +51 -50
  197. package/dist-esm/ui/comments-list.mjs.map +2 -2
  198. package/dist-esm/ui/count-badge.mjs +3 -2
  199. package/dist-esm/ui/count-badge.mjs.map +2 -2
  200. package/dist-esm/ui/emoji-picker.mjs +34 -0
  201. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  202. package/dist-esm/ui/empty-state.mjs +3 -3
  203. package/dist-esm/ui/empty-state.mjs.map +2 -2
  204. package/dist-esm/ui/format-time.mjs +16 -1
  205. package/dist-esm/ui/format-time.mjs.map +2 -2
  206. package/dist-esm/ui/icons.mjs +95 -0
  207. package/dist-esm/ui/icons.mjs.map +7 -0
  208. package/dist-esm/ui/reaction-picker.mjs +59 -0
  209. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  210. package/dist-esm/ui/reaction.mjs +59 -7
  211. package/dist-esm/ui/reaction.mjs.map +2 -2
  212. package/dist-esm/ui/reactions.mjs +21 -8
  213. package/dist-esm/ui/reactions.mjs.map +2 -2
  214. package/dist-esm/ui/reply-count.mjs +9 -0
  215. package/dist-esm/ui/reply-count.mjs.map +7 -0
  216. package/dist-esm/ui/send-button.mjs +13 -1
  217. package/dist-esm/ui/send-button.mjs.map +2 -2
  218. package/dist-esm/ui/visual-viewport.mjs +12 -0
  219. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  220. package/package.json +6 -6
  221. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  222. package/src/canvas/anchor-lifecycle.ts +191 -0
  223. package/src/canvas/canvas-events.ts +18 -0
  224. package/src/canvas/canvas.css +464 -55
  225. package/src/canvas/cluster-badge.tsx +133 -0
  226. package/src/canvas/cluster-fade.ts +102 -0
  227. package/src/canvas/cluster-input.test.ts +178 -36
  228. package/src/canvas/cluster-input.ts +95 -9
  229. package/src/canvas/cluster-model.ts +378 -0
  230. package/src/canvas/comment-body.tsx +1 -1
  231. package/src/canvas/comment-drafts.test.ts +55 -0
  232. package/src/canvas/comment-drafts.ts +46 -0
  233. package/src/canvas/comment-mutations.test.ts +452 -0
  234. package/src/canvas/comment-mutations.ts +299 -0
  235. package/src/canvas/comment-reactions.test.ts +154 -0
  236. package/src/canvas/comment-reactions.tsx +246 -0
  237. package/src/canvas/comment-render.test.ts +1 -1
  238. package/src/canvas/comment-render.ts +17 -13
  239. package/src/canvas/comment-store.ts +68 -33
  240. package/src/canvas/comment-tool.test.ts +162 -0
  241. package/src/canvas/comment-tool.tsx +103 -25
  242. package/src/canvas/comments-filter-menu.tsx +20 -36
  243. package/src/canvas/comments-overflow-menu.tsx +17 -19
  244. package/src/canvas/comments-overlay.tsx +252 -1369
  245. package/src/canvas/comments-sidebar.test.ts +45 -0
  246. package/src/canvas/comments-sidebar.tsx +112 -73
  247. package/src/canvas/comments-visibility-toggle.tsx +25 -0
  248. package/src/canvas/context.ts +48 -0
  249. package/src/canvas/hooks.test.ts +94 -0
  250. package/src/canvas/hooks.ts +66 -11
  251. package/src/canvas/license.ts +1 -1
  252. package/src/canvas/mobile-placement.ts +115 -0
  253. package/src/canvas/options.test.ts +176 -4
  254. package/src/canvas/options.ts +276 -18
  255. package/src/canvas/pending-composer.tsx +157 -0
  256. package/src/canvas/pin-stacking.test.ts +224 -0
  257. package/src/canvas/pin-stacking.ts +103 -0
  258. package/src/canvas/region-box.tsx +124 -0
  259. package/src/canvas/sidebar-filters.ts +4 -3
  260. package/src/canvas/state.ts +88 -39
  261. package/src/canvas/thread-pin.tsx +418 -0
  262. package/src/canvas/thread-preview.test.ts +91 -0
  263. package/src/canvas/thread-preview.tsx +294 -0
  264. package/src/canvas/thread-stack.tsx +242 -0
  265. package/src/canvas/thread-state.test.ts +260 -0
  266. package/src/canvas/thread-state.ts +171 -37
  267. package/src/canvas/thread-view.test.ts +72 -0
  268. package/src/canvas/thread-view.tsx +614 -0
  269. package/src/clustering/computeClusterTable.test.ts +24 -23
  270. package/src/clustering/computeClusterTable.ts +17 -8
  271. package/src/clustering/replay.test.ts +0 -7
  272. package/src/clustering/replay.ts +131 -32
  273. package/src/clustering/runtime.test.ts +267 -13
  274. package/src/clustering/runtime.ts +176 -19
  275. package/src/clustering/schedule.test.ts +0 -6
  276. package/src/clustering/screen-offsets.test.ts +171 -0
  277. package/src/clustering/types.ts +19 -9
  278. package/src/index.ts +91 -42
  279. package/src/ui/byline.tsx +21 -9
  280. package/src/ui/comment-card.tsx +20 -10
  281. package/src/ui/comment-composer.tsx +178 -56
  282. package/src/ui/comment-pin.tsx +13 -2
  283. package/src/ui/comment-thread.tsx +13 -6
  284. package/src/ui/comments-list.test.ts +31 -0
  285. package/src/ui/comments-list.tsx +82 -47
  286. package/src/ui/comments.css +577 -294
  287. package/src/ui/count-badge.tsx +9 -2
  288. package/src/ui/emoji-picker.test.ts +25 -0
  289. package/src/ui/emoji-picker.tsx +76 -0
  290. package/src/ui/empty-state.tsx +3 -3
  291. package/src/ui/format-time.test.ts +69 -0
  292. package/src/ui/format-time.ts +27 -2
  293. package/src/ui/icons.tsx +116 -0
  294. package/src/ui/reaction-picker.tsx +102 -0
  295. package/src/ui/reaction.tsx +125 -6
  296. package/src/ui/reactions.tsx +69 -10
  297. package/src/ui/reply-count.ts +16 -0
  298. package/src/ui/send-button.tsx +13 -3
  299. package/src/ui/visual-viewport.test.ts +36 -0
  300. package/src/ui/visual-viewport.ts +27 -0
  301. package/dist-cjs/canvas/region-options.js +0 -49
  302. package/dist-cjs/canvas/region-options.js.map +0 -7
  303. package/dist-cjs/ui/avatar.js.map +0 -7
  304. package/dist-cjs/ui/comment-mention.js +0 -42
  305. package/dist-cjs/ui/comment-mention.js.map +0 -7
  306. package/dist-cjs/ui/comment-text.js.map +0 -7
  307. package/dist-cjs/ui/mention-list.js +0 -70
  308. package/dist-cjs/ui/mention-list.js.map +0 -7
  309. package/dist-cjs/ui/mention-suggestion.js +0 -206
  310. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  311. package/dist-cjs/ui/mention.js.map +0 -7
  312. package/dist-cjs/ui/render-markdown.js +0 -63
  313. package/dist-cjs/ui/render-markdown.js.map +0 -7
  314. package/dist-esm/canvas/region-options.mjs +0 -29
  315. package/dist-esm/canvas/region-options.mjs.map +0 -7
  316. package/dist-esm/ui/avatar.mjs +0 -23
  317. package/dist-esm/ui/avatar.mjs.map +0 -7
  318. package/dist-esm/ui/comment-mention.mjs +0 -22
  319. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  320. package/dist-esm/ui/comment-text.mjs +0 -9
  321. package/dist-esm/ui/comment-text.mjs.map +0 -7
  322. package/dist-esm/ui/mention-list.mjs +0 -50
  323. package/dist-esm/ui/mention-list.mjs.map +0 -7
  324. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  325. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  326. package/dist-esm/ui/mention.mjs +0 -11
  327. package/dist-esm/ui/mention.mjs.map +0 -7
  328. package/dist-esm/ui/render-markdown.mjs +0 -45
  329. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  330. package/src/canvas/region-options.ts +0 -57
  331. package/src/ui/avatar.tsx +0 -31
  332. package/src/ui/comment-mention.ts +0 -47
  333. package/src/ui/comment-text.tsx +0 -12
  334. package/src/ui/mention-list.tsx +0 -106
  335. package/src/ui/mention-suggestion.test.ts +0 -18
  336. package/src/ui/mention-suggestion.tsx +0 -285
  337. package/src/ui/mention.tsx +0 -9
  338. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,299 @@
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
+ * The file layers bottom-up: {@link commitCommentMutation} resolves the history mode and hands its
20
+ * callback a writer, {@link putCommentRecords} and {@link removeCommentRecords} are the typed
21
+ * writes that run under it, and the verbs below are those writes plus the one rule each carries.
22
+ * Every verb takes the record it acts on as the identity of what to change, not the value to write
23
+ * back — see {@link readLatest}.
24
+ *
25
+ * Posting carries no such rule, so it isn't a verb here: build the records with
26
+ * `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.
27
+ */
28
+
29
+ /**
30
+ * Which history policy a comment write follows:
31
+ *
32
+ * - `mutation` — {@link CommentingOptions.history}: posts, replies, edits, resolves.
33
+ * - `drag` — {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region
34
+ * re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.
35
+ * - `delete` — always `'ignore'`, whatever the options say. A soft-delete flag is write-once
36
+ * server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.
37
+ *
38
+ * @internal
39
+ */
40
+ export type CommentMutationKind = 'delete' | 'drag' | 'mutation'
41
+
42
+ interface CommentMutationWriter {
43
+ put(records: TLCommentRecord[]): void
44
+ remove(ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]): void
45
+ }
46
+
47
+ const activeCommentMutations = new WeakMap<Editor, { history: TLHistoryBatchOptions['history'] }>()
48
+
49
+ /** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */
50
+ function historyModeFor(
51
+ options: CommentingOptions,
52
+ kind: CommentMutationKind
53
+ ): TLHistoryBatchOptions['history'] {
54
+ switch (kind) {
55
+ case 'delete':
56
+ return 'ignore'
57
+ case 'drag':
58
+ return options.dragHistory ?? options.history
59
+ case 'mutation':
60
+ return options.history
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Commit a comment mutation with the configured undo/redo behavior, so the
66
+ * {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to
67
+ * `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.
68
+ *
69
+ * `editor.run`'s history option isn't additive — a nested run overwrites the enclosing mode — so
70
+ * constituent records go through the callback's writer instead of opening a commit of their own,
71
+ * which would quietly make a `drag` write non-undoable.
72
+ *
73
+ * Commits that nest are only a problem when they resolve to different modes, and then neither is
74
+ * the right one to keep, so it throws. Matching modes have to nest: a store side effect runs inside
75
+ * the write that triggered it, with no "after the commit" to defer to. (A `store.listen` handler
76
+ * normally flushes on a later frame, so its writes open a commit of their own, but a synchronous
77
+ * flush — as under test — lands it inside too.)
78
+ * @internal
79
+ */
80
+ export function commitCommentMutation<T>(
81
+ editor: Editor,
82
+ fn: (writer: CommentMutationWriter) => T,
83
+ kind: CommentMutationKind = 'mutation'
84
+ ): T {
85
+ const history = historyModeFor(getCommentingOptions(editor), kind)
86
+ const enclosing = activeCommentMutations.get(editor)
87
+ if (enclosing && enclosing.history !== history) {
88
+ throw new Error(
89
+ `A comment mutation that records history as '${history}' can't run inside one recording it as '${enclosing.history}': one of the two modes would be silently discarded. Use the provided writer for constituent records, or run this operation after the enclosing one has committed.`
90
+ )
91
+ }
92
+
93
+ activeCommentMutations.set(editor, { history })
94
+ try {
95
+ let result: T
96
+ editor.run(
97
+ () => {
98
+ let isWriterActive = true
99
+ const assertWriterActive = () => {
100
+ if (!isWriterActive) {
101
+ throw new Error(
102
+ 'A comment mutation writer cannot be used after its commit has finished.'
103
+ )
104
+ }
105
+ }
106
+ try {
107
+ result = fn({
108
+ put: (records) => {
109
+ assertWriterActive()
110
+ editor.store.put(records as unknown as TLRecord[])
111
+ },
112
+ remove: (ids) => {
113
+ assertWriterActive()
114
+ editor.store.remove(ids as unknown as TLRecord['id'][])
115
+ },
116
+ })
117
+ } finally {
118
+ isWriterActive = false
119
+ }
120
+ },
121
+ { history }
122
+ )
123
+ return result!
124
+ } finally {
125
+ if (enclosing) {
126
+ activeCommentMutations.set(editor, enclosing)
127
+ } else {
128
+ activeCommentMutations.delete(editor)
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Write comment records to the store, under the configured {@link CommentingOptions.history}
135
+ * behavior. Defaults to `'ignore'`.
136
+ *
137
+ * Use it to seed or import threads, and to save an edit. To delete, prefer {@link deleteComment}
138
+ * and {@link deleteThread}: comments are soft-deleted, and a synced server rejects the hard delete.
139
+ *
140
+ * @public
141
+ */
142
+ export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
143
+ commitCommentMutation(editor, ({ put }) => put(records))
144
+ }
145
+
146
+ /**
147
+ * Remove comment records from the store by id, under the configured
148
+ * {@link CommentingOptions.history} behavior.
149
+ *
150
+ * This is a hard delete, which is rarely what you want: the built-in UI soft-deletes
151
+ * ({@link deleteComment}, {@link deleteThread}) so the server can prune the records, and a server
152
+ * enforcing per-record permissions vetoes a hard delete outright. Reach for this on a local,
153
+ * unsynced store, or to drop a reaction (see {@link toggleCommentReaction}).
154
+ *
155
+ * @public
156
+ */
157
+ export function removeCommentRecords(
158
+ editor: Editor,
159
+ ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
160
+ ): void {
161
+ commitCommentMutation(editor, ({ remove }) => remove(ids))
162
+ }
163
+
164
+ /**
165
+ * The record as the store currently holds it, or `undefined` if it isn't there any more.
166
+ *
167
+ * A verb is handed a record, but that record is a snapshot and comment records move underneath it:
168
+ * deleting a pinned shape converts the anchor to a point, reparenting rehomes the thread, a drag
169
+ * re-anchors it. Writing the caller's snapshot back would revert those fields for everyone. `put`
170
+ * is also an upsert, so a record a remote delete already removed would come back.
171
+ *
172
+ * So a verb reads what it's changing rather than trusting what it was given, and a record that's
173
+ * gone is a no-op.
174
+ */
175
+ function readLatest<T extends TLComment | TLCommentThread>(
176
+ editor: Editor,
177
+ record: T
178
+ ): T | undefined {
179
+ const current = getCommentRecord(editor, record.id)
180
+ // Record ids carry their type, so a matching `typeName` means a record of exactly T.
181
+ return current?.typeName === record.typeName ? (current as T) : undefined
182
+ }
183
+
184
+ /**
185
+ * Replace a comment's body and stamp it as edited, which renders the "(edited)" marker on its
186
+ * byline. Editing is the author's to do by default ({@link CommentingOptions.canModifyComment}),
187
+ * and a server enforcing per-record permissions rejects anyone else's. Widening one end without the
188
+ * other leaves an edit that's offered and then rejected, so widen both.
189
+ *
190
+ * The body lands on the version the store currently holds, so a stale copy can't revert a later
191
+ * change or re-create a removed comment — editing one of those does nothing.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * editComment(editor, comment, toRichText('Actually, make it dashed'))
196
+ * ```
197
+ *
198
+ * @public
199
+ */
200
+ export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {
201
+ commitCommentMutation(editor, ({ put }) => {
202
+ const current = readLatest(editor, comment)
203
+ if (!current) return
204
+ put([{ ...current, body, editedAt: Date.now() }])
205
+ })
206
+ }
207
+
208
+ /**
209
+ * Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a
210
+ * checked one) and are hidden from the sidebar until its "show resolved" filter is on.
211
+ *
212
+ * Only the resolution is written — the rest of the thread is read fresh, so a stale copy can't drag
213
+ * a pin back. A no-op on a thread that's gone.
214
+ *
215
+ * @public
216
+ */
217
+ export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {
218
+ commitCommentMutation(editor, ({ put }) => {
219
+ const current = readLatest(editor, thread)
220
+ if (!current) return
221
+ put([{ ...current, resolved: { at: Date.now(), by: userId } }])
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and
227
+ * on one that's gone. Like {@link resolveThread}, it touches only the resolution.
228
+ *
229
+ * @public
230
+ */
231
+ export function reopenThread(editor: Editor, thread: TLCommentThread): void {
232
+ commitCommentMutation(editor, ({ put }) => {
233
+ const current = readLatest(editor, thread)
234
+ if (!current) return
235
+ put([{ ...current, resolved: null }])
236
+ })
237
+ }
238
+
239
+ /**
240
+ * Delete a comment.
241
+ *
242
+ * This is a soft delete: it sets `isDeleted` rather than removing the record, and the server prunes
243
+ * the comment and its reactions once the flag is persisted — so no client removes records it
244
+ * doesn't own, and a server enforcing per-record permissions has a write it can check.
245
+ *
246
+ * Deleting is the author's to do by default; {@link CommentingOptions.canModifyComment} widens
247
+ * that, as does its counterpart on the server.
248
+ *
249
+ * Never undoable, whatever {@link CommentingOptions.history} says: the flag is write-once
250
+ * server-side, so an undo clearing it would be vetoed rather than bring the comment back.
251
+ *
252
+ * Deleting a thread's last comment closes it and leaves the thread record for the server to prune,
253
+ * since the deleter may not be its creator. An already-deleted comment is a no-op.
254
+ *
255
+ * @public
256
+ */
257
+ export function deleteComment(editor: Editor, comment: TLComment): void {
258
+ commitCommentMutation(
259
+ editor,
260
+ ({ put }) => {
261
+ const current = readLatest(editor, comment)
262
+ // Deleting twice is nothing to do rather than something to redo. The check below counts this
263
+ // comment among the live ones, so it only reads as "the last one" while this delete takes it away.
264
+ if (!current || current.isDeleted) return
265
+ const isLastInThread =
266
+ getLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1
267
+ if (isLastInThread && openThreadId.get(editor) === current.threadId) {
268
+ openThreadId.set(editor, null)
269
+ }
270
+ put([{ ...current, isDeleted: true }])
271
+ },
272
+ 'delete'
273
+ )
274
+ }
275
+
276
+ /**
277
+ * Delete a thread and, with it, the whole conversation.
278
+ *
279
+ * A soft delete on the same model as {@link deleteComment}: the server prunes the thread, its
280
+ * comments, and their reactions once the flag is persisted. Deleting a thread is its creator's to
281
+ * do by default ({@link CommentingOptions.canModifyComment}), and the write is never undoable.
282
+ * Closes the thread if it's the open one; a pruned thread is a no-op.
283
+ *
284
+ * @public
285
+ */
286
+ export function deleteThread(editor: Editor, thread: TLCommentThread): void {
287
+ commitCommentMutation(
288
+ editor,
289
+ ({ put }) => {
290
+ const current = readLatest(editor, thread)
291
+ if (!current) return
292
+ if (openThreadId.get(editor) === current.id) {
293
+ openThreadId.set(editor, null)
294
+ }
295
+ put([{ ...current, isDeleted: true }])
296
+ },
297
+ 'delete'
298
+ )
299
+ }
@@ -0,0 +1,154 @@
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('summarizeReactions input', () => {
91
+ // the notifications panel tallies rows synced from the app database, which carry only the
92
+ // fields the tally needs — full TLCommentReaction records must not be required
93
+ it('accepts minimal rows with only userId, emoji, and createdAt', () => {
94
+ const rows = [
95
+ { userId: 'user1', emoji: '👍', createdAt: 100 },
96
+ { userId: 'user2', emoji: '👍', createdAt: 200 },
97
+ ]
98
+ expect(summarizeReactions(rows, 'user1')).toEqual([
99
+ {
100
+ emoji: '👍',
101
+ count: 2,
102
+ active: true,
103
+ reactors: [
104
+ { name: 'Someone', you: true },
105
+ { name: 'Someone', you: false },
106
+ ],
107
+ },
108
+ ])
109
+ })
110
+ })
111
+
112
+ describe('createCommentReactionId', () => {
113
+ // the id is what makes reaction identity structural: re-picking the same emoji addresses the
114
+ // same record (toggle off), while a different emoji is its own record (independent)
115
+ it('is stable for the same comment, user, and emoji', () => {
116
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).toBe(
117
+ createCommentReactionId(COMMENT_A, 'user1', '👍')
118
+ )
119
+ })
120
+
121
+ it('differs per user, per comment, and per emoji', () => {
122
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
123
+ createCommentReactionId(COMMENT_A, 'user2', '👍')
124
+ )
125
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
126
+ createCommentReactionId(COMMENT_B, 'user1', '👍')
127
+ )
128
+ // same user + comment, different emoji → a distinct record (this is what enables multi-react)
129
+ expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
130
+ createCommentReactionId(COMMENT_A, 'user1', '🎉')
131
+ )
132
+ })
133
+
134
+ it('is a comment-reaction id, not a comment id', () => {
135
+ const id: string = createCommentReactionId(COMMENT_A, 'user1', '👍')
136
+ expect(id.startsWith('comment-reaction:')).toBe(true)
137
+ })
138
+ })
139
+
140
+ describe('reaction records', () => {
141
+ // a comment's reactions are found by commentId, so a reaction to another comment must not
142
+ // leak into this one's tally
143
+ it('are scoped to one comment by commentId', () => {
144
+ const forOtherComment: TLCommentReaction = {
145
+ ...reaction('user9', '💩', 400),
146
+ commentId: COMMENT_B,
147
+ }
148
+ const all = [reaction('user1', '👍', 100), forOtherComment]
149
+ const mine = all.filter((r) => r.commentId === (COMMENT_A as TLComment['id']))
150
+ expect(summarizeReactions(mine, 'user1')).toEqual([
151
+ { emoji: '👍', count: 1, active: true, reactors: [{ name: 'Someone', you: true }] },
152
+ ])
153
+ })
154
+ })
@@ -0,0 +1,246 @@
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 { commitCommentMutation } from './comment-mutations'
15
+ import { UNKNOWN_AUTHOR } from './comment-render'
16
+ import { getCommentReactions } from './comment-store'
17
+ import { getCommentingOptions, useCommentingOptions } from './options'
18
+
19
+ /**
20
+ * One comment's reactions, oldest first, reactively.
21
+ *
22
+ * @public
23
+ */
24
+ export function useCommentReactions(
25
+ editor: Editor,
26
+ commentId: TLComment['id']
27
+ ): TLCommentReaction[] {
28
+ return useValue(
29
+ 'comment reactions',
30
+ () =>
31
+ getCommentReactions(editor)
32
+ .filter((reaction) => reaction.commentId === commentId)
33
+ .sort((a, b) => a.createdAt - b.createdAt),
34
+ [editor, commentId]
35
+ )
36
+ }
37
+
38
+ /**
39
+ * The reaction fields {@link summarizeReactions} needs — a structural subset of
40
+ * {@link tldraw#TLCommentReaction}, so tallies can also be built from rows synced outside the
41
+ * editor store.
42
+ *
43
+ * @public
44
+ */
45
+ export interface ReactionSummaryInput {
46
+ userId: string
47
+ emoji: string
48
+ createdAt: number
49
+ }
50
+
51
+ /**
52
+ * Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used so
53
+ * the row stays stable as later reactions arrive. `active` marks the current user's emoji and
54
+ * `reactors` lists who reacted, in reaction order. `resolveName` names each reactor; an id it can't
55
+ * name falls back to a generic "Someone", never the raw user id.
56
+ *
57
+ * @public
58
+ */
59
+ export function summarizeReactions(
60
+ reactions: readonly ReactionSummaryInput[],
61
+ currentUserId?: string | null,
62
+ resolveName?: (userId: string) => string | undefined
63
+ ): ReactionSummary[] {
64
+ const groups = new Map<
65
+ string,
66
+ { count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] }
67
+ >()
68
+ for (const reaction of reactions) {
69
+ const mine = currentUserId != null && reaction.userId === currentUserId
70
+ // Fall back to a generic name, never the raw user id, when the id can't be resolved.
71
+ const reactor = { name: resolveName?.(reaction.userId) ?? UNKNOWN_AUTHOR, you: mine }
72
+ const group = groups.get(reaction.emoji)
73
+ if (group) {
74
+ group.count++
75
+ group.active ||= mine
76
+ group.firstAt = Math.min(group.firstAt, reaction.createdAt)
77
+ group.reactors.push(reactor)
78
+ } else {
79
+ groups.set(reaction.emoji, {
80
+ count: 1,
81
+ active: mine,
82
+ firstAt: reaction.createdAt,
83
+ reactors: [reactor],
84
+ })
85
+ }
86
+ }
87
+ return [...groups]
88
+ .sort(([, a], [, b]) => a.firstAt - b.firstAt)
89
+ .map(([emoji, group]) => ({
90
+ emoji,
91
+ count: group.count,
92
+ active: group.active,
93
+ reactors: group.reactors,
94
+ }))
95
+ }
96
+
97
+ /**
98
+ * Toggle one user's reaction with a given emoji on a comment.
99
+ *
100
+ * Each reaction is its own record keyed by (comment, user, emoji), so this only touches that user's
101
+ * own records and two people reacting at once never conflict. Behaviour depends on
102
+ * `allowMultipleReactions`:
103
+ *
104
+ * - **multiple** (default): the emoji toggles independently, leaving other reactions alone.
105
+ * - **single**: a new emoji replaces the user's existing reaction; the same one removes it.
106
+ *
107
+ * @public
108
+ */
109
+ export function toggleCommentReaction(
110
+ editor: Editor,
111
+ comment: TLComment,
112
+ userId: string,
113
+ emoji: string,
114
+ now = Date.now()
115
+ ): void {
116
+ const { allowMultipleReactions, isAllowedReaction } = getCommentingOptions(editor)
117
+ const targetId = createCommentReactionId(comment.id, userId, emoji)
118
+ const mine = getCommentReactions(editor).filter(
119
+ (reaction) => reaction.commentId === comment.id && reaction.userId === userId
120
+ )
121
+ const removing = mine.some((reaction) => reaction.id === targetId)
122
+ // Only a token the configured palette allows may be added. Removals always go through — if a
123
+ // reaction somehow carries an off-palette token, the user must still be able to clear it.
124
+ if (!removing && !isAllowedReaction(emoji)) return
125
+ commitCommentMutation(editor, ({ put, remove }) => {
126
+ if (removing) {
127
+ remove([targetId])
128
+ return
129
+ }
130
+ // Single-select: a new emoji replaces the user's existing reaction(s) on this comment.
131
+ if (!allowMultipleReactions && mine.length > 0) {
132
+ remove(mine.map((reaction) => reaction.id))
133
+ }
134
+ put([
135
+ createCommentReaction({
136
+ commentId: comment.id,
137
+ threadId: comment.threadId,
138
+ pageId: comment.pageId,
139
+ userId,
140
+ emoji,
141
+ now,
142
+ }),
143
+ ])
144
+ })
145
+ }
146
+
147
+ /** @public */
148
+ export interface CommentReactionsProps {
149
+ comment: TLComment
150
+ /** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the
151
+ * pills don't toggle. */
152
+ currentUserId?: string | null
153
+ /** Names a reactor id for the hover list. Ids it can't name fall back to a generic "Someone". */
154
+ resolveName?(userId: string): string | undefined
155
+ }
156
+
157
+ /**
158
+ * Adapts the `ReactionContent` override into a `renderReaction` function for the presentational
159
+ * components. Undefined when no override is set, so they fall back to drawing the token string.
160
+ */
161
+ function useReactionRenderer(): RenderReaction | undefined {
162
+ const { components } = useCommentingOptions()
163
+ const ReactionContent = components.ReactionContent
164
+ return useMemo(
165
+ () => (ReactionContent ? (token: string) => <ReactionContent token={token} /> : undefined),
166
+ [ReactionContent]
167
+ )
168
+ }
169
+
170
+ /**
171
+ * The tallied reaction row under one comment. Pair with `CommentReactionPicker`, which is what
172
+ * adds a reaction.
173
+ * @public @react
174
+ */
175
+ export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps) {
176
+ const editor = useEditor()
177
+ const renderReaction = useReactionRenderer()
178
+ const { components } = useCommentingOptions()
179
+ const reactions = useCommentReactions(editor, comment.id)
180
+ const summaries = useMemo(
181
+ () => summarizeReactions(reactions, currentUserId, resolveName),
182
+ [reactions, currentUserId, resolveName]
183
+ )
184
+ // Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it
185
+ // doesn't compete with the menu the user is actually working in. Edit mode already hides the
186
+ // pills entirely (the card becomes a composer), so it needs no special case here.
187
+ const anyMenuOpen = useValue('any menu open', () => editor.menus.getOpenMenus().length > 0, [
188
+ editor,
189
+ ])
190
+ return (
191
+ <Reactions
192
+ reactions={summaries}
193
+ canReact={currentUserId != null}
194
+ enableHoverList={!anyMenuOpen}
195
+ renderReaction={renderReaction}
196
+ ReactionTooltip={components.ReactionTooltip}
197
+ onToggle={(value) => {
198
+ if (currentUserId == null) return
199
+ toggleCommentReaction(editor, comment, currentUserId, value)
200
+ }}
201
+ />
202
+ )
203
+ }
204
+
205
+ /** @public */
206
+ export interface CommentReactionPickerProps {
207
+ comment: TLComment
208
+ /** The reacting user. Null/omitted renders nothing — there's nobody to react as. */
209
+ currentUserId?: string | null
210
+ /** The emoji the picker offers. Defaults to `DEFAULT_REACTION_EMOJI`. */
211
+ emoji?: string[]
212
+ }
213
+
214
+ /**
215
+ * The add-reaction button for one comment. Belongs with the comment card's hover actions rather
216
+ * than in the reaction row, so opening it doesn't chase the row as reactions are added.
217
+ * @public @react
218
+ */
219
+ export function CommentReactionPicker({
220
+ comment,
221
+ currentUserId,
222
+ emoji,
223
+ }: CommentReactionPickerProps) {
224
+ const editor = useEditor()
225
+ const { components } = useCommentingOptions()
226
+ const renderReaction = useReactionRenderer()
227
+ const reactions = useCommentReactions(editor, comment.id)
228
+ const selected = useMemo(
229
+ () =>
230
+ reactions
231
+ .filter((reaction) => currentUserId != null && reaction.userId === currentUserId)
232
+ .map((reaction) => reaction.emoji),
233
+ [reactions, currentUserId]
234
+ )
235
+ if (currentUserId == null) return null
236
+ return (
237
+ <ReactionPicker
238
+ emoji={emoji}
239
+ selected={selected}
240
+ renderReaction={renderReaction}
241
+ palette={components.ReactionPalette}
242
+ menuId={`comment-reactions-${comment.id}`}
243
+ onSelect={(value) => toggleCommentReaction(editor, comment, currentUserId, value)}
244
+ />
245
+ )
246
+ }