@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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 (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  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/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -460,6 +460,7 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
460
460
  ])
461
461
  )
462
462
  expect(rt.k).toBe(1)
463
+ expect(rt.getSuppressedCount()).toBe(0)
463
464
  expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
464
465
  })
465
466
 
@@ -477,7 +478,8 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
477
478
  [D.id, D],
478
479
  ])
479
480
  )
480
- expect(rt.k).toBe(0)
481
+ expect(rt.k).toBe(1)
482
+ expect(rt.getSuppressedCount()).toBe(1)
481
483
  expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
482
484
  })
483
485
 
@@ -512,7 +514,9 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
512
514
  const table = microTraceTable()
513
515
  const rt = createClusterRuntime(table)
514
516
  rt.seedFrom(5, new Map())
515
- expect(rt.k).toBe(0)
517
+ expect(rt.k).toBe(1)
518
+ expect(rt.getSuppressedCount()).toBe(1)
519
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
516
520
  const partial = node(['a', 'b'], 5, 0)
517
521
  rt.seedFrom(
518
522
  5,
@@ -522,7 +526,9 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
522
526
  [D.id, D],
523
527
  ])
524
528
  )
525
- expect(rt.k).toBe(0)
529
+ expect(rt.k).toBe(1)
530
+ expect(rt.getSuppressedCount()).toBe(1)
531
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
526
532
  })
527
533
 
528
534
  it('membership in a superset cluster counts as merged', () => {
@@ -534,10 +540,58 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
534
540
  expect(visibleIds(rt)).toEqual(['cluster:4:a'])
535
541
  })
536
542
 
537
- it('classification stops at the first inactive event (prefix cut is conservative)', () => {
538
- // Two independent band events at zoom 4.5. The first is split in history, the
539
- // second merged but active events must be a prefix, so the second resolves
540
- // to split (conservative: never merges anything history did not sanction).
543
+ it('carries over mixed band states exactly (split before merged in table order)', () => {
544
+ // Two independent band events at zoom 4.5: the earlier-sorted one split in history,
545
+ // the later one merged. The old prefix cut forced the second to split (the mass-split
546
+ // bug); with suppression the carryover is exact each keeps its own state.
547
+ const AB = node(['a', 'b'], 5, 0)
548
+ const CD = node(['c', 'd'], 105, 0)
549
+ const table: ClusterTable = {
550
+ events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
551
+ leaves: [A, B, C, D],
552
+ }
553
+ const rt = createClusterRuntime(table)
554
+ rt.seedFrom(
555
+ 4.5,
556
+ new Map([
557
+ [A.id, A],
558
+ [B.id, B],
559
+ [CD.id, CD],
560
+ ])
561
+ )
562
+ expect(rt.k).toBe(2)
563
+ expect(rt.getSuppressedCount()).toBe(1)
564
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'cluster:2:c'])
565
+ })
566
+
567
+ it('a suppressed event merges (heals) when a zoom-out crosses its own zMerge', () => {
568
+ const AB = node(['a', 'b'], 5, 0)
569
+ const CD = node(['c', 'd'], 105, 0)
570
+ const table: ClusterTable = {
571
+ events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
572
+ leaves: [A, B, C, D],
573
+ }
574
+ const rt = createClusterRuntime(table)
575
+ rt.seedFrom(
576
+ 4.5,
577
+ new Map([
578
+ [A.id, A],
579
+ [B.id, B],
580
+ [CD.id, CD],
581
+ ])
582
+ )
583
+ const versionBefore = rt.version
584
+ rt.onCamera(4.4) // still inside AB's band: nothing happens
585
+ expect(rt.version).toBe(versionBefore)
586
+ rt.onCamera(3.9) // below AB's zMerge (4): the held-out merge fires at its own threshold
587
+ expect(rt.version).toBeGreaterThan(versionBefore)
588
+ expect(rt.k).toBe(2)
589
+ expect(rt.getSuppressedCount()).toBe(0)
590
+ expect(visibleIds(rt)).toEqual(['cluster:2:a', 'cluster:2:c'])
591
+ expectPostconditions(rt, table, 3.9)
592
+ })
593
+
594
+ it('the split walk retreats past a suppressed event without corrupting the partition', () => {
541
595
  const AB = node(['a', 'b'], 5, 0)
542
596
  const CD = node(['c', 'd'], 105, 0)
543
597
  const table: ClusterTable = {
@@ -553,8 +607,52 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
553
607
  [CD.id, CD],
554
608
  ])
555
609
  )
