@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
@@ -1,9 +1,16 @@
1
1
  /** @public */
2
2
  export interface CountBadgeProps {
3
3
  count: number
4
+ /** The badge's list is showing — shows the active/selected indicator state, as an open pin does. */
5
+ open?: boolean
4
6
  }
5
7
 
6
8
  /** @public @react */
7
- export function CountBadge({ count }: CountBadgeProps) {
8
- return <div className="cmt-count-badge">{count}</div>
9
+ export function CountBadge({ count, open }: CountBadgeProps) {
10
+ // `tlui-cmt-marker` carries the resting shadow, the hover lift, and the open ring — the same
11
+ // treatment a comment pin wears, so the two markers behave alike.
12
+ const className = ['tlui-cmt-marker', 'tlui-cmt-count-badge', open && 'tlui-cmt-marker--open']
13
+ .filter(Boolean)
14
+ .join(' ')
15
+ return <div className={className}>{count}</div>
9
16
  }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { DEFAULT_REACTION_EMOJI, isAllowedReactionEmoji } from './emoji-picker'
3
+
4
+ describe('isAllowedReactionEmoji', () => {
5
+ it('accepts every emoji in the default palette', () => {
6
+ for (const emoji of DEFAULT_REACTION_EMOJI) {
7
+ expect(isAllowedReactionEmoji(emoji)).toBe(true)
8
+ }
9
+ })
10
+
11
+ it('rejects emoji not in the palette', () => {
12
+ expect(isAllowedReactionEmoji('🦄')).toBe(false)
13
+ })
14
+
15
+ // the record's emoji field is a free string, so this is the guard against junk values
16
+ it('rejects arbitrary non-emoji strings', () => {
17
+ expect(isAllowedReactionEmoji('not an emoji')).toBe(false)
18
+ expect(isAllowedReactionEmoji('')).toBe(false)
19
+ })
20
+
21
+ it('checks against a custom palette when given one', () => {
22
+ expect(isAllowedReactionEmoji('🦄', ['🦄', '🌈'])).toBe(true)
23
+ expect(isAllowedReactionEmoji('👍', ['🦄', '🌈'])).toBe(false)
24
+ })
25
+ })
@@ -0,0 +1,76 @@
1
+ import { RenderReaction, defaultRenderReaction } from './reaction'
2
+
3
+ /**
4
+ * The reaction emoji palette: the small, opinionated set the picker offers *and* the set a
5
+ * reaction is allowed to use. It's deliberately short — a quick affordance under a comment, not a
6
+ * full emoji keyboard — and it's the single source of truth for "which emoji may a reaction
7
+ * carry". A reaction's `emoji` field is a free-form `string`, so this palette is what keeps it to
8
+ * real, offered emoji; check membership with {@link isAllowedReactionEmoji}. Pass a custom `emoji`
9
+ * array to `ReactionPicker` to offer a different set — validate against that same set if you do.
10
+ *
11
+ * @public
12
+ */
13
+ export const DEFAULT_REACTION_EMOJI = ['👍', '👎', '❤️', '🎉', '😄', '😮', '😢', '🙏', '👀', '🔥']
14
+
15
+ /**
16
+ * Whether `emoji` belongs to a reaction palette (defaults to {@link DEFAULT_REACTION_EMOJI}). Use
17
+ * it to reject arbitrary strings before writing a reaction, so a scripted client can't spam a
18
+ * comment with junk `emoji` values that the picker would never offer. Pass a custom `palette` to
19
+ * match a customized picker.
20
+ *
21
+ * @public
22
+ */
23
+ export function isAllowedReactionEmoji(
24
+ emoji: string,
25
+ palette: readonly string[] = DEFAULT_REACTION_EMOJI
26
+ ): boolean {
27
+ return palette.includes(emoji)
28
+ }
29
+
30
+ /** @public */
31
+ export interface EmojiPickerProps {
32
+ /** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */
33
+ emoji?: string[]
34
+ /** Emoji the current user has already reacted with; shown as pressed. */
35
+ selected?: string[]
36
+ /** Called when an emoji is chosen. */
37
+ onSelect?(emoji: string): void
38
+ /** How to draw each emoji token. Defaults to the token string (OS emoji font). */
39
+ renderReaction?: RenderReaction
40
+ }
41
+
42
+ /**
43
+ * A grid of emoji to react with. Presentational and unpositioned — the host owns placement and
44
+ * dismissal; `ReactionPicker` renders it in a dropdown under the add-reaction button.
45
+ * @public @react
46
+ */
47
+ export function EmojiPicker({
48
+ emoji = DEFAULT_REACTION_EMOJI,
49
+ selected,
50
+ onSelect,
51
+ renderReaction = defaultRenderReaction,
52
+ }: EmojiPickerProps) {
53
+ return (
54
+ <div className="tlui-cmt-emoji-picker" role="group">
55
+ {emoji.map((value) => {
56
+ const active = selected?.includes(value) ?? false
57
+ return (
58
+ <button
59
+ key={value}
60
+ type="button"
61
+ className={
62
+ active
63
+ ? 'tlui-cmt-emoji-picker__item tlui-cmt-emoji-picker__item--active'
64
+ : 'tlui-cmt-emoji-picker__item'
65
+ }
66
+ aria-label={value}
67
+ aria-pressed={active}
68
+ onClick={() => onSelect?.(value)}
69
+ >
70
+ {renderReaction(value)}
71
+ </button>
72
+ )
73
+ })}
74
+ </div>
75
+ )
76
+ }
@@ -6,11 +6,11 @@ export interface EmptyStateProps {
6
6
  /** The empty state shown when a thread has no comments yet. @public @react */
7
7
  export function EmptyState({ message }: EmptyStateProps) {
8
8
  return (
9
- <div className="cmt-empty">
10
- <div className="cmt-empty__icon" aria-hidden="true">
9
+ <div className="tlui-cmt-empty">
10
+ <div className="tlui-cmt-empty__icon" aria-hidden="true">
11
11
  💬
12
12
  </div>
13
- <p className="cmt-empty__message">{message}</p>
13
+ <p className="tlui-cmt-empty__message">{message}</p>
14
14
  </div>
15
15
  )
16
16
  }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatFullDateTime, formatRelativeTime } from './format-time'
3
+
4
+ /** An ISO timestamp `seconds` in the past, relative to now. */
5
+ function agoIso(seconds: number): string {
6
+ return new Date(Date.now() - seconds * 1000).toISOString()
7
+ }
8
+
9
+ const TWO_HOURS = 2 * 60 * 60
10
+
11
+ describe('formatRelativeTime', () => {
12
+ it('formats in English by default', () => {
13
+ expect(formatRelativeTime(agoIso(TWO_HOURS))).toBe(
14
+ new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-2, 'hour')
15
+ )
16
+ })
17
+
18
+ // `Byline` passes the UI's current translation locale; without this a localized app showed
19
+ // English timestamps under localized everything else.
20
+ it('formats in the given locale', () => {
21
+ const french = formatRelativeTime(agoIso(TWO_HOURS), 'fr')
22
+ expect(french).toBe(
23
+ new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'short' }).format(-2, 'hour')
24
+ )
25
+ expect(french).not.toBe(formatRelativeTime(agoIso(TWO_HOURS), 'en'))
26
+ })
27
+
28
+ // The parameter is optional, so undefined has to mean "the default", not "no locale".
29
+ it('treats an undefined locale as the default', () => {
30
+ expect(formatRelativeTime(agoIso(TWO_HOURS), undefined)).toBe(
31
+ formatRelativeTime(agoIso(TWO_HOURS))
32
+ )
33
+ })
34
+
35
+ it('reads under a minute as "now" in the given locale', () => {
36
+ expect(formatRelativeTime(agoIso(5), 'fr')).toBe(
37
+ new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'short' }).format(0, 'second')
38
+ )
39
+ })
40
+
41
+ it('returns nothing for an unparseable date', () => {
42
+ expect(formatRelativeTime('not a date', 'fr')).toBe('')
43
+ })
44
+ })
45
+
46
+ describe('formatFullDateTime', () => {
47
+ it('formats an ISO datetime as a full date and time', () => {
48
+ const iso = '2025-07-22T16:44:00.000Z'
49
+ expect(formatFullDateTime(iso)).toBe(
50
+ new Intl.DateTimeFormat('en', { dateStyle: 'full', timeStyle: 'short' }).format(new Date(iso))
51
+ )
52
+ })
53
+
54
+ it('formats in the given locale', () => {
55
+ const french = formatFullDateTime('2025-07-22T16:44:00.000Z', 'fr')
56
+ expect(french).toContain('juillet')
57
+ expect(french).not.toBe(formatFullDateTime('2025-07-22T16:44:00.000Z', 'en'))
58
+ })
59
+
60
+ // The parameter is optional, so undefined has to mean "the default", not "no locale".
61
+ it('treats an undefined locale as the default', () => {
62
+ const iso = '2025-07-22T16:44:00.000Z'
63
+ expect(formatFullDateTime(iso, undefined)).toBe(formatFullDateTime(iso))
64
+ })
65
+
66
+ it('returns an empty string for invalid dates', () => {
67
+ expect(formatFullDateTime('not a date')).toBe('')
68
+ })
69
+ })
@@ -9,7 +9,7 @@ const DIVISIONS = [
9
9
  ] as const
