@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,30 +1,83 @@
1
- import {
2
- EditorAtom,
3
- type BoxModel,
4
- type Editor,
5
- type TLHistoryBatchOptions,
6
- useEditor,
7
- useValue,
8
- } from 'tldraw'
1
+ import { EditorAtom, type BoxModel, type Editor, useEditor, useValue } from 'tldraw'
9
2
  import type { PendingComment } from './comment-tool'
10
- import { getCommentingOptions } from './options'
11
3
  import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
12
4
 
13
5
  /**
14
- * Transient commenting UI state, scoped per editor via {@link EditorAtom}. Editor-scoping (rather
15
- * than module-global atoms) keeps two editors on one page or an editor and a second instance —
16
- * from sharing open-thread, visibility, and filter state. Reachable from both React (`useEditor()`)
17
- * and the comment tool (`this.editor`).
6
+ * Transient commenting UI state, scoped per editor via {@link EditorAtom} so two editors on one
7
+ * page don't share open-thread, visibility, and filter state. Reachable from both React
8
+ * (`useEditor()`) and the comment tool (`this.editor`).
18
9
  */
19
10
 
20
11
  /** The id of the one open thread (only one popover is open at a time), or null when all closed.
21
12
  * @public */
22
13
  export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
23
14
 
15
+ /** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread id),
16
+ * or null. Editor state rather than component state: the stack pin remounts when its owning render
17
+ * path changes, and the open list must survive that.
18
+ * @internal */
19
+ export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
20
+
24
21
  /** The comment currently being placed (composer open, not yet posted), or null.
25
- * @public */
22
+ * @internal */
26
23
  export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
27
24
 
25
+ /**
26
+ * A pending request to reveal a thread: a thread or comment id to open and bring into view, or
27
+ * null when none is pending. Written by {@link revealThread}; served and cleared by
28
+ * `CanvasComments`, which owns the wait for the records to sync in and the cluster-aware reveal.
29
+ * @internal
30
+ */
31
+ export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
32
+
33
+ /**
34
+ * Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it to
35
+ * jump to a thread from outside the canvas — a notification, a deep link, your own list.
36
+ *
37
+ * The request is served by `CanvasComments`, so it works before the records have arrived: the layer
38
+ * waits for them, switches pages, unhides pins, and zooms far enough to split the thread out of any
39
+ * cluster. That also means nothing happens if `CanvasComments` isn't mounted.
40
+ *
41
+ * To open a thread you already hold and skip the wait, see {@link focusThread}.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * revealThread(editor, new URLSearchParams(location.search).get('comment')!)
46
+ * ```
47
+ *
48
+ * @public
49
+ */
50
+ export function revealThread(editor: Editor, threadOrCommentId: string): void {
51
+ revealThreadRequest.set(editor, threadOrCommentId)
52
+ }
53
+
54
+ /**
55
+ * The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
56
+ * yet, or null. A request also clears when `CanvasComments` unmounts.
57
+ *
58
+ * This is a plain, untracked read — in React, use {@link useRevealThreadPending}, unless you need
59
+ * the value as of *now* rather than as of the render you closed over.
60
+ *
61
+ * @public
62
+ */
63
+ export function getRevealThreadPending(editor: Editor): string | null {
64
+ return revealThreadRequest.get(editor)
65
+ }
66
+
67
+ /**
68
+ * Reactive React hook for {@link getRevealThreadPending}.
69
+ *
70
+ * Use it to notice a reveal that never lands — usually a deep link to a deleted comment. Give it a
71
+ * grace period first, since a request also sits here while its records sync in, and re-check with
72
+ * {@link getRevealThreadPending} when it elapses.
73
+ *
74
+ * @public
75
+ */
76
+ export function useRevealThreadPending(): string | null {
77
+ const editor = useEditor()
78
+ return useValue('pending reveal thread', () => getRevealThreadPending(editor), [editor])
79
+ }
80
+
28
81
  /** The region rectangle being dragged out right now (page coords), or null when not dragging. The
29
82
  * comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
30
83
  export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
@@ -36,6 +89,13 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
36
89
  */
