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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1152 -350
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/{ui/avatar.js → canvas/canvas-events.js} +14 -22
  6. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  7. package/dist-cjs/canvas/cluster-badge.js +121 -0
  8. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  9. package/dist-cjs/canvas/cluster-fade.js +85 -0
  10. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  11. package/dist-cjs/canvas/cluster-input.js +53 -3
  12. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  13. package/dist-cjs/canvas/cluster-model.js +252 -0
  14. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  15. package/dist-cjs/canvas/comment-body.js +1 -1
  16. package/dist-cjs/canvas/comment-body.js.map +2 -2
  17. package/dist-cjs/canvas/comment-drafts.js +54 -0
  18. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  19. package/dist-cjs/canvas/comment-mutations.js +152 -0
  20. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  21. package/dist-cjs/canvas/comment-reactions.js +160 -0
  22. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  23. package/dist-cjs/canvas/comment-render.js +9 -6
  24. package/dist-cjs/canvas/comment-render.js.map +2 -2
  25. package/dist-cjs/canvas/comment-store.js +19 -9
  26. package/dist-cjs/canvas/comment-store.js.map +2 -2
  27. package/dist-cjs/canvas/comment-tool.js +63 -14
  28. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  29. package/dist-cjs/canvas/comments-filter-menu.js +17 -37
  30. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  31. package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
  32. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  33. package/dist-cjs/canvas/comments-overlay.js +151 -989
  34. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  35. package/dist-cjs/canvas/comments-sidebar.js +49 -41
  36. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  37. package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
  38. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  39. package/dist-cjs/canvas/context.js +17 -0
  40. package/dist-cjs/canvas/context.js.map +7 -0
  41. package/dist-cjs/canvas/hooks.js +28 -3
  42. package/dist-cjs/canvas/hooks.js.map +2 -2
  43. package/dist-cjs/canvas/license.js.map +1 -1
  44. package/dist-cjs/canvas/mobile-placement.js +91 -0
  45. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  46. package/dist-cjs/canvas/options.js +57 -2
  47. package/dist-cjs/canvas/options.js.map +2 -2
  48. package/dist-cjs/canvas/pending-composer.js +134 -0
  49. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  50. package/dist-cjs/canvas/pin-stacking.js +81 -0
  51. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  52. package/dist-cjs/canvas/region-box.js +113 -0
  53. package/dist-cjs/canvas/region-box.js.map +7 -0
  54. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  55. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  56. package/dist-cjs/canvas/state.js +28 -14
  57. package/dist-cjs/canvas/state.js.map +2 -2
  58. package/dist-cjs/canvas/thread-pin.js +304 -0
  59. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  60. package/dist-cjs/canvas/thread-preview.js +177 -0
  61. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  62. package/dist-cjs/canvas/thread-stack.js +199 -0
  63. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  64. package/dist-cjs/canvas/thread-state.js +82 -21
  65. package/dist-cjs/canvas/thread-state.js.map +2 -2
  66. package/dist-cjs/canvas/thread-view.js +450 -0
  67. package/dist-cjs/canvas/thread-view.js.map +7 -0
  68. package/dist-cjs/clustering/computeClusterTable.js +6 -6
  69. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  70. package/dist-cjs/clustering/replay.js +89 -22
  71. package/dist-cjs/clustering/replay.js.map +3 -3
  72. package/dist-cjs/clustering/runtime.js +126 -7
  73. package/dist-cjs/clustering/runtime.js.map +2 -2
  74. package/dist-cjs/clustering/types.js.map +1 -1
  75. package/dist-cjs/index.d.ts +904 -353
  76. package/dist-cjs/index.js +52 -28
  77. package/dist-cjs/index.js.map +2 -2
  78. package/dist-cjs/ui/byline.js +10 -6
  79. package/dist-cjs/ui/byline.js.map +2 -2
  80. package/dist-cjs/ui/comment-card.js +15 -8
  81. package/dist-cjs/ui/comment-card.js.map +2 -2
  82. package/dist-cjs/ui/comment-composer.js +124 -39
  83. package/dist-cjs/ui/comment-composer.js.map +2 -2
  84. package/dist-cjs/ui/comment-pin.js +11 -2
  85. package/dist-cjs/ui/comment-pin.js.map +2 -2
  86. package/dist-cjs/ui/comment-thread.js +9 -7
  87. package/dist-cjs/ui/comment-thread.js.map +2 -2
  88. package/dist-cjs/ui/comments-list.js +39 -40
  89. package/dist-cjs/ui/comments-list.js.map +2 -2
  90. package/dist-cjs/ui/count-badge.js +3 -2
  91. package/dist-cjs/ui/count-badge.js.map +2 -2
  92. package/dist-cjs/ui/emoji-picker.js +54 -0
  93. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  94. package/dist-cjs/ui/empty-state.js +3 -3
  95. package/dist-cjs/ui/empty-state.js.map +2 -2
  96. package/dist-cjs/ui/format-time.js +16 -1
  97. package/dist-cjs/ui/format-time.js.map +2 -2
  98. package/dist-cjs/ui/icons.js +115 -0
  99. package/dist-cjs/ui/icons.js.map +7 -0
  100. package/dist-cjs/ui/reaction-picker.js +71 -0
  101. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  102. package/dist-cjs/ui/reaction.js +58 -6
  103. package/dist-cjs/ui/reaction.js.map +2 -2
  104. package/dist-cjs/ui/reactions.js +20 -7
  105. package/dist-cjs/ui/reactions.js.map +2 -2
  106. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  107. package/dist-cjs/ui/reply-count.js.map +7 -0
  108. package/dist-cjs/ui/send-button.js +13 -1
  109. package/dist-cjs/ui/send-button.js.map +2 -2
  110. package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
  111. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  112. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  113. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  114. package/dist-esm/canvas/canvas-events.mjs +15 -0
  115. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  116. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  117. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  118. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  119. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  120. package/dist-esm/canvas/cluster-input.mjs +54 -4
  121. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  122. package/dist-esm/canvas/cluster-model.mjs +236 -0
  123. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  124. package/dist-esm/canvas/comment-body.mjs +1 -1
  125. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  126. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  127. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  128. package/dist-esm/canvas/comment-mutations.mjs +132 -0
  129. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  130. package/dist-esm/canvas/comment-reactions.mjs +145 -0
  131. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  132. package/dist-esm/canvas/comment-render.mjs +10 -7
  133. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  134. package/dist-esm/canvas/comment-store.mjs +19 -9
  135. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  136. package/dist-esm/canvas/comment-tool.mjs +66 -17
  137. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  138. package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
  139. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  140. package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
  141. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  142. package/dist-esm/canvas/comments-overlay.mjs +157 -1010
  143. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  144. package/dist-esm/canvas/comments-sidebar.mjs +53 -46
  145. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  146. package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
  147. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  148. package/dist-esm/canvas/context.mjs +1 -0
  149. package/dist-esm/canvas/context.mjs.map +7 -0
  150. package/dist-esm/canvas/hooks.mjs +35 -5
  151. package/dist-esm/canvas/hooks.mjs.map +2 -2
  152. package/dist-esm/canvas/license.mjs.map +1 -1
  153. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  154. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  155. package/dist-esm/canvas/options.mjs +59 -3
  156. package/dist-esm/canvas/options.mjs.map +2 -2
  157. package/dist-esm/canvas/pending-composer.mjs +126 -0
  158. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  159. package/dist-esm/canvas/pin-stacking.mjs +61 -0
  160. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  161. package/dist-esm/canvas/region-box.mjs +93 -0
  162. package/dist-esm/canvas/region-box.mjs.map +7 -0
  163. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  164. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  165. package/dist-esm/canvas/state.mjs +29 -19
  166. package/dist-esm/canvas/state.mjs.map +2 -2
  167. package/dist-esm/canvas/thread-pin.mjs +310 -0
  168. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  169. package/dist-esm/canvas/thread-preview.mjs +163 -0
  170. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  171. package/dist-esm/canvas/thread-stack.mjs +189 -0
  172. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  173. package/dist-esm/canvas/thread-state.mjs +86 -22
  174. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  175. package/dist-esm/canvas/thread-view.mjs +461 -0
  176. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  177. package/dist-esm/clustering/computeClusterTable.mjs +6 -6
  178. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  179. package/dist-esm/clustering/replay.mjs +89 -22
  180. package/dist-esm/clustering/replay.mjs.map +3 -3
  181. package/dist-esm/clustering/runtime.mjs +126 -7
  182. package/dist-esm/clustering/runtime.mjs.map +2 -2
  183. package/dist-esm/index.d.mts +904 -353
  184. package/dist-esm/index.mjs +102 -43
  185. package/dist-esm/index.mjs.map +2 -2
  186. package/dist-esm/ui/byline.mjs +11 -7
  187. package/dist-esm/ui/byline.mjs.map +2 -2
  188. package/dist-esm/ui/comment-card.mjs +15 -8
  189. package/dist-esm/ui/comment-card.mjs.map +2 -2
  190. package/dist-esm/ui/comment-composer.mjs +135 -39
  191. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  192. package/dist-esm/ui/comment-pin.mjs +11 -2
  193. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  194. package/dist-esm/ui/comment-thread.mjs +9 -7
  195. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  196. package/dist-esm/ui/comments-list.mjs +51 -50
  197. package/dist-esm/ui/comments-list.mjs.map +2 -2
  198. package/dist-esm/ui/count-badge.mjs +3 -2
  199. package/dist-esm/ui/count-badge.mjs.map +2 -2
  200. package/dist-esm/ui/emoji-picker.mjs +34 -0
  201. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  202. package/dist-esm/ui/empty-state.mjs +3 -3
  203. package/dist-esm/ui/empty-state.mjs.map +2 -2
  204. package/dist-esm/ui/format-time.mjs +16 -1
  205. package/dist-esm/ui/format-time.mjs.map +2 -2
  206. package/dist-esm/ui/icons.mjs +95 -0
  207. package/dist-esm/ui/icons.mjs.map +7 -0
  208. package/dist-esm/ui/reaction-picker.mjs +59 -0
  209. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  210. package/dist-esm/ui/reaction.mjs +59 -7
  211. package/dist-esm/ui/reaction.mjs.map +2 -2
  212. package/dist-esm/ui/reactions.mjs +21 -8
  213. package/dist-esm/ui/reactions.mjs.map +2 -2
  214. package/dist-esm/ui/reply-count.mjs +9 -0
  215. package/dist-esm/ui/reply-count.mjs.map +7 -0
  216. package/dist-esm/ui/send-button.mjs +13 -1
  217. package/dist-esm/ui/send-button.mjs.map +2 -2
  218. package/dist-esm/ui/visual-viewport.mjs +12 -0
  219. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  220. package/package.json +6 -6
  221. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  222. package/src/canvas/anchor-lifecycle.ts +191 -0
  223. package/src/canvas/canvas-events.ts +18 -0
  224. package/src/canvas/canvas.css +464 -55
  225. package/src/canvas/cluster-badge.tsx +133 -0
  226. package/src/canvas/cluster-fade.ts +102 -0
  227. package/src/canvas/cluster-input.test.ts +178 -36
  228. package/src/canvas/cluster-input.ts +95 -9
  229. package/src/canvas/cluster-model.ts +378 -0
  230. package/src/canvas/comment-body.tsx +1 -1
  231. package/src/canvas/comment-drafts.test.ts +55 -0
  232. package/src/canvas/comment-drafts.ts +46 -0
  233. package/src/canvas/comment-mutations.test.ts +452 -0
  234. package/src/canvas/comment-mutations.ts +299 -0
  235. package/src/canvas/comment-reactions.test.ts +154 -0
  236. package/src/canvas/comment-reactions.tsx +246 -0
  237. package/src/canvas/comment-render.test.ts +1 -1
  238. package/src/canvas/comment-render.ts +17 -13
  239. package/src/canvas/comment-store.ts +68 -33
  240. package/src/canvas/comment-tool.test.ts +162 -0
  241. package/src/canvas/comment-tool.tsx +103 -25
  242. package/src/canvas/comments-filter-menu.tsx +20 -36
  243. package/src/canvas/comments-overflow-menu.tsx +17 -19
  244. package/src/canvas/comments-overlay.tsx +252 -1369
  245. package/src/canvas/comments-sidebar.test.ts +45 -0
  246. package/src/canvas/comments-sidebar.tsx +112 -73
  247. package/src/canvas/comments-visibility-toggle.tsx +25 -0
  248. package/src/canvas/context.ts +48 -0
  249. package/src/canvas/hooks.test.ts +94 -0
  250. package/src/canvas/hooks.ts +66 -11
  251. package/src/canvas/license.ts +1 -1
  252. package/src/canvas/mobile-placement.ts +115 -0
  253. package/src/canvas/options.test.ts +176 -4
  254. package/src/canvas/options.ts +276 -18
  255. package/src/canvas/pending-composer.tsx +157 -0
  256. package/src/canvas/pin-stacking.test.ts +224 -0
  257. package/src/canvas/pin-stacking.ts +103 -0
  258. package/src/canvas/region-box.tsx +124 -0
  259. package/src/canvas/sidebar-filters.ts +4 -3
  260. package/src/canvas/state.ts +88 -39
  261. package/src/canvas/thread-pin.tsx +418 -0
  262. package/src/canvas/thread-preview.test.ts +91 -0
  263. package/src/canvas/thread-preview.tsx +294 -0
  264. package/src/canvas/thread-stack.tsx +242 -0
  265. package/src/canvas/thread-state.test.ts +260 -0
  266. package/src/canvas/thread-state.ts +171 -37
  267. package/src/canvas/thread-view.test.ts +72 -0
  268. package/src/canvas/thread-view.tsx +614 -0
  269. package/src/clustering/computeClusterTable.test.ts +24 -23
  270. package/src/clustering/computeClusterTable.ts +17 -8
  271. package/src/clustering/replay.test.ts +0 -7
  272. package/src/clustering/replay.ts +131 -32
  273. package/src/clustering/runtime.test.ts +267 -13
  274. package/src/clustering/runtime.ts +176 -19
  275. package/src/clustering/schedule.test.ts +0 -6
  276. package/src/clustering/screen-offsets.test.ts +171 -0
  277. package/src/clustering/types.ts +19 -9
  278. package/src/index.ts +91 -42
  279. package/src/ui/byline.tsx +21 -9
  280. package/src/ui/comment-card.tsx +20 -10
  281. package/src/ui/comment-composer.tsx +178 -56
  282. package/src/ui/comment-pin.tsx +13 -2
  283. package/src/ui/comment-thread.tsx +13 -6
  284. package/src/ui/comments-list.test.ts +31 -0
  285. package/src/ui/comments-list.tsx +82 -47
  286. package/src/ui/comments.css +577 -294
  287. package/src/ui/count-badge.tsx +9 -2
  288. package/src/ui/emoji-picker.test.ts +25 -0
  289. package/src/ui/emoji-picker.tsx +76 -0
  290. package/src/ui/empty-state.tsx +3 -3
  291. package/src/ui/format-time.test.ts +69 -0
  292. package/src/ui/format-time.ts +27 -2
  293. package/src/ui/icons.tsx +116 -0
  294. package/src/ui/reaction-picker.tsx +102 -0
  295. package/src/ui/reaction.tsx +125 -6
  296. package/src/ui/reactions.tsx +69 -10
  297. package/src/ui/reply-count.ts +16 -0
  298. package/src/ui/send-button.tsx +13 -3
  299. package/src/ui/visual-viewport.test.ts +36 -0
  300. package/src/ui/visual-viewport.ts +27 -0
  301. package/dist-cjs/canvas/region-options.js +0 -49
  302. package/dist-cjs/canvas/region-options.js.map +0 -7
  303. package/dist-cjs/ui/avatar.js.map +0 -7
  304. package/dist-cjs/ui/comment-mention.js +0 -42
  305. package/dist-cjs/ui/comment-mention.js.map +0 -7
  306. package/dist-cjs/ui/comment-text.js.map +0 -7
  307. package/dist-cjs/ui/mention-list.js +0 -70
  308. package/dist-cjs/ui/mention-list.js.map +0 -7
  309. package/dist-cjs/ui/mention-suggestion.js +0 -206
  310. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  311. package/dist-cjs/ui/mention.js.map +0 -7
  312. package/dist-cjs/ui/render-markdown.js +0 -63
  313. package/dist-cjs/ui/render-markdown.js.map +0 -7
  314. package/dist-esm/canvas/region-options.mjs +0 -29
  315. package/dist-esm/canvas/region-options.mjs.map +0 -7
  316. package/dist-esm/ui/avatar.mjs +0 -23
  317. package/dist-esm/ui/avatar.mjs.map +0 -7
  318. package/dist-esm/ui/comment-mention.mjs +0 -22
  319. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  320. package/dist-esm/ui/comment-text.mjs +0 -9
  321. package/dist-esm/ui/comment-text.mjs.map +0 -7
  322. package/dist-esm/ui/mention-list.mjs +0 -50
  323. package/dist-esm/ui/mention-list.mjs.map +0 -7
  324. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  325. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  326. package/dist-esm/ui/mention.mjs +0 -11
  327. package/dist-esm/ui/mention.mjs.map +0 -7
  328. package/dist-esm/ui/render-markdown.mjs +0 -45
  329. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  330. package/src/canvas/region-options.ts +0 -57
  331. package/src/ui/avatar.tsx +0 -31
  332. package/src/ui/comment-mention.ts +0 -47
  333. package/src/ui/comment-text.tsx +0 -12
  334. package/src/ui/mention-list.tsx +0 -106
  335. package/src/ui/mention-suggestion.test.ts +0 -18
  336. package/src/ui/mention-suggestion.tsx +0 -285
  337. package/src/ui/mention.tsx +0 -9
  338. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,133 @@