10
10
 
11
11
  /**
12
- * Format an ISO datetime as relative time ("2 hours ago", "yesterday", "last week").
12
+ * Format an ISO datetime as short relative time ("2 hr. ago", "yesterday", "last wk.").
13
13
  * Locale-aware via Intl.RelativeTimeFormat.
14
14
  * @public
15
15
  */
@@ -17,8 +17,13 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
17
17
  const then = new Date(iso).getTime()
18
18
  if (Number.isNaN(then)) return ''
19
19
 
20
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
20
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'short' })
21
21
  let duration = (then - Date.now()) / 1000
22
+ // Under a minute is just "now" — with second granularity the label visibly ticks while
23
+ // typing a reply (every keystroke re-renders the card).
24
+ if (Math.abs(duration) < 60) {
25
+ return rtf.format(0, 'second')
26
+ }
22
27
  for (const division of DIVISIONS) {
23
28
  if (Math.abs(duration) < division.amount) {
24
29
  return rtf.format(Math.round(duration), division.unit)
@@ -27,3 +32,23 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
27
32
  }
28
33
  return ''
29
34
  }
35
+
36
+ // Cached per locale — comment cards re-render on every reply keystroke, and constructing a
37
+ // DateTimeFormat is far more expensive than formatting with one.
38
+ const fullDateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
39
+
40
+ /**
41
+ * Format an ISO datetime as a full date and time ("Tuesday, July 22, 2025 at 4:44 PM").
42
+ * Locale-aware via Intl.DateTimeFormat.
43
+ * @public
44
+ */
45
+ export function formatFullDateTime(iso: string, locale = 'en'): string {
46
+ const date = new Date(iso)
47
+ if (Number.isNaN(date.getTime())) return ''
48
+ let formatter = fullDateTimeFormatters.get(locale)
49
+ if (!formatter) {
50
+ formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' })
51
+ fullDateTimeFormatters.set(locale, formatter)
52
+ }
53
+ return formatter.format(date)
54
+ }
@@ -0,0 +1,116 @@
1
+ /* The commenting UI's inline icons, kept together so the set is visible in one place.
2
+ *
3
+ * Anything with an equivalent in tldraw's icon assets goes through `TldrawUiIcon` instead (the ⋯
4
+ * menu, the dismiss cross, the resolve button's check). What's left lives here because the asset
5
+ * set has no counterpart — a smiley, a send arrow, the eye pair, a filter — or because the asset
6
+ * reads wrong at the size it's needed, which is the case for both resolved checks below. Inline
7
+ * also means they render without an `AssetUrlsProvider` and without the consumer serving files.
8
+ *
9
+ * All internal — none are exported from the package. */
10
+
11
+ /** The check inside a resolved comment pin. A drawn check, not the '✓' glyph — that character sits
12
+ * off-baseline and varies by font. Heavier and rounder than `CheckIcon`: it has to hold its weight
13
+ * against the pin's 28px filled face, where the asset check reads too light. */
14
+ export function PinCheckIcon() {
15
+ return (
16
+ <svg
17
+ viewBox="0 0 24 24"
18
+ width="15"
19
+ height="15"
20
+ fill="none"
21
+ stroke="currentColor"
22
+ strokeWidth="2.5"
23
+ strokeLinecap="round"
24
+ strokeLinejoin="round"
25
+ aria-hidden="true"
26
+ >
27
+ <path d="M4 12.5l5 5L20 6.5" />
28
+ </svg>
29
+ )
30
+ }
31
+
32
+ /** The check beside the "Resolved" label on a comments list row. Thinner than `PinCheckIcon` — it
33
+ * sits next to text at 12px, so it's drawn to match the label's weight rather than shout. */
34
+ export function CheckIcon() {
35
+ return (
36
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
37
+ <path
38
+ d="M2.5 6.2 4.7 8.4 9.5 3.6"
39
+ stroke="currentColor"
40
+ strokeWidth="1.5"
41
+ strokeLinecap="round"
42
+ strokeLinejoin="round"
43
+ />
44
+ </svg>
45
+ )
46
+ }
47
+
48
+ /** The reaction picker's trigger. */
49
+ export function SmileyIcon() {
50
+ return (
51
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
52
+ <path
53
+ d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82708 7.49972C1.82708 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82708 10.6327 1.82708 7.49972ZM5.03747 9.21395C4.87949 8.98746 4.56782 8.93193 4.34133 9.08991C4.11484 9.24789 4.05931 9.55956 4.21729 9.78605C4.93926 10.8211 6.14033 11.5 7.50004 11.5C8.85974 11.5 10.0608 10.8211 10.7828 9.78605C10.9408 9.55956 10.8852 9.24789 10.6587 9.08991C10.4323 8.93193 10.1206 8.98746 9.9626 9.21395C9.41963 9.99238 8.51907 10.5 7.50004 10.5C6.481 10.5 5.58044 9.99238 5.03747 9.21395ZM5.37503 6.84998C5.85828 6.84998 6.25003 6.45815 6.25003 5.97498C6.25003 5.4918 5.85828 5.09998 5.37503 5.09998C4.89179 5.09998 4.50003 5.4918 4.50003 5.97498C4.50003 6.45815 4.89179 6.84998 5.37503 6.84998ZM10.5 5.97498C10.5 6.45815 10.1083 6.84998 9.62503 6.84998C9.14179 6.84998 8.75003 6.45815 8.75003 5.97498C8.75003 5.4918 9.14179 5.09998 9.62503 5.09998C10.1083 5.09998 10.5 5.4918 10.5 5.97498Z"
54
+ fill="currentColor"
55
+ fillRule="evenodd"
56
+ clipRule="evenodd"
57
+ />
58
+ </svg>
59
+ )
60
+ }
61
+
62
+ /** The composer's post button — an up arrow. */
63
+ export function SendIcon() {
64
+ return (
65
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
66
+ <path
67
+ d="M7.14645 2.14645C7.34171 1.95118 7.65829 1.95118 7.85355 2.14645L11.8536 6.14645C12.0488 6.34171 12.0488 6.65829 11.8536 6.85355C11.6583 7.04882 11.3417 7.04882 11.1464 6.85355L8 3.70711L8 12.5C8 12.7761 7.77614 13 7.5 13C7.22386 13 7 12.7761 7 12.5L7 3.70711L3.85355 6.85355C3.65829 7.04882 3.34171 7.04882 3.14645 6.85355C2.95118 6.65829 2.95118 6.34171 3.14645 6.14645L7.14645 2.14645Z"
68
+ fill="currentColor"
69
+ fillRule="evenodd"
70
+ clipRule="evenodd"
71
+ />
72
+ </svg>
73
+ )
74
+ }
75
+
76
+ /** Comment pins are showing. Pairs with `EyeClosedIcon` — the two must stay the same family. */
77
+ export function EyeOpenIcon() {
78
+ return (
79
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
80
+ <path
81
+ d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.5C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
82
+ fill="currentColor"
83
+ fillRule="evenodd"
84
+ clipRule="evenodd"
85
+ />
86
+ </svg>
87
+ )
88
+ }
89
+
90
+ /** Comment pins are hidden. */
91
+ export function EyeClosedIcon() {
92
+ return (
93
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
94
+ <path
95
+ d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 11 7.50001 11C6.99965 11 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.39889 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 10 7.50001 10C10.3078 10 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
96
+ fill="currentColor"
97
+ fillRule="evenodd"
98
+ clipRule="evenodd"
99
+ />
100
+ </svg>
101
+ )
102
+ }
103
+
104
+ /** The sidebar's filter menu trigger. */
105
+ export function FilterIcon() {
106
+ return (
107
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
108
+ <path
109
+ d="M5.5 3C4.67157 3 4 3.67157 4 4.5C4 5.32843 4.67157 6 5.5 6C6.32843 6 7 5.32843 7 4.5C7 3.67157 6.32843 3 5.5 3ZM3 5C3.01671 5 3.03323 4.99918 3.04952 4.99758C3.28022 6.1399 4.28967 7 5.5 7C6.71033 7 7.71978 6.1399 7.95048 4.99758C7.96677 4.99918 7.98329 5 8 5H13.5C13.7761 5 14 4.77614 14 4.5C14 4.22386 13.7761 4 13.5 4H8C7.98329 4 7.96677 4.00082 7.95048 4.00242C7.71978 2.86009 6.71033 2 5.5 2C4.28967 2 3.28022 2.86009 3.04952 4.00242C3.03323 4.00082 3.01671 4 3 4H1.5C1.22386 4 1 4.22386 1 4.5C1 4.77614 1.22386 5 1.5 5H3ZM11.9505 10.9976C11.7198 12.1399 10.7103 13 9.5 13C8.28967 13 7.28022 12.1399 7.04952 10.9976C7.03323 10.9992 7.01671 11 7 11H1.5C1.22386 11 1 10.7761 1 10.5C1 10.2239 1.22386 10 1.5 10H7C7.01671 10 7.03323 10.0008 7.04952 10.0024C7.28022 8.8601 8.28967 8 9.5 8C10.7103 8 11.7198 8.8601 11.9505 10.0024C11.9668 10.0008 11.9833 10 12 10H13.5C13.7761 10 14 10.2239 14 10.5C14 10.7761 13.7761 11 13.5 11H12C11.9833 11 11.9668 10.9992 11.9505 10.9976ZM8 10.5C8 9.67157 8.67157 9 9.5 9C10.3284 9 11 9.67157 11 10.5C11 11.3284 10.3284 12 9.5 12C8.67157 12 8 11.3284 8 10.5Z"
110
+ fill="currentColor"
111
+ fillRule="evenodd"
112
+ clipRule="evenodd"
113
+ />
114
+ </svg>
115
+ )
116
+ }
@@ -0,0 +1,102 @@
1
+ import { useCallback, useId, type ComponentType } from 'react'
2
+ import {
3
+ TldrawUiButton,
4
+ TldrawUiDropdownMenuContent,
5
+ TldrawUiDropdownMenuRoot,
6
+ TldrawUiDropdownMenuTrigger,
7
+ tlmenus,
8
+ useMaybeEditor,
9
+ useTranslation,
10
+ } from 'tldraw'
11
+ import { EmojiPicker, type EmojiPickerProps } from './emoji-picker'
12
+ import { SmileyIcon } from './icons'
13
+ import { RenderReaction } from './reaction'
14
+
15
+ /** @public */
16
+ export interface ReactionPickerProps {
17
+ /** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */
18
+ emoji?: string[]
19
+ /** Emoji the current user has already reacted with; shown as pressed in the grid. */
20
+ selected?: string[]
21
+ /** Called when an emoji is chosen. */
22
+ onSelect?(emoji: string): void
23
+ /** How to draw each emoji token. Defaults to the token string (OS emoji font). */
24
+ renderReaction?: RenderReaction
25
+ /**
26
+ * What the button opens — the thing that produces a token. Defaults to `EmojiPicker`, the grid of
27
+ * emoji. Swap it for any component taking the same props to offer something else entirely (the
28
+ * drawn reactions example replaces it with a canvas you draw in); the props are passed straight
29
+ * through either way.
30
+ */
31
+ palette?: ComponentType<EmojiPickerProps>
32
+ /**
33
+ * Identifies the menu in tldraw's global menu registry, which keys open/closed state by id.
34
+ * A thread renders one picker per comment, so this must differ per comment — sharing an id
35
+ * makes every picker in the thread open at once. Defaults to a per-instance generated id;
36
+ * pass one only for a stabler, more debuggable value.
37
+ */
38
+ menuId?: string
39
+ /** Class for the trigger button. Defaults to the card-action style, matching the ⋯ button. */
40
+ className?: string
41
+ }
42
+
43
+ /**
44
+ * The add-reaction affordance: a smiley button that opens an emoji grid.
45
+ *
46
+ * Anchored to its own button rather than to the reactions row, so the menu keeps its position as
47
+ * reactions are added and the row reflows.
48
+ *
49
+ * Picking an emoji dismisses the grid — adding and removing alike, since either way the picker has
50
+ * done its job — and hands focus back to the trigger.
51
+ * @public @react
52
+ */
53
+ export function ReactionPicker({
54
+ emoji,
55
+ selected,
56
+ onSelect,
57
+ renderReaction,
58
+ palette: Palette = EmojiPicker,
59
+ menuId,
60
+ className = 'tlui-cmt-thread__action',
61
+ }: ReactionPickerProps) {
62
+ const msg = useTranslation()
63
+ const editor = useMaybeEditor()
64
+ // Unique per mounted picker unless the host supplies something stabler — see `menuId`.
65
+ const generatedId = useId()
66
+ const id = menuId ?? `comment-reactions-${generatedId}`
67
+
68
+ // The dropdown's open state lives in tldraw's global menu registry, so dropping the entry is
69
+ // what closes it. The palette's tokens are plain buttons — a swappable component, not radix
70
+ // menu items — so nothing dismisses the menu on its own.
71
+ const handleSelect = useCallback(
72
+ (value: string) => {
73
+ onSelect?.(value)
74
+ tlmenus.deleteOpenMenu(id, editor?.contextId)
75
+ },
76
+ [onSelect, id, editor]
77
+ )
78
+
79
+ return (
80
+ <TldrawUiDropdownMenuRoot id={id}>
81
+ <TldrawUiDropdownMenuTrigger>
82
+ <TldrawUiButton
83
+ type="icon"
84
+ tooltip={msg('comments.add-reaction')}
85
+ title={msg('comments.add-reaction')}
86
+ className={className}
87
+ >
88
+ <SmileyIcon />
89
+ </TldrawUiButton>
90
+ </TldrawUiDropdownMenuTrigger>
91
+ {/* left-aligned under the trigger, so the grid opens rightward off the card's edge */}
92
+ <TldrawUiDropdownMenuContent side="bottom" align="start" alignOffset={0} sideOffset={4}>
93
+ <Palette
94
+ emoji={emoji}
95
+ selected={selected}
96
+ onSelect={handleSelect}
97
+ renderReaction={renderReaction}
98
+ />
99
+ </TldrawUiDropdownMenuContent>
100
+ </TldrawUiDropdownMenuRoot>
101
+ )
102
+ }
@@ -1,16 +1,135 @@
1
+ import { ComponentType, ReactNode } from 'react'
2
+ import { TldrawUiTooltip, useTranslation } from 'tldraw'
3
+ import { ReactionReactor } from './reactions'
4
+
5
+ /** Render a reaction token to its visual — the emoji glyph by default. @public */
6
+ export type RenderReaction = (token: string) => ReactNode
7
+
8
+ /** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */
9
+ export function defaultRenderReaction(token: string): ReactNode {
10
+ return token
11
+ }
12
+
1
13
  /** @public */
