@tldraw/commenting 5.3.0-next.d7d8ced023d5 → 5.3.0-next.df310c6196c0

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 (240) hide show
  1. package/commenting.css +85 -87
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +276 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +12 -7
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +81 -871
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +135 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +326 -203
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +13 -1
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +138 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +260 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +12 -7
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +58 -879
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +127 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +326 -203
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +13 -1
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +118 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +7 -6
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.test.ts +88 -0
  182. package/src/canvas/cluster-model.ts +421 -0
  183. package/src/canvas/comment-mutations.test.ts +46 -4
  184. package/src/canvas/comment-mutations.ts +115 -104
  185. package/src/canvas/comment-reactions.test.ts +22 -0
  186. package/src/canvas/comment-reactions.tsx +33 -32
  187. package/src/canvas/comment-render.ts +8 -8
  188. package/src/canvas/comment-store.ts +12 -17
  189. package/src/canvas/comment-tool.test.ts +109 -4
  190. package/src/canvas/comment-tool.tsx +31 -19
  191. package/src/canvas/comments-filter-menu.tsx +10 -17
  192. package/src/canvas/comments-overflow-menu.tsx +8 -3
  193. package/src/canvas/comments-overlay.tsx +160 -1345
  194. package/src/canvas/comments-sidebar.tsx +40 -19
  195. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  196. package/src/canvas/context.ts +4 -3
  197. package/src/canvas/hooks.test.ts +94 -0
  198. package/src/canvas/hooks.ts +17 -3
  199. package/src/canvas/license.ts +1 -1
  200. package/src/canvas/mobile-placement.ts +115 -0
  201. package/src/canvas/options.test.ts +124 -2
  202. package/src/canvas/options.ts +192 -58
  203. package/src/canvas/pending-composer.tsx +161 -0
  204. package/src/canvas/pin-stacking.test.ts +111 -1
  205. package/src/canvas/pin-stacking.ts +46 -0
  206. package/src/canvas/region-box.tsx +124 -0
  207. package/src/canvas/state.ts +20 -28
  208. package/src/canvas/thread-pin.tsx +419 -0
  209. package/src/canvas/thread-preview.tsx +77 -87
  210. package/src/canvas/thread-stack.tsx +19 -29
  211. package/src/canvas/thread-state.test.ts +51 -0
  212. package/src/canvas/thread-state.ts +56 -23
  213. package/src/canvas/thread-view.test.ts +72 -0
  214. package/src/canvas/thread-view.tsx +231 -133
  215. package/src/clustering/computeClusterTable.ts +12 -3
  216. package/src/clustering/replay.test.ts +0 -7
  217. package/src/clustering/replay.ts +131 -32
  218. package/src/clustering/runtime.test.ts +50 -6
  219. package/src/clustering/runtime.ts +42 -39
  220. package/src/clustering/schedule.test.ts +0 -6
  221. package/src/clustering/screen-offsets.test.ts +171 -0
  222. package/src/clustering/types.ts +10 -0
  223. package/src/index.ts +15 -2
  224. package/src/ui/byline.tsx +16 -6
  225. package/src/ui/comment-composer.tsx +25 -69
  226. package/src/ui/comment-pin.tsx +3 -16
  227. package/src/ui/comments-list.tsx +40 -29
  228. package/src/ui/comments.css +80 -76
  229. package/src/ui/format-time.test.ts +69 -0
  230. package/src/ui/format-time.ts +22 -2
  231. package/src/ui/icons.tsx +138 -0
  232. package/src/ui/reaction-picker.tsx +9 -16
  233. package/src/ui/reaction.tsx +10 -6
  234. package/src/ui/send-button.tsx +3 -8
  235. package/src/ui/visual-viewport.test.ts +36 -0
  236. package/src/ui/visual-viewport.ts +27 -0
  237. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  238. package/dist-esm/ui/tooltip-button.mjs +0 -12
  239. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  240. package/src/ui/tooltip-button.tsx +0 -20
