@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,102 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { ClusterNode } from '../clustering/types'
3
+
4
+ const CLUSTER_FADE_MS = 150
5
+
6
+ export type ClusterFadePhase = 'entering' | 'present' | 'exiting'
7
+
8
+ export interface ClusterFadeNode {
9
+ node: ClusterNode
10
+ phase: ClusterFadePhase
11
+ }
12
+
13
+ /**
14
+ * Cross-fades the visible cluster nodes as the partition changes: a node that appears starts
15
+ * `entering` and flips to `present` on the next frame, and one that leaves is kept as `exiting`
16
+ * until the CSS transition has run. `resetKey` is compared by identity — a new cluster model
17
+ * replaces the whole world, so its nodes snap to `present` instead of animating.
18
+ */
19
+ export function useFadeVisibleNodes(
20
+ nodes: readonly ClusterNode[],
21
+ resetKey: object
22
+ ): ClusterFadeNode[] {
23
+ const resetKeyRef = useRef(resetKey)
24
+ const didReset = resetKeyRef.current !== resetKey
25
+ if (didReset) {
26
+ resetKeyRef.current = resetKey
27
+ }
28
+
29
+ const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
30
+ const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
31
+
32
+ useEffect(() => {
33
+ setFadeNodes(toPresentFadeNodes(nodes))
34
+ // Resets only on a new model (resetKey); node-list changes within the same model are
35
+ // handled by the reconcile effect below, which fades entries in/out instead of snapping.
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [resetKey])
38
+
39
+ useEffect(() => {
40
+ if (didReset) return
41
+ setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
42
+ }, [didReset, nodes])
43
+
44
+ const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
45
+ useEffect(() => {
46
+ if (!hasEntering) return
47
+ const frame = requestAnimationFrame(() => {
48
+ setFadeNodes((previous) =>
49
+ previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
50
+ )
51
+ })
52
+ return () => cancelAnimationFrame(frame)
53
+ }, [hasEntering, renderedNodes])
54
+
55
+ const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
56
+ useEffect(() => {
57
+ if (!hasExiting) return
58
+ const timeout = window.setTimeout(() => {
59
+ setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
60
+ }, CLUSTER_FADE_MS)
61
+ return () => window.clearTimeout(timeout)
62
+ }, [hasExiting, renderedNodes])
63
+
64
+ return renderedNodes
65
+ }
66
+
67
+ function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
68
+ return nodes.map((node) => ({ node, phase: 'present' }))
69
+ }
70
+
71
+ function reconcileFadeNodes(
72
+ previous: readonly ClusterFadeNode[],
73
+ nextNodes: readonly ClusterNode[]
74
+ ): ClusterFadeNode[] {
75
+ const previousById = new Map(previous.map((item) => [item.node.id, item]))
76
+ const nextIds = new Set(nextNodes.map((node) => node.id))
77
+ const next: ClusterFadeNode[] = []
78
+
79
+ for (const node of nextNodes) {
80
+ const previousItem = previousById.get(node.id)
81
+ next.push({
82
+ node,
83
+ phase:
84
+ previousItem && previousItem.phase !== 'exiting'
85
+ ? previousItem.phase
86
+ : previousItem
87
+ ? 'present'
88
+ : 'entering',
89
+ })
90
+ }
91
+
92
+ for (const item of previous) {
93
+ if (nextIds.has(item.node.id)) continue
94
+ next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
95
+ }
96
+
97
+ return next
98
+ }
99
+
100
+ export function clusterFadeClassName(phase: ClusterFadePhase): string {
101
+ return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
102
+ }
@@ -3,7 +3,13 @@ import { describe, expect, it } from 'vitest'
3
3
  // This import is red until step 6's filter module is implemented — that is
4
4
  // intentional. Implement `cluster-input.ts` per CLUSTERING-STEPS.md step 6
5
5
  // until this suite passes, without modifying this file.
6
- import { collectClusterLeaves } from './cluster-input'
6
+ import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
7
+ import {
8
+ type ClusterInput,
9
+ clusterInputEqual,
10
+ clusterInputIdsEqual,
11
+ collectClusterLeaves,
12
+ } from './cluster-input'
7
13
  import { defaultCommentingOptions, type CommentingOptions } from './options'