610
+ rt.onCamera(5.5) // past CD's zSplit (5): CD splits; suppressed AB stays put
611
+ expect(rt.k).toBe(1)
612
+ expect(rt.getSuppressedCount()).toBe(1)
613
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
614
+ rt.onCamera(6.5) // past AB's zSplit (6): retreat past the suppressed (never-applied) event
556
615
  expect(rt.k).toBe(0)
616
+ expect(rt.getSuppressedCount()).toBe(0)
557
617
  expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
618
+ rt.onCamera(3.9) // zoom back out: normal cursor walk from a clean state
619
+ expect(rt.k).toBe(1)
620
+ expect(visibleIds(rt)).toEqual(['c', 'cluster:2:a', 'd'])
621
+ expectPostconditions(rt, table, 3.9)
622
+ })
623
+
624
+ it('heals a suppressed event whose result a later event consumes, in one zoom-out jump', () => {
625
+ // E1 consumes E0's result, so a single jump past both thresholds must apply E0 first.
626
+ // Applying E1 first would no-op its delete of the never-applied ABC and then re-add ABC
627
+ // on top of ABCD, covering a, b and c twice.
628
+ const ABC = node(['a', 'b', 'c'], 10, 0)
629
+ const ABCD = node(['a', 'b', 'c', 'd'], 32.5, 0)
630
+ const table: ClusterTable = {
631
+ events: [mev(4, 6, [A, B, C], ABC), mev(1, 1.5, [ABC, D], ABCD)],
632
+ leaves: [A, B, C, D],
633
+ }
634
+ const rt = createClusterRuntime(table)
635
+ // previous partition: everything split apart, so E0 seeds suppressed inside its band
636
+ rt.seedFrom(
637
+ 5,
638
+ new Map([
639
+ [A.id, A],
640
+ [B.id, B],
641
+ [C.id, C],
642
+ [D.id, D],
643
+ ])
644
+ )
645
+ expect(rt.getSuppressedCount()).toBe(1)
646
+
647
+ rt.onCamera(0.9) // one jump past E0's zMerge (4) and E1's zMerge (1)
648
+
649
+ expect(rt.getSuppressedCount()).toBe(0)
650
+ expect(rt.k).toBe(2)
651
+ expect(visibleIds(rt)).toEqual(['cluster:4:a'])
652
+ // conservation: every leaf is covered exactly once
653
+ const members = [...rt.getVisible().values()].flatMap((n) => n.members)
654
+ expect(members.slice().sort()).toEqual(['a', 'b', 'c', 'd'])
655
+ expectPostconditions(rt, table, 0.9)
558
656
  })
559
657
 
560
658
  it('seedFrom followed by onCamera at the same zoom is a no-op', () => {
@@ -594,3 +692,115 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
594
692
  expect(() => rt.seedFrom(NaN, new Map())).toThrow()
595
693
  })
596
694
  })