1
+ import { memo, useMemo, useRef } from 'react'
2
+ import {
3
+ Editor,
4
+ TLCommentThread,
5
+ useContainer,
6
+ usePassThroughWheelEvents,
7
+ useTranslation,
8
+ useValue,
9
+ } from 'tldraw'
10
+ import type { ClusterNode } from '../clustering/types'
11
+ import { CountBadge } from '../ui/count-badge'
12
+ import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
13
+ import { type CommentingContext } from './context'
14
+ import { sortThreadsForPreview, ThreadPreview, useMarkerPreview } from './thread-preview'
15
+ import { impreciseShapePinInset, isInInflatedViewport } from './thread-state'
16
+
17
+ /**
18
+ * The count badge standing in for several threads folded together at the current zoom. Hovering it
19
+ * previews its threads as a list; clicking zooms to the zoom level at which the cluster splits.
20
+ *
21
+ * Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
22
+ * badges skip re-rendering when the parent re-renders for reasons that don't concern them
23
+ * (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
24
+ * works — each component subscribes to its own viewport position via signals, not via props.
25
+ */
26
+ export const ClusterBadge = memo(function ClusterBadge({
27
+ editor,
28
+ node,
29
+ onExpand,
30
+ onSelectThread,
31
+ threadsById,
32
+ ...props
33
+ }: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
34
+ editor: Editor
35
+ node: ClusterNode
36
+ onExpand(node: ClusterNode): void
37
+ onSelectThread(thread: TLCommentThread): void
38
+ threadsById: ReadonlyMap<string, TLCommentThread>
39
+ }) {
40
+ const container = useContainer()
41
+ const msg = useTranslation()
42
+ const badgeRef = useRef<HTMLButtonElement>(null)
43
+ const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
44
+ // Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
45
+ // note on the layer.
46
+ usePassThroughWheelEvents(badgeRef)
47
+ // The mean of the members' render offsets, in screen px. Imprecise members' pins draw tucked
48
+ // into their shape (see impreciseShapePinInset), and the merge thresholds are priced at those
49
+ // visual positions — so the badge draws at the centroid of the pins as drawn, which (screen
50
+ // mapping being affine) is the anchor centroid plus this mean. Camera-independent: its own
51
+ // computed so the per-frame point below only adds two numbers, and a cluster of precise
52
+ // members tracks nothing and adds zero.
53
+ const meanInset = useValue(
54
+ 'cluster badge inset',
55
+ () => {
56
+ let x = 0
57
+ let y = 0
58
+ for (const id of node.members) {
59
+ const thread = threadsById.get(id)
60
+ if (!thread) continue
61
+ const inset = impreciseShapePinInset(editor, thread.anchor)
62
+ if (inset) {
63
+ x += inset.x
64
+ y += inset.y
65
+ }
66
+ }
67
+ return { x: x / node.count, y: y / node.count }
68
+ },
69
+ [editor, node, threadsById]
70
+ )
71
+ const point = useValue(
72
+ 'cluster badge point',
73
+ () => {
74
+ const pagePoint = editor.pageToViewport(node.centroid)
75
+ if (!isInInflatedViewport(editor, pagePoint)) return null
76
+ return { x: pagePoint.x + meanInset.x, y: pagePoint.y + meanInset.y }
77
+ },
78
+ [editor, node, meanInset]
79
+ )
80
+
81
+ // `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
82
+ // the order a reader would expect. Only computed while the preview is up.
83
+ const previewThreads = useMemo(() => {
84
+ if (!previewShown) return []
85
+ const threads: TLCommentThread[] = []
86
+ for (const id of node.members) {
87
+ const thread = threadsById.get(id)
88
+ if (thread) threads.push(thread)
89
+ }
90
+ return sortThreadsForPreview(threads)
91
+ }, [previewShown, node.members, threadsById])
92
+
93
+ if (!point) return null
94
+
95
+ return (
96
+ <>
97
+ <button
98
+ ref={badgeRef}
99
+ type="button"
100
+ className="tlui-cmt-button tlui-cmt-canvas-cluster"
101
+ style={{ left: point.x, top: point.y }}
102
+ aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
103
+ onPointerDown={(e) => {
104
+ if (isCanvasPanGesture(editor, e)) {
105
+ forwardPointerEventToCanvas(container, e)
106
+ return
107
+ }
108
+ e.stopPropagation()
109
+ }}
110
+ onClick={(e) => {
111
+ e.stopPropagation()
112
+ onExpand(node)
113
+ }}
114
+ {...previewHandlers}
115
+ onFocus={previewHandlers.onPointerEnter}
116
+ onBlur={previewHandlers.onPointerLeave}
117
+ >
118
+ <CountBadge count={node.count} />
119
+ </button>
120
+ {previewShown && previewThreads.length > 0 && (
121
+ <ThreadPreview
122
+ editor={editor}
123
+ threads={previewThreads}
124
+ variant="list"
125
+ point={point}
126
+ onSelectThread={onSelectThread}
127
+ {...previewHandlers}
128
+ {...props}
129
+ />
130
+ )}
131
+ </>
132
+ )
133
+ })
@@ -0,0 +1,102 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { ClusterNode } from '../clustering/types'
3
+
4
+ const CLUSTER_FADE_MS = 150
5
+
6
+ export type ClusterFadePhase = 'entering' | 'present' | 'exiting'
7
+
8
+ export interface ClusterFadeNode {
9
+ node: ClusterNode
10
+ phase: ClusterFadePhase
11
+ }
12
+
13
+ /**
14
+ * Cross-fades the visible cluster nodes as the partition changes: a node that appears starts
15
+ * `entering` and flips to `present` on the next frame, and one that leaves is kept as `exiting`
16
+ * until the CSS transition has run. `resetKey` is compared by identity — a new cluster model
17
+ * replaces the whole world, so its nodes snap to `present` instead of animating.
18
+ */
19
+ export function useFadeVisibleNodes(
20
+ nodes: readonly ClusterNode[],
21
+ resetKey: object
22
+ ): ClusterFadeNode[] {
23
+ const resetKeyRef = useRef(resetKey)
24
+ const didReset = resetKeyRef.current !== resetKey
25
+ if (didReset) {
26
+ resetKeyRef.current = resetKey
27
+ }
28
+
29
+ const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
30
+ const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
31
+
32
+ useEffect(() => {
33
+ setFadeNodes(toPresentFadeNodes(nodes))
34
+ // Resets only on a new model (resetKey); node-list changes within the same model are
35
+ // handled by the reconcile effect below, which fades entries in/out instead of snapping.
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [resetKey])
38
+
39
+ useEffect(() => {
40
+ if (didReset) return
41
+ setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
42
+ }, [didReset, nodes])
43
+
44
+ const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
45
+ useEffect(() => {
46
+ if (!hasEntering) return
47
+ const frame = requestAnimationFrame(() => {
48
+ setFadeNodes((previous) =>
49
+ previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
50
+ )
51
+ })
52
+ return () => cancelAnimationFrame(frame)
53
+ }, [hasEntering, renderedNodes])
54
+
55
+ const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
56
+ useEffect(() => {
57
+ if (!hasExiting) return
58
+ const timeout = window.setTimeout(() => {
59
+ setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
60
+ }, CLUSTER_FADE_MS)
61
+ return () => window.clearTimeout(timeout)
62
+ }, [hasExiting, renderedNodes])
63
+
64
+ return renderedNodes
65
+ }
66
+
67
+ function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
68
+ return nodes.map((node) => ({ node, phase: 'present' }))
69
+ }
70
+
71
+ function reconcileFadeNodes(
72
+ previous: readonly ClusterFadeNode[],
73
+ nextNodes: readonly ClusterNode[]
74
+ ): ClusterFadeNode[] {
75
+ const previousById = new Map(previous.map((item) => [item.node.id, item]))
76
+ const nextIds = new Set(nextNodes.map((node) => node.id))
77
+ const next: ClusterFadeNode[] = []
78
+
79
+ for (const node of nextNodes) {
80
+ const previousItem = previousById.get(node.id)
81
+ next.push({
82
+ node,
83
+ phase:
84
+ previousItem && previousItem.phase !== 'exiting'
85
+ ? previousItem.phase
86
+ : previousItem
87
+ ? 'present'
88
+ : 'entering',
89
+ })
90
+ }
91
+
92
+ for (const item of previous) {
93
+ if (nextIds.has(item.node.id)) continue
94
+ next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
95
+ }
96
+
97
+ return next
98
+ }
99
+
100
+ export function clusterFadeClassName(phase: ClusterFadePhase): string {
101
+ return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
102
+ }
@@ -1,9 +1,16 @@
1
- import type { Editor, TLCommentAnchor, TLCommentThread } from 'tldraw'
1
+ import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
2
2
  import { describe, expect, it } from 'vitest'
