@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.
- package/commenting.css +63 -71
- package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
- package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
- package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
- package/dist-cjs/canvas/canvas-events.js.map +7 -0
- package/dist-cjs/canvas/cluster-badge.js +121 -0
- package/dist-cjs/canvas/cluster-badge.js.map +7 -0
- package/dist-cjs/canvas/cluster-fade.js +85 -0
- package/dist-cjs/canvas/cluster-fade.js.map +7 -0
- package/dist-cjs/canvas/cluster-input.js +51 -1
- package/dist-cjs/canvas/cluster-input.js.map +2 -2
- package/dist-cjs/canvas/cluster-model.js +252 -0
- package/dist-cjs/canvas/cluster-model.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +57 -28
- package/dist-cjs/canvas/comment-mutations.js.map +2 -2
- package/dist-cjs/canvas/comment-reactions.js +4 -7
- package/dist-cjs/canvas/comment-reactions.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js +4 -3
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +0 -1
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +11 -13
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +77 -870
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +16 -11
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
- package/dist-cjs/canvas/context.js.map +1 -1
- package/dist-cjs/canvas/hooks.js +8 -2
- package/dist-cjs/canvas/hooks.js.map +2 -2
- package/dist-cjs/canvas/license.js.map +1 -1
- package/dist-cjs/canvas/mobile-placement.js +91 -0
- package/dist-cjs/canvas/mobile-placement.js.map +7 -0
- package/dist-cjs/canvas/options.js +39 -1
- package/dist-cjs/canvas/options.js.map +2 -2
- package/dist-cjs/canvas/pending-composer.js +134 -0
- package/dist-cjs/canvas/pending-composer.js.map +7 -0
- package/dist-cjs/canvas/pin-stacking.js +26 -1
- package/dist-cjs/canvas/pin-stacking.js.map +2 -2
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.js.map +7 -0
- package/dist-cjs/canvas/state.js.map +2 -2
- package/dist-cjs/canvas/thread-pin.js +304 -0
- package/dist-cjs/canvas/thread-pin.js.map +7 -0
- package/dist-cjs/canvas/thread-preview.js +41 -49
- package/dist-cjs/canvas/thread-preview.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +7 -7
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +15 -0
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +120 -67
- package/dist-cjs/canvas/thread-view.js.map +3 -3
- package/dist-cjs/clustering/computeClusterTable.js +2 -2
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/replay.js +89 -22
- package/dist-cjs/clustering/replay.js.map +3 -3
- package/dist-cjs/clustering/runtime.js +19 -12
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +323 -202
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +5 -3
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +2 -18
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +2 -15
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +11 -24
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/format-time.js +12 -0
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/icons.js +115 -0
- package/dist-cjs/ui/icons.js.map +7 -0
- package/dist-cjs/ui/reaction-picker.js +11 -13
- package/dist-cjs/ui/reaction-picker.js.map +2 -2
- package/dist-cjs/ui/reaction.js +3 -4
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/send-button.js +2 -9
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/visual-viewport.js +32 -0
- package/dist-cjs/ui/visual-viewport.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
- package/dist-esm/canvas/canvas-events.mjs +15 -0
- package/dist-esm/canvas/canvas-events.mjs.map +7 -0
- package/dist-esm/canvas/cluster-badge.mjs +106 -0
- package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
- package/dist-esm/canvas/cluster-fade.mjs +65 -0
- package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +52 -2
- package/dist-esm/canvas/cluster-input.mjs.map +2 -2
- package/dist-esm/canvas/cluster-model.mjs +236 -0
- package/dist-esm/canvas/cluster-model.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +57 -28
- package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
- package/dist-esm/canvas/comment-reactions.mjs +5 -12
- package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs +5 -4
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +0 -1
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +54 -878
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +18 -14
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
- package/dist-esm/canvas/hooks.mjs +9 -2
- package/dist-esm/canvas/hooks.mjs.map +2 -2
- package/dist-esm/canvas/license.mjs.map +1 -1
- package/dist-esm/canvas/mobile-placement.mjs +71 -0
- package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +39 -1
- package/dist-esm/canvas/options.mjs.map +2 -2
- package/dist-esm/canvas/pending-composer.mjs +126 -0
- package/dist-esm/canvas/pending-composer.mjs.map +7 -0
- package/dist-esm/canvas/pin-stacking.mjs +26 -1
- package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs.map +2 -2
- package/dist-esm/canvas/thread-pin.mjs +310 -0
- package/dist-esm/canvas/thread-pin.mjs.map +7 -0
- package/dist-esm/canvas/thread-preview.mjs +42 -49
- package/dist-esm/canvas/thread-preview.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +8 -9
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +15 -0
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +131 -72
- package/dist-esm/canvas/thread-view.mjs.map +3 -3
- package/dist-esm/clustering/computeClusterTable.mjs +2 -2
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/replay.mjs +89 -22
- package/dist-esm/clustering/replay.mjs.map +3 -3
- package/dist-esm/clustering/runtime.mjs +19 -12
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +323 -202
- package/dist-esm/index.mjs +16 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +6 -4
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +2 -18
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +2 -15
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +10 -23
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +12 -0
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/icons.mjs +95 -0
- package/dist-esm/ui/icons.mjs.map +7 -0
- package/dist-esm/ui/reaction-picker.mjs +12 -13
- package/dist-esm/ui/reaction-picker.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +3 -4
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/send-button.mjs +2 -9
- package/dist-esm/ui/send-button.mjs.map +2 -2
- package/dist-esm/ui/visual-viewport.mjs +12 -0
- package/dist-esm/ui/visual-viewport.mjs.map +7 -0
- package/package.json +5 -5
- package/src/canvas/anchor-lifecycle.test.ts +2 -2
- package/src/canvas/anchor-lifecycle.ts +25 -39
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +5 -11
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +148 -14
- package/src/canvas/cluster-input.ts +92 -5
- package/src/canvas/cluster-model.ts +378 -0
- package/src/canvas/comment-mutations.test.ts +46 -4
- package/src/canvas/comment-mutations.ts +115 -104
- package/src/canvas/comment-reactions.test.ts +22 -0
- package/src/canvas/comment-reactions.tsx +33 -32
- package/src/canvas/comment-render.ts +8 -8
- package/src/canvas/comment-store.ts +12 -17
- package/src/canvas/comment-tool.tsx +8 -12
- package/src/canvas/comments-filter-menu.tsx +9 -16
- package/src/canvas/comments-overflow-menu.tsx +8 -3
- package/src/canvas/comments-overlay.tsx +154 -1344
- package/src/canvas/comments-sidebar.tsx +40 -19
- package/src/canvas/comments-visibility-toggle.tsx +6 -30
- package/src/canvas/context.ts +4 -3
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +17 -3
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +124 -2
- package/src/canvas/options.ts +192 -58
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +111 -1
- package/src/canvas/pin-stacking.ts +46 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/state.ts +20 -28
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.tsx +77 -87
- package/src/canvas/thread-stack.tsx +19 -29
- package/src/canvas/thread-state.test.ts +51 -0
- package/src/canvas/thread-state.ts +56 -23
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +231 -133
- package/src/clustering/computeClusterTable.ts +12 -3
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +50 -6
- package/src/clustering/runtime.ts +42 -39
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +10 -0
- package/src/index.ts +15 -2
- package/src/ui/byline.tsx +16 -6
- package/src/ui/comment-composer.tsx +25 -69
- package/src/ui/comment-pin.tsx +3 -16
- package/src/ui/comments-list.tsx +40 -29
- package/src/ui/comments.css +58 -60
- package/src/ui/format-time.test.ts +69 -0
- package/src/ui/format-time.ts +20 -0
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +9 -16
- package/src/ui/reaction.tsx +10 -6
- package/src/ui/send-button.tsx +3 -8
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -0
- package/dist-cjs/ui/tooltip-button.js.map +0 -7
- package/dist-esm/ui/tooltip-button.mjs +0 -12
- package/dist-esm/ui/tooltip-button.mjs.map +0 -7
- 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 {
|
|
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([
|
|
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
|
-
):
|
|
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
|
}
|