8
14
 
9
15
  const CURRENT_PAGE = 'page:one'
@@ -67,7 +73,7 @@ describe('collectClusterLeaves anchor resolution', () => {
67
73
  stubEditor(),
68
74
  [thread('t1', { type: 'point', x: 12, y: 34 })],
69
75
  null
70
- )
76
+ ).leaves
71
77
  expect(leaves).toEqual([{ id: 't1', point: { x: 12, y: 34 } }])
72
78
  })
73
79
 
@@ -76,7 +82,7 @@ describe('collectClusterLeaves anchor resolution', () => {
76
82
  stubEditor(),
77
83
  [thread('t1', { type: 'region', x: 10, y: 20, w: 30, h: 40 })],
78
84
  null
79
- )
85
+ ).leaves
80
86
  expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
81
87
  })
82
88
 
@@ -88,7 +94,7 @@ describe('collectClusterLeaves anchor resolution', () => {
88
94
  editor,
89
95
  [thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false })],
90
96
  null
91
- )
97
+ ).leaves
92
98
  expect(leaves).toEqual([{ id: 't1', point: { x: 100, y: 5 } }])
93
99
  })
94
100
 
@@ -114,7 +120,7 @@ describe('collectClusterLeaves anchor resolution', () => {
114
120
  isPrecise: true,
115
121
  }),
116
122
  ]
117
- const leaves = collectClusterLeaves(editor, threads, null)
123
+ const leaves = collectClusterLeaves(editor, threads, null).leaves
118
124
  expect(leaves).toEqual([
119
125
  { id: 'imprecise', point: { x: 50, y: 50 } },
120
126
  { id: 'precise', point: { x: 50, y: 25 } },
@@ -135,7 +141,7 @@ describe('collectClusterLeaves anchor resolution', () => {
135
141
  thread('kept', { type: 'point', x: 1, y: 2 }),
136
142
  ],
137
143
  null
138
- )
144
+ ).leaves
139
145
  expect(leafIds(leaves)).toEqual(['kept'])
140
146
  })
141
147
 
@@ -144,7 +150,7 @@ describe('collectClusterLeaves anchor resolution', () => {
144
150
  stubEditor(),
145
151
  [thread('pageThread', { type: 'page' }), thread('kept', { type: 'point', x: 1, y: 2 })],
146
152
  null
147
- )
153
+ ).leaves
148
154
  expect(leafIds(leaves)).toEqual(['kept'])
149
155
  })
150
156
  })
@@ -158,7 +164,7 @@ describe('collectClusterLeaves filtering', () => {
158
164
  thread('elsewhere', { type: 'point', x: 0, y: 0 }, { pageId: OTHER_PAGE }),
159
165
  ],
160
166
  null
161
- )
167
+ ).leaves
162
168
  expect(leafIds(leaves)).toEqual(['here'])
163
169
  })
164
170
 
@@ -167,10 +173,13 @@ describe('collectClusterLeaves filtering', () => {
167
173
  thread('open', { type: 'point', x: 0, y: 0 }),
168
174
  thread('closed', { type: 'point', x: 10, y: 0 }),
169
175
  ]
170
- expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open'))).toEqual(['closed'])
171
- expect(leafIds(collectClusterLeaves(stubEditor(), threads, null))).toEqual(['closed', 'open'])
176
+ expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open').leaves)).toEqual(['closed'])
177
+ expect(leafIds(collectClusterLeaves(stubEditor(), threads, null).leaves)).toEqual([
178
+ 'closed',
179
+ 'open',
180
+ ])
172
181
  // an id that matches no thread excludes nothing
173
- expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown'))).toEqual([
182
+ expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown').leaves)).toEqual([
174
183
  'closed',
175
184
  'open',
176
185
  ])
@@ -184,12 +193,12 @@ describe('collectClusterLeaves filtering', () => {
184
193
  thread('unresolved', { type: 'point', x: 10, y: 0 }),
185
194
  ],
186
195
  null
187
- )
196
+ ).leaves
188
197
  expect(leafIds(leaves)).toEqual(['resolved', 'unresolved'])
