@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,17 +1,29 @@
1
- import { Extension, JSONContent } from '@tiptap/core'
1
+ import { JSONContent } from '@tiptap/core'
2
2
  import { EditorContent, useEditor } from '@tiptap/react'
3
- import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
3
+ import {
4
+ Avatar,
5
+ type CommentAuthor,
6
+ createMentionExtension,
7
+ createMentionSuggestion,
8
+ isMentionPickerOpen,
9
+ MentionMember,
10
+ } from '@tldraw/mentions'
11
+ import {
12
+ type MouseEvent as ReactMouseEvent,
13
+ ReactNode,
14
+ useEffect,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from 'react'
4
20
  import { isEqual, TLRichText, useMaybeEditor } from 'tldraw'
5
- import { Avatar } from './avatar'
6
21
  import { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'
7
- import { commentMention } from './comment-mention'
8
- import { MentionMember } from './mention-list'
9
- import { createMentionSuggestion, isMentionPickerOpen } from './mention-suggestion'
10
22
  import { SendButton } from './send-button'
11
23
 
12
24
  /** @public */
13
25
  export interface CommentComposerProps {
14
- author: string
26
+ author: CommentAuthor
15
27
  placeholder: string
16
28
  /** Controlled rich-text value. Omit for the presentational (display-only) composer. */
17
29
  value?: TLRichText
@@ -19,6 +31,9 @@ export interface CommentComposerProps {
19
31
  /** Called on Send click or Enter. When set, the composer is interactive. */
20
32
  onSubmit?(): void
21
33
  sendLabel?: string
34
+ /** Called when Up is pressed in an empty composer — e.g. to start editing the comment above,
35
+ * the way chat apps edit your last message. With content in the field, Up moves the cursor. */
36
+ onArrowUpWhenEmpty?(): void
22
37
  disabled?: boolean
23
38
  autoFocus?: boolean
24
39
  /** The leading element before the field. Defaults to the author's avatar. */
@@ -43,6 +58,7 @@ export function CommentComposer({
43
58
  onChange,
44
59
  onSubmit,
45
60
  sendLabel = 'Send',
61
+ onArrowUpWhenEmpty,
46
62
  disabled,
47
63
  autoFocus,
48
64
  leading,
@@ -50,16 +66,16 @@ export function CommentComposer({
50
66
  renderMentionSuggestion,
51
67
  }: CommentComposerProps) {
52
68
  const interactive = !!onChange || !!onSubmit
53
- // The canvas editor the composer lives in, if any — lets the mention popup track the camera. Null
54
- // when the composer is used outside a tldraw editor (e.g. an isolated demo).
69
+ // Lets the mention popup track the camera. Null outside a tldraw editor.
55
70
  const tlEditor = useMaybeEditor()
56
71
 
57
- // Callbacks are read through refs so the editor instance doesn't need to be recreated when they
58
- // change identity between renders.
72
+ // Read through refs so the editor isn't recreated when callback identity changes.
59
73
  const onChangeRef = useRef(onChange)
60
74
  onChangeRef.current = onChange
61
75
  const onSubmitRef = useRef(onSubmit)
62
76
  onSubmitRef.current = onSubmit
77
+ const onArrowUpWhenEmptyRef = useRef(onArrowUpWhenEmpty)
78
+ onArrowUpWhenEmptyRef.current = onArrowUpWhenEmpty
63
79
  const disabledRef = useRef(disabled)
64
80
  disabledRef.current = disabled
65
81
  const getMentionSuggestionsRef = useRef(getMentionSuggestions)
@@ -68,50 +84,80 @@ export function CommentComposer({
68
84
  renderMentionSuggestionRef.current = renderMentionSuggestion
69
85
 
70
86
  const [isEmpty, setIsEmpty] = useState(() => !value || isCommentEmpty(value))
87
+ // Whether the field has grown to two rows: input across the full width, send button below.
88
+ // Flips when the (single-line) content width reaches the space left beside the send button.
89
+ const [expanded, setExpanded] = useState(false)
90
+ const expandedRef = useRef(expanded)
91
+ expandedRef.current = expanded
92
+ const inputWrapRef = useRef<HTMLDivElement>(null)
93
+ const mirrorRef = useRef<HTMLDivElement>(null)
71
94
 
72
- // Enter submits the comment. Shift+Enter (and Cmd/Ctrl+Enter) keep their default behavior a
73
- // new line for the occasional multi-line comment.
74
- const submitExtension = useMemo(
75
- () =>
76
- Extension.create({
77
- name: 'commentComposerSubmit',
78
- priority: 1000,
79
- addKeyboardShortcuts() {
80
- return {
81
- Enter: () => {
82
- // While the @-mention picker is open, Enter selects the highlighted member.
83
- // This extension outranks the mention plugin (priority 1000), so defer to the
84
- // picker here or Enter would submit before the member could be inserted.
85
- if (isMentionPickerOpen()) return false
86
- if (!disabledRef.current) onSubmitRef.current?.()
87
- return true
88
- },
89
- }
90
- },
91
- }),
92
- []
93
- )
95
+ // Measure whether the content still fits on one line beside the send button, against a hidden
96
+ // nowrap clone so marks and mention chips measure at their true width. The gap between the
97
+ // expand and collapse thresholds is a dead zone, so the layout can't flap at the boundary.
98
+ const remeasure = () => {
99
+ const wrap = inputWrapRef.current
100
+ const mirror = mirrorRef.current
101
+ const editor = editorRef.current
102
+ if (!wrap || !mirror || !editor) return
103
+ // Empty always fits — and measuring before TipTap has laid out reads zero widths, flashing
104
+ // the expanded layout for a frame on mount.
105
+ if (editor.isEmpty) {
106
+ if (expandedRef.current) setExpanded(false)
107
+ return
108
+ }
109
+ if (wrap.clientWidth === 0) return
110
+ const doc = editor.state.doc
111
+ const multiBlock =
112
+ doc.childCount > 1 || (doc.firstChild !== null && doc.firstChild.type.name !== 'paragraph')
113
+ if (multiBlock) {
114
+ if (!expandedRef.current) setExpanded(true)
115
+ return
116
+ }
117
+ const field = wrap.parentElement
118
+ const send = field ? field.querySelector<HTMLElement>('.tlui-cmt-send') : null
119
+ const input = wrap.querySelector('.tlui-cmt-input')
120
+ if (!send || !input) return
121
+ mirror.innerHTML = input.innerHTML
122
+ const textWidth = mirror.offsetWidth
123
+ // Measured against the wrap's *content* box: text wraps at clientWidth minus padding, so
124
+ // without subtracting it the input grows a line before expansion fires and then snaps.
125
+ const wrapStyle = getComputedStyle(wrap)
126
+ const wrapPadX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight)
127
+ const collapsedAvailable =
128
+ wrap.clientWidth - wrapPadX - (expandedRef.current ? send.offsetWidth + 6 : 0)
129
+ if (!expandedRef.current && textWidth > collapsedAvailable - 8) {
130
+ setExpanded(true)
131
+ } else if (expandedRef.current && textWidth < collapsedAvailable - 24) {
132
+ setExpanded(false)
133
+ }
134
+ }
135
+ const remeasureRef = useRef(remeasure)
136
+ remeasureRef.current = remeasure
137
+
138
+ // Reachable from `handleKeyDown`, which is created before `useEditor` returns.
139
+ const editorRef = useRef<ReturnType<typeof useEditor>>(null)
140
+
141
+ // `commands.enter()` re-dispatches through `handleKeyDown`; without this guard our own handler
142
+ // would catch the synthetic Enter and submit.
143
+ const replayingEnter = useRef(false)
94
144
 
95
- // The suggestion plugin is built once (the editor is recreated only on `interactive`) and runs
96
- // outside React, so it must read the mention callbacks through refs like onChange/onSubmit
97
- // or it queries the roster present at mount forever, never seeing a member who loads or joins
98
- // later. Whether mentions (and a custom picker row) are wired at all is fixed at mount; only the
99
- // callbacks themselves are live.
145
+ // The suggestion plugin is built once and runs outside React, so it reads the mention callbacks
146
+ // through refs otherwise it queries the roster present at mount forever, never seeing a member
147
+ // who joins later. Whether mentions are wired at all is fixed at mount; the callbacks are live.
100
148
  const mentionsEnabled = !!getMentionSuggestions
101
149
  const hasCustomRow = !!renderMentionSuggestion
102
150
  const extensions = useMemo(() => {
103
- const list = [...commentTipTapExtensions, submitExtension]
104
- // Always register the mention node so an existing body that contains a mention keeps it on
105
- // edit (an unregistered node would be stripped by ProseMirror when the content loads). The `@`
106
- // picker itself only turns on when the host provides a resolver; otherwise the node is present
107
- // but its trigger is disabled.
151
+ const list = [...commentTipTapExtensions]
152
+ // The mention node is always registered, or ProseMirror strips existing mentions when an
153
+ // edited body loads. Only the `@` trigger is gated on the host providing a resolver.
108
154
  if (mentionsEnabled) {
109
155
  const resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []
110
156
  const renderRow = hasCustomRow
111
157
  ? (member: MentionMember) => renderMentionSuggestionRef.current?.(member)
112
158
  : undefined
113
159
  list.push(
114
- commentMention({
160
+ createMentionExtension({
115
161
  suggestion: createMentionSuggestion(resolveSuggestions, {
116
162
  renderMember: renderRow,
117
163
  editor: tlEditor,
@@ -119,29 +165,76 @@ export function CommentComposer({
119
165
  })
120
166
  )
121
167
  } else {
122
- list.push(commentMention({ suggestion: { char: '@', allow: () => false } }))
168
+ list.push(createMentionExtension({ suggestion: { char: '@', allow: () => false } }))
123
169
  }
124
170
  return list
125
- }, [submitExtension, mentionsEnabled, hasCustomRow, tlEditor])
171
+ }, [mentionsEnabled, hasCustomRow, tlEditor])
126
172
 
127
173
  const editor = useEditor(
128
174
  {
129
175
  extensions,
130
176
  content: (value ?? EMPTY_COMMENT) as JSONContent,
131
177
  editable: interactive,
132
- // tldraw's default extensions add their own TextDirection extension (so it can be
133
- // overridden), so disable TipTap's core one to avoid a duplicate-extension warning —
134
- // mirrors RichTextArea's setup.
178
+ // tldraw ships its own TextDirection extension, so TipTap's core one would warn about a
179
+ // duplicate. Mirrors RichTextArea's setup.
135
180
  enableCoreExtensions: { textDirection: false },
136
181
  textDirection: 'auto',
137
- editorProps: { attributes: { class: 'cmt-input' } },
182
+ editorProps: {
183
+ attributes: {
184
+ class: 'tlui-cmt-input',
185
+ 'aria-label': placeholder,
186
+ role: 'textbox',
187
+ 'aria-multiline': 'true',
188
+ },
189
+ // Runs before every keymap plugin, so it can distinguish Shift+Enter from Enter — an
190
+ // `Enter` keymap binding also fires on Shift+Enter and would otherwise swallow it.
191
+ handleKeyDown: (_view, event) => {
192
+ // Let the keymaps handle the synthetic Enter we replay for a Shift+Enter newline.
193
+ if (replayingEnter.current) return false
194
+ // Up in an empty composer hands off to the host. With content it stays cursor
195
+ // movement, and with the picker open it navigates the roster.
196
+ if (event.key === 'ArrowUp' && !event.isComposing) {
197
+ if (
198
+ onArrowUpWhenEmptyRef.current &&
199
+ editorRef.current?.isEmpty &&
200
+ !isMentionPickerOpen()
201
+ ) {
202
+ onArrowUpWhenEmptyRef.current()
203
+ return true
204
+ }
205
+ return false
206
+ }
207
+ if (event.key !== 'Enter' || event.isComposing) return false
208
+ // While the @-mention picker is open, Enter selects the highlighted member — defer.
209
+ if (isMentionPickerOpen()) return false
210
+ // Shift+Enter inserts a new line by replaying a plain Enter through the keymaps, reusing
211
+ // the editor's list-aware handling. tldraw doesn't do soft breaks.
212
+ if (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
213
+ // An empty field would open the comment with a stray leading blank line.
214
+ if (editorRef.current?.isEmpty) return true
215
+ replayingEnter.current = true
216
+ try {
217
+ editorRef.current?.commands.enter()
218
+ } finally {
219
+ replayingEnter.current = false
220
+ }
221
+ return true
222
+ }
223
+ // Enter, Cmd+Enter, and Ctrl+Enter submit.
224
+ if (!disabledRef.current) onSubmitRef.current?.()
225
+ return true
226
+ },
227
+ },
138
228
  onUpdate: ({ editor }) => {
139
229
  setIsEmpty(editor.isEmpty)
230
+ // Same-value state sets don't re-render, and the DOM is already updated here.
231
+ remeasureRef.current()
140
232
  onChangeRef.current?.(editor.getJSON() as TLRichText)
141
233
  },
142
234
  },
143
- [interactive]
235
+ [interactive, placeholder]
144
236
  )
237
+ editorRef.current = editor
145
238
 
146
239
  // Sync controlled resets (e.g. the parent clearing to EMPTY_COMMENT after posting) into the
147
240
  // editor without echoing back the value the editor itself just emitted.
@@ -152,6 +245,12 @@ export function CommentComposer({
152
245
  setIsEmpty(editor.isEmpty)
153
246
  }, [editor, value])
154
247
 
248
+ // Measure once the editor's content is in the DOM, so a composer that mounts pre-filled (the
249
+ // edit-in-place composer) starts in the right layout.
250
+ useLayoutEffect(() => {
251
+ remeasureRef.current()
252
+ }, [editor, value])
253
+
155
254
  // Focus on the next frame rather than via TipTap's autofocus: the composer often mounts from a
156
255
  // canvas pointer event whose default focus handling would otherwise steal it back.
157
256
  useEffect(() => {
@@ -160,17 +259,40 @@ export function CommentComposer({
160
259
  return () => cancelAnimationFrame(raf)
161
260
  }, [autoFocus, editor])
162
261
 
262
+ // The whole field behaves like the text input: clicking its empty area (the padding, or the
263
+ // space beside/below a short line) focuses the editor rather than only the text glyphs being
264
+ // clickable. The input (caret placement) and the send button keep their own click handling.
265
+ const focusEditorFromField = (e: ReactMouseEvent<HTMLDivElement>) => {
266
+ const target = e.target as HTMLElement
267
+ if (target.closest('.tlui-cmt-input') || target.closest('.tlui-cmt-send')) return
268
+ e.preventDefault()
269
+ editor?.commands.focus('end')
270
+ }
271
+
163
272
  return (
164
- <div className="cmt-composer">
165
- {leading ?? <Avatar name={author} />}
166
- <div className="cmt-composer__field">
167
- <div className="cmt-composer__input-wrap">
273
+ <div className="tlui-cmt-composer">
274
+ {leading ?? <Avatar author={author} />}
275
+ <div
276
+ className={[
277
+ 'tlui-cmt-composer__field',
278
+ interactive && expanded && 'tlui-cmt-composer__field--expanded',
279
+ ]
280
+ .filter(Boolean)
281
+ .join(' ')}
282
+ onMouseDown={interactive ? focusEditorFromField : undefined}
283
+ >
284
+ <div className="tlui-cmt-composer__input-wrap" ref={inputWrapRef}>
168
285
  <EditorContent editor={editor} />
169
286
  {isEmpty && (
170
- <div className="cmt-input__placeholder" aria-hidden="true">
287
+ <div className="tlui-cmt-input__placeholder" aria-hidden="true">
171
288
  {placeholder}
172
289
  </div>
173
290
  )}
291
+ <div
292
+ className="tlui-cmt-composer__mirror tlui-cmt-input"
293
+ ref={mirrorRef}
294
+ aria-hidden="true"
295
+ />
174
296
  </div>
175
297
  {interactive && <SendButton label={sendLabel} onClick={onSubmit} disabled={disabled} />}
176
298
  </div>
@@ -1,4 +1,5 @@
1
1
  import { ReactNode } from 'react'
2
+ import { PinCheckIcon } from './icons'
2
3
 
3
4
  /** @public */
4
5
  export interface CommentPinProps {
@@ -10,12 +11,22 @@ export interface CommentPinProps {
10
11
  open?: boolean
11
12
  }
12
13
 
14
+ /* Decorative — the pin's accessible name lives on whatever wraps it. */
15
+ const resolvedCheck = <PinCheckIcon />
16
+
13
17
  /** A canvas comment marker: shows its `children` (or a check when resolved). Purely
14
18
  * presentational — it reflects open/resolved state via CSS; wrap it to make it clickable.
15
19
  * @public @react */
16
20
  export function CommentPin({ children, resolved, open }: CommentPinProps) {
17
- const className = ['cmt-pin', resolved && 'cmt-pin--resolved', open && 'cmt-pin--open']
21
+ const className = [
22
+ // `tlui-cmt-marker` carries the resting shadow and the hover lift — the same treatment a
23
+ // count badge wears, so the two markers behave alike.
24
+ 'tlui-cmt-marker',
25
+ 'tlui-cmt-pin',
26
+ resolved && 'tlui-cmt-pin--resolved',
27
+ open && 'tlui-cmt-marker--open',
28
+ ]
18
29
  .filter(Boolean)
19
30
  .join(' ')
20
- return <div className={className}>{resolved ? '✓' : children}</div>
31
+ return <div className={className}>{resolved ? resolvedCheck : children}</div>
21
32
  }
@@ -14,6 +14,9 @@ export interface CommentThreadProps {
14
14
  resolvedBanner?: ReactNode
15
15
  /** Reply composer props. Omit for a read-only thread (no composer). */
16
16
  composer?: CommentComposerProps
17
+ /** Shown at the bottom of the thread (e.g. a sign-in prompt when there's no composer). When
18
+ * both are set, the footer renders after the composer. */
19
+ footer?: ReactNode
17
20
  /** Override how each comment renders. Defaults to `<CommentCard>`. */
18
21
  renderComment?(comment: CommentCardProps, index: number): ReactNode
19
22
  }
@@ -30,20 +33,23 @@ export function CommentThread({
30
33
  headerActions,
31
34
  resolvedBanner,
32
35
  composer,
36
+ footer,
33
37
  renderComment,
34
38
  }: CommentThreadProps) {
35
39
  return (
36
- <div className="cmt-thread">
40
+ <div className="tlui-cmt-thread">
37
41
  {(header !== undefined || headerActions !== undefined) && (
38
- <div className="cmt-thread__header">
39
- <span className="cmt-thread__title">{header}</span>
42
+ <div className="tlui-cmt-thread__header">
43
+ {header !== undefined && <span className="tlui-cmt-thread__title">{header}</span>}
40
44
  {headerActions !== undefined && (
41
- <div className="cmt-thread__actions">{headerActions}</div>
45
+ <div className="tlui-cmt-thread__actions">{headerActions}</div>
42
46
  )}
43
47
  </div>
44
48
  )}
45
- {resolvedBanner !== undefined && <div className="cmt-thread__resolved">{resolvedBanner}</div>}
46
- <div className="cmt-thread__list">
49
+ {resolvedBanner !== undefined && (
50
+ <div className="tlui-cmt-thread__resolved">{resolvedBanner}</div>
51
+ )}
52
+ <div className="tlui-cmt-thread__list">
47
53
  {comments.map((comment, i) => (
48
54
  <div key={i}>
49
55
  {renderComment ? renderComment(comment, i) : <CommentCard {...comment} />}
@@ -51,6 +57,7 @@ export function CommentThread({
51
57
  ))}
52
58
  </div>
53
59
  {composer !== undefined && <CommentComposer {...composer} />}
60
+ {footer !== undefined && <div className="tlui-cmt-thread__footer">{footer}</div>}
54
61
  </div>
55
62
  )
56
63
  }
@@ -0,0 +1,31 @@
1
+ import { MouseEvent } from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { isOpenInNewTabClick } from './comments-list'
4
+
5
+ function click(overrides: Partial<MouseEvent> = {}): MouseEvent {
6
+ return {
7
+ metaKey: false,
8
+ ctrlKey: false,
9
+ shiftKey: false,
10
+ altKey: false,
11
+ button: 0,
12
+ ...overrides,
13
+ } as MouseEvent
14
+ }
15
+
16
+ describe('isOpenInNewTabClick', () => {
17
+ it('treats a plain left click as an in-place select', () => {
18
+ expect(isOpenInNewTabClick(click())).toBe(false)
19
+ })
20
+
21
+ it.each(['metaKey', 'ctrlKey', 'shiftKey', 'altKey'] as const)(
22
+ 'leaves a %s click to the browser',
23
+ (modifier) => {
24
+ expect(isOpenInNewTabClick(click({ [modifier]: true }))).toBe(true)
25
+ }
26
+ )
27
+
28
+ it('leaves non-primary-button clicks to the browser', () => {
29
+ expect(isOpenInNewTabClick(click({ button: 1 }))).toBe(true)
30
+ })
31
+ })
@@ -1,11 +1,15 @@
1
- import { ReactNode } from 'react'
2
- import { Avatar } from './avatar'
1
+ import { Avatar, type CommentAuthor } from '@tldraw/mentions'
2
+ import { Fragment, MouseEvent, ReactNode } from 'react'
3
+ import { useTranslation } from 'tldraw'
3
4
  import { Byline } from './byline'
5
+ import { CheckIcon } from './icons'
6
+ import { Reactions, type ReactionSummary } from './reactions'
7
+ import { replyCountLabel } from './reply-count'
4
8
 
5
9
  /** @public */
6
10
  export interface CommentListItemProps {
7
11
  id: string
8
- author: string
12
+ author: CommentAuthor
9
13
  /** A short preview of the thread — e.g. the first comment's body. */
10
14
  preview: ReactNode
11
15
  /** ISO datetime of the thread's first comment. */
@@ -17,6 +21,28 @@ export interface CommentListItemProps {
17
21
  count?: number
18
22
  /** Whether this thread is the open one. */
19
23
  selected?: boolean
24
+ /** Tallied reactions for the row (a thread's, or a single comment's when the row is one
25
+ * comment), shown as inert pills under the preview. Omit to hide. */
26
+ reactions?: ReactionSummary[]
27
+ /**
28
+ * Link target for the item. When set, the row renders as an anchor so browser affordances
29
+ * (ctrl/cmd-click, middle-click) open it in a new tab; a plain click still calls `onSelect`.
30
+ */
31
+ href?: string
32
+ }
33
+
34
+ /**
35
+ * What a row is rendered with: the item, plus the list-level wiring it needs to be interactive.
36
+ * A custom row gets the same props the default `<CommentListItem>` does, so it can wrap the
37
+ * default rather than reimplement it.
38
+ *
39
+ * @public
40
+ */
41
+ export interface CommentListItemRenderProps extends CommentListItemProps {
42
+ /** Label for a resolved thread's marker on its row. */
43
+ resolvedLabel?: string
44
+ /** Called with the thread id when the row is chosen. */
45
+ onSelect?(id: string): void
20
46
  }
21
47
 
22
48
  /** @public */
@@ -32,8 +58,12 @@ export interface CommentsListProps {
32
58
  empty?: ReactNode
33
59
  /** Label for a resolved thread's marker on its row. Defaults to "Resolved". */
34
60
  resolvedLabel?: string
35
- /** Override how each item renders. Defaults to `<CommentListItem>`. */
36
- renderItem?(item: CommentListItemProps): ReactNode
61
+ /**
62
+ * Override how each item renders. Defaults to `<CommentListItem>`, which is exported — so a
63
+ * row that only adds something can spread these props into it rather than start over. The list
64
+ * supplies the key, so a custom row doesn't need one.
65
+ */
66
+ renderItem?(props: CommentListItemRenderProps): ReactNode
37
67
  }
38
68
 
39
69
  /**
@@ -52,36 +82,36 @@ export function CommentsList({
52
82
  renderItem,
53
83
  }: CommentsListProps) {
54
84
  return (
55
- <div className="cmt-list">
85
+ <div className="tlui-cmt-list">
56
86
  {(header !== undefined || headerAction) && (
57
- <div className="cmt-list__header">
58
- {header !== undefined && <span className="cmt-list__header-title">{header}</span>}
87
+ <div className="tlui-cmt-list__header">
88
+ {header !== undefined && <span className="tlui-cmt-list__header-title">{header}</span>}
59
89
  {headerAction}
60
90
  </div>
61
91
  )}
62
92
  {items.length === 0 ? (
63
- <div className="cmt-list__empty">{empty}</div>
93
+ <div className="tlui-cmt-list__empty">{empty}</div>
64
94
  ) : (
65
- <div className="cmt-list__items">
66
- {items.map((item) =>
67
- renderItem ? (
68
- renderItem(item)
95
+ <div className="tlui-cmt-list__items">
96
+ {items.map((item) => {
97
+ const props: CommentListItemRenderProps = { ...item, resolvedLabel, onSelect }
98
+ // A custom row is wrapped rather than keyed directly: it's the consumer's element,
99
+ // and requiring them to remember a key is the kind of thing that only shows up as a
100
+ // console warning in someone else's app.
101
+ return renderItem ? (
102
+ <Fragment key={item.id}>{renderItem(props)}</Fragment>
69
103
  ) : (
70
- <CommentListItem
71
- key={item.id}
72
- {...item}
73
- resolvedLabel={resolvedLabel}
74
- onSelect={onSelect}
75
- />
104
+ <CommentListItem key={item.id} {...props} />
76
105
  )
77
- )}
106
+ })}
78
107
  </div>
79
108
  )}
80
109
  </div>
81
110
  )
82
111
  }
83
112
 
84
- function CommentListItem({
113
+ /** One thread's row in a {@link CommentsList}. @public @react */
114
+ export function CommentListItem({
85
115
  id,
86
116
  author,
87
117
  preview,
@@ -90,50 +120,55 @@ function CommentListItem({
90
120
  page,
91
121
  count,
92
122
  selected,
123
+ reactions,
124
+ href,
93
125
  resolvedLabel = 'Resolved',
94
126
  onSelect,
95
- }: CommentListItemProps & { resolvedLabel?: string; onSelect?(id: string): void }) {
96
- const handleClick = () => {
127
+ }: CommentListItemRenderProps) {
128
+ const msg = useTranslation()
129
+ const handleClick = (e: MouseEvent) => {
130
+ if (href && isOpenInNewTabClick(e)) return
131
+ e.preventDefault()
97
132
  if (onSelect) onSelect(id)
98
133
  }
134
+ const Tag = href ? 'a' : 'button'
135
+ // `count` is the thread's total comments; a reply is every comment after the opening one.
136
+ const replies = count !== undefined ? replyCountLabel(msg, count - 1) : null
99
137
  return (
100
- <button
101
- type="button"
102
- className={selected ? 'cmt-list__item cmt-list__item--selected' : 'cmt-list__item'}
138
+ <Tag
139
+ {...(href ? { href } : { type: 'button' })}
140
+ className={
141
+ selected ? 'tlui-cmt-list__item tlui-cmt-list__item--selected' : 'tlui-cmt-list__item'
142
+ }
103
143
  data-resolved={resolved || undefined}
104
144
  onClick={handleClick}
105
145
  >
106
- <Avatar name={author} />
107
- <div className="cmt-list__item-body">
146
+ <Avatar author={author} />
147
+ <div className="tlui-cmt-list__item-body">
108
148
  <Byline author={author} date={date} />
109
- <div className="cmt-list__item-preview">{preview}</div>
110
- {(resolved || page !== undefined) && (
111
- <div className="cmt-list__item-meta">
149
+ <div className="tlui-cmt-list__item-preview">{preview}</div>
150
+ {reactions && <Reactions reactions={reactions} canReact={false} enableHoverList={false} />}
151
+ {(resolved || page !== undefined || replies) && (
152
+ <div className="tlui-cmt-list__item-meta">
112
153
  {resolved && (
113
- <span className="cmt-list__item-resolved">
154
+ <span className="tlui-cmt-list__item-resolved">
114
155
  <CheckIcon />
115
156
  {resolvedLabel}
116
157
  </span>
117
158
  )}
118
- {page !== undefined && <span className="cmt-list__item-page">{page}</span>}
159
+ {page !== undefined && <span className="tlui-cmt-list__item-page">{page}</span>}
160
+ {replies && <span className="tlui-cmt-list__item-replies">{replies}</span>}
119
161
  </div>
120
162
  )}
121
163
  </div>
122
- {count !== undefined && count > 1 && <span className="cmt-list__item-count">{count}</span>}
123
- </button>
164
+ </Tag>
124
165
  )
125
166
  }
126
167
 
127
- function CheckIcon() {
128
- return (
129
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
130
- <path
131
- d="M2.5 6.2 4.7 8.4 9.5 3.6"
132
- stroke="currentColor"
133
- strokeWidth="1.5"
134
- strokeLinecap="round"
135
- strokeLinejoin="round"
136
- />
137
- </svg>
138
- )
168
+ /**
169
+ * Whether a click should be left to the browser's link handling (new tab, new window, or download).
170
+ * @public
171
+ */
172
+ export function isOpenInNewTabClick(e: MouseEvent) {
173
+ return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0
139
174
  }