3
3
  // This import is red until step 6's filter module is implemented — that is
4
4
  // intentional. Implement `cluster-input.ts` per CLUSTERING-STEPS.md step 6
5
5
  // until this suite passes, without modifying this file.
6
- import { collectClusterLeaves } from './cluster-input'
6
+ import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
7
+ import {
8
+ type ClusterInput,
9
+ clusterInputEqual,
10
+ clusterInputIdsEqual,
11
+ collectClusterLeaves,
12
+ } from './cluster-input'
13
+ import { defaultCommentingOptions, type CommentingOptions } from './options'
7
14
 
8
15
  const CURRENT_PAGE = 'page:one'
9
16
  const OTHER_PAGE = 'page:two'
@@ -27,19 +34,31 @@ function thread(
27
34
  }
28
35
 
29
36
  /**
30
- * Stub editor: the filter's only editor dependencies are the current page id
31
- * and shape page bounds (via anchorPagePoint). `shapes` maps shape id → bounds
32
- * for shapes that exist; anything else resolves to undefined (deleted shape).
37
+ * Stub editor: the filter's editor dependencies are the current page id, shape
38
+ * geometry and page transform, and the commenting options (via anchorPagePoint,
39
+ * read off the registered comment tool). `shapes` maps shape id → page bounds for
40
+ * unrotated shapes that exist, modeled as local geometry sized by the box plus a
41
+ * translate-only page transform; anything else resolves to undefined (deleted
42
+ * shape). `options` stands in for `CommentTool.configure({ ... })`; omit it for a
43
+ * editor with no comment tool registered, which falls back to the defaults.
33
44
  */