189
198
  })
190
199
 
191
200
  it('returns [] for no threads', () => {
192
- expect(collectClusterLeaves(stubEditor(), [], null)).toEqual([])
201
+ expect(collectClusterLeaves(stubEditor(), [], null).leaves).toEqual([])
193
202
  })
194
203
 
195
204
  it('applies every rule at once on a mixed set', () => {
@@ -218,7 +227,7 @@ describe('collectClusterLeaves filtering', () => {
218
227
  thread('openOne', { type: 'point', x: 3, y: 3 }),
219
228
  thread('resolvedOne', { type: 'point', x: 4, y: 4 }, { resolved: true }),
220
229
  ]
221
- const leaves = collectClusterLeaves(editor, threads, 'openOne')
230
+ const leaves = collectClusterLeaves(editor, threads, 'openOne').leaves
222
231
  expect(leafIds(leaves)).toEqual(['onShape', 'point', 'region', 'resolvedOne'])
223
232
  })
224
233
 
@@ -229,3 +238,128 @@ describe('collectClusterLeaves filtering', () => {
229
238
  expect(JSON.parse(JSON.stringify(threads))).toEqual(snapshot)
230
239
  })
231
240
  })
241
+
242
+ describe('collectClusterLeaves screen offsets', () => {
243
+ it('is undefined when every pin renders on its anchor', () => {
244
+ const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
245
+ const threads = [
246
+ thread('point', { type: 'point', x: 1, y: 2 }),
247
+ thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
248
+ thread('precise', {
249
+ type: 'shape',
250
+ shapeId: 'shape:a' as any,
251
+ x: 0.5,
252
+ y: 0.5,
253
+ isPrecise: true,
254
+ }),
255
+ ]
256
+ expect(collectClusterLeaves(editor, threads, null).screenOffsets).toBeUndefined()
257
+ })
258
+
259
+ it('maps imprecise leaves to their pin inset, and only those', () => {
260
+ const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
261
+ const threads = [
262
+ thread('imprecise', {
263
+ type: 'shape',
264
+ shapeId: 'shape:a' as any,
265
+ x: 0,
266
+ y: 0,
267
+ isPrecise: false,
268
+ }),
269
+ thread('point', { type: 'point', x: 1, y: 2 }),
270
+ ]
271
+ const { screenOffsets } = collectClusterLeaves(editor, threads, null)
272
+ // Default top-right anchor spot: the pin tucks left and down into the shape.
273
+ expect(screenOffsets).toEqual(new Map([['imprecise', { x: -20, y: 20 }]]))
274
+ })
275
+
276
+ it('excludes threads that produced no leaf', () => {
277
+ const editor = stubEditor({}) // shape does not resolve
278
+ const threads = [
279
+ thread('gone', { type: 'shape', shapeId: 'shape:x' as any, x: 0, y: 0, isPrecise: false }),
280
+ ]
281
+ const input = collectClusterLeaves(editor, threads, null)
282
+ expect(input.leaves).toEqual([])
283
+ expect(input.screenOffsets).toBeUndefined()
284
+ })
285
+ })
286
+
287
+ // These two gate the O(N²) rebuild in `useClusterModel`. Both compare the leaves positionally, so
288
+ // the order cases below are what pin the gate to `collectClusterLeaves` walking threads in order.
289
+ const leaf = (id: string, x: number, y: number) => ({ id, point: { x, y } })
290
+ const input = (leaves: LeafInput[], screenOffsets?: LeafScreenOffsets): ClusterInput => ({
291
+ leaves,
292
+ screenOffsets,
293
+ })
294
+
295
+ describe('clusterInputEqual', () => {
296
+ it('accepts the same leaves at the same positions, whatever the identity', () => {
297
+ const a = input([leaf('t1', 0, 0), leaf('t2', 10, 20)])
298
+ expect(clusterInputEqual(a, a)).toBe(true)
299
+ expect(clusterInputEqual(a, input([leaf('t1', 0, 0), leaf('t2', 10, 20)]))).toBe(true)
300
+ expect(clusterInputEqual(input([]), input([]))).toBe(true)
301
+ })
302
+
303
+ it('rejects a position change — a moved pin must rebuild the table', () => {
304
+ expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0, 0.5)]))).toBe(false)
305
+ expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0.5, 0)]))).toBe(false)
306
+ })
307
+
308
+ it('rejects an added, removed, renamed, or reordered leaf', () => {
309
+ const one = input([leaf('t1', 0, 0)])
310
+ const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
311
+ expect(clusterInputEqual(one, two)).toBe(false)
312
+ expect(clusterInputEqual(two, one)).toBe(false)
313
+ expect(clusterInputEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
314
+ expect(clusterInputEqual(two, input([leaf('t2', 1, 1), leaf('t1', 0, 0)]))).toBe(false)
315
+ })
316
+
317
+ it('rejects an offset change, which prices the same leaves differently', () => {
318
+ const leaves = [leaf('t1', 0, 0)]
319
+ const offsets = (x: number) => new Map([['t1', { x, y: 20 }]])
320
+ expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(-20)))).toBe(true)
321
+ expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(20)))).toBe(false)
322
+ // gaining or losing offsets entirely (a pin's precision changed)
323
+ expect(clusterInputEqual(input(leaves), input(leaves, offsets(-20)))).toBe(false)
324
+ expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves))).toBe(false)
325
+ })
326
+ })
327
+
328
+ describe('clusterInputIdsEqual', () => {
329
+ it('ignores positions and offset vectors — this is what defers the rebuild mid-drag', () => {
330
+ expect(clusterInputIdsEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 999, 999)]))).toBe(
331
+ true
332
+ )
333
+ // rotating mid-drag turns the inset; deferred like the anchor move it comes with
334
+ expect(
335
+ clusterInputIdsEqual(
336
+ input([leaf('t1', 0, 0)], new Map([['t1', { x: -20, y: 20 }]])),
337
+ input([leaf('t1', 5, 5)], new Map([['t1', { x: 20, y: -20 }]]))
338
+ )
339
+ ).toBe(true)
340
+ })
341
+
342
+ it('still rejects an added or removed thread, so those rebuild even mid-drag', () => {
343
+ const one = input([leaf('t1', 0, 0)])
344
+ const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
345
+ expect(clusterInputIdsEqual(one, two)).toBe(false)
346
+ expect(clusterInputIdsEqual(two, one)).toBe(false)
347
+ expect(clusterInputIdsEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
348
+ })
349
+
350
+ it('still rejects a leaf that gained or lost its offset — a precision change, not a gesture', () => {
351
+ const leaves = [leaf('t1', 0, 0)]
352
+ expect(
353
+ clusterInputIdsEqual(input(leaves), input(leaves, new Map([['t1', { x: -20, y: 20 }]])))
354
+ ).toBe(false)
355
+ })
356
+
357
+ it('rejects a reorder', () => {
358
+ expect(
359
+ clusterInputIdsEqual(
360
+ input([leaf('t1', 0, 0), leaf('t2', 1, 1)]),
361
+ input([leaf('t2', 1, 1), leaf('t1', 0, 0)])
362
+ )
363
+ ).toBe(false)
364
+ })
365
+ })
@@ -1,15 +1,29 @@
1
- import type { Editor, TLCommentThread } from 'tldraw'
2
- import type { LeafInput } from '../clustering/types'
3
- import { anchorPagePoint } from './thread-state'
1
+ import type { Editor, TLCommentThread, VecLike } from 'tldraw'
2
+ import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
3
+ import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
4
+
5
+ /** The clustering input for the current page's pinned threads. @internal */
6
+ export interface ClusterInput {
7
+ /** One leaf per pinned thread, at its anchor page point. */
8
+ leaves: LeafInput[]
9
+ /**
10
+ * The render offsets of the imprecise pins among the leaves — those draw tucked into their
11
+ * shape by a constant screen-px inset rather than on their anchor, and the clustering prices
12
+ * their merges at the visual positions. Undefined when every pin renders on its anchor,
13
+ * which guarantees a byte-identical offset-unaware clustering run.
14
+ */
15
+ screenOffsets: LeafScreenOffsets | undefined
16
+ }
4
17
 