695
+
696
+ describe('createClusterRuntime detachLeaf (local partition edits)', () => {
697
+ it('shrinks the containing badge in place: members, count, centroid', () => {
698
+ const table = microTraceTable()
699
+ const rt = createClusterRuntime(table)
700
+ rt.seed(4) // P = a+b+c visible, d separate
701
+ const versionBefore = rt.version
702
+ rt.detachLeaf('a')
703
+ expect(rt.version).toBeGreaterThan(versionBefore)
704
+ expect(rt.getDetachedCount()).toBe(1)
705
+ const patchedP = rt.getVisible().get(P.id)!
706
+ expect(patchedP.members).toEqual(['b', 'c'])
707
+ expect(patchedP.count).toBe(2)
708
+ // centroid recomputed from the remaining leaves: B (10,0), C (20,0)
709
+ expect(patchedP.centroid).toEqual({ x: 15, y: 0 })
710
+ // keeps the structural id so cursor events keep addressing it
711
+ expect(patchedP.id).toBe(P.id)
712
+ // everything else untouched
713
+ expect(rt.getVisible().get('d')).toEqual(D)
714
+ expect(rt.getVisible().size).toBe(2)
715
+ })
716
+
717
+ it('is idempotent and ignores unknown ids', () => {
718
+ const table = microTraceTable()
719
+ const rt = createClusterRuntime(table)
720
+ rt.seed(4)
721
+ rt.detachLeaf('a')
722
+ const version = rt.version
723
+ rt.detachLeaf('a')
724
+ rt.detachLeaf('nonexistent')
725
+ expect(rt.version).toBe(version)
726
+ expect(rt.getDetachedCount()).toBe(1)
727
+ })
728
+
729
+ it('collapses a badge to its surviving leaf, and to nothing', () => {
730
+ const table = microTraceTable()
731
+ const rt = createClusterRuntime(table)
732
+ rt.seed(4)
733
+ rt.detachLeaf('a')
734
+ rt.detachLeaf('b')
735
+ // P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
736
+ expect(rt.getVisible().get('c')).toEqual(C)
737
+ expect(rt.getVisible().has(P.id)).toBe(false)
738
+ rt.detachLeaf('c')
739
+ expect(visibleIds(rt)).toEqual(['d'])
740
+ })
741
+
742
+ it('removes a leaf that is visible as its own pin', () => {
743
+ const table = microTraceTable()
744
+ const rt = createClusterRuntime(table)
745
+ rt.seed(8) // everything split
746
+ rt.detachLeaf('a')
747
+ expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
748
+ })
749
+
750
+ it('zoom walks stay correct while patches are active (split)', () => {
751
+ const table = microTraceTable()
752
+ const rt = createClusterRuntime(table)
753
+ rt.seed(4)
754
+ rt.detachLeaf('a')
755
+ rt.onCamera(6.5) // past P's zSplit (6): structural split of a+b+c
756
+ expect(rt.k).toBe(0)
757
+ // resolved view: a stays gone, b/c/d as pins — the split itself changed nothing for a
758
+ expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
759
+ })
760
+
761
+ it('zoom walks stay correct while patches are active (merge)', () => {
762
+ const table = microTraceTable()
763
+ const rt = createClusterRuntime(table)
764
+ rt.seed(4)
765
+ rt.detachLeaf('a')
766
+ rt.onCamera(0.9) // below E1's zMerge (1): P+D merge into Q
767
+ expect(rt.k).toBe(2)
768
+ const patchedQ = rt.getVisible().get(Q.id)!
769
+ expect(patchedQ.members).toEqual(['b', 'c', 'd'])
770
+ expect(patchedQ.count).toBe(3)
771
+ // mean of B (10,0), C (20,0), D (100,0)
772
+ expect(patchedQ.centroid.x).toBeCloseTo(130 / 3)
773
+ expect(rt.getVisible().size).toBe(1)
774
+ })
775
+
776
+ it('seed and seedFrom clear detaches', () => {
777
+ const table = microTraceTable()
778
+ const rt = createClusterRuntime(table)
779
+ rt.seed(4)
780
+ rt.detachLeaf('a')
781
+ rt.seed(4)
782
+ expect(rt.getDetachedCount()).toBe(0)
783
+ expect(rt.getVisible().get(P.id)).toEqual(P)
784
+ rt.detachLeaf('a')
785
+ rt.seedFrom(
786
+ 4,
787
+ new Map([
788
+ [P.id, P],
789
+ [D.id, D],
790
+ ])
791
+ )
792
+ expect(rt.getDetachedCount()).toBe(0)
793
+ expect(rt.getVisible().get(P.id)).toEqual(P)
794
+ })
795
+
796
+ it('getVisible returns a stable reference until the partition changes', () => {
797
+ const table = microTraceTable()
798
+ const rt = createClusterRuntime(table)
799
+ rt.seed(4)
800
+ rt.detachLeaf('a')
801
+ const first = rt.getVisible()
802
+ expect(rt.getVisible()).toBe(first)
803
+ rt.detachLeaf('b')
804
+ expect(rt.getVisible()).not.toBe(first)
805
+ })
806
+ })
@@ -1,28 +1,48 @@
1
1
  import type { ClusterNode, ClusterTable, MergeEvent } from './types'