34
45
  function stubEditor(
35
- shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
46
+ shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {},
47
+ options?: Partial<CommentingOptions>
36
48
  ): Editor {
49
+ const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
37
50
  return {
38
51
  getCurrentPageId: () => CURRENT_PAGE,
39
- getShapePageBounds: (id: string) => {
40
- const bounds = shapes[id]
41
- if (!bounds) return undefined
42
- return { ...bounds, w: bounds.maxX - bounds.minX, h: bounds.maxY - bounds.minY }
52
+ getStateDescendant: () =>
53
+ options ? { options: { ...defaultCommentingOptions, ...options } } : undefined,
54
+ getShape: (id: string) => (shapes[id] ? { id } : undefined),
55
+ getShapeGeometry: (shape: string | { id: string }) => {
56
+ const bounds = shapes[shapeId(shape)]
57
+ return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
58
+ },
59
+ getShapePageTransform: (shape: string | { id: string }) => {
60
+ const bounds = shapes[shapeId(shape)]
61
+ return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
43
62
  },
44
63
  } as unknown as Editor
45
64
  }
@@ -54,7 +73,7 @@ describe('collectClusterLeaves anchor resolution', () => {
54
73
  stubEditor(),
55
74
  [thread('t1', { type: 'point', x: 12, y: 34 })],
56
75
  null
57
- )
76
+ ).leaves
58
77
  expect(leaves).toEqual([{ id: 't1', point: { x: 12, y: 34 } }])
59
78
  })