2
14
  export interface ReactionProps {
3
15
  emoji: string
4
16
  count: number
5
17
  active: boolean
18
+ /** How to draw the emoji token. Defaults to the token string (OS emoji font). */
19
+ renderReaction?: RenderReaction
20
+ /** Who reacted with this emoji, in reaction order — shown when the pill is hovered. */
21
+ reactors: ReactionReactor[]
22
+ /** Whether hovering shows the reactor list. Pass false to suppress it — e.g. while another
23
+ * popup menu on the comment is open. Defaults to true. */
24
+ enableHoverList?: boolean
25
+ /** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill
26
+ * itself (as `children`) and returns the whole thing — so it owns the tooltip, its box, size,
27
+ * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which
28
+ * hangs the built-in inline sentence below the pill. */
29
+ ReactionTooltip?: ComponentType<ReactionTooltipProps>
30
+ /** Called when the pill is pressed — toggles the current user's reaction. */
31
+ onClick?(): void
6
32
  }
7
33
 
8
- /** A single emoji reaction pill with a count, highlighted when the user reacted. @public @react */
9
- export function Reaction({ emoji, count, active }: ReactionProps) {
34
+ /**
35
+ * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a
36
+ * tooltip naming who reacted (the current user included), when `enableHoverList` is set.
37
+ * @public @react
38
+ */
39
+ export function Reaction({
40
+ emoji,
41
+ count,
42
+ active,
43
+ reactors,
44
+ enableHoverList = true,
45
+ renderReaction = defaultRenderReaction,
46
+ ReactionTooltip = DefaultReactionTooltip,
47
+ onClick,
48
+ }: ReactionProps) {
49
+ // An inert pill (no handler) is plain content, not a control — rendering it as a span keeps it
50
+ // valid inside an anchor row (e.g. a notification list item) and out of the tab order.
51
+ const Tag = onClick ? 'button' : 'span'
52
+ const pill = (
53
+ <Tag
54
+ className={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}
55
+ {...(onClick
56
+ ? { type: 'button' as const, 'aria-pressed': active, onClick }
57
+ : { role: 'img' as const })}
58
+ // The emoji alone announces as its unicode name ("thumbs up"); pairing it with the count
59
+ // is what makes the pill's state legible to a screen reader. The inert span needs the
60
+ // img role for the label to be announced at all.
61
+ aria-label={`${emoji} ${count}`}
62
+ >
63
+ <span className="tlui-cmt-reaction__emoji">{renderReaction(emoji)}</span>
64
+ <span className="tlui-cmt-reaction__count">{count}</span>
65
+ </Tag>
66
+ )
67
+
68
+ // A bare pill when hovering is suppressed, or when there's no one to name.
69
+ if (!enableHoverList || reactors.length === 0) return pill
70
+
71
+ // Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the
72
+ // reactor list is presented.
73
+ return <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>
74
+ }
75
+
76
+ /** @public */
77
+ export interface ReactionTooltipProps {
78
+ /** Who reacted with this emoji, in reaction order. */
79
+ reactors: ReactionReactor[]
80
+ /** The reaction pill to wrap. Anchor the hover affordance to this and render it. */
81
+ children: ReactNode
82
+ }
83
+
84
+ /**
85
+ * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)
86
+ * in a standard tooltip below the pill — the same tooltip the toolbar uses. Replace it via
87
+ * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,
88
+ * avatars, a banner, anywhere on screen); it receives the pill as `children`.
89
+ * @public @react
90
+ */
91
+ export function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {
10
92
  return (
11
- <button className={active ? 'cmt-reaction cmt-reaction--active' : 'cmt-reaction'} type="button">
12
- <span className="cmt-reaction__emoji">{emoji}</span>
13
- <span className="cmt-reaction__count">{count}</span>
14
- </button>
93
+ <TldrawUiTooltip side="bottom" content={<DefaultReactionTooltipContent reactors={reactors} />}>
94
+ {children}
95
+ </TldrawUiTooltip>
96
+ )
97
+ }
98
+
99
+ /**
100
+ * The default reactor sentence naming who reacted — up to three names spelled out, then "and N
101
+ * others" (e.g. "You reacted", "You and Bo reacted", "You, Bo and Ada reacted", "You, Bo, Ada and 2
102
+ * others reacted"). The wording lives in the `comments.reacted-*` translation strings so each locale
103
+ * controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.
104
+ * @public @react
105
+ */
106
+ export function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {
107
+ const msg = useTranslation()
108
+ const names = reactors.map((reactor) =>
109
+ reactor.you ? msg('comments.mention-you') : reactor.name
15
110
  )
111
+ const [a, b, c] = names
112
+ switch (names.length) {
113
+ case 0:
114
+ return null
115
+ case 1:
116
+ return <>{msg('comments.reacted-1').replace('{a}', a)}</>
117
+ case 2:
118
+ return <>{msg('comments.reacted-2').replace('{a}', a).replace('{b}', b)}</>
119
+ case 3:
120
+ return <>{msg('comments.reacted-3').replace('{a}', a).replace('{b}', b).replace('{c}', c)}</>
121
+ default: {
122
+ const others = names.length - 3
123
+ const template = others === 1 ? 'comments.reacted-more-one' : 'comments.reacted-more'
124
+ return (
125
+ <>
126
+ {msg(template)
127
+ .replace('{a}', a)
128
+ .replace('{b}', b)
129
+ .replace('{c}', c)
130
+ .replace('{count}', String(others))}
131
+ </>
132
+ )
133
+ }
134
+ }
16
135
  }