@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d
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/README.md +0 -2
- package/commenting.css +1152 -350
- package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
- package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
- package/dist-cjs/{ui/avatar.js → canvas/canvas-events.js} +14 -22
- 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 +53 -3
- 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-body.js +1 -1
- package/dist-cjs/canvas/comment-body.js.map +2 -2
- package/dist-cjs/canvas/comment-drafts.js +54 -0
- package/dist-cjs/canvas/comment-drafts.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +152 -0
- package/dist-cjs/canvas/comment-mutations.js.map +7 -0
- package/dist-cjs/canvas/comment-reactions.js +160 -0
- package/dist-cjs/canvas/comment-reactions.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +9 -6
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js +19 -9
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +63 -14
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +17 -37
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +151 -989
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +49 -41
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
- package/dist-cjs/canvas/context.js +17 -0
- package/dist-cjs/canvas/context.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +28 -3
- 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 +57 -2
- 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 +81 -0
- package/dist-cjs/canvas/pin-stacking.js.map +7 -0
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +2 -2
- package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
- package/dist-cjs/canvas/state.js +28 -14
- 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 +177 -0
- package/dist-cjs/canvas/thread-preview.js.map +7 -0
- package/dist-cjs/canvas/thread-stack.js +199 -0
- package/dist-cjs/canvas/thread-stack.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +82 -21
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +450 -0
- package/dist-cjs/canvas/thread-view.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +6 -6
- 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 +126 -7
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +904 -353
- package/dist-cjs/index.js +52 -28
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +10 -6
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-card.js +15 -8
- package/dist-cjs/ui/comment-card.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +124 -39
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +11 -2
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comment-thread.js +9 -7
- package/dist-cjs/ui/comment-thread.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +39 -40
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/count-badge.js +3 -2
- package/dist-cjs/ui/count-badge.js.map +2 -2
- package/dist-cjs/ui/emoji-picker.js +54 -0
- package/dist-cjs/ui/emoji-picker.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +3 -3
- package/dist-cjs/ui/empty-state.js.map +2 -2
- package/dist-cjs/ui/format-time.js +16 -1
- 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 +71 -0
- package/dist-cjs/ui/reaction-picker.js.map +7 -0
- package/dist-cjs/ui/reaction.js +58 -6
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/reactions.js +20 -7
- package/dist-cjs/ui/reactions.js.map +2 -2
- package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
- package/dist-cjs/ui/reply-count.js.map +7 -0
- package/dist-cjs/ui/send-button.js +13 -1
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
- package/dist-cjs/ui/visual-viewport.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
- 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 +54 -4
- 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-body.mjs +1 -1
- package/dist-esm/canvas/comment-body.mjs.map +2 -2
- package/dist-esm/canvas/comment-drafts.mjs +34 -0
- package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +132 -0
- package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
- package/dist-esm/canvas/comment-reactions.mjs +145 -0
- package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +10 -7
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs +19 -9
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +66 -17
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +157 -1010
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +53 -46
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
- package/dist-esm/canvas/context.mjs +1 -0
- package/dist-esm/canvas/context.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +35 -5
- 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 +59 -3
- 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 +61 -0
- package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +2 -2
- package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
- package/dist-esm/canvas/state.mjs +29 -19
- 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 +163 -0
- package/dist-esm/canvas/thread-preview.mjs.map +7 -0
- package/dist-esm/canvas/thread-stack.mjs +189 -0
- package/dist-esm/canvas/thread-stack.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +86 -22
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +461 -0
- package/dist-esm/canvas/thread-view.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +6 -6
- 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 +126 -7
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +904 -353
- package/dist-esm/index.mjs +102 -43
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +11 -7
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-card.mjs +15 -8
- package/dist-esm/ui/comment-card.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +135 -39
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +11 -2
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comment-thread.mjs +9 -7
- package/dist-esm/ui/comment-thread.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +51 -50
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/count-badge.mjs +3 -2
- package/dist-esm/ui/count-badge.mjs.map +2 -2
- package/dist-esm/ui/emoji-picker.mjs +34 -0
- package/dist-esm/ui/emoji-picker.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +3 -3
- package/dist-esm/ui/empty-state.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +16 -1
- 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 +59 -0
- package/dist-esm/ui/reaction-picker.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +59 -7
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/reactions.mjs +21 -8
- package/dist-esm/ui/reactions.mjs.map +2 -2
- package/dist-esm/ui/reply-count.mjs +9 -0
- package/dist-esm/ui/reply-count.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +13 -1
- 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 +6 -6
- package/src/canvas/anchor-lifecycle.test.ts +290 -0
- package/src/canvas/anchor-lifecycle.ts +191 -0
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +464 -55
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +178 -36
- package/src/canvas/cluster-input.ts +95 -9
- package/src/canvas/cluster-model.ts +378 -0
- package/src/canvas/comment-body.tsx +1 -1
- package/src/canvas/comment-drafts.test.ts +55 -0
- package/src/canvas/comment-drafts.ts +46 -0
- package/src/canvas/comment-mutations.test.ts +452 -0
- package/src/canvas/comment-mutations.ts +299 -0
- package/src/canvas/comment-reactions.test.ts +154 -0
- package/src/canvas/comment-reactions.tsx +246 -0
- package/src/canvas/comment-render.test.ts +1 -1
- package/src/canvas/comment-render.ts +17 -13
- package/src/canvas/comment-store.ts +68 -33
- package/src/canvas/comment-tool.test.ts +162 -0
- package/src/canvas/comment-tool.tsx +103 -25
- package/src/canvas/comments-filter-menu.tsx +20 -36
- package/src/canvas/comments-overflow-menu.tsx +17 -19
- package/src/canvas/comments-overlay.tsx +252 -1369
- package/src/canvas/comments-sidebar.test.ts +45 -0
- package/src/canvas/comments-sidebar.tsx +112 -73
- package/src/canvas/comments-visibility-toggle.tsx +25 -0
- package/src/canvas/context.ts +48 -0
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +66 -11
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +176 -4
- package/src/canvas/options.ts +276 -18
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +224 -0
- package/src/canvas/pin-stacking.ts +103 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/sidebar-filters.ts +4 -3
- package/src/canvas/state.ts +88 -39
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.test.ts +91 -0
- package/src/canvas/thread-preview.tsx +294 -0
- package/src/canvas/thread-stack.tsx +242 -0
- package/src/canvas/thread-state.test.ts +260 -0
- package/src/canvas/thread-state.ts +171 -37
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +614 -0
- package/src/clustering/computeClusterTable.test.ts +24 -23
- package/src/clustering/computeClusterTable.ts +17 -8
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +267 -13
- package/src/clustering/runtime.ts +176 -19
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +19 -9
- package/src/index.ts +91 -42
- package/src/ui/byline.tsx +21 -9
- package/src/ui/comment-card.tsx +20 -10
- package/src/ui/comment-composer.tsx +178 -56
- package/src/ui/comment-pin.tsx +13 -2
- package/src/ui/comment-thread.tsx +13 -6
- package/src/ui/comments-list.test.ts +31 -0
- package/src/ui/comments-list.tsx +82 -47
- package/src/ui/comments.css +577 -294
- package/src/ui/count-badge.tsx +9 -2
- package/src/ui/emoji-picker.test.ts +25 -0
- package/src/ui/emoji-picker.tsx +76 -0
- package/src/ui/empty-state.tsx +3 -3
- package/src/ui/format-time.test.ts +69 -0
- package/src/ui/format-time.ts +27 -2
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +102 -0
- package/src/ui/reaction.tsx +125 -6
- package/src/ui/reactions.tsx +69 -10
- package/src/ui/reply-count.ts +16 -0
- package/src/ui/send-button.tsx +13 -3
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -0
- package/dist-cjs/canvas/region-options.js +0 -49
- package/dist-cjs/canvas/region-options.js.map +0 -7
- package/dist-cjs/ui/avatar.js.map +0 -7
- package/dist-cjs/ui/comment-mention.js +0 -42
- package/dist-cjs/ui/comment-mention.js.map +0 -7
- package/dist-cjs/ui/comment-text.js.map +0 -7
- package/dist-cjs/ui/mention-list.js +0 -70
- package/dist-cjs/ui/mention-list.js.map +0 -7
- package/dist-cjs/ui/mention-suggestion.js +0 -206
- package/dist-cjs/ui/mention-suggestion.js.map +0 -7
- package/dist-cjs/ui/mention.js.map +0 -7
- package/dist-cjs/ui/render-markdown.js +0 -63
- package/dist-cjs/ui/render-markdown.js.map +0 -7
- package/dist-esm/canvas/region-options.mjs +0 -29
- package/dist-esm/canvas/region-options.mjs.map +0 -7
- package/dist-esm/ui/avatar.mjs +0 -23
- package/dist-esm/ui/avatar.mjs.map +0 -7
- package/dist-esm/ui/comment-mention.mjs +0 -22
- package/dist-esm/ui/comment-mention.mjs.map +0 -7
- package/dist-esm/ui/comment-text.mjs +0 -9
- package/dist-esm/ui/comment-text.mjs.map +0 -7
- package/dist-esm/ui/mention-list.mjs +0 -50
- package/dist-esm/ui/mention-list.mjs.map +0 -7
- package/dist-esm/ui/mention-suggestion.mjs +0 -186
- package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
- package/dist-esm/ui/mention.mjs +0 -11
- package/dist-esm/ui/mention.mjs.map +0 -7
- package/dist-esm/ui/render-markdown.mjs +0 -45
- package/dist-esm/ui/render-markdown.mjs.map +0 -7
- package/src/canvas/region-options.ts +0 -57
- package/src/ui/avatar.tsx +0 -31
- package/src/ui/comment-mention.ts +0 -47
- package/src/ui/comment-text.tsx +0 -12
- package/src/ui/mention-list.tsx +0 -106
- package/src/ui/mention-suggestion.test.ts +0 -18
- package/src/ui/mention-suggestion.tsx +0 -285
- package/src/ui/mention.tsx +0 -9
- package/src/ui/render-markdown.tsx +0 -72
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
|
|
2
2
|
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'
|
|
13
|
+
import { defaultCommentingOptions, type CommentingOptions } from './options'
|
|
7
14
|
|
|
8
15
|
const CURRENT_PAGE = 'page:one'
|
|
9
16
|
const OTHER_PAGE = 'page:two'
|
|
@@ -27,19 +34,31 @@ function thread(
|
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
|
-
* Stub editor: the filter's
|
|
31
|
-
* and
|
|
32
|
-
*
|
|
37
|
+
* Stub editor: the filter's editor dependencies are the current page id, shape
|
|
38
|
+
* geometry and page transform, and the commenting options (via anchorPagePoint,
|
|
39
|
+
* read off the registered comment tool). `shapes` maps shape id → page bounds for
|
|
40
|
+
* unrotated shapes that exist, modeled as local geometry sized by the box plus a
|
|
41
|
+
* translate-only page transform; anything else resolves to undefined (deleted
|
|
42
|
+
* shape). `options` stands in for `CommentTool.configure({ ... })`; omit it for a
|
|
43
|
+
* editor with no comment tool registered, which falls back to the defaults.
|
|
33
44
|
*/
|
|
34
45
|
function stubEditor(
|
|
35
|
-
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
|
|
46
|
+
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {},
|
|
47
|
+
options?: Partial<CommentingOptions>
|
|
36
48
|
): Editor {
|
|
49
|
+
const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
|
|
37
50
|
return {
|
|
38
51
|
getCurrentPageId: () => CURRENT_PAGE,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
getStateDescendant: () =>
|
|
53
|
+
options ? { options: { ...defaultCommentingOptions, ...options } } : undefined,
|
|
54
|
+
getShape: (id: string) => (shapes[id] ? { id } : undefined),
|
|
55
|
+
getShapeGeometry: (shape: string | { id: string }) => {
|
|
56
|
+
const bounds = shapes[shapeId(shape)]
|
|
57
|
+
return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
|
|
58
|
+
},
|
|
59
|
+
getShapePageTransform: (shape: string | { id: string }) => {
|
|
60
|
+
const bounds = shapes[shapeId(shape)]
|
|
61
|
+
return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
|
|
43
62
|
},
|
|
44
63
|
} as unknown as Editor
|
|
45
64
|
}
|
|
@@ -54,7 +73,7 @@ describe('collectClusterLeaves anchor resolution', () => {
|
|
|
54
73
|
stubEditor(),
|
|
55
74
|
[thread('t1', { type: 'point', x: 12, y: 34 })],
|
|
56
75
|
null
|
|
57
|
-
)
|
|
76
|
+
).leaves
|
|
58
77
|
expect(leaves).toEqual([{ id: 't1', point: { x: 12, y: 34 } }])
|
|
59
78
|
})
|
|
60
79
|
|
|
@@ -63,32 +82,28 @@ describe('collectClusterLeaves anchor resolution', () => {
|
|
|
63
82
|
stubEditor(),
|
|
64
83
|
[thread('t1', { type: 'region', x: 10, y: 20, w: 30, h: 40 })],
|
|
65
84
|
null
|
|
66
|
-
)
|
|
85
|
+
).leaves
|
|
67
86
|
expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
|
|
68
87
|
})
|
|
69
88
|
|
|
70
|
-
it('maps shape
|
|
89
|
+
it('maps shape anchors to the shape bounds top-right corner', () => {
|
|
71
90
|
const editor = stubEditor({
|
|
72
91
|
'shape:a': { minX: 0, minY: 5, maxX: 100, maxY: 50 },
|
|
73
92
|
})
|
|
74
93
|
const leaves = collectClusterLeaves(
|
|
75
94
|
editor,
|
|
76
|
-
[
|
|
77
|
-
thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false }),
|
|
78
|
-
thread('t2', { type: 'text-range', shapeId: 'shape:a' as any, from: 0, to: 3 }),
|
|
79
|
-
],
|
|
95
|
+
[thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false })],
|
|
80
96
|
null
|
|
81
|
-
)
|
|
82
|
-
expect(leaves).toEqual([
|
|
83
|
-
{ id: 't1', point: { x: 100, y: 5 } },
|
|
84
|
-
{ id: 't2', point: { x: 100, y: 5 } },
|
|
85
|
-
])
|
|
97
|
+
).leaves
|
|
98
|
+
expect(leaves).toEqual([{ id: 't1', point: { x: 100, y: 5 } }])
|
|
86
99
|
})
|
|
87
100
|
|
|
88
101
|
it('places imprecise shape leaves at a custom impreciseShapeAnchor, matching pin rendering', () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
// bottom-center instead of the default top-right
|
|
103
|
+
const editor = stubEditor(
|
|
104
|
+
{ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } },
|
|
105
|
+
{ impreciseShapeAnchor: { x: 0.5, y: 1 } }
|
|
106
|
+
)
|
|
92
107
|
const threads = [
|
|
93
108
|
thread('imprecise', {
|
|
94
109
|
type: 'shape',
|
|
@@ -105,8 +120,7 @@ describe('collectClusterLeaves anchor resolution', () => {
|
|
|
105
120
|
isPrecise: true,
|
|
106
121
|
}),
|
|
107
122
|
]
|
|
108
|
-
|
|
109
|
-
const leaves = collectClusterLeaves(editor, threads, null, { x: 0.5, y: 1 })
|
|
123
|
+
const leaves = collectClusterLeaves(editor, threads, null).leaves
|
|
110
124
|
expect(leaves).toEqual([
|
|
111
125
|
{ id: 'imprecise', point: { x: 50, y: 50 } },
|
|
112
126
|
{ id: 'precise', point: { x: 50, y: 25 } },
|
|
@@ -127,7 +141,7 @@ describe('collectClusterLeaves anchor resolution', () => {
|
|
|
127
141
|
thread('kept', { type: 'point', x: 1, y: 2 }),
|
|
128
142
|
],
|
|
129
143
|
null
|
|
130
|
-
)
|
|
144
|
+
).leaves
|
|
131
145
|
expect(leafIds(leaves)).toEqual(['kept'])
|
|
132
146
|
})
|
|
133
147
|
|
|
@@ -136,7 +150,7 @@ describe('collectClusterLeaves anchor resolution', () => {
|
|
|
136
150
|
stubEditor(),
|
|
137
151
|
[thread('pageThread', { type: 'page' }), thread('kept', { type: 'point', x: 1, y: 2 })],
|
|
138
152
|
null
|
|
139
|
-
)
|
|
153
|
+
).leaves
|
|
140
154
|
expect(leafIds(leaves)).toEqual(['kept'])
|
|
141
155
|
})
|
|
142
156
|
})
|
|
@@ -150,7 +164,7 @@ describe('collectClusterLeaves filtering', () => {
|
|
|
150
164
|
thread('elsewhere', { type: 'point', x: 0, y: 0 }, { pageId: OTHER_PAGE }),
|
|
151
165
|
],
|
|
152
166
|
null
|
|
153
|
-
)
|
|
167
|
+
).leaves
|
|
154
168
|
expect(leafIds(leaves)).toEqual(['here'])
|
|
155
169
|
})
|
|
156
170
|
|
|
@@ -159,10 +173,13 @@ describe('collectClusterLeaves filtering', () => {
|
|
|
159
173
|
thread('open', { type: 'point', x: 0, y: 0 }),
|
|
160
174
|
thread('closed', { type: 'point', x: 10, y: 0 }),
|
|
161
175
|
]
|
|
162
|
-
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open'))).toEqual(['closed'])
|
|
163
|
-
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
|
+
])
|
|
164
181
|
// an id that matches no thread excludes nothing
|
|
165
|
-
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown'))).toEqual([
|
|
182
|
+
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown').leaves)).toEqual([
|
|
166
183
|
'closed',
|
|
167
184
|
'open',
|
|
168
185
|
])
|
|
@@ -176,12 +193,12 @@ describe('collectClusterLeaves filtering', () => {
|
|
|
176
193
|
thread('unresolved', { type: 'point', x: 10, y: 0 }),
|
|
177
194
|
],
|
|
178
195
|
null
|
|
179
|
-
)
|
|
196
|
+
).leaves
|
|
180
197
|
expect(leafIds(leaves)).toEqual(['resolved', 'unresolved'])
|
|
181
198
|
})
|
|
182
199
|
|
|
183
200
|
it('returns [] for no threads', () => {
|
|
184
|
-
expect(collectClusterLeaves(stubEditor(), [], null)).toEqual([])
|
|
201
|
+
expect(collectClusterLeaves(stubEditor(), [], null).leaves).toEqual([])
|
|
185
202
|
})
|
|
186
203
|
|
|
187
204
|
it('applies every rule at once on a mixed set', () => {
|
|
@@ -210,7 +227,7 @@ describe('collectClusterLeaves filtering', () => {
|
|
|
210
227
|
thread('openOne', { type: 'point', x: 3, y: 3 }),
|
|
211
228
|
thread('resolvedOne', { type: 'point', x: 4, y: 4 }, { resolved: true }),
|
|
212
229
|
]
|
|
213
|
-
const leaves = collectClusterLeaves(editor, threads, 'openOne')
|
|
230
|
+
const leaves = collectClusterLeaves(editor, threads, 'openOne').leaves
|
|
214
231
|
expect(leafIds(leaves)).toEqual(['onShape', 'point', 'region', 'resolvedOne'])
|
|
215
232
|
})
|
|
216
233
|
|
|
@@ -221,3 +238,128 @@ describe('collectClusterLeaves filtering', () => {
|
|
|
221
238
|
expect(JSON.parse(JSON.stringify(threads))).toEqual(snapshot)
|
|
222
239
|
})
|
|
223
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,29 +1,115 @@
|
|
|
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
4
|
|
|
5
|
-
/** @
|
|
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
|
+
}
|
|
17
|
+
|
|
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
|
-
openThreadId: string | null
|
|
10
|
-
|
|
11
|
-
): LeafInput[] {
|
|
22
|
+
openThreadId: string | null
|
|
23
|
+
): ClusterInput {
|
|
12
24
|
const pageId = editor.getCurrentPageId()
|
|
13
25
|
const leaves: LeafInput[] = []
|
|
26
|
+
let screenOffsets: Map<string, VecLike> | undefined
|
|
14
27
|
|
|
15
28
|
for (const thread of threads) {
|
|
16
29
|
if (thread.id === openThreadId) continue
|
|
17
30
|
if (thread.pageId !== pageId) continue
|
|
18
31
|
|
|
19
|
-
const point = anchorPagePoint(editor, thread.anchor
|
|
32
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
20
33
|
if (!point) continue
|
|
21
34
|
|
|
22
35
|
leaves.push({
|
|
23
36
|
id: thread.id,
|
|
24
37
|
point: { x: point.x, y: point.y },
|
|
25
38
|
})
|
|
39
|
+
|
|
40
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
41
|
+
if (inset) (screenOffsets ??= new Map()).set(thread.id, inset)
|
|
26
42
|
}
|
|
27
43
|
|
|
28
|
-
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
|
|
29
115
|
}
|