60
79
 
@@ -63,32 +82,28 @@ describe('collectClusterLeaves anchor resolution', () => {
63
82
  stubEditor(),
64
83
  [thread('t1', { type: 'region', x: 10, y: 20, w: 30, h: 40 })],
65
84
  null
66
- )
85
+ ).leaves
67
86
  expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
68
87
  })
69
88
 
70
- it('maps shape and text-range anchors to the shape bounds top-right corner', () => {
89
+ it('maps shape anchors to the shape bounds top-right corner', () => {
71
90
  const editor = stubEditor({
72
91
  'shape:a': { minX: 0, minY: 5, maxX: 100, maxY: 50 },
73
92
  })
74
93
  const leaves = collectClusterLeaves(
75
94
  editor,
76
- [
77
- thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false }),
78
- thread('t2', { type: 'text-range', shapeId: 'shape:a' as any, from: 0, to: 3 }),
79
- ],
95
+ [thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false })],
80
96
  null
81
- )
82
- expect(leaves).toEqual([
83
- { id: 't1', point: { x: 100, y: 5 } },
84
- { id: 't2', point: { x: 100, y: 5 } },
85
- ])
97
+ ).leaves
98
+ expect(leaves).toEqual([{ id: 't1', point: { x: 100, y: 5 } }])
86
99
  })