5
18
  /** The cluster leaves for the threads pinned on the current page. @internal */
6
19
  export function collectClusterLeaves(
7
20
  editor: Editor,
8
21
  threads: readonly TLCommentThread[],
9
22
  openThreadId: string | null
10
- ): LeafInput[] {
23
+ ): ClusterInput {
11
24
  const pageId = editor.getCurrentPageId()
12
25
  const leaves: LeafInput[] = []
26
+ let screenOffsets: Map<string, VecLike> | undefined
13
27
 
14
28
  for (const thread of threads) {
15
29
  if (thread.id === openThreadId) continue
@@ -22,7 +36,80 @@ export function collectClusterLeaves(
22
36
  id: thread.id,
23
37
  point: { x: point.x, y: point.y },
24
38
  })
39
+
40
+ const inset = impreciseShapePinInset(editor, thread.anchor)
41
+ if (inset) (screenOffsets ??= new Map()).set(thread.id, inset)
25
42
  }
26
43
 
27
- return leaves
44
+ return { leaves, screenOffsets }
45
+ }
46
+
47
+ /**
48
+ * Value equality over everything `computeClusterTable` reads. Keeps the input's identity stable
49
+ * across recomputes that changed nothing, so the O(N²) rebuild only runs when it has to.
50
+ * @internal
51
+ */
52
+ export function clusterInputEqual(a: ClusterInput, b: ClusterInput): boolean {
53
+ if (a === b) return true
54
+ return (
55
+ clusterLeavesEqual(a.leaves, b.leaves) && screenOffsetsEqual(a.screenOffsets, b.screenOffsets)
56
+ )
57
+ }
58
+
59
+ /**
60
+ * The same, ignoring everything positional — anchor points and offset vectors alike. The mid-drag
61
+ * gate: a move defers, but a thread added, removed, or changed precision still rebuilds.
62
+ * @internal
63
+ */
64
+ export function clusterInputIdsEqual(a: ClusterInput, b: ClusterInput): boolean {
65
+ if (a === b) return true
66
+ return (
67
+ clusterLeafIdsEqual(a.leaves, b.leaves) &&
68
+ screenOffsetIdsEqual(a.screenOffsets, b.screenOffsets)
69
+ )
70
+ }
71
+
72
+ function screenOffsetsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
73
+ if (a === b) return true
74
+ if (!a || !b) return false
75
+ if (a.size !== b.size) return false
76
+ for (const [id, offset] of b) {
77
+ const prev = a.get(id)
78
+ if (!prev || prev.x !== offset.x || prev.y !== offset.y) return false
79
+ }
80
+ return true
81
+ }
82
+
83
+ /** Which leaves carry an offset, ignoring the vectors: a leaf gains or loses one by changing
84
+ * precision, which is a record change rather than a gesture. */
85
+ function screenOffsetIdsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
86
+ if (a === b) return true
87
+ if (!a || !b) return false
88
+ if (a.size !== b.size) return false
89
+ for (const id of b.keys()) {
90
+ if (!a.has(id)) return false
91
+ }
92
+ return true
93
+ }
94
+
95
+ /** Whether two leaf lists have the same threads at the same positions, in the same order. */
96
+ function clusterLeavesEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
97
+ if (a === b) return true
98
+ if (a.length !== b.length) return false
99
+ for (let i = 0; i < a.length; i++) {
100
+ const la = a[i]
101
+ const lb = b[i]
102
+ if (la.id !== lb.id || la.point.x !== lb.point.x || la.point.y !== lb.point.y) return false
103
+ }
104
+ return true
105
+ }
106
+
107
+ /** Whether two leaf lists contain the same thread ids in the same order, ignoring positions. */
108
+ function clusterLeafIdsEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
109
+ if (a === b) return true
110
+ if (a.length !== b.length) return false
111
+ for (let i = 0; i < a.length; i++) {
112
+ if (a[i].id !== b[i].id) return false
113
+ }
114
+ return true
28
115
  }