@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,133 @@
1
+ import { memo, useMemo, useRef } from 'react'
2
+ import {
3
+ Editor,
4
+ TLCommentThread,
5
+ useContainer,
6
+ usePassThroughWheelEvents,
7
+ useTranslation,
8
+ useValue,
9
+ } from 'tldraw'
10
+ import type { ClusterNode } from '../clustering/types'
11
+ import { CountBadge } from '../ui/count-badge'
12
+ import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
13
+ import { type CommentingContext } from './context'
14
+ import { sortThreadsForPreview, ThreadPreview, useMarkerPreview } from './thread-preview'
15
+ import { impreciseShapePinInset, isInInflatedViewport } from './thread-state'
16
+
17
+ /**
18
+ * The count badge standing in for several threads folded together at the current zoom. Hovering it
19
+ * previews its threads as a list; clicking zooms to the zoom level at which the cluster splits.
20
+ *
21
+ * Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
22
+ * badges skip re-rendering when the parent re-renders for reasons that don't concern them
23
+ * (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
24
+ * works — each component subscribes to its own viewport position via signals, not via props.
25
+ */
26
+ export const ClusterBadge = memo(function ClusterBadge({
27
+ editor,
28
+ node,
29
+ onExpand,
30
+ onSelectThread,
31
+ threadsById,
32
+ ...props
33
+ }: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
34
+ editor: Editor
35
+ node: ClusterNode
36
+ onExpand(node: ClusterNode): void
37
+ onSelectThread(thread: TLCommentThread): void
38
+ threadsById: ReadonlyMap<string, TLCommentThread>
39
+ }) {
40
+ const container = useContainer()
41
+ const msg = useTranslation()
42
+ const badgeRef = useRef<HTMLButtonElement>(null)
43
+ const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
44
+ // Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
45
+ // note on the layer.
46
+ usePassThroughWheelEvents(badgeRef)
47
+ // The mean of the members' render offsets, in screen px. Imprecise members' pins draw tucked
48
+ // into their shape (see impreciseShapePinInset), and the merge thresholds are priced at those
49
+ // visual positions — so the badge draws at the centroid of the pins as drawn, which (screen
50
+ // mapping being affine) is the anchor centroid plus this mean. Camera-independent: its own
51
+ // computed so the per-frame point below only adds two numbers, and a cluster of precise
52
+ // members tracks nothing and adds zero.
53
+ const meanInset = useValue(
54
+ 'cluster badge inset',
55
+ () => {
56
+ let x = 0
57
+ let y = 0
58
+ for (const id of node.members) {
59
+ const thread = threadsById.get(id)
60
+ if (!thread) continue
61
+ const inset = impreciseShapePinInset(editor, thread.anchor)
62
+ if (inset) {
63
+ x += inset.x
64
+ y += inset.y
65
+ }
66
+ }
67
+ return { x: x / node.count, y: y / node.count }
68
+ },
69
+ [editor, node, threadsById]
70
+ )
71
+ const point = useValue(
72
+ 'cluster badge point',
73
+ () => {
74
+ const pagePoint = editor.pageToViewport(node.centroid)
75
+ if (!isInInflatedViewport(editor, pagePoint)) return null
76
+ return { x: pagePoint.x + meanInset.x, y: pagePoint.y + meanInset.y }
77
+ },
78
+ [editor, node, meanInset]
79
+ )
80
+
81
+ // `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
82
+ // the order a reader would expect. Only computed while the preview is up.
83
+ const previewThreads = useMemo(() => {
84
+ if (!previewShown) return []
85
+ const threads: TLCommentThread[] = []
86
+ for (const id of node.members) {
87
+ const thread = threadsById.get(id)
88
+ if (thread) threads.push(thread)
89
+ }
90
+ return sortThreadsForPreview(threads)
91
+ }, [previewShown, node.members, threadsById])
92
+
93
+ if (!point) return null
94
+
95
+ return (
96
+ <>
97
+ <button
98
+ ref={badgeRef}
99
+ type="button"
100
+ className="tlui-cmt-button tlui-cmt-canvas-cluster"
101
+ style={{ left: point.x, top: point.y }}
102
+ aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
103
+ onPointerDown={(e) => {
104
+ if (isCanvasPanGesture(editor, e)) {
105
+ forwardPointerEventToCanvas(container, e)
106
+ return
107
+ }
108
+ e.stopPropagation()
109
+ }}
110
+ onClick={(e) => {
111
+ e.stopPropagation()
112
+ onExpand(node)
113
+ }}
114
+ {...previewHandlers}
115
+ onFocus={previewHandlers.onPointerEnter}
116
+ onBlur={previewHandlers.onPointerLeave}
117
+ >
118
+ <CountBadge count={node.count} />
119
+ </button>
120
+ {previewShown && previewThreads.length > 0 && (
121
+ <ThreadPreview
122
+ editor={editor}
123
+ threads={previewThreads}
124
+ variant="list"
125
+ point={point}
126
+ onSelectThread={onSelectThread}
127
+ {...previewHandlers}
128
+ {...props}
129
+ />
130
+ )}
131
+ </>
132
+ )
133
+ })
@@ -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
  }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createClusterRuntime } from '../clustering/runtime'