2
2
 
3
- /** @public */
3
+ /** @internal */
4
4
  export interface ClusterRuntime {
5
- /** Events[0..k) are active. Exposed for tests and debugging. */
5
+ /** Events[0..k) are in the cursor (applied unless suppressed). Exposed for tests/debugging. */
6
6
  readonly k: number
7
- /** Reset state from scratch for the given zoom (cold start / after rebuild). */
7
+ /**
8
+ * Increments whenever the displayed partition changes. Subscribe to this, not `k`: detaches
9
+ * and suppressed-event healing change the partition without moving the cursor.
10
+ */
11
+ readonly version: number
12
+ /** Number of suppressed band events currently inside the cursor. Exposed for tests. */
13
+ getSuppressedCount(): number
14
+ /** Number of leaves currently detached from the displayed partition. Exposed for tests. */
15
+ getDetachedCount(): number
16
+ /** Reset state from scratch for the given zoom (cold start / after rebuild). Clears detaches. */
8
17
  seed(zoom: number): void
9
18
  /**
10
19
  * Reset state for the given zoom, carrying hysteresis state over from a previous partition
11
20
  * (the visible map of the model being replaced). Threshold-forced events ignore history:
12
- * zoom \<= zMerge is always active, zoom \>= zSplit always inactive. An event inside its band
13
- * is active iff its members were merged together in `previous`; a band event with no history
14
- * (e.g. introduced by the rebuild) stays inactive until the next zoom-out crosses its zMerge.
15
- * Active events must form a prefix of the table, so classification stops at the first
16
- * inactive event; later band-active events conservatively resolve to split.
21
+ * zoom \<= zMerge is always merged, zoom \>= zSplit always split. An event inside its band
22
+ * keeps its previous state: merged iff its members were merged together in `previous`; a band
23
+ * event that was unmerged (or has no history, e.g. introduced by the rebuild) stays unmerged
24
+ * inside the cursor but suppressed until a zoom-out crosses its own zMerge, exactly like any
25
+ * other pending merge. Carryover is exact: no group changes state because of the swap alone.
26
+ * Clears detaches.
17
27
  */
18
28
  seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
19
29
  /** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
20
30
  onCamera(zoom: number): void
21
- /** The current partition: cluster id → node. Do not mutate. */
31
+ /**
32
+ * Remove one leaf from the displayed partition without touching the event table. Local by
33
+ * construction: only the nodes containing the leaf change — a badge shrinks in place (count
34
+ * and centroid recomputed from its remaining members), a pair collapses to its surviving
35
+ * leaf, and the leaf on its own disappears. Everything else is untouched, so a deletion,
36
+ * pop-out, or thread-open never re-flows the rest of the document. The table's thresholds
37
+ * around the detached leaf go stale; the caller is expected to hold a corrected rebuild and
38
+ * adopt it (with seedFrom) at the next zoom-out. Unknown or already-detached ids are no-ops.
39
+ */
40
+ detachLeaf(leafId: string): void
41
+ /** The displayed partition: cluster id → node, with detaches applied. Do not mutate. */
22
42
  getVisible(): ReadonlyMap<string, ClusterNode>
23
43
  }