87
100
 
88
101
  it('places imprecise shape leaves at a custom impreciseShapeAnchor, matching pin rendering', () => {
89
- const editor = stubEditor({
90
- 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 },
91
- })
102
+ // bottom-center instead of the default top-right
103
+ const editor = stubEditor(
104
+ { 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } },
105
+ { impreciseShapeAnchor: { x: 0.5, y: 1 } }
106
+ )
92
107
  const threads = [
93
108
  thread('imprecise', {
94
109
  type: 'shape',
@@ -105,8 +120,7 @@ describe('collectClusterLeaves anchor resolution', () => {
105
120
  isPrecise: true,
106
121
  }),
107
122
  ]
108
- // bottom-center instead of the default top-right
109
- const leaves = collectClusterLeaves(editor, threads, null, { x: 0.5, y: 1 })
123
+ const leaves = collectClusterLeaves(editor, threads, null).leaves
110
124
  expect(leaves).toEqual([
111
125
  { id: 'imprecise', point: { x: 50, y: 50 } },
112
126
  { id: 'precise', point: { x: 50, y: 25 } },
@@ -127,7 +141,7 @@ describe('collectClusterLeaves anchor resolution', () => {
127
141
  thread('kept', { type: 'point', x: 1, y: 2 }),
128
142
  ],
129
143
  null
130
- )
144
+ ).leaves
131
145
  expect(leafIds(leaves)).toEqual(['kept'])
132
146
  })
133
147
 
@@ -136,7 +150,7 @@ describe('collectClusterLeaves anchor resolution', () => {
136
150
  stubEditor(),
137
151
  [thread('pageThread', { type: 'page' }), thread('kept', { type: 'point', x: 1, y: 2 })],
138
152
  null
139
- )
153
+ ).leaves
140
154
  expect(leafIds(leaves)).toEqual(['kept'])
141
155
  })
142
156
  })
@@ -150,7 +164,7 @@ describe('collectClusterLeaves filtering', () => {
150
164
  thread('elsewhere', { type: 'point', x: 0, y: 0 }, { pageId: OTHER_PAGE }),
151
165
  ],
152
166
  null
153
- )
167
+ ).leaves
154
168
  expect(leafIds(leaves)).toEqual(['here'])
155
169
  })
156
170
 
@@ -159,10 +173,13 @@ describe('collectClusterLeaves filtering', () => {
159
173
  thread('open', { type: 'point', x: 0, y: 0 }),
160
174
  thread('closed', { type: 'point', x: 10, y: 0 }),
161
175
  ]
162
- expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open'))).toEqual(['closed'])
163
- expect(leafIds(collectClusterLeaves(stubEditor(), threads, null))).toEqual(['closed', 'open'])
176
+ expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open').leaves)).toEqual(['closed'])
177
+ expect(leafIds(collectClusterLeaves(stubEditor(), threads, null).leaves)).toEqual([
178
+ 'closed',
179
+ 'open',
180
+ ])
164
181
  // an id that matches no thread excludes nothing
165
- expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown'))).toEqual([
182
+ expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown').leaves)).toEqual([
166
183
  'closed',
167
184
  'open',
168
185
  ])
@@ -176,12 +193,12 @@ describe('collectClusterLeaves filtering', () => {
176
193
  thread('unresolved', { type: 'point', x: 10, y: 0 }),
177
194
  ],
178
195
  null
179
- )
196
+ ).leaves
180
197
  expect(leafIds(leaves)).toEqual(['resolved', 'unresolved'])
181
198
  })
182
199
 
183
200
  it('returns [] for no threads', () => {
184
- expect(collectClusterLeaves(stubEditor(), [], null)).toEqual([])
201
+ expect(collectClusterLeaves(stubEditor(), [], null).leaves).toEqual([])
185
202
  })
186
203
 