@@ -0,0 +1,421 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Editor, react, TLCommentThread, useValue } from 'tldraw'
3
+ import { computeClusterTable } from '../clustering/computeClusterTable'
4
+ import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
5
+ import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
6
+ import { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'
7
+ import {
8
+ type ClusterInput,
9
+ clusterInputEqual,
10
+ clusterInputIdsEqual,
11
+ collectClusterLeaves,
12
+ } from './cluster-input'
13
+ import { type CommentingOptions } from './options'
14
+ import { openThreadId } from './state'
15
+ import { anchorPagePoint, commentCenterScreenOffset } from './thread-state'
16
+
17
+ /** Duration of the click-a-badge zoom-to-split animation. */
18
+ export const CLUSTER_EXPAND_ZOOM_MS = 450
19
+ /** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
20
+ * lands clear of the threshold it just crossed rather than flickering on it. */
21
+ const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
22
+
23
+ const EMPTY_SET: ReadonlySet<string> = new Set()
24
+ const MOVED_LEAF_EPSILON = 1e-6
25
+
26
+ /** Default select-tool states that move shapes continuously, one store write per pointermove. A
27
+ * custom tool in their place just never matches, falling back to a rebuild per frame. */
28
+ const SHAPE_DRAG_STATE_PATHS = [
29
+ 'select.translating',
30
+ 'select.resizing',
31
+ 'select.rotating',
32
+ 'select.dragging_handle',
33
+ 'select.crop.cropping',
34
+ ] as const
35
+
36
+ /** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the
37
+ * gesture starts or settles. */
38
+ function isShapeDragInProgress(editor: Editor): boolean {
39
+ return editor.isInAny(...SHAPE_DRAG_STATE_PATHS)
40
+ }
41
+
42
+ /** The clustering table for the current scene, plus the runtime walking its merge events. */
43
+ export interface ClusterModel {
44
+ runtime: ClusterRuntime
45
+ table: ClusterTable
46
+ }
47
+
48
+ export interface ClusterZoomBounds {
49
+ minZoom: number
50
+ maxZoom: number
51
+ }
52
+
53
+ export interface ClusterModelState {
54
+ /** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */
55
+ model: ClusterModel
56
+ zoomBounds: ClusterZoomBounds
57
+ /** The displayed nodes, each tagged with its cross-fade phase. */
58
+ fadeNodes: ClusterFadeNode[]
59
+ /** Threads in the input that the displayed partition doesn't show — render them as plain pins. */
60
+ orphanThreads: TLCommentThread[]
61
+ /** Threads held out of clustering because their anchor moved while folded into a badge. */
62
+ heldThreads: TLCommentThread[]
63
+ }
64
+
65
+ /**
66
+ * The clustering state machine behind the comments layer.
67
+ *
68
+ * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is
69
+ * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only
70
+ * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
71
+ * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in
72
+ * place and nothing else on the canvas moves.
73
+ *
74
+ * Threads the displayed partition can't represent are returned separately (`orphanThreads`,
75
+ * `heldThreads`) for the layer to draw as ordinary pins.
76
+ */
77
+ export function useClusterModel(
78
+ editor: Editor,
79
+ threads: readonly TLCommentThread[],
80
+ openId: string | null
81
+ ): ClusterModelState {
82
+ // Threads held out of clustering because their anchor moved while folded inside a badge
83
+ // (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
84
+ // as live pins riding their anchor and rejoin clustering on the next zoom-out.
85
+ const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
86
+ const adoptOnRebuild = useRef(false)
87
+ // This input's identity keys the O(N²) table rebuild below, so it's gated on value equality: a
88
+ // reply, a reaction, a resolve — anything that touches comment records without moving a pin —
89
+ // returns the previous input and rebuilds nothing.
90
+ //
91
+ // Mid-drag the gate ignores positions too — except a folded leaf's, which a badge can't
92
+ // follow. Its first move passes through, the pop-out below holds the pin out as a live pin,
93
+ // and the input is static again for the rest of the drag.
94
+ const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
95
+ const renderedModelRef = useRef<ClusterModel | null>(null)
96
+ const clusterInput = useValue(
97
+ 'comment cluster leaves',
98
+ () => {
99
+ const next = collectClusterLeaves(
100
+ editor,
101
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
102
+ openThreadId.get(editor)
103
+ )
104
+ const prev = clusterInputRef.current
105
+ if (
106
+ isShapeDragInProgress(editor) &&
107
+ clusterInputIdsEqual(prev, next) &&
108
+ !anyFoldedLeafMoved(prev, next, renderedModelRef.current)
109
+ ) {
110
+ return prev
111
+ }
112
+ if (clusterInputEqual(prev, next)) return prev
113
+ clusterInputRef.current = next
114
+ return next
115
+ },
116
+ [editor, threads, heldThreadIds]
117
+ )
118
+ const clusterZoomBounds = useValue(
119
+ 'comment cluster zoom bounds',
120
+ () => getClusterZoomBounds(editor),
121
+ [editor]
122
+ )
123
+ const latestModel = useMemo(() => {
124
+ const table = computeClusterTable(
125
+ clusterInput.leaves,
126
+ clusterZoomBounds,
127
+ clusterInput.screenOffsets
128
+ )
129
+ const runtime = createClusterRuntime(table)
130
+ runtime.seed(editor.getZoomLevel())
131
+ return { runtime, table }
132
+ }, [clusterInput, clusterZoomBounds, editor])
133
+ const [renderedModel, setRenderedModel] = useState(latestModel)
134
+ let clusterModel = renderedModel
135
+ // A page switch replaces the whole scene: hard-reset rather than detach the world.
136
+ const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
137
+ const pageRef = useRef(pageId)
138
+ if (pageRef.current !== pageId) {
139
+ pageRef.current = pageId
140
+ adoptOnRebuild.current = false
141
+ latestModel.runtime.seed(editor.getZoomLevel())
142
+ if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
143
+ setRenderedModel(latestModel)
144
+ clusterModel = latestModel
145
+ }
146
+ // adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust
147
+ // it once that pairing is visible here, or an unrelated re-render can land in the gap.
148
+ const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
149
+ if (renderedModel !== latestModel && rejoinPending) {
150
+ adoptOnRebuild.current = false
151
+ // Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
152
+ // nothing changes state because of the swap alone. Idempotent, so safe during render.
153
+ latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
154
+ setRenderedModel(latestModel)
155
+ clusterModel = latestModel
156
+ } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
157
+ // Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
158
+ // survive to force-adopt a later, unrelated rebuild.
159
+ adoptOnRebuild.current = false
160
+ }
161
+ // For the input gate above: folded-vs-visible is judged against what's on screen.
162
+ renderedModelRef.current = clusterModel
163
+ // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
164
+ // is baked into the model), so when its live position drifts from the baked one, hold it out.
165
+ // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
166
+ const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
167
+ if (newlyMovedIds.length > 0) {
168
+ const next = new Set(heldThreadIds)
169
+ for (const id of newlyMovedIds) next.add(id)
170
+ setHeldThreadIds(next)
171
+ }
172
+ // Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
173
+ // cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
174
+ // awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
175
+ if (clusterModel !== latestModel) {
176
+ const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
177
+ const removedLeafIds: string[] = []
178
+ for (const leaf of clusterModel.table.leaves) {
179
+ if (!latestLeafIds.has(leaf.id)) {
180
+ removedLeafIds.push(leaf.id)
181
+ }
182
+ }
183
+ // Batched: one patch rebuild and one version bump for the whole set.
184
+ if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)
185
+ }
186
+ // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
187
+ // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
188
+ // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
189
+ useEffect(() => {
190
+ if (heldThreadIds.size === 0) return
191
+ let lastZoom = editor.getZoomLevel()
192
+ return react('rejoin moved comment pins on zoom out', () => {
193
+ const zoom = editor.getZoomLevel()
194
+ const prevZoom = lastZoom
195
+ lastZoom = zoom
196
+ if (zoom >= prevZoom) return
197
+ adoptOnRebuild.current = true
198
+ setHeldThreadIds(EMPTY_SET)
199
+ })
200
+ }, [heldThreadIds, editor])
201
+ // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
202
+ // a merge, and merging only happens while zooming out. While zooming in, the stale table still
203
+ // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
204
+ useEffect(() => {
205
+ if (clusterModel === latestModel) return
206
+ let lastZoom = editor.getZoomLevel()
207
+ return react('adopt pending cluster model on zoom out', () => {
208
+ const zoom = editor.getZoomLevel()
209
+ const prevZoom = lastZoom
210
+ lastZoom = zoom
211
+ if (zoom >= prevZoom) return
212
+ latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
213
+ setRenderedModel(latestModel)
214
+ })
215
+ }, [clusterModel, latestModel, editor])
216
+ // Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone
217
+ // deletions) render as plain pins until the next zoom-out folds them in. Judged against the
218
+ // displayed partition, so a detached-then-restored leaf reappears.
219
+ const partitionVersion = clusterModel.runtime.version
220
+ const orphanThreads = useMemo(() => {
221
+ if (clusterModel === latestModel) return []
222
+ const displayed = new Set<string>()
223
+ for (const node of clusterModel.runtime.getVisible().values()) {
224
+ for (const member of node.members) displayed.add(member)
225
+ }
226
+ const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
227
+ return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
228
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
229
+ // eslint-disable-next-line react-hooks/exhaustive-deps
230
+ }, [clusterModel, latestModel, threads, partitionVersion])
231
+ const heldThreads = useMemo(
232
+ () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
233
+ [threads, heldThreadIds, openId]
234
+ )
235
+ // Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster
236
+ // changes rather than every camera frame. The memo below re-reads the version inline because
237
+ // render-time detaches bump it after the subscription's computed already evaluated.
238
+ useValue(
239
+ 'comment cluster version',
240
+ () => {
241
+ clusterModel.runtime.onCamera(editor.getZoomLevel())
242
+ return clusterModel.runtime.version
243
+ },
244
+ [clusterModel, editor]
245
+ )
246
+ const visibleNodes = useMemo(() => {
247
+ return Array.from(clusterModel.runtime.getVisible().values())
248
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, [clusterModel, partitionVersion])
251
+ const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
252
+
253
+ return {
254
+ model: clusterModel,
255
+ zoomBounds: clusterZoomBounds,
256
+ fadeNodes,
257
+ orphanThreads,
258
+ heldThreads,
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge
264
+ * of the rendered partition — the one move the mid-drag freeze must let through.
265
+ *
266
+ * Folded means a member of a displayed badge, not merely "absent from the displayed partition":
267
+ * the input also carries orphans (threads the rendered partition has never seen) and detached
268
+ * leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the
269
+ * pop-out below can never hold one — counting them here would break the freeze on every
270
+ * pointermove for the rest of the drag.
271
+ * @internal
272
+ */
273
+ export function anyFoldedLeafMoved(
274
+ prev: ClusterInput,
275
+ next: ClusterInput,
276
+ rendered: ClusterModel | null
277
+ ): boolean {
278
+ if (!rendered) return false
279
+ if (prev.leaves.length !== next.leaves.length) return false
280
+ const folded = new Set<string>()
281
+ for (const node of rendered.runtime.getVisible().values()) {
282
+ if (node.count < 2) continue
283
+ for (const member of node.members) folded.add(member)
284
+ }
285
+ // No badges on screen, so nothing can be folded — the common case, and the cheap way out of it.
286
+ if (folded.size === 0) return false
287
+ for (let i = 0; i < next.leaves.length; i++) {
288
+ if (!folded.has(next.leaves[i].id)) continue
289
+ const a = prev.leaves[i].point
290
+ const b = next.leaves[i].point
291
+ if (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {
292
+ return true
293
+ }
294
+ }
295
+ return false
296
+ }
297
+
298
+ /**
299
+ * Leaves folded inside a badge whose live anchor no longer matches the position the rendered
300
+ * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
301
+ * stay deferred; a badge can't follow a member, so these must pop out of clustering.
302
+ */
303
+ function findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {
304
+ if (rendered.table === latest.table) return []
305
+ const visible = rendered.runtime.getVisible()
306
+ const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
307
+ const moved: string[] = []
308
+ for (const leaf of rendered.table.leaves) {
309
+ if (visible.has(leaf.id)) continue
310
+ const current = latestById.get(leaf.id)
311
+ if (!current) continue
312
+ if (
313
+ Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
314
+ Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
315
+ ) {
316
+ moved.push(leaf.id)
317
+ }
318
+ }
319
+ return moved
320
+ }
321
+
322
+ function getClusterZoomBounds(editor: Editor): ClusterZoomBounds {
323
+ const cameraOptions = editor.getCameraOptions()
324
+ const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
325
+ const zoomSteps = cameraOptions.zoomSteps
326
+ return {
327
+ minZoom: zoomSteps[0] * baseZoom,
328
+ maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split
334
+ * that unfolds it from its badge (or just centre on it when it isn't clustered).
335
+ */
336
+ export function revealThreadPin(
337
+ editor: Editor,
338
+ thread: TLCommentThread,
339
+ table: ClusterTable,
340
+ zoomBounds: ClusterZoomBounds,
341
+ options: CommentingOptions,
342
+ duration = 200
343
+ ) {
344
+ if (thread.pageId !== editor.getCurrentPageId()) {
345
+ editor.setCurrentPage(thread.pageId as any)
346
+ }
347
+
348
+ const point = anchorPagePoint(editor, thread.anchor)
349
+ if (!point) return
350
+
351
+ // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
352
+ // badge never exists) and just center on the pin.
353
+ if (options.enableClustering) {
354
+ const parentEvent = findDirectParentEvent(table, thread.id)
355
+ if (
356
+ parentEvent &&
357
+ Number.isFinite(parentEvent.zSplit) &&
358
+ parentEvent.zSplit <= zoomBounds.maxZoom
359
+ ) {
360
+ const zoom = clamp(
361
+ parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
362
+ zoomBounds.minZoom,
363
+ zoomBounds.maxZoom
364
+ )
365
+ centerOnPointAtZoom(editor, point, zoom, duration)
366
+ return
367
+ }
368
+ }
369
+
370
+ const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
371
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
372
+ }
373
+
374
+ /**
375
+ * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The
376
+ * event that created a visible cluster is the event that splits it, and has the smallest zSplit of
377
+ * everything applied inside it — so its zSplit is exactly the first split within those comments.
378
+ * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.
379
+ */
380
+ export function zoomToClusterSplit(
381
+ editor: Editor,
382
+ table: ClusterTable,
383
+ zoomBounds: ClusterZoomBounds,
384
+ node: ClusterNode
385
+ ) {
386
+ const event = table.events.find((e) => e.result.id === node.id)
387
+ if (!event || !Number.isFinite(event.zSplit)) return
388
+ const zoom = clamp(
389
+ event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
390
+ zoomBounds.minZoom,
391
+ zoomBounds.maxZoom
392
+ )
393
+ centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
394
+ }
395
+
396
+ function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
397
+ return table.events.find((event) => event.children.some((child) => child.id === threadId))
398
+ }
399
+
400
+ function centerOnPointAtZoom(
401
+ editor: Editor,
402
+ point: { x: number; y: number },
403
+ zoom: number,
404
+ duration = 200
405
+ ) {
406
+ const viewport = editor.getViewportScreenBounds()
407
+ // The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
408
+ const offset = commentCenterScreenOffset(editor)
409
+ editor.setCamera(
410
+ {
411
+ x: (viewport.w / 2 - offset) / zoom - point.x,
412
+ y: viewport.h / (2 * zoom) - point.y,
413
+ z: zoom,
414
+ },
415
+ { animation: { duration } }
416
+ )
417
+ }
418
+
419
+ function clamp(value: number, min: number, max: number): number {
420
+ return Math.max(min, Math.min(max, value))
421
+ }
@@ -20,7 +20,6 @@ import {
20
20
  deleteThread,
21
21
  editComment,
22
22
  putCommentRecords,
23
- putRecordsInCommit,
24
23
  removeCommentRecords,
25
24
  reopenThread,
26
25
  resolveThread,
@@ -366,8 +365,51 @@ describe('history', () => {
366
365
  expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
367
366
  })
368
367
 
369
- // A host can want pin drags undoable while posts and edits aren't. The drag owns its commit and
370
- // writes inside it, which is what keeps `dragHistory` in charge of the mode.
368
+ it('rejects a nested mutation that resolves to a different history mode', () => {
369
+ const editor = makeEditor(CommentTool.configure({ history: 'record' }))
370
+ const { comment } = makeThread(editor)
371
+
372
+ // A delete always ignores history, so it can't run inside a `record` commit.
373
+ expect(() => commitCommentMutation(editor, () => deleteComment(editor, comment))).toThrow(
374
+ "records history as 'ignore' can't run inside one recording it as 'record'"
375
+ )
376
+
377
+ deleteComment(editor, comment)
378
+ expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
379
+ })
380
+
381
+ // Store history flushes synchronously under test, so this listener writes from inside the commit
382
+ // that triggered it — where a side effect always sits, with no "after the mutation" to defer to.
383
+ // Matching modes have nothing to disagree about, so its write goes through.
384
+ it('lets a store listener write comments during a commit when the modes match', () => {
385
+ const editor = makeEditor()
386
+ const { thread, comment } = makeThread(editor)
387
+ let hasReacted = false
388
+ editor.store.listen(() => {
389
+ if (hasReacted) return
390
+ hasReacted = true
391
+ putCommentRecords(editor, [{ ...thread, meta: { lastEditedComment: comment.id } }])
392
+ })
393
+
394
+ editComment(editor, comment, toRichText('edited'))
395
+
396
+ expect(readThread(editor, thread)!.meta).toEqual({ lastEditedComment: comment.id })
397
+ })
398
+
399
+ it('rejects a writer used after its commit', () => {
400
+ const editor = makeEditor()
401
+ const { thread } = makeThread(editor)
402
+ let writeAfterCommit: () => void
403
+
404
+ commitCommentMutation(editor, ({ put }) => {
405
+ writeAfterCommit = () => put([{ ...thread, resolved: { at: 1, by: 'ada' } }])
406
+ })
407
+
408
+ expect(() => writeAfterCommit!()).toThrow('cannot be used after its commit has finished')
409
+ })
410
+
411
+ // A host can want pin drags undoable while posts and edits aren't. The drag owns its commit, and
412
+ // its records go through the writer, which keeps `dragHistory` in charge of them.
371
413
  it('lets dragHistory govern a drag on its own', () => {
372
414
  const editor = makeEditor(CommentTool.configure({ history: 'ignore', dragHistory: 'record' }))
373
415
  const { thread } = makeThread(editor)
@@ -375,7 +417,7 @@ describe('history', () => {
375
417
 
376
418
  commitCommentMutation(
377
419
  editor,
378
- () => putRecordsInCommit(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }]),
420
+ ({ put }) => put([{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }]),
379
421
  'drag'
380
422
  )
381
423
  editor.undo()