24
44
 
25
- /** @public */
45
+ /** @internal */
26
46
  export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
27
47
  return new ClusterRuntimeImpl(table)
28
48
  }
@@ -30,21 +50,49 @@ export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
30
50
  class ClusterRuntimeImpl implements ClusterRuntime {
31
51
  // mutable internally; readonly through the ClusterRuntime interface
32
52
  k = 0
53
+ version = 0
54
+ // Structural partition: exactly leaves + applied events, never patched. The cursor invariants
55
+ // live here, untouched by detaches — resolution to the displayed partition happens at read
56
+ // time in getVisible().
33
57
  private visible = new Map<string, ClusterNode>()
34
58
  private seeded = false
59
+ // Indices (< k) of band events held unmerged by seedFrom carryover. The single cursor can
60
+ // only express "merged up to here", but a carried-over partition can be "merged except these"
61
+ // — the exceptions live here. Self-draining: an entry leaves via onCamera when the zoom
62
+ // crosses its own zMerge (merges) or its zSplit (the split walk retreats past it).
63
+ private suppressed = new Set<number>()
64
+ // Leaves removed from the displayed partition (deleted / popped out / opened). Patches map
65
+ // each structural node containing a detached leaf to its displayed replacement (or null to
66
+ // drop it). Rebuilt from the detached set; cleared by seed/seedFrom.
67
+ private detached = new Set<string>()
68
+ private patched = new Map<string, ClusterNode | null>()
69
+ private leafById: Map<string, ClusterNode> | null = null
70
+ private resolvedCache: { version: number; map: Map<string, ClusterNode> } | null = null
35
71
 
36
72
  constructor(private readonly table: ClusterTable) {
37
73
  this.resetVisible()
38
74
  }
39
75
 
76
+ getSuppressedCount(): number {
77
+ return this.suppressed.size
78
+ }
79
+
80
+ getDetachedCount(): number {
81
+ return this.detached.size
82
+ }
83
+
40
84
  seed(zoom: number): void {
41
85
  validateZoom(zoom)
42
86
  this.k = seedCount(this.table.events, zoom)
87
+ this.suppressed.clear()
88
+ this.detached.clear()
89
+ this.patched.clear()
43
90
  this.resetVisible()
44
91
  for (let i = 0; i < this.k; i++) {
45
92
  applyEvent(this.visible, this.table.events[i])
46
93
  }
47
94
  this.seeded = true
95
+ this.version++
48
96
  }
49
97
 
50
98
  seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void {
@@ -56,21 +104,34 @@ class ClusterRuntimeImpl implements ClusterRuntime {
56
104
  }
57
105
  }
58
106
 
107
+ // Cut the cursor purely on split thresholds. zSplit is non-increasing down the table, so
108
+ // everything past the first mandatory split (zoom >= zSplit) is also mandatorily split —
109
+ // this cut can never conflict with an event that should be merged.
59
110
  const events = this.table.events
60
111
  let k = 0
61
- while (k < events.length) {
62
- const event = events[k]
63
- if (zoom >= event.zSplit) break
64
- if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) break
112
+ while (k < events.length && zoom < events[k].zSplit) {
65
113
  k++
66
114
  }
67
115
 
68
116
  this.k = k
117
+ this.suppressed.clear()
118
+ this.detached.clear()
119
+ this.patched.clear()
69
120
  this.resetVisible()
70
121
  for (let i = 0; i < k; i++) {
71
- applyEvent(this.visible, events[i])
122
+ const event = events[i]
123
+ if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) {
124
+ // In its band and previously unmerged: keep it unmerged, as an exception inside
125
+ // the cursor. Dependency-safe: an applied event can never consume a suppressed
126
+ // result — merged members imply merged (subset) children, and threshold-forced
127
+ // events force their children too (zMerge is non-increasing down the table).
128
+ this.suppressed.add(i)
129
+ } else {
130
+ applyEvent(this.visible, event)
131
+ }
72
132
  }