187
204
  it('applies every rule at once on a mixed set', () => {
@@ -210,7 +227,7 @@ describe('collectClusterLeaves filtering', () => {
210
227
  thread('openOne', { type: 'point', x: 3, y: 3 }),
211
228
  thread('resolvedOne', { type: 'point', x: 4, y: 4 }, { resolved: true }),
212
229
  ]
213
- const leaves = collectClusterLeaves(editor, threads, 'openOne')
230
+ const leaves = collectClusterLeaves(editor, threads, 'openOne').leaves
214
231
  expect(leafIds(leaves)).toEqual(['onShape', 'point', 'region', 'resolvedOne'])
215
232
  })
216
233
 
@@ -221,3 +238,128 @@ describe('collectClusterLeaves filtering', () => {
221
238
  expect(JSON.parse(JSON.stringify(threads))).toEqual(snapshot)
222
239
  })
223
240
  })
241
+
242
+ describe('collectClusterLeaves screen offsets', () => {
243
+ it('is undefined when every pin renders on its anchor', () => {
244
+ const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
245
+ const threads = [
246
+ thread('point', { type: 'point', x: 1, y: 2 }),
247
+ thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
248
+ thread('precise', {
249
+ type: 'shape',
250
+ shapeId: 'shape:a' as any,
251
+ x: 0.5,
252
+ y: 0.5,
253
+ isPrecise: true,
254
+ }),
255
+ ]
256
+ expect(collectClusterLeaves(editor, threads, null).screenOffsets).toBeUndefined()
257
+ })
258
+
259
+ it('maps imprecise leaves to their pin inset, and only those', () => {
260
+ const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
261
+ const threads = [
262
+ thread('imprecise', {
263
+ type: 'shape',
264
+ shapeId: 'shape:a' as any,
265
+ x: 0,
266
+ y: 0,
267
+ isPrecise: false,
268
+ }),
269
+ thread('point', { type: 'point', x: 1, y: 2 }),
270
+ ]
271
+ const { screenOffsets } = collectClusterLeaves(editor, threads, null)
272
+ // Default top-right anchor spot: the pin tucks left and down into the shape.
273
+ expect(screenOffsets).toEqual(new Map([['imprecise', { x: -20, y: 20 }]]))
274
+ })
275
+
276
+ it('excludes threads that produced no leaf', () => {
277
+ const editor = stubEditor({}) // shape does not resolve
278
+ const threads = [
279
+ thread('gone', { type: 'shape', shapeId: 'shape:x' as any, x: 0, y: 0, isPrecise: false }),
280
+ ]
281
+ const input = collectClusterLeaves(editor, threads, null)
282
+ expect(input.leaves).toEqual([])
283
+ expect(input.screenOffsets).toBeUndefined()
284
+ })
285
+ })
286
+
287
+ // These two gate the O(N²) rebuild in `useClusterModel`. Both compare the leaves positionally, so
288
+ // the order cases below are what pin the gate to `collectClusterLeaves` walking threads in order.
289
+ const leaf = (id: string, x: number, y: number) => ({ id, point: { x, y } })
290
+ const input = (leaves: LeafInput[], screenOffsets?: LeafScreenOffsets): ClusterInput => ({
291
+ leaves,
292
+ screenOffsets,
293
+ })
294
+
295
+ describe('clusterInputEqual', () => {
296
+ it('accepts the same leaves at the same positions, whatever the identity', () => {
297
+ const a = input([leaf('t1', 0, 0), leaf('t2', 10, 20)])
298
+ expect(clusterInputEqual(a, a)).toBe(true)
299
+ expect(clusterInputEqual(a, input([leaf('t1', 0, 0), leaf('t2', 10, 20)]))).toBe(true)
300
+ expect(clusterInputEqual(input([]), input([]))).toBe(true)
301
+ })
302
+
303
+ it('rejects a position change — a moved pin must rebuild the table', () => {
304
+ expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0, 0.5)]))).toBe(false)
305
+ expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0.5, 0)]))).toBe(false)
306
+ })
307
+
308
+ it('rejects an added, removed, renamed, or reordered leaf', () => {
309
+ const one = input([leaf('t1', 0, 0)])
310
+ const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
311
+ expect(clusterInputEqual(one, two)).toBe(false)
312
+ expect(clusterInputEqual(two, one)).toBe(false)
313
+ expect(clusterInputEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
314
+ expect(clusterInputEqual(two, input([leaf('t2', 1, 1), leaf('t1', 0, 0)]))).toBe(false)
315
+ })
316
+
317
+ it('rejects an offset change, which prices the same leaves differently', () => {
318
+ const leaves = [leaf('t1', 0, 0)]
319
+ const offsets = (x: number) => new Map([['t1', { x, y: 20 }]])
320
+ expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(-20)))).toBe(true)
321
+ expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(20)))).toBe(false)
322
+ // gaining or losing offsets entirely (a pin's precision changed)
323
+ expect(clusterInputEqual(input(leaves), input(leaves, offsets(-20)))).toBe(false)
324
+ expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves))).toBe(false)
325
+ })
326
+ })
327
+
328
+ describe('clusterInputIdsEqual', () => {
329
+ it('ignores positions and offset vectors — this is what defers the rebuild mid-drag', () => {
330
+ expect(clusterInputIdsEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 999, 999)]))).toBe(
331
+ true
332
+ )
333
+ // rotating mid-drag turns the inset; deferred like the anchor move it comes with
334
+ expect(
335
+ clusterInputIdsEqual(
336
+ input([leaf('t1', 0, 0)], new Map([['t1', { x: -20, y: 20 }]])),
337
+ input([leaf('t1', 5, 5)], new Map([['t1', { x: 20, y: -20 }]]))
338
+ )
339
+ ).toBe(true)
340
+ })
341
+
342
+ it('still rejects an added or removed thread, so those rebuild even mid-drag', () => {
343
+ const one = input([leaf('t1', 0, 0)])
344
+ const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
345
+ expect(clusterInputIdsEqual(one, two)).toBe(false)
346
+ expect(clusterInputIdsEqual(two, one)).toBe(false)
347
+ expect(clusterInputIdsEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
348
+ })
349
+
350
+ it('still rejects a leaf that gained or lost its offset — a precision change, not a gesture', () => {
351
+ const leaves = [leaf('t1', 0, 0)]
352
+ expect(
353
+ clusterInputIdsEqual(input(leaves), input(leaves, new Map([['t1', { x: -20, y: 20 }]])))
354
+ ).toBe(false)
355
+ })
356
+
357
+ it('rejects a reorder', () => {
358
+ expect(
359
+ clusterInputIdsEqual(
360
+ input([leaf('t1', 0, 0), leaf('t2', 1, 1)]),
361
+ input([leaf('t2', 1, 1), leaf('t1', 0, 0)])
362
+ )
363
+ ).toBe(false)
364
+ })
365
+ })
@@ -1,29 +1,115 @@
1
- import type { Editor, TLCommentThread } from 'tldraw'
2
- import type { LeafInput } from '../clustering/types'
3
- import { anchorPagePoint } from './thread-state'
1
+ import type { Editor, TLCommentThread, VecLike } from 'tldraw'
2
+ import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
3
+ import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
4
4
 