3
+ import type { ClusterNode, ClusterTable, LeafInput } from '../clustering/types'
4
+ import type { ClusterInput } from './cluster-input'
5
+ import { anyFoldedLeafMoved, type ClusterModel } from './cluster-model'
6
+
7
+ function node(ids: string[], x = 0, y = 0): ClusterNode {
8
+ const members = ids.slice().sort()
9
+ return {
10
+ id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
11
+ centroid: { x, y },
12
+ count: members.length,
13
+ members,
14
+ }
15
+ }
16
+
17
+ /** t1 and t2 fold into one badge below zoom 1; t3 sits far away and never merges. */
18
+ const TABLE: ClusterTable = {
19
+ leaves: [node(['t1'], 0, 0), node(['t2'], 10, 0), node(['t3'], 5000, 0)],
20
+ events: [
21
+ {
22
+ zMerge: 1,
23
+ zSplit: 2,
24
+ children: [node(['t1']), node(['t2'])],
25
+ result: node(['t1', 't2'], 5, 0),
26
+ },
27
+ ],
28
+ }
29
+
30
+ /** The rendered partition at `zoom`: below 1 the badge is up, above 2 every pin stands alone. */
31
+ function rendered(zoom: number): ClusterModel {
32
+ const runtime = createClusterRuntime(TABLE)
33
+ runtime.seed(zoom)
34
+ return { runtime, table: TABLE }
35
+ }
36
+
37
+ const leaf = (id: string, x: number, y: number): LeafInput => ({ id, point: { x, y } })
38
+ const input = (leaves: LeafInput[]): ClusterInput => ({ leaves, screenOffsets: undefined })
39
+
40
+ const AT_REST = [leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)]
41
+
42
+ describe('anyFoldedLeafMoved', () => {
43
+ it('breaks the freeze when a leaf folded into a badge moves', () => {
44
+ const model = rendered(0.5)
45
+ expect([...model.runtime.getVisible().keys()].sort()).toEqual(['cluster:2:t1', 't3'])
46
+ const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
47
+ expect(anyFoldedLeafMoved(input(AT_REST), next, model)).toBe(true)
48
+ })
49
+
50
+ it('holds the freeze when a visible leaf moves — its pin already rides its anchor', () => {
51
+ const next = input([leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5040, 0)])
52
+ expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
53
+ // and with the badge split, the same move of a former badge member is visible too
54
+ const split = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
55
+ expect(anyFoldedLeafMoved(input(AT_REST), split, rendered(3))).toBe(false)
56
+ })
57
+
58
+ it('holds the freeze when an orphan moves — it is not in the rendered partition to pop out of', () => {
59
+ // t4 was added since the last adopted rebuild: it is in the input but not in the rendered
60
+ // table, so it renders as a plain live pin and no pop-out can ever hold it. Counting it as
61
+ // folded would rebuild on every pointermove for the rest of the drag.
62
+ const atRest = [...AT_REST, leaf('t4', 20, 0)]
63
+ const next = input([...AT_REST, leaf('t4', 60, 0)])
64
+ expect(anyFoldedLeafMoved(input(atRest), next, rendered(0.5))).toBe(false)
65
+ })
66
+
67
+ it('holds the freeze when a detached leaf moves — it left the badge already', () => {
68
+ const model = rendered(0.5)
69
+ model.runtime.detachLeaves(['t1'])
70
+ expect(
71
+ anyFoldedLeafMoved(input(AT_REST), input([leaf('t1', 40, 0), ...AT_REST.slice(1)]), model)
72
+ ).toBe(false)
73
+ })
74
+
75
+ it('holds the freeze when nothing moved', () => {
76
+ expect(anyFoldedLeafMoved(input(AT_REST), input([...AT_REST]), rendered(0.5))).toBe(false)
77
+ })
78
+
79
+ it('holds the freeze with no rendered model yet', () => {
80
+ const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
81
+ expect(anyFoldedLeafMoved(input(AT_REST), next, null)).toBe(false)
82
+ })
83
+
84
+ it('holds the freeze on mismatched inputs rather than reading past the end', () => {
85
+ const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0)])
86
+ expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
87
+ })
88
+ })