37
90
  export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
38
91
 
92
+ /**
93
+ * Whether the comments sidebar (the thread list) is open. Driven by an explicit control rather than
94
+ * by which tool is active, so browsing threads is separate from placing them.
95
+ * @public
96
+ */
97
+ export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
98
+
39
99
  /** Which threads the comments sidebar shows.
40
100
  * @public */
41
101
  export const sidebarFilters = new EditorAtom<SidebarFilters>(
@@ -49,6 +109,12 @@ export function toggleCommentsHidden(editor: Editor): void {
49
109
  commentsHidden.update(editor, (hidden) => !hidden)
50
110
  }
51
111
 
112
+ /** Open or close the comments sidebar for an editor.
113
+ * @public */
114
+ export function toggleCommentsSidebar(editor: Editor): void {
115
+ commentsSidebarOpen.update(editor, (open) => !open)
116
+ }
117
+
52
118
  /** React hook for the open thread id.
53
119
  * @public */
54
120
  export function useOpenThreadId(): string | null {
@@ -57,7 +123,7 @@ export function useOpenThreadId(): string | null {
57
123
  }
58
124
 
59
125
  /** React hook for the pending (being-placed) comment.
60
- * @public */
126
+ * @internal */
61
127
  export function usePendingComment(): PendingComment | null {
62
128
  const editor = useEditor()
63
129
  return useValue('pending comment', () => pendingComment.get(editor), [editor])
@@ -70,33 +136,16 @@ export function useCommentsHidden(): boolean {
70
136
  return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
71
137
  }
72
138
 
139
+ /** React hook for whether the comments sidebar is open.
140
+ * @public */
141
+ export function useCommentsSidebarOpen(): boolean {
142
+ const editor = useEditor()
143
+ return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
144
+ }
145
+
73
146
  /** React hook for the current sidebar filters.
74
147
  * @public */
75
148
  export function useSidebarFilters(): SidebarFilters {
76
149
  const editor = useEditor()
77
150
  return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
78
151
  }
79
-
80
- /**
81
- * Commit a comment mutation with the configured undo/redo behavior. All comment writes go through
82
- * here so the {@link CommentingOptions.history} option (and {@link CommentingOptions.dragHistory}
83
- * for pin re-anchors) governs whether they land on the undo stack. Defaults to `'ignore'`.
84
- * @public
85
- */
86
- export function commitCommentMutation<T>(
87
- editor: Editor,
88
- fn: () => T,
89
- kind: 'mutation' | 'drag' = 'mutation'
90
- ): T {
91
- const options = getCommentingOptions(editor)
92
- const history: TLHistoryBatchOptions['history'] =
93
- kind === 'drag' ? (options.dragHistory ?? options.history) : options.history
94
- let result: T
95
- editor.run(
96
- () => {
97
- result = fn()
98
- },
99
- { history }
100
- )
101
- return result!
102
- }
@@ -0,0 +1,418 @@
1
+ import { Avatar } from '@tldraw/mentions'
2
+ import {
3
+ memo,
4
+ type PointerEvent as ReactPointerEvent,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react'
10
+ import {
11
+ type BoxModel,
12
+ Editor,
13
+ TLCommentThread,
14
+ useContainer,
15
+ usePassThroughWheelEvents,
16
+ useTranslation,
17
+ useValue,
18
+ } from 'tldraw'
19
+ import { CommentPin } from '../ui/comment-pin'
20
+ import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
21
+ import { commitCommentMutation } from './comment-mutations'
22
+ import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
23
+ import { type CommentingContext } from './context'
24
+ import { useThreadComments } from './hooks'
25
+ import { getCommentingOptions, useCanComment, useCommentingOptions } from './options'
26
+ import {
27
+ REGION_CORNERS,
28
+ REGION_HANDLE_MARGIN_PX,
29
+ RegionBox,
30
+ RegionResizeHandles,
31
+ } from './region-box'
32
+ import { openThreadId } from './state'
33
+ import { ThreadPreview, useMarkerPreview } from './thread-preview'
34
+ import {
35
+ anchorPagePoint,
36
+ commentTargetShapeAt,
37
+ impreciseShapePinInset,
38
+ isBoxInInflatedViewport,
39
+ isInInflatedViewport,
40
+ REGION_PIN_CORNER,
41
+ regionAnchorPinCorner,
42
+ regionPinPoint,
43
+ shapeAnchorAt,
44
+ } from './thread-state'
45
+ import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
46
+
47
+ /** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
48
+ * first comment then lands where the preview's sat. Re-measure if the header height or the preview
49
+ * panel padding changes. */
50
+ const THREAD_HEADER_BLOCK = 36
51
+
52
+ /**
53
+ * A single thread's pin: the marker at its anchor, the thread popover when it's open, the hover
54
+ * preview, and — for a region anchor — the dashed box and its resize handles. Dragging the marker
55
+ * re-anchors the thread.
56
+ *
57
+ * Memoized so a pin skips re-rendering when the layer re-renders for unrelated reasons; camera
58
+ * tracking still works, since the pin subscribes to its own viewport position via signals.
59
+ */
60
+ export const ThreadPin = memo(function ThreadPin({
61
+ editor,
62
+ thread,
63
+ ...props
64
+ }: CommentingContext & {
65
+ editor: Editor
66
+ thread: TLCommentThread
67
+ }) {
68
+ const { resolveAuthor } = props
69
+ const options = useCommentingOptions()
70
+ const canComment = useCanComment(props.currentUserId)
71
+ const container = useContainer()
72
+ const msg = useTranslation()
73
+ const comments = useThreadComments(editor, thread.id)
74
+ // Only one thread's popover is open at a time — shared across pins via the atom.
75
+ const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
76
+ editor,
77
+ thread.id,
78
+ ])
79
+ // While dragging the marker, its page point overrides the anchor's; committed on drop.
80
+ const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
81
+ const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
82
+ // Hovering the marker previews the thread's opening comment, on the delay every marker uses.
83
+ const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
84
+ const previewThreads = useMemo(() => [thread], [thread])
85
+ // The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
86
+ // Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
87
+ // handle never loses the affordance — the box stays `pointer-events: none`.
88
+ const pointerInRegion = useValue(
89
+ 'pointer in region',
90
+ () => {
91
+ if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
92
+ return false
93
+ const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
94
+ const p = editor.inputs.getCurrentPagePoint()
95
+ const a = thread.anchor
96
+ return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
97
+ },
98
+ [editor, thread.anchor, thread.pageId]
99
+ )
100
+ // A region's box and handles are revealed while open, mid-resize, or while the pointer is
101
+ // within the region.
102
+ const revealed = open || resizeBounds != null || pointerInRegion
103
+ // A region thread's pin corner is its own (the corner its creating drag released on), with
104
+ // the default as the fallback for older records.
105
+ const pinCorner =
106
+ thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
107
+ // A region resizes from its corners — every corner but the pin's own, which the pin owns.
108
+ const resizeHandles = useMemo(
109
+ () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
110
+ [pinCorner]
111
+ )
112
+ const dragRef = useRef<{
113
+ startX: number
114
+ startY: number
115
+ moved: boolean
116
+ // The anchor's page-space offset from the grab point, so a drag translates the pin by the
117
+ // cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
118
+ offsetX: number
119
+ offsetY: number
120
+ } | null>(null)
121
+ const markerRef = useRef<HTMLButtonElement>(null)
122
+ // Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
123
+ // see the note on the layer.
124
+ usePassThroughWheelEvents(markerRef)
125
+
126
+ // The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
127
+ // mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
128
+ // clear the hint here or it stays on the shape indefinitely.
129
+ useEffect(() => {
130
+ return () => {
131
+ if (dragRef.current) editor.setHintingShapes([])
132
+ }
133
+ }, [editor])
134
+
135
+ // Clicking outside the open popover closes the thread. Capture phase + a class check rather than
136
+ // stopPropagation, since the popover portals elsewhere in the DOM. The marker is excluded so its own
137
+ // click-to-toggle handles it instead of this closing and the toggle reopening.
138
+ useEffect(() => {
139
+ if (!open) return
140
+ const onPointerDown = (e: PointerEvent) => {
141
+ const target = e.target as HTMLElement | null
142
+ if (!target) return
143
+ if (target.closest('.tlui-cmt-canvas-popover')) return
144
+ const marker = markerRef.current
145
+ if (marker && marker.contains(target)) return
146
+ // A press on a region's resize handle edits this thread — don't dismiss it.
147
+ if (target.closest('.tlui-cmt-canvas-region-handle')) return
148
+ // A click inside a menu/popover layered above us (the sidebar's filter or overflow
149
+ // dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
150
+ // layer; defer to its own dismissal instead of closing the thread out from under it.
151
+ if (
152
+ target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
153
+ )
154
+ return
155
+ openThreadId.set(editor, null)
156
+ }
157
+ document.addEventListener('pointerdown', onPointerDown, true)
158
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
159
+ }, [open, editor])
160
+
161
+ // The pin must not unmount mid-interaction: an open thread's popover hangs off it, and a drag
162
+ // or resize holds pointer capture on it — so those states are exempt from the viewport cull.
163
+ const exemptFromCull = open || dragPagePoint != null || resizeBounds != null
164
+ const point = useValue(
165
+ 'pin point',
166
+ () => {
167
+ if (thread.pageId !== editor.getCurrentPageId()) return null
168
+ const pagePoint = anchorPagePoint(editor, thread.anchor)
169
+ if (!pagePoint) return null
170
+ const viewportPoint = editor.pageToViewport(pagePoint)
171
+ const inset = impreciseShapePinInset(editor, thread.anchor)
172
+ const point = inset
173
+ ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y }
174
+ : viewportPoint
175
+ // Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
176
+ // region thread stays mounted while any part of its box is on screen, since the box renders here too.
177
+ if (!exemptFromCull) {
178
+ const visible =
179
+ thread.anchor.type === 'region'
180
+ ? isBoxInInflatedViewport(editor, thread.anchor)
181
+ : isInInflatedViewport(editor, point)
182
+ if (!visible) return null
183
+ }
184
+ return point
185
+ },
186
+ [editor, thread.anchor, thread.pageId, exemptFromCull]
187
+ )
188
+ if (!point) return null
189
+
190
+ const PinContent = options.components.PinContent
191
+ const threadAuthor = resolveAuthor(thread.createdBy)
192
+ const pinContent = PinContent ? (
193
+ <PinContent thread={thread} comments={comments} />
194
+ ) : (
195
+ <Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
196
+ )
197
+ const pinLabel = msg(
198
+ thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
199
+ ).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
200
+
201
+ // Drag the marker to move the thread: position is overridden locally while dragging, then re-anchored
202
+ // on drop. A region translates keeping its size; a barely-moved pointer is a click.
203
+ const isRegion = thread.anchor.type === 'region'
204
+ // The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
205
+ const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
206
+ // A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
207
+ // hand it to the canvas untouched.
208
+ if (isCanvasPanGesture(editor, e)) {
209
+ forwardPointerEventToCanvas(container, e)
210
+ return
211
+ }
212
+ e.stopPropagation()
213
+ const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
214
+ const anchorPage = anchorPagePoint(editor, thread.anchor)
215
+ // The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
216
+ // is inset from its anchor point — without this the pin jumps by the inset on drag start.
217
+ const inset = impreciseShapePinInset(editor, thread.anchor)
218
+ if (anchorPage && inset) {
219
+ const zoom = editor.getZoomLevel()
220
+ anchorPage.x += inset.x / zoom
221
+ anchorPage.y += inset.y / zoom
222
+ }
223
+ dragRef.current = {
224
+ startX: e.clientX,
225
+ startY: e.clientY,
226
+ moved: false,
227
+ offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
228
+ offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
229
+ }
230
+ e.currentTarget.setPointerCapture(e.pointerId)
231
+ }
232
+ const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
233
+ const drag = dragRef.current
234
+ if (!drag) return
235
+ // Moving a pin re-anchors the thread record — a commenting write. Without the permission the
236
+ // press stays a click (`moved` never sets, so release toggles the popover and never commits).
237
+ if (!canComment) return
238
+ if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
239
+ drag.moved = true
240
+ const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
241
+ const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
242
+ setDragPagePoint(pagePoint)
243
+ // Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
244
+ // with. Regions translate rather than re-anchor, so they never hint.
245
+ if (!isRegion) {
246
+ const hit = commentTargetShapeAt(editor, pagePoint)
247
+ editor.setHintingShapes(hit ? [hit.id] : [])
248
+ }
249
+ }
250
+ // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
251
+ // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
252
+ const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
253
+ const drag = dragRef.current
254
+ dragRef.current = null
255
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
256
+ e.currentTarget.releasePointerCapture(e.pointerId)
257
+ }
258
+ if (!drag) return
259
+ setDragPagePoint(null)
260
+ editor.setHintingShapes([])
261
+ }
262
+ const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
263
+ const drag = dragRef.current
264
+ dragRef.current = null
265
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
266
+ e.currentTarget.releasePointerCapture(e.pointerId)
267
+ }
268
+ if (!drag) return
269
+ editor.setHintingShapes([])
270
+ if (!drag.moved) {
271
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
272
+ return
273
+ }
274
+ const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
275
+ const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
276
+ setDragPagePoint(null)
277
+ let anchor: TLCommentThread['anchor']
278
+ if (thread.anchor.type === 'region') {
279
+ // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
280
+ anchor = {
281
+ ...thread.anchor,
282
+ x: pagePoint.x - pinCorner.x * thread.anchor.w,
283
+ y: pagePoint.y - pinCorner.y * thread.anchor.h,
284
+ }
285
+ } else {
286
+ const hit = commentTargetShapeAt(editor, pagePoint)
287
+ anchor = hit
288
+ ? shapeAnchorAt(
289
+ editor,
290
+ hit.id,
291
+ pagePoint,
292
+ getCommentingOptions(editor).shouldBePrecise(editor, {
293
+ shapeId: hit.id,
294
+ point: pagePoint,
295
+ altKey: e.altKey,
296
+ })
297
+ )
298
+ : { type: 'point', x: pagePoint.x, y: pagePoint.y }
299
+ }
300
+ commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')
301
+ }
302
+
303
+ // The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
304
+ // move to the drag point; otherwise it sits at the stored anchor's viewport point.
305
+ const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
306
+ const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
307
+ // A region's pin centres on its corner — overlapping the box — rather than hanging off it.
308
+ // The marker anchors bottom-left, so step half its 34px size left and down (screen px).
309
+ const renderPoint = isRegion
310
+ ? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
311
+ : renderPointBase
312
+
313
+ // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
314
+ // corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
315
+ const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
316
+ const movedRegion =
317
+ regionAnchor && dragPagePoint
318
+ ? {
319
+ ...regionAnchor,
320
+ x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
321
+ y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
322
+ }
323
+ : regionAnchor
324
+ const regionBoxBounds = resizeBounds ?? movedRegion
325
+ const commitResize = (bounds: BoxModel) => {
326
+ setResizeBounds(null)
327
+ if (!canComment) return
328
+ // Same commit path as a pin drag, so the configured `dragHistory` governs both — going
329
+ // straight to `editor.run` here would make region resizes silently ignore the option.
330
+ commitCommentMutation(
331
+ editor,
332
+ // Spread the existing anchor first so the region's pin corner survives a resize.
333
+ ({ put }) => put([{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
334
+ 'drag'
335
+ )
336
+ }
337
+
338
+ return (
339
+ <>
340
+ {regionBoxBounds && (dragPagePoint || revealed) && (
341
+ <RegionBox editor={editor} box={regionBoxBounds} />
342
+ )}
343
+ {regionBoxBounds && revealed && !dragPagePoint && canComment && (
344
+ <RegionResizeHandles
345
+ editor={editor}
346
+ box={regionBoxBounds}
347
+ handles={resizeHandles}
348
+ onPreview={setResizeBounds}
349
+ onCommit={commitResize}
350
+ />
351
+ )}
352
+ <div
353
+ className={[
354
+ 'tlui-cmt-canvas-pin',
355
+ open && 'tlui-cmt-canvas-pin--open',
356
+ dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
357
+ ]
358
+ .filter(Boolean)
359
+ .join(' ')}
360
+ style={{ left: renderPoint.x, top: renderPoint.y }}
361
+ >
362
+ <button
363
+ ref={markerRef}
364
+ type="button"
365
+ className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
366
+ aria-label={pinLabel}
367
+ aria-expanded={open}
368
+ onPointerDown={startDrag}
369
+ onPointerMove={onDrag}
370
+ onPointerUp={endDrag}
371
+ onPointerCancel={cancelDrag}
372
+ // Pointer activation is already handled by endDrag (which distinguishes a click
373
+ // from a drag), so only take keyboard-synthesised clicks here — those report
374
+ // `detail === 0` — or the thread would toggle twice per mouse click.
375
+ onClick={(e) => {
376
+ if (e.detail !== 0) return
377
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
378
+ }}
379
+ onPointerEnter={previewHandlers.onPointerEnter}
380
+ onPointerLeave={previewHandlers.onPointerLeave}
381
+ // Focus stands in for hover, so tabbing to a marker gets the same preview.
382
+ onFocus={previewHandlers.onPointerEnter}
383
+ onBlur={previewHandlers.onPointerLeave}
384
+ >
385
+ <CommentPin resolved={thread.resolved != null} open={open}>
386
+ {pinContent}
387
+ </CommentPin>
388
+ </button>
389
+ {/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
390
+ the pin itself stays in the canvas-in-front layer, beneath the UI. */}
391
+ {open && (
392
+ <ThreadPopover
393
+ base={{
394
+ x: renderPoint.x + POPOVER_OFFSET.thread.x,
395
+ y: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
396
+ }}
397
+ >
398
+ <ThreadView editor={editor} thread={thread} {...props} />
399
+ </ThreadPopover>
400
+ )}
401
+ {/* Not while dragging: the pin is being moved, not read, and a panel trailing the
402
+ cursor would obscure the drop target. */}
403
+ {previewShown && !dragPagePoint && (
404
+ <ThreadPreview
405
+ editor={editor}
406
+ threads={previewThreads}
407
+ variant="thread"
408
+ point={renderPoint}
409
+ onSelectThread={() => openThreadId.set(editor, thread.id)}
410
+ {...previewHandlers}
411
+ currentUserId={props.currentUserId}
412
+ resolveAuthor={resolveAuthor}
413
+ />
414
+ )}
415
+ </div>
416
+ </>
417
+ )
418
+ })
@@ -0,0 +1,91 @@
1
+ import type { TLComment, TLCommentThread } from 'tldraw'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { selectPreviewCards, sortThreadsForPreview } from './thread-preview'
4
+
5
+ function thread(id: string, createdAt: number): TLCommentThread {
6
+ return {
7
+ id,
8
+ typeName: 'comment-thread',
9
+ pageId: 'page:one',
10
+ anchor: { type: 'point', x: 0, y: 0 },
11
+ createdBy: 'user:1',
12
+ createdAt,
13
+ resolved: null,
14
+ meta: {},
15
+ } as unknown as TLCommentThread
16
+ }
17
+
18
+ function comment(threadId: string): TLComment {
19
+ return { id: `comment:${threadId}`, typeName: 'comment', threadId } as unknown as TLComment
20
+ }
21
+
22
+ /** Stands in for the store lookup: only the named threads have their opening comment yet. */
23
+ function arrived(...threadIds: string[]) {
24
+ return (t: TLCommentThread) => (threadIds.includes(t.id) ? comment(t.id) : undefined)
25
+ }
26
+
27
+ describe('sortThreadsForPreview', () => {
28
+ it('orders threads oldest first', () => {
29
+ const sorted = sortThreadsForPreview([thread('t2', 20), thread('t1', 10), thread('t3', 30)])
30
+ expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
31
+ })
32
+
33
+ it('breaks creation-time ties by id, matching the stack ordering', () => {
34
+ const sorted = sortThreadsForPreview([thread('t3', 5), thread('t1', 5), thread('t2', 5)])
35
+ expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
36
+ })
37
+
38
+ // A cluster hands over `node.members`, which the clustering table sorts by id — the preview has
39
+ // to re-sort rather than inherit that order, or a cluster would read newest-first at random.
40
+ it('reorders an id-sorted cluster membership by creation time', () => {
41
+ const sorted = sortThreadsForPreview([thread('t1', 300), thread('t2', 200), thread('t3', 100)])
42
+ expect(sorted.map((t) => t.id)).toEqual(['t3', 't2', 't1'])
43
+ })
44
+
45
+ it('does not mutate the input', () => {
46
+ const input = [thread('t2', 20), thread('t1', 10)]
47
+ sortThreadsForPreview(input)
48
+ expect(input.map((t) => t.id)).toEqual(['t2', 't1'])
49
+ })
50
+ })
51
+
52
+ describe('selectPreviewCards', () => {
53
+ it('pairs each thread with its opening comment', () => {
54
+ const threads = [thread('t1', 10), thread('t2', 20)]
55
+ const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2'))
56
+ expect(cards.map((c) => [c.thread.id, c.first.id])).toEqual([
57
+ ['t1', 'comment:t1'],
58
+ ['t2', 'comment:t2'],
59
+ ])
60
+ expect(overflow).toBe(0)
61
+ })
62
+
63
+ // A thread record can sync ahead of its first comment. It has nothing to show, and rendering it
64
+ // anyway would paint a blank card — or, for a single-pin preview, a blank panel.
65
+ it('drops threads whose opening comment has not arrived', () => {
66
+ const threads = [thread('t1', 10), thread('t2', 20), thread('t3', 30)]
67
+ const { cards } = selectPreviewCards(threads, arrived('t1', 't3'))
68
+ expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't3'])
69
+ })
70
+
71
+ it('reports nothing to show when no comment has arrived', () => {
72
+ const { cards, overflow } = selectPreviewCards([thread('t1', 10)], arrived())
73
+ expect(cards).toEqual([])
74
+ expect(overflow).toBe(0)
75
+ })
76
+
77
+ it('caps the cards and counts the rest as overflow', () => {
78
+ const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
79
+ const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3', 't4'), 2)
80
+ expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
81
+ expect(overflow).toBe(2)
82
+ })
83
+
84
+ // "+N more" should promise N readable threads, so unarrived ones can't pad the count.
85
+ it('excludes unarrived threads from the overflow tally', () => {
86
+ const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
87
+ const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3'), 2)
88
+ expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
89
+ expect(overflow).toBe(1)
90
+ })
91
+ })