5
- /** @public */
5
+ /** The clustering input for the current page's pinned threads. @internal */
6
+ export interface ClusterInput {
7
+ /** One leaf per pinned thread, at its anchor page point. */
8
+ leaves: LeafInput[]
9
+ /**
10
+ * The render offsets of the imprecise pins among the leaves — those draw tucked into their
11
+ * shape by a constant screen-px inset rather than on their anchor, and the clustering prices
12
+ * their merges at the visual positions. Undefined when every pin renders on its anchor,
13
+ * which guarantees a byte-identical offset-unaware clustering run.
14
+ */
15
+ screenOffsets: LeafScreenOffsets | undefined
16
+ }
17
+
18
+ /** The cluster leaves for the threads pinned on the current page. @internal */
6
19
  export function collectClusterLeaves(
7
20
  editor: Editor,
8
21
  threads: readonly TLCommentThread[],
9
- openThreadId: string | null,
10
- impreciseShapeAnchor?: { x: number; y: number }
11
- ): LeafInput[] {
22
+ openThreadId: string | null
23
+ ): ClusterInput {
12
24
  const pageId = editor.getCurrentPageId()
13
25
  const leaves: LeafInput[] = []
26
+ let screenOffsets: Map<string, VecLike> | undefined
14
27
 
15
28
  for (const thread of threads) {
16
29
  if (thread.id === openThreadId) continue
17
30
  if (thread.pageId !== pageId) continue
18
31
 
19
- const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
32
+ const point = anchorPagePoint(editor, thread.anchor)
20
33
  if (!point) continue
21
34
 
22
35
  leaves.push({
23
36
  id: thread.id,
24
37
  point: { x: point.x, y: point.y },
25
38
  })
39
+
40
+ const inset = impreciseShapePinInset(editor, thread.anchor)
41
+ if (inset) (screenOffsets ??= new Map()).set(thread.id, inset)
26
42
  }
27
43
 
28
- return leaves
44
+ return { leaves, screenOffsets }
45
+ }
46
+
47
+ /**
48
+ * Value equality over everything `computeClusterTable` reads. Keeps the input's identity stable
49
+ * across recomputes that changed nothing, so the O(N²) rebuild only runs when it has to.
50
+ * @internal
51
+ */
52
+ export function clusterInputEqual(a: ClusterInput, b: ClusterInput): boolean {
53
+ if (a === b) return true
54
+ return (
55
+ clusterLeavesEqual(a.leaves, b.leaves) && screenOffsetsEqual(a.screenOffsets, b.screenOffsets)
56
+ )
57
+ }
58
+
59
+ /**
60
+ * The same, ignoring everything positional — anchor points and offset vectors alike. The mid-drag
61
+ * gate: a move defers, but a thread added, removed, or changed precision still rebuilds.
62
+ * @internal
63
+ */
64
+ export function clusterInputIdsEqual(a: ClusterInput, b: ClusterInput): boolean {
65
+ if (a === b) return true
66
+ return (
67
+ clusterLeafIdsEqual(a.leaves, b.leaves) &&
68
+ screenOffsetIdsEqual(a.screenOffsets, b.screenOffsets)
69
+ )
70
+ }
71
+
72
+ function screenOffsetsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
73
+ if (a === b) return true
74
+ if (!a || !b) return false
75
+ if (a.size !== b.size) return false
76
+ for (const [id, offset] of b) {
77
+ const prev = a.get(id)
78
+ if (!prev || prev.x !== offset.x || prev.y !== offset.y) return false
79
+ }
80
+ return true
81
+ }
82
+
83
+ /** Which leaves carry an offset, ignoring the vectors: a leaf gains or loses one by changing
84
+ * precision, which is a record change rather than a gesture. */
85
+ function screenOffsetIdsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
86
+ if (a === b) return true
87
+ if (!a || !b) return false
88
+ if (a.size !== b.size) return false
89
+ for (const id of b.keys()) {
90
+ if (!a.has(id)) return false
91
+ }
92
+ return true
93
+ }
94
+
95
+ /** Whether two leaf lists have the same threads at the same positions, in the same order. */
96
+ function clusterLeavesEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
97
+ if (a === b) return true
98
+ if (a.length !== b.length) return false
99
+ for (let i = 0; i < a.length; i++) {
100
+ const la = a[i]
101
+ const lb = b[i]
102
+ if (la.id !== lb.id || la.point.x !== lb.point.x || la.point.y !== lb.point.y) return false
103
+ }
104
+ return true
105
+ }
106
+
107
+ /** Whether two leaf lists contain the same thread ids in the same order, ignoring positions. */
108
+ function clusterLeafIdsEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
109
+ if (a === b) return true
110
+ if (a.length !== b.length) return false
111
+ for (let i = 0; i < a.length; i++) {
112
+ if (a[i].id !== b[i].id) return false
113
+ }
114
+ return true
29
115
  }