73
133
  this.seeded = true
134
+ this.version++
74
135
  }
75
136
 
76
137
  onCamera(zoom: number): void {
@@ -79,18 +140,111 @@ class ClusterRuntimeImpl implements ClusterRuntime {
79
140
  throw new Error('Cluster runtime must be seeded before onCamera')
80
141
  }
81
142
 
143
+ let changed = false
144
+ // Heal suppressed events at their own merge threshold BEFORE the merge walk: a zoom-out
145
+ // past zMerge merges a held-out band event exactly as if it had still been ahead of the
146
+ // cursor. Healing must precede the walk because a single zoom jump can cross both a
147
+ // suppressed event and an event that consumes its result; applying the consumer first
148
+ // would leave the unapplied producer's children in `visible` (its delete is a no-op) and
149
+ // then re-add the producer on top, double-counting those leaves. Threshold monotonicity
150
+ // guarantees a suppressed producer's zMerge is >= its consumer's, so anything the walk
151
+ // needs has already healed. Set iteration is insertion order (ascending index), so a
152
+ // healed event's suppressed children (larger zMerge, smaller index) heal before it.
153
+ if (this.suppressed.size > 0) {
154
+ for (const i of [...this.suppressed]) {
155
+ if (zoom <= this.table.events[i].zMerge) {
156
+ this.suppressed.delete(i)
157
+ applyEvent(this.visible, this.table.events[i])
158
+ changed = true
159
+ }
160
+ }
161
+ }
82
162
  while (this.k < this.table.events.length && zoom <= this.table.events[this.k].zMerge) {
83
163
  applyEvent(this.visible, this.table.events[this.k])
84
164
  this.k++
165
+ changed = true
85
166
  }
86
167
  while (this.k > 0 && zoom >= this.table.events[this.k - 1].zSplit) {
87
168
  this.k--
88
- unapplyEvent(this.visible, this.table.events[this.k])
169
+ // A suppressed event was never applied — retreating past it is bookkeeping only.
170
+ if (!this.suppressed.delete(this.k)) {
171
+ unapplyEvent(this.visible, this.table.events[this.k])
172
+ changed = true
173
+ }
89
174
  }
175
+ if (changed) this.version++
176
+ }
177
+
178
+ detachLeaf(leafId: string): void {
179
+ if (this.detached.has(leafId)) return
180
+ if (!this.getLeafById().has(leafId)) return
181
+ this.detached.add(leafId)
182
+ this.rebuildPatches()
183
+ this.version++
90
184
  }
91
185
 
92
186
  getVisible(): ReadonlyMap<string, ClusterNode> {
93
- return this.visible
187
+ if (this.resolvedCache && this.resolvedCache.version === this.version) {
188
+ return this.resolvedCache.map
189
+ }
190
+ let map: Map<string, ClusterNode>
191
+ if (this.patched.size === 0) {
192
+ map = this.visible
193
+ } else {
194
+ map = new Map()
195
+ for (const node of this.visible.values()) {
196
+ const resolved = this.patched.has(node.id) ? this.patched.get(node.id)! : node
197
+ if (resolved) map.set(resolved.id, resolved)
198
+ }
199
+ }
200
+ this.resolvedCache = { version: this.version, map }
201
+ return map
202
+ }
203
+
204
+ private getLeafById(): Map<string, ClusterNode> {
205
+ if (!this.leafById) {
206
+ this.leafById = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
207
+ }
208
+ return this.leafById
209
+ }
210
+
211
+ /** Recompute the patch map from the detached set. A node is patched iff it contains a
212
+ * detached member; the patch drops those members and recomputes count/centroid, collapsing
213
+ * to the surviving leaf node at count 1 and to nothing at count 0. Patched nodes keep their
214
+ * structural id, so cursor events keep addressing them. */
215
+ private rebuildPatches() {
216
+ this.patched.clear()
217
+ const leafById = this.getLeafById()
218
+ for (const id of this.detached) {
219
+ this.patched.set(id, null)
220
+ }
221
+ const patch = (node: ClusterNode) => {
222
+ if (node.members.length === 1 || !node.members.some((m) => this.detached.has(m))) return
223
+ const members = node.members.filter((m) => !this.detached.has(m))
224
+ if (members.length === 0) {
225
+ this.patched.set(node.id, null)
226
+ } else if (members.length === 1) {
227
+ this.patched.set(node.id, leafById.get(members[0])!)
228
+ } else {
229
+ let x = 0
230
+ let y = 0
231
+ for (const m of members) {
232
+ const leaf = leafById.get(m)!
233
+ x += leaf.centroid.x
234
+ y += leaf.centroid.y
235
+ }
236
+ this.patched.set(node.id, {
237
+ id: node.id,
238
+ centroid: { x: x / members.length, y: y / members.length },
239
+ count: members.length,
240
+ members,
241
+ })
242
+ }
243
+ }
244
+ for (const event of this.table.events) {
245
+ patch(event.result)
246
+ for (const child of event.children) patch(child)
247
+ }
94
248
  }
