@tldraw/commenting 0.0.0-bootstrap → 5.3.0-internal.c5c7f1d817d0

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 (125) hide show
  1. package/commenting.css +131 -131
  2. package/dist-cjs/canvas/comment-body.js +1 -1
  3. package/dist-cjs/canvas/comment-body.js.map +2 -2
  4. package/dist-cjs/canvas/comments-filter-menu.js +1 -1
  5. package/dist-cjs/canvas/comments-filter-menu.js.map +1 -1
  6. package/dist-cjs/canvas/comments-overflow-menu.js +3 -3
  7. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  8. package/dist-cjs/canvas/comments-overlay.js +65 -50
  9. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  10. package/dist-cjs/canvas/comments-sidebar.js +2 -2
  11. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  12. package/dist-cjs/clustering/runtime.js +119 -7
  13. package/dist-cjs/clustering/runtime.js.map +2 -2
  14. package/dist-cjs/index.d.ts +28 -8
  15. package/dist-cjs/index.js +1 -1
  16. package/dist-cjs/ui/avatar.js +10 -2
  17. package/dist-cjs/ui/avatar.js.map +2 -2
  18. package/dist-cjs/ui/byline.js +4 -4
  19. package/dist-cjs/ui/byline.js.map +2 -2
  20. package/dist-cjs/ui/comment-card.js +3 -3
  21. package/dist-cjs/ui/comment-card.js.map +2 -2
  22. package/dist-cjs/ui/comment-composer.js +30 -24
  23. package/dist-cjs/ui/comment-composer.js.map +2 -2
  24. package/dist-cjs/ui/comment-mention.js +1 -1
  25. package/dist-cjs/ui/comment-mention.js.map +2 -2
  26. package/dist-cjs/ui/comment-pin.js +5 -1
  27. package/dist-cjs/ui/comment-pin.js.map +2 -2
  28. package/dist-cjs/ui/comment-text.js +1 -1
  29. package/dist-cjs/ui/comment-text.js.map +2 -2
  30. package/dist-cjs/ui/comment-thread.js +6 -6
  31. package/dist-cjs/ui/comment-thread.js.map +2 -2
  32. package/dist-cjs/ui/comments-list.js +11 -11
  33. package/dist-cjs/ui/comments-list.js.map +2 -2
  34. package/dist-cjs/ui/count-badge.js +1 -1
  35. package/dist-cjs/ui/count-badge.js.map +2 -2
  36. package/dist-cjs/ui/empty-state.js +3 -3
  37. package/dist-cjs/ui/empty-state.js.map +2 -2
  38. package/dist-cjs/ui/mention-list.js +7 -7
  39. package/dist-cjs/ui/mention-list.js.map +2 -2
  40. package/dist-cjs/ui/mention-suggestion.js +3 -3
  41. package/dist-cjs/ui/mention-suggestion.js.map +2 -2
  42. package/dist-cjs/ui/mention.js +1 -1
  43. package/dist-cjs/ui/mention.js.map +2 -2
  44. package/dist-cjs/ui/reaction.js +11 -4
  45. package/dist-cjs/ui/reaction.js.map +2 -2
  46. package/dist-cjs/ui/reactions.js +10 -2
  47. package/dist-cjs/ui/reactions.js.map +2 -2
  48. package/dist-cjs/ui/send-button.js +1 -1
  49. package/dist-cjs/ui/send-button.js.map +2 -2
  50. package/dist-esm/canvas/comment-body.mjs +1 -1
  51. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  52. package/dist-esm/canvas/comments-filter-menu.mjs +1 -1
  53. package/dist-esm/canvas/comments-filter-menu.mjs.map +1 -1
  54. package/dist-esm/canvas/comments-overflow-menu.mjs +3 -3
  55. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  56. package/dist-esm/canvas/comments-overlay.mjs +65 -50
  57. package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
  58. package/dist-esm/canvas/comments-sidebar.mjs +2 -2
  59. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  60. package/dist-esm/clustering/runtime.mjs +119 -7
  61. package/dist-esm/clustering/runtime.mjs.map +2 -2
  62. package/dist-esm/index.d.mts +28 -8
  63. package/dist-esm/index.mjs +1 -1
  64. package/dist-esm/ui/avatar.mjs +10 -2
  65. package/dist-esm/ui/avatar.mjs.map +2 -2
  66. package/dist-esm/ui/byline.mjs +4 -4
  67. package/dist-esm/ui/byline.mjs.map +2 -2
  68. package/dist-esm/ui/comment-card.mjs +3 -3
  69. package/dist-esm/ui/comment-card.mjs.map +2 -2
  70. package/dist-esm/ui/comment-composer.mjs +30 -24
  71. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  72. package/dist-esm/ui/comment-mention.mjs +1 -1
  73. package/dist-esm/ui/comment-mention.mjs.map +2 -2
  74. package/dist-esm/ui/comment-pin.mjs +5 -1
  75. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  76. package/dist-esm/ui/comment-text.mjs +1 -1
  77. package/dist-esm/ui/comment-text.mjs.map +2 -2
  78. package/dist-esm/ui/comment-thread.mjs +6 -6
  79. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  80. package/dist-esm/ui/comments-list.mjs +11 -11
  81. package/dist-esm/ui/comments-list.mjs.map +2 -2
  82. package/dist-esm/ui/count-badge.mjs +1 -1
  83. package/dist-esm/ui/count-badge.mjs.map +2 -2
  84. package/dist-esm/ui/empty-state.mjs +3 -3
  85. package/dist-esm/ui/empty-state.mjs.map +2 -2
  86. package/dist-esm/ui/mention-list.mjs +7 -7
  87. package/dist-esm/ui/mention-list.mjs.map +2 -2
  88. package/dist-esm/ui/mention-suggestion.mjs +3 -3
  89. package/dist-esm/ui/mention-suggestion.mjs.map +2 -2
  90. package/dist-esm/ui/mention.mjs +1 -1
  91. package/dist-esm/ui/mention.mjs.map +2 -2
  92. package/dist-esm/ui/reaction.mjs +11 -4
  93. package/dist-esm/ui/reaction.mjs.map +2 -2
  94. package/dist-esm/ui/reactions.mjs +10 -2
  95. package/dist-esm/ui/reactions.mjs.map +2 -2
  96. package/dist-esm/ui/send-button.mjs +1 -1
  97. package/dist-esm/ui/send-button.mjs.map +2 -2
  98. package/package.json +3 -3
  99. package/src/canvas/canvas.css +25 -25
  100. package/src/canvas/comment-body.tsx +1 -1
  101. package/src/canvas/comment-render.test.ts +1 -1
  102. package/src/canvas/comments-filter-menu.tsx +1 -1
  103. package/src/canvas/comments-overflow-menu.tsx +3 -3
  104. package/src/canvas/comments-overlay.tsx +109 -76
  105. package/src/canvas/comments-sidebar.tsx +2 -2
  106. package/src/clustering/runtime.test.ts +183 -7
  107. package/src/clustering/runtime.ts +164 -15
  108. package/src/ui/avatar.tsx +9 -2
  109. package/src/ui/byline.tsx +4 -4
  110. package/src/ui/comment-card.tsx +3 -3
  111. package/src/ui/comment-composer.tsx +49 -30
  112. package/src/ui/comment-mention.ts +2 -2
  113. package/src/ui/comment-pin.tsx +5 -1
  114. package/src/ui/comment-text.tsx +1 -1
  115. package/src/ui/comment-thread.tsx +8 -6
  116. package/src/ui/comments-list.tsx +16 -12
  117. package/src/ui/comments.css +106 -106
  118. package/src/ui/count-badge.tsx +1 -1
  119. package/src/ui/empty-state.tsx +3 -3
  120. package/src/ui/mention-list.tsx +8 -8
  121. package/src/ui/mention-suggestion.tsx +3 -3
  122. package/src/ui/mention.tsx +1 -1
  123. package/src/ui/reaction.tsx +6 -3
  124. package/src/ui/reactions.tsx +6 -2
  125. package/src/ui/send-button.tsx +1 -1
