@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02

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 (238) hide show
  1. package/commenting.css +63 -71
  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 +252 -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 +0 -1
  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 +77 -870
  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 +134 -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 +323 -202
  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 +12 -0
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +115 -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 +236 -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 +0 -1
  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 +54 -878
  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 +126 -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 +323 -202
  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 +12 -0
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +95 -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 +5 -5
  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.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.tsx +8 -12
  189. package/src/canvas/comments-filter-menu.tsx +9 -16
  190. package/src/canvas/comments-overflow-menu.tsx +8 -3
  191. package/src/canvas/comments-overlay.tsx +154 -1344
  192. package/src/canvas/comments-sidebar.tsx +40 -19
  193. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  194. package/src/canvas/context.ts +4 -3
  195. package/src/canvas/hooks.test.ts +94 -0
  196. package/src/canvas/hooks.ts +17 -3
  197. package/src/canvas/license.ts +1 -1
  198. package/src/canvas/mobile-placement.ts +115 -0
  199. package/src/canvas/options.test.ts +124 -2
  200. package/src/canvas/options.ts +192 -58
  201. package/src/canvas/pending-composer.tsx +157 -0
  202. package/src/canvas/pin-stacking.test.ts +111 -1
  203. package/src/canvas/pin-stacking.ts +46 -0
  204. package/src/canvas/region-box.tsx +124 -0
  205. package/src/canvas/state.ts +20 -28
  206. package/src/canvas/thread-pin.tsx +418 -0
  207. package/src/canvas/thread-preview.tsx +77 -87
  208. package/src/canvas/thread-stack.tsx +19 -29
  209. package/src/canvas/thread-state.test.ts +51 -0
  210. package/src/canvas/thread-state.ts +56 -23
  211. package/src/canvas/thread-view.test.ts +72 -0
  212. package/src/canvas/thread-view.tsx +231 -133
  213. package/src/clustering/computeClusterTable.ts +12 -3
  214. package/src/clustering/replay.test.ts +0 -7
  215. package/src/clustering/replay.ts +131 -32
  216. package/src/clustering/runtime.test.ts +50 -6
  217. package/src/clustering/runtime.ts +42 -39
  218. package/src/clustering/schedule.test.ts +0 -6
  219. package/src/clustering/screen-offsets.test.ts +171 -0
  220. package/src/clustering/types.ts +10 -0
  221. package/src/index.ts +15 -2
  222. package/src/ui/byline.tsx +16 -6
  223. package/src/ui/comment-composer.tsx +25 -69
  224. package/src/ui/comment-pin.tsx +3 -16
  225. package/src/ui/comments-list.tsx +40 -29
  226. package/src/ui/comments.css +58 -60
  227. package/src/ui/format-time.test.ts +69 -0
  228. package/src/ui/format-time.ts +20 -0
  229. package/src/ui/icons.tsx +116 -0
  230. package/src/ui/reaction-picker.tsx +9 -16
  231. package/src/ui/reaction.tsx +10 -6
  232. package/src/ui/send-button.tsx +3 -8
  233. package/src/ui/visual-viewport.test.ts +36 -0
  234. package/src/ui/visual-viewport.ts +27 -0
  235. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  236. package/dist-esm/ui/tooltip-button.mjs +0 -12
  237. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  238. package/src/ui/tooltip-button.tsx +0 -20