95
249
 
96
250
  private resetVisible() {
@@ -2,7 +2,7 @@ import type { VecLike } from 'tldraw'
2
2
 
3
3
  /**
4
4
  * One comment thread's pin, already resolved to a page-space anchor point.
5
- * @public
5
+ * @internal
6
6
  */
7
7
  export interface LeafInput {
8
8
  /** Unique. Thread id. Uniqueness is a precondition — throw on duplicates. */
@@ -23,7 +23,7 @@ export interface MstEdge {
23
23
 
24
24
  /**
25
25
  * A cluster in the merge tree: a leaf (one thread) or a merged group.
26
- * @public
26
+ * @internal
27
27
  */
28
28
  export interface ClusterNode {
29
29
  /** Leaves: the thread id verbatim. Merged nodes: `cluster:${count}:${minMemberId}`. */
@@ -58,7 +58,7 @@ export interface ContractedEvent {
58
58
 
59
59
  /**
60
60
  * A finalized merge event, ready for the runtime.
61
- * @public
61
+ * @internal
62
62
  */
63
63
  export interface MergeEvent {
64
64
  zMerge: number
@@ -70,7 +70,7 @@ export interface MergeEvent {
70
70
 
71
71
  /**
72
72
  * The precomputed clustering schedule for one page's comments.
73
- * @public
73
+ * @internal
74
74
  */
75
75
  export interface ClusterTable {
76
76
  /** Sorted non-increasing by zMerge; satisfies the invariants of CLUSTERING.md §7.6. */
@@ -79,15 +79,15 @@ export interface ClusterTable {
79
79
  leaves: readonly ClusterNode[]
80
80
  }
81
81
 
82
- /** @public */
82
+ /** @internal */
83
83
  export interface ClusterOptions {
84
- /** Cluster (merge) distance, screen px. Default 40. */
84
+ /** Cluster (merge) distance, screen px. Default 22. */
85
85
  Tc?: number
86
- /** Uncluster (split) distance, screen px. Must be \> Tc. Default 1.5 · Tc. */
86
+ /** Uncluster (split) distance, screen px. Must be \> Tc. Default 1.2 · Tc. */
87
87
  Tu?: number
88
- /** Contraction window ratio. Default 0.12. */
88
+ /** Contraction window ratio. Default 0.7. */
89
89
  eps?: number
90
- /** Max cluster screen extent at birth, screen px. Must be \>= Tc. Default 3 · Tc. */
90
+ /** Max cluster screen extent at birth, screen px. Must be \>= Tc. Default 3.75 · Tc. */
91
91
  Dmax?: number
92
92
  /** Camera zoom bounds — pass the editor's camera constraints. Required. */
93
93
  minZoom: number