@@ -1,69 +1,69 @@
1
1
  /* The comments layer is portaled into the editor container. Pins live in the canvas-in-front layer
2
2
  (beneath the UI panels at z-index 300), so the toolbar and style panel draw over them; the open
3
- thread's popover portals up to the menus layer separately (`.cmt-canvas-popover`) so it isn't
3
+ thread's popover portals up to the menus layer separately (`.tlui-cmt-canvas-popover`) so it isn't
4
4
  clipped. Click-through except on its own children. */
5
- .cmt-canvas-layer {
5
+ .tlui-cmt-canvas-layer {
6
6
  position: absolute;
7
7
  inset: 0;
8
8
  z-index: var(--tl-layer-canvas-in-front);
9
9
  pointer-events: none;
10
10
  }
11
11
 
12
- .cmt-canvas-pin,
13
- .cmt-canvas-cluster,
14
- .cmt-canvas-composer {
12
+ .tlui-cmt-canvas-pin,
13
+ .tlui-cmt-canvas-cluster,
14
+ .tlui-cmt-canvas-composer {
15
15
  position: absolute;
16
16
  }
17
17
 
18
- .cmt-canvas-pin,
19
- .cmt-canvas-composer {
18
+ .tlui-cmt-canvas-pin,
19
+ .tlui-cmt-canvas-composer {
20
20
  pointer-events: auto;
21
21
  }
22
22
 
23
23
  /* Badges are clickable (zoom to the cluster's first split). */
24
- .cmt-canvas-cluster {
24
+ .tlui-cmt-canvas-cluster {
25
25
  transform: translate(-50%, -50%);
26
26
  pointer-events: auto;
27
27
  cursor: pointer;
28
28
  }
29
29
 
30
- .cmt-cluster-fade {
30
+ .tlui-cmt-cluster-fade {
31
31
  opacity: 1;
32
32
  transition: opacity 150ms ease;
33
33
  }
34
34
 
35
- .cmt-cluster-fade--entering {
35
+ .tlui-cmt-cluster-fade--entering {
36
36
  opacity: 0;
37
37
  }
38
38
 
39
- .cmt-cluster-fade--present {
39
+ .tlui-cmt-cluster-fade--present {
40
40
  opacity: 1;
41
41
  }
42
42
 
43
- .cmt-cluster-fade--exiting {
43
+ .tlui-cmt-cluster-fade--exiting {
44
44
  opacity: 0;
45
45
  }
46
46
 
47
- .cmt-cluster-fade--entering *,
48
- .cmt-cluster-fade--exiting * {
47
+ .tlui-cmt-cluster-fade--entering *,
48
+ .tlui-cmt-cluster-fade--exiting * {
49
49
  pointer-events: none !important;
50
50
  }
51
51
 
52
52
  @media (prefers-reduced-motion: reduce) {
53
- .cmt-cluster-fade {
53
+ .tlui-cmt-cluster-fade {
54
54
  transition: none;
55
55
  }
56
56
  }
57
57
 
58
58
  /* The open thread (its pin + popover) sits above the other pins, so nearby markers don't
59
59
  overlap its popover — they tuck behind it. */
60
- .cmt-canvas-pin--open {
60
+ .tlui-cmt-canvas-pin--open {
61
61
  z-index: 1;
62
62
  }
63
63
 
64
64
  /* A region anchor's area: a dashed box under the pins, non-interactive so canvas events pass
65
65
  through. Drawn for the live drag draft and for a region thread while hovered or open. */
66
- .cmt-canvas-region {
66
+ .tlui-cmt-canvas-region {
67
67
  position: absolute;
68
68
  pointer-events: none;
69
69
  border: 2px dashed var(--tl-color-selected);
@@ -73,13 +73,13 @@
73
73
 
74
74
  /* A movable region body (the 'body'/'both' move modes): draggable to translate the whole region.
75
75
  Interactive, so it captures the interior while shown — the tradeoff of dragging the body. */
76
- .cmt-canvas-region--movable {
76
+ .tlui-cmt-canvas-region--movable {
77
77
  pointer-events: auto;
78
78
  cursor: move;
79
79
  }
80
80
 
81
81
  /* Corner resize handles on a region box (three corners; the pin corner is the move handle). */
82
- .cmt-canvas-region-handle {
82
+ .tlui-cmt-canvas-region-handle {
83
83
  position: absolute;
84
84
  width: 9px;
85
85
  height: 9px;
@@ -92,18 +92,18 @@
92
92
  }
93
93
 
94
94
  /* Centre the pin marker on its anchor point. */
95
- .cmt-canvas-pin__marker {
95
+ .tlui-cmt-canvas-pin__marker {
96
96
  width: max-content;
97
97
  transform: translate(-50%, -50%);
98
98
  cursor: grab;
99
99
  touch-action: none;
100
100
  }
101
- .cmt-canvas-pin__marker:active {
101
+ .tlui-cmt-canvas-pin__marker:active {
102
102
  cursor: grabbing;
103
103
  }
104
104
 
105
105
  /* The open thread's popover — portaled to the menus layer (above the UI), positioned at the pin. */
106
- .cmt-canvas-popover {
106
+ .tlui-cmt-canvas-popover {
107
107
  position: absolute;
108
108
  z-index: var(--tl-layer-menus);
109
109
  pointer-events: auto;
@@ -112,7 +112,7 @@
112
112
  /* The placement composer — a distinct floating view (portaled to the menus layer, below the click
113
113
  point): a pencil "draft" avatar beside a pill field that is itself the surface (no wrapping
114
114
  panel). Scoped here so the in-thread reply composer keeps its own squarer look. */
115
- .cmt-canvas-composer {
115
+ .tlui-cmt-canvas-composer {
116
116
  position: absolute;
117
117
  z-index: var(--tl-layer-menus);
118
118
  pointer-events: auto;
@@ -121,7 +121,7 @@
121
121
  placement composer a definite width or it collapses to nothing. */
122
122
  width: 280px;
123
123
  }
124
- .cmt-canvas-composer .cmt-composer__field {
124
+ .tlui-cmt-canvas-composer .tlui-cmt-composer__field {
125
125
  background: var(--tl-color-panel);
126
126
  border-color: transparent;
127
127
  border-radius: var(--tl-radius-4);
@@ -132,7 +132,7 @@
132
132
  the popover layer, so an open thread still draws over it. Sits under the top-right share panel
133
133
  (top offset clears its ~48px height) and sizes to its content, scrolling past max-height rather
134
134
  than stretching the full canvas height. */
135
- .cmt-canvas-sidebar {
135
+ .tlui-cmt-canvas-sidebar {
136
136
  position: absolute;
137
137
  top: 56px;
138
138
  right: 8px;
@@ -17,5 +17,5 @@ export interface CommentBodyProps {
17
17
  */
18
18
  export function CommentBody({ richText, resolveName }: CommentBodyProps) {
19
19
  const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
20
- return <div className="cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
20
+ return <div className="tlui-cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
21
21
  }
@@ -38,7 +38,7 @@ describe('renderCommentHtml', () => {
38
38
  it('renders a mention as a pill and resolves its id to the current name', () => {
39
39
  const body = doc(para(text('hey '), mention('u1', 'Ada')))
40
40
  const html = renderCommentHtml(body, (id) => (id === 'u1' ? 'Ada Lovelace' : '?'))
41
- expect(html).toContain('cmt-mention')
41
+ expect(html).toContain('tlui-cmt-mention')
42
42
  // the live name from the resolver, not the label stored at insert time
43
43
  expect(html).toContain('@Ada Lovelace')
44
44
  expect(html).not.toContain('@Ada<')
@@ -38,7 +38,7 @@ export function CommentsFilterMenu({
38
38
  <TldrawUiDropdownMenuTrigger>
39
39
  <button
40
40
  type="button"
41
- className="cmt-header-btn"
41
+ className="tlui-cmt-header-btn"
42
42
  title={msg('comments.filter')}
43
43
  aria-label={msg('comments.filter')}
44
44
  >
@@ -26,7 +26,7 @@ export function CommentsOverflowMenu() {
26
26
  <TldrawUiDropdownMenuTrigger>
27
27
  <button
28
28
  type="button"
29
- className="cmt-header-btn"
29
+ className="tlui-cmt-header-btn"
30
30
  title={msg('comments.more-options')}
31
31
  aria-label={msg('comments.more-options')}
32
32
  >
@@ -38,11 +38,11 @@ export function CommentsOverflowMenu() {
38
38
  <TldrawUiDropdownMenuItem>
39
39
  <button
40
40
  type="button"
41
- className="cmt-menu-item"
41
+ className="tlui-cmt-menu-item"
42
42
  onClick={() => toggleCommentsHidden(editor)}
43
43
  >
44
44
  <span>{hidden ? msg('comments.show') : msg('comments.hide')}</span>
45
- <span className="cmt-menu-item__shortcut">{HIDE_SHORTCUT}</span>
45
+ <span className="tlui-cmt-menu-item__shortcut">{HIDE_SHORTCUT}</span>
46
46
  </button>
47
47
  </TldrawUiDropdownMenuItem>
48
48
  </TldrawUiDropdownMenuGroup>
@@ -186,21 +186,21 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
186
186
  const pending = usePendingComment()
187
187
  const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
188
188
  const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
189
- // Threads whose anchor has moved (by any means drag, nudge, align, undo, a collaborator)
190
- // since the rendered clustering was built. They pop out of clustering and render as live pins,
191
- // and only rejoin at the next zoom event, when everything re-clusters anyway.
192
- const [movedThreadIds, setMovedThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
189
+ // Threads held out of clustering because their anchor moved while folded inside a badge
190
+ // (drag, nudge, align, undo, a collaborator detected by position, not gesture). They render
191
+ // as live pins riding their anchor and rejoin clustering on the next zoom-out.
192
+ const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
193
193
  const adoptOnRebuild = useRef(false)
194
194
  const clusterLeaves = useValue(
195
195
  'comment cluster leaves',
196
196
  () =>
197
197
  collectClusterLeaves(
198
198
  editor,
199
- threads.filter((thread) => !movedThreadIds.has(thread.id)),
199
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
200
200
  openThreadId.get(editor),
201
201
  impreciseShapeAnchor
202
202
  ),
203
- [editor, threads, impreciseShapeAnchor, movedThreadIds]
203
+ [editor, threads, impreciseShapeAnchor, heldThreadIds]
204
204
  )
205
205
  const clusterZoomBounds = useValue(
206
206
  'comment cluster zoom bounds',
@@ -213,44 +213,69 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
213
213
  runtime.seed(editor.getZoomLevel())
214
214
  return { runtime, table }
215
215
  }, [clusterLeaves, clusterZoomBounds, editor])
216
- // Re-clustering only applies while zooming: a rebuilt model (thread added, moved, or closed)
217
- // is held as `latestModel` and adopted on the next zoom change, so pins never re-flow into
218
- // clusters under a static camera. Until adoption, threads the rendered model doesn't know
219
- // about show as plain unclustered pins (`orphanThreads`). Exception: a rebuild that *removed*
220
- // leaves (thread deleted or opened, page changed) is adopted immediately, so stale pins and
221
- // badge counts never linger.
216
+ // The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild
217
+ // (add / move / delete / open / pop-out) is computed immediately as `latestModel` the MST
218
+ // stays correct but the on-screen partition is `renderedModel`, and it only ever changes via
219
+ // (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
220
+ // (c) LOCAL detach patches: a leaf that left the input (deleted, opened, popped out) is
221
+ // detached from its own badge in place — count and centroid update for that badge alone,
222
+ // and nothing else on the canvas moves.
222
223
  const [renderedModel, setRenderedModel] = useState(latestModel)
223
224
  let clusterModel = renderedModel
224
- if (
225
- renderedModel !== latestModel &&
226
- (adoptOnRebuild.current || hasRemovedLeaves(renderedModel.table, latestModel.table))
227
- ) {
225
+ // A page switch replaces the whole scene: hard-reset rather than detach the world.
226
+ const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
227
+ const pageRef = useRef(pageId)
228
+ if (pageRef.current !== pageId) {
229
+ pageRef.current = pageId
228
230
  adoptOnRebuild.current = false
229
- // Carryover seed: events inside their hysteresis band inherit the outgoing partition's
230
- // merged/unmerged state instead of the geometric-mean tiebreak, so untouched pins never
231
- // snap together (or apart) just because the model was swapped. Idempotent, so safe to
232
- // run during render.
231
+ latestModel.runtime.seed(editor.getZoomLevel())
232
+ if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
233
+ setRenderedModel(latestModel)
234
+ clusterModel = latestModel
235
+ }
236
+ // adoptOnRebuild is set by the rejoin reaction below, outside React's render cycle, paired
237
+ // with clearing heldThreadIds. Only trust it once that pairing is actually visible here
238
+ // (heldThreadIds confirmed empty) — an unrelated re-render can land in the gap between the
239
+ // ref being set and the state update it was paired with being applied.
240
+ const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
241
+ if (renderedModel !== latestModel && rejoinPending) {
242
+ adoptOnRebuild.current = false
243
+ // Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
244
+ // nothing changes state because of the swap alone. Idempotent, so safe during render.
233
245
  latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
234
246
  setRenderedModel(latestModel)
235
247
  clusterModel = latestModel
248
+ } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
249
+ // Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
250
+ // survive to force-adopt a later, unrelated rebuild.
251
+ adoptOnRebuild.current = false
236
252
  }
237
253
  // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
238
- // is baked into the model), so when its live position drifts from the baked one it ghosts.
239
- // Marking it moved excludes it from the cluster input, which reads as a removal above and
240
- // re-clusters the rest of its pile immediately; the pin itself renders live below.
254
+ // is baked into the model), so when its live position drifts from the baked one, hold it out.
255
+ // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
241
256
  const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
242
257
  if (newlyMovedIds.length > 0) {
243
- // eslint-disable-next-line no-console
244
- console.debug(`[comments] pins popped out of clustering: ${newlyMovedIds.join(', ')}`)
245
- const next = new Set(movedThreadIds)
258
+ const next = new Set(heldThreadIds)
246
259
  for (const id of newlyMovedIds) next.add(id)
247
- setMovedThreadIds(next)
260
+ setHeldThreadIds(next)
261
+ }
262
+ // Local partition maintenance — the only non-zoom visual change, and it is local by
263
+ // construction: any displayed leaf that has left the cluster input (deleted, thread opened,
264
+ // popped out above) is detached from its badge in place. The corrected rebuild is already
265
+ // sitting in latestModel awaiting the next zoom-out.
266
+ {
267
+ const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
268
+ for (const leaf of clusterModel.table.leaves) {
269
+ if (!latestLeafIds.has(leaf.id)) {
270
+ clusterModel.runtime.detachLeaf(leaf.id)
271
+ }
272
+ }
248
273
  }
249
274
  // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
250
275
  // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
251
276
  // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
252
277
  useEffect(() => {
253
- if (movedThreadIds.size === 0) return
278
+ if (heldThreadIds.size === 0) return
254
279
  let lastZoom = editor.getZoomLevel()
255
280
  return react('rejoin moved comment pins on zoom out', () => {
256
281
  const zoom = editor.getZoomLevel()
@@ -258,9 +283,9 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
258
283
  lastZoom = zoom
259
284
  if (zoom >= prevZoom) return
260
285
  adoptOnRebuild.current = true
261
- setMovedThreadIds(EMPTY_SET)
286
+ setHeldThreadIds(EMPTY_SET)
262
287
  })
263
- }, [movedThreadIds, editor])
288
+ }, [heldThreadIds, editor])
264
289
  // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
265
290
  // a merge, and merging only happens while zooming out. While zooming in, the stale table still
266
291
  // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
@@ -276,36 +301,44 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
276
301
  setRenderedModel(latestModel)
277
302
  })
278
303
  }, [clusterModel, latestModel, editor])
304
+ // Threads in the current input that the displayed partition doesn't show anywhere (new
305
+ // comments, reopened threads, undone deletions): render as plain pins until the next
306
+ // zoom-out folds them in. Membership is judged against the *displayed* partition (with
307
+ // detaches applied), not the rendered table, so a detached-then-restored leaf reappears.
308
+ const partitionVersion = clusterModel.runtime.version
279
309
  const orphanThreads = useMemo(() => {
280
310
  if (clusterModel === latestModel) return []
281
- const renderedIds = new Set(clusterModel.table.leaves.map((leaf) => leaf.id))
311
+ const displayed = new Set<string>()
312
+ for (const node of clusterModel.runtime.getVisible().values()) {
313
+ for (const member of node.members) displayed.add(member)
314
+ }
282
315
  const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
283
- return threads.filter((thread) => latestIds.has(thread.id) && !renderedIds.has(thread.id))
284
- }, [clusterModel, latestModel, threads])
285
- const movedThreads = useMemo(
286
- () => threads.filter((thread) => movedThreadIds.has(thread.id) && thread.id !== openId),
287
- [threads, movedThreadIds, openId]
316
+ return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
317
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
319
+ }, [clusterModel, latestModel, threads, partitionVersion])
320
+ const heldThreads = useMemo(
321
+ () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
322
+ [threads, heldThreadIds, openId]
288
323
  )
289
- // Subscribe to the runtime cursor, not the raw zoom: onCamera runs on every zoom tick (two
290
- // O(1) threshold checks against the event table) but returns the same integer until a merge
291
- // or split event actually fires — so this component only re-renders on cluster changes, not
292
- // on every camera frame.
293
- const clusterCursor = useValue(
294
- 'comment cluster cursor',
324
+ // Subscribe to the runtime's partition version, not the raw zoom: onCamera runs on every zoom
325
+ // tick (O(1) threshold checks) but the version only moves when the partition actually changes
326
+ // — so this component only re-renders on cluster changes, not on every camera frame. The memo
327
+ // below keys on a fresh inline read of the version rather than the subscribed value, because
328
+ // render-time detaches (above) bump it after the subscription's computed already evaluated.
329
+ useValue(
330
+ 'comment cluster version',
295
331
  () => {
296
332
  clusterModel.runtime.onCamera(editor.getZoomLevel())
297
- return clusterModel.runtime.k
333
+ return clusterModel.runtime.version
298
334
  },
299
335
  [clusterModel, editor]
300
336
  )
301
337
  const visibleNodes = useMemo(() => {
302
- const nodes = Array.from(clusterModel.runtime.getVisible().values())
303
- // eslint-disable-next-line no-console
304
- console.debug(
305
- `[comments] cluster cursor k=${clusterCursor} re-rendering ${nodes.length} visible nodes`
306
- )
307
- return nodes
308
- }, [clusterModel, clusterCursor])
338
+ return Array.from(clusterModel.runtime.getVisible().values())
339
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
341
+ }, [clusterModel, partitionVersion])
309
342
  const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
310
343
  const threadsById = useMemo(
311
344
  () => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
@@ -384,7 +417,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
384
417
  // The mention picker owns Escape while it's open — let it dismiss the roster alone.
385
418
  if (isMentionPickerOpen()) return
386
419
  const target = e.target as HTMLElement | null
387
- if (target && target.closest('.cmt-editing')) return
420
+ if (target && target.closest('.tlui-cmt-editing')) return
388
421
  openThreadId.set(editor, null)
389
422
  e.preventDefault()
390
423
  e.stopPropagation()
@@ -414,7 +447,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
414
447
  // Render into the container (above the panels' stacking context) so the pins and popovers
415
448
  // live in the UI layer rather than being clipped by the canvas layer.
416
449
  return createPortal(
417
- <div ref={layerRef} className="cmt-canvas-layer">
450
+ <div ref={layerRef} className="tlui-cmt-canvas-layer">
418
451
  {options.enableClustering ? (
419
452
  <>
420
453
  {fadeNodes.map(({ node, phase }) => {
@@ -448,7 +481,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
448
481
  regionOptions={regionOptions}
449
482
  />
450
483
  ))}
451
- {movedThreads.map((thread) => (
484
+ {heldThreads.map((thread) => (
452
485
  <ThreadPin
453
486
  key={thread.id}
454
487
  editor={editor}
@@ -597,7 +630,7 @@ function cancelClusterFadeFrame(frame: number) {
597
630
  }
598
631
 
599
632
  function clusterFadeClassName(phase: ClusterFadePhase): string {
600
- return `cmt-cluster-fade cmt-cluster-fade--${phase}`
633
+ return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
601
634
  }
602
635
 
603
636
  /**
@@ -627,12 +660,6 @@ function findMovedClusteredLeafIds(
627
660
  return moved
628
661
  }
629
662
 
630
- function hasRemovedLeaves(rendered: ClusterTable, latest: ClusterTable): boolean {
631
- if (rendered.leaves.length === 0) return false
632
- const latestIds = new Set(latest.leaves.map((leaf) => leaf.id))
633
- return rendered.leaves.some((leaf) => !latestIds.has(leaf.id))
634
- }
635
-
636
663
  function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
637
664
  const cameraOptions = editor.getCameraOptions()
638
665
  const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
@@ -733,7 +760,7 @@ const ClusterBadge = memo(function ClusterBadge({
733
760
 
734
761
  return (
735
762
  <div
736
- className="cmt-canvas-cluster"
763
+ className="tlui-cmt-canvas-cluster"
737
764
  style={{ left: point.x, top: point.y }}
738
765
  onPointerDown={stop}
739
766
  onClick={(e) => {
@@ -772,7 +799,7 @@ function ThreadPopover({
772
799
  usePassThroughWheelEvents(ref)
773
800
  usePassThroughMouseOverEvents(ref)
774
801
  return createPortal(
775
- <div ref={ref} className="cmt-canvas-popover" style={style} onPointerDown={stop}>
802
+ <div ref={ref} className="tlui-cmt-canvas-popover" style={style} onPointerDown={stop}>
776
803
  {children}
777
804
  </div>,
778
805
  container
@@ -831,7 +858,11 @@ function RegionBox({
831
858
  }
832
859
  return (
833
860
  <div
834
- className={movable ? 'cmt-canvas-region cmt-canvas-region--movable' : 'cmt-canvas-region'}
861
+ className={
862
+ movable
863
+ ? 'tlui-cmt-canvas-region tlui-cmt-canvas-region--movable'
864
+ : 'tlui-cmt-canvas-region'
865
+ }
835
866
  style={rect}
836
867
  onPointerDown={movable ? startMove : undefined}
837
868
  onPointerMove={movable ? onMove : undefined}
@@ -939,7 +970,7 @@ function RegionResizeHandles({
939
970
  {points.map((h) => (
940
971
  <div
941
972
  key={h.key}
942
- className="cmt-canvas-region-handle"
973
+ className="tlui-cmt-canvas-region-handle"
943
974
  style={{ left: h.left, top: h.top, cursor: h.cursor }}
944
975
  onPointerDown={startResize}
945
976
  onPointerMove={onResize(h)}
@@ -1032,15 +1063,17 @@ const ThreadPin = memo(function ThreadPin({
1032
1063
  const onPointerDown = (e: PointerEvent) => {
1033
1064
  const target = e.target as HTMLElement | null
1034
1065
  if (!target) return
1035
- if (target.closest('.cmt-canvas-popover')) return
1066
+ if (target.closest('.tlui-cmt-canvas-popover')) return
1036
1067
  const marker = markerRef.current
1037
1068
  if (marker && marker.contains(target)) return
1038
1069
  // A press on a region's resize handle or movable body edits this thread — don't dismiss it.
1039
- if (target.closest('.cmt-canvas-region-handle, .cmt-canvas-region--movable')) return
1070
+ if (target.closest('.tlui-cmt-canvas-region-handle, .tlui-cmt-canvas-region--movable')) return
1040
1071
  // A click inside a menu/popover layered above us (the sidebar's filter or overflow
1041
1072
  // dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
1042
1073
  // layer; defer to its own dismissal instead of closing the thread out from under it.
1043
- if (target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .cmt-mention-popup'))
1074
+ if (
1075
+ target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
1076
+ )
1044
1077
  return
1045
1078
  openThreadId.set(editor, null)
1046
1079
  }
@@ -1128,7 +1161,7 @@ const ThreadPin = memo(function ThreadPin({
1128
1161
  if (editingId === comment.id) {
1129
1162
  return (
1130
1163
  <div
1131
- className="cmt-editing"
1164
+ className="tlui-cmt-editing"
1132
1165
  onKeyDown={(e) => {
1133
1166
  if (e.key === 'Escape') {
1134
1167
  setEditingId(null)
@@ -1157,7 +1190,7 @@ const ThreadPin = memo(function ThreadPin({
1157
1190
  actions={
1158
1191
  comment.authorId === currentUserId ? (
1159
1192
  <button
1160
- className="cmt-thread__action"
1193
+ className="tlui-cmt-thread__action"
1161
1194
  title={msg('comments.edit')}
1162
1195
  onClick={() => startEdit(comment)}
1163
1196
  >
@@ -1173,7 +1206,7 @@ const ThreadPin = memo(function ThreadPin({
1173
1206
  <>
1174
1207
  {currentUserId && (
1175
1208
  <button
1176
- className="cmt-thread__action"
1209
+ className="tlui-cmt-thread__action"
1177
1210
  title={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
1178
1211
  onClick={toggleResolve}
1179
1212
  >
@@ -1186,7 +1219,7 @@ const ThreadPin = memo(function ThreadPin({
1186
1219
  )}
1187
1220
  {currentUserId && (
1188
1221
  <button
1189
- className="cmt-thread__action"
1222
+ className="tlui-cmt-thread__action"
1190
1223
  title={msg('comments.delete')}
1191
1224
  onClick={deleteThread}
1192
1225
  >
@@ -1194,7 +1227,7 @@ const ThreadPin = memo(function ThreadPin({
1194
1227
  </button>
1195
1228
  )}
1196
1229
  <button
1197
- className="cmt-thread__action"
1230
+ className="tlui-cmt-thread__action"
1198
1231
  title={msg('comments.dismiss')}
1199
1232
  onClick={() => openThreadId.set(editor, null)}
1200
1233
  >
@@ -1313,12 +1346,12 @@ const ThreadPin = memo(function ThreadPin({
1313
1346
  />
1314
1347
  )}
1315
1348
  <div
1316
- className={open ? 'cmt-canvas-pin cmt-canvas-pin--open' : 'cmt-canvas-pin'}
1349
+ className={open ? 'tlui-cmt-canvas-pin tlui-cmt-canvas-pin--open' : 'tlui-cmt-canvas-pin'}
1317
1350
  style={{ left: renderPoint.x, top: renderPoint.y }}
1318
1351
  >
1319
1352
  <div
1320
1353
  ref={markerRef}
1321
- className="cmt-canvas-pin__marker"
1354
+ className="tlui-cmt-canvas-pin__marker"
1322
1355
  onPointerDown={startDrag}
1323
1356
  onPointerMove={onDrag}
1324
1357
  onPointerUp={endDrag}
@@ -1402,7 +1435,7 @@ function PendingComposer({
1402
1435
  if (!el || !target) return
1403
1436
  // A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
1404
1437
  // not "outside" — keep the draft open so the pick can insert.
1405
- if (el.contains(target) || target.closest('.cmt-mention-popup')) return
1438
+ if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
1406
1439
  pendingComment.set(editor, null)
1407
1440
  }
1408
1441
  document.addEventListener('pointerdown', onPointerDown, true)
@@ -1434,7 +1467,7 @@ function PendingComposer({
1434
1467
  return createPortal(
1435
1468
  <div
1436
1469
  ref={ref}
1437
- className="cmt-canvas-composer"
1470
+ className="tlui-cmt-canvas-composer"
1438
1471
  style={{ left: point.x, top: point.y }}
1439
1472
  onPointerDown={stop}
1440
1473
  onKeyDown={(e) => {
@@ -149,7 +149,7 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
149
149
  items={items}
150
150
  header={header ?? msg('comments.title')}
151
151
  headerAction={
152
- <div className="cmt-list__header-actions">
152
+ <div className="tlui-cmt-list__header-actions">
153
153
  <CommentsFilterMenu
154
154
  canFilterByAuthor={currentUserId !== undefined}
155
155
  canFilterByUnread={isCommentUnread !== undefined}
@@ -176,7 +176,7 @@ function SidebarPanel({ container, children }: { container: HTMLElement; childre
176
176
  const ref = useRef<HTMLDivElement>(null)
177
177
  usePassThroughMouseOverEvents(ref)
178
178
  return createPortal(
179
- <div ref={ref} className="cmt-canvas-sidebar">
179
+ <div ref={ref} className="tlui-cmt-canvas-sidebar">
180
180
  {children}
181
181
  </div>,
182
182
  container