@@ -0,0 +1,378 @@
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 widens to ignore positions too, and that costs something real: it freezes
92
+ // `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin
93
+ // folded into a badge stops popping out to ride its anchor and corrects on release instead. The
94
+ // per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.
95
+ // An added or deleted thread changes the id set, so it still rebuilds promptly.
96
+ const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
97
+ const clusterInput = useValue(
98
+ 'comment cluster leaves',
99
+ () => {
100
+ const next = collectClusterLeaves(
101
+ editor,
102
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
103
+ openThreadId.get(editor)
104
+ )
105
+ const prev = clusterInputRef.current
106
+ if (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev
107
+ if (clusterInputEqual(prev, next)) return prev
108
+ clusterInputRef.current = next
109
+ return next
110
+ },
111
+ [editor, threads, heldThreadIds]
112
+ )
113
+ const clusterZoomBounds = useValue(
114
+ 'comment cluster zoom bounds',
115
+ () => getClusterZoomBounds(editor),
116
+ [editor]
117
+ )
118
+ const latestModel = useMemo(() => {
119
+ const table = computeClusterTable(
120
+ clusterInput.leaves,
121
+ clusterZoomBounds,
122
+ clusterInput.screenOffsets
123
+ )
124
+ const runtime = createClusterRuntime(table)
125
+ runtime.seed(editor.getZoomLevel())
126
+ return { runtime, table }
127
+ }, [clusterInput, clusterZoomBounds, editor])
128
+ const [renderedModel, setRenderedModel] = useState(latestModel)
129
+ let clusterModel = renderedModel
130
+ // A page switch replaces the whole scene: hard-reset rather than detach the world.
131
+ const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
132
+ const pageRef = useRef(pageId)
133
+ if (pageRef.current !== pageId) {
134
+ pageRef.current = pageId
135
+ adoptOnRebuild.current = false
136
+ latestModel.runtime.seed(editor.getZoomLevel())
137
+ if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
138
+ setRenderedModel(latestModel)
139
+ clusterModel = latestModel
140
+ }
141
+ // adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust
142
+ // it once that pairing is visible here, or an unrelated re-render can land in the gap.
143
+ const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
144
+ if (renderedModel !== latestModel && rejoinPending) {
145
+ adoptOnRebuild.current = false
146
+ // Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
147
+ // nothing changes state because of the swap alone. Idempotent, so safe during render.
148
+ latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
149
+ setRenderedModel(latestModel)
150
+ clusterModel = latestModel
151
+ } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
152
+ // Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
153
+ // survive to force-adopt a later, unrelated rebuild.
154
+ adoptOnRebuild.current = false
155
+ }
156
+ // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
157
+ // is baked into the model), so when its live position drifts from the baked one, hold it out.
158
+ // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
159
+ const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
160
+ if (newlyMovedIds.length > 0) {
161
+ const next = new Set(heldThreadIds)
162
+ for (const id of newlyMovedIds) next.add(id)
163
+ setHeldThreadIds(next)
164
+ }
165
+ // Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
166
+ // cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
167
+ // awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
168
+ if (clusterModel !== latestModel) {
169
+ const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
170
+ const removedLeafIds: string[] = []
171
+ for (const leaf of clusterModel.table.leaves) {
172
+ if (!latestLeafIds.has(leaf.id)) {
173
+ removedLeafIds.push(leaf.id)
174
+ }
175
+ }
176
+ // Batched: one patch rebuild and one version bump for the whole set.
177
+ if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)
178
+ }
179
+ // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
180
+ // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
181
+ // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
182
+ useEffect(() => {
183
+ if (heldThreadIds.size === 0) return
184
+ let lastZoom = editor.getZoomLevel()
185
+ return react('rejoin moved comment pins on zoom out', () => {
186
+ const zoom = editor.getZoomLevel()
187
+ const prevZoom = lastZoom
188
+ lastZoom = zoom
189
+ if (zoom >= prevZoom) return
190
+ adoptOnRebuild.current = true
191
+ setHeldThreadIds(EMPTY_SET)
192
+ })
193
+ }, [heldThreadIds, editor])
194
+ // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
195
+ // a merge, and merging only happens while zooming out. While zooming in, the stale table still
196
+ // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
197
+ useEffect(() => {
198
+ if (clusterModel === latestModel) return
199
+ let lastZoom = editor.getZoomLevel()
200
+ return react('adopt pending cluster model on zoom out', () => {
201
+ const zoom = editor.getZoomLevel()
202
+ const prevZoom = lastZoom
203
+ lastZoom = zoom
204
+ if (zoom >= prevZoom) return
205
+ latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
206
+ setRenderedModel(latestModel)
207
+ })
208
+ }, [clusterModel, latestModel, editor])
209
+ // Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone
210
+ // deletions) render as plain pins until the next zoom-out folds them in. Judged against the
211
+ // displayed partition, so a detached-then-restored leaf reappears.
212
+ const partitionVersion = clusterModel.runtime.version
213
+ const orphanThreads = useMemo(() => {
214
+ if (clusterModel === latestModel) return []
215
+ const displayed = new Set<string>()
216
+ for (const node of clusterModel.runtime.getVisible().values()) {
217
+ for (const member of node.members) displayed.add(member)
218
+ }
219
+ const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
220
+ return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
221
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [clusterModel, latestModel, threads, partitionVersion])
224
+ const heldThreads = useMemo(
225
+ () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
226
+ [threads, heldThreadIds, openId]
227
+ )
228
+ // Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster
229
+ // changes rather than every camera frame. The memo below re-reads the version inline because
230
+ // render-time detaches bump it after the subscription's computed already evaluated.
231
+ useValue(
232
+ 'comment cluster version',
233
+ () => {
234
+ clusterModel.runtime.onCamera(editor.getZoomLevel())
235
+ return clusterModel.runtime.version
236
+ },
237
+ [clusterModel, editor]
238
+ )
239
+ const visibleNodes = useMemo(() => {
240
+ return Array.from(clusterModel.runtime.getVisible().values())
241
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
242
+ // eslint-disable-next-line react-hooks/exhaustive-deps
243
+ }, [clusterModel, partitionVersion])
244
+ const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
245
+
246
+ return {
247
+ model: clusterModel,
248
+ zoomBounds: clusterZoomBounds,
249
+ fadeNodes,
250
+ orphanThreads,
251
+ heldThreads,
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Leaves folded inside a badge whose live anchor no longer matches the position the rendered
257
+ * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
258
+ * stay deferred; a badge can't follow a member, so these must pop out of clustering.
259
+ */
260
+ function findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {
261
+ if (rendered.table === latest.table) return []
262
+ const visible = rendered.runtime.getVisible()
263
+ const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
264
+ const moved: string[] = []
265
+ for (const leaf of rendered.table.leaves) {
266
+ if (visible.has(leaf.id)) continue
267
+ const current = latestById.get(leaf.id)
268
+ if (!current) continue
269
+ if (
270
+ Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
271
+ Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
272
+ ) {
273
+ moved.push(leaf.id)
274
+ }
275
+ }
276
+ return moved
277
+ }
278
+
279
+ function getClusterZoomBounds(editor: Editor): ClusterZoomBounds {
280
+ const cameraOptions = editor.getCameraOptions()
281
+ const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
282
+ const zoomSteps = cameraOptions.zoomSteps
283
+ return {
284
+ minZoom: zoomSteps[0] * baseZoom,
285
+ maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split
291
+ * that unfolds it from its badge (or just centre on it when it isn't clustered).
292
+ */
293
+ export function revealThreadPin(
294
+ editor: Editor,
295
+ thread: TLCommentThread,
296
+ table: ClusterTable,
297
+ zoomBounds: ClusterZoomBounds,
298
+ options: CommentingOptions,
299
+ duration = 200
300
+ ) {
301
+ if (thread.pageId !== editor.getCurrentPageId()) {
302
+ editor.setCurrentPage(thread.pageId as any)
303
+ }
304
+
305
+ const point = anchorPagePoint(editor, thread.anchor)
306
+ if (!point) return
307
+
308
+ // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
309
+ // badge never exists) and just center on the pin.
310
+ if (options.enableClustering) {
311
+ const parentEvent = findDirectParentEvent(table, thread.id)
312
+ if (
313
+ parentEvent &&
314
+ Number.isFinite(parentEvent.zSplit) &&
315
+ parentEvent.zSplit <= zoomBounds.maxZoom
316
+ ) {
317
+ const zoom = clamp(
318
+ parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
319
+ zoomBounds.minZoom,
320
+ zoomBounds.maxZoom
321
+ )
322
+ centerOnPointAtZoom(editor, point, zoom, duration)
323
+ return
324
+ }
325
+ }
326
+
327
+ const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
328
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
329
+ }
330
+
331
+ /**
332
+ * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The
333
+ * event that created a visible cluster is the event that splits it, and has the smallest zSplit of
334
+ * everything applied inside it — so its zSplit is exactly the first split within those comments.
335
+ * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.
336
+ */
337
+ export function zoomToClusterSplit(
338
+ editor: Editor,
339
+ table: ClusterTable,
340
+ zoomBounds: ClusterZoomBounds,
341
+ node: ClusterNode
342
+ ) {
343
+ const event = table.events.find((e) => e.result.id === node.id)
344
+ if (!event || !Number.isFinite(event.zSplit)) return
345
+ const zoom = clamp(
346
+ event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
347
+ zoomBounds.minZoom,
348
+ zoomBounds.maxZoom
349
+ )
350
+ centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
351
+ }
352
+
353
+ function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
354
+ return table.events.find((event) => event.children.some((child) => child.id === threadId))
355
+ }
356
+
357
+ function centerOnPointAtZoom(
358
+ editor: Editor,
359
+ point: { x: number; y: number },
360
+ zoom: number,
361
+ duration = 200
362
+ ) {
363
+ const viewport = editor.getViewportScreenBounds()
364
+ // The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
365
+ const offset = commentCenterScreenOffset(editor)
366
+ editor.setCamera(
367
+ {
368
+ x: (viewport.w / 2 - offset) / zoom - point.x,
369
+ y: viewport.h / (2 * zoom) - point.y,
370
+ z: zoom,
371
+ },
372
+ { animation: { duration } }
373
+ )
374
+ }
375
+
376
+ function clamp(value: number, min: number, max: number): number {
377
+ return Math.max(min, Math.min(max, value))
378
+ }
@@ -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()