@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
|
@@ -1,86 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
type PointerEvent as ReactPointerEvent,
|
|
6
|
-
ReactNode,
|
|
7
|
-
useCallback,
|
|
8
|
-
useEffect,
|
|
9
|
-
useLayoutEffect,
|
|
10
|
-
useMemo,
|
|
11
|
-
useRef,
|
|
12
|
-
useState,
|
|
13
|
-
} from 'react'
|
|
14
|
-
import { createPortal } from 'react-dom'
|
|
15
|
-
import {
|
|
16
|
-
type BoxModel,
|
|
17
|
-
createComment,
|
|
18
|
-
createCommentThread,
|
|
19
|
-
Editor,
|
|
20
|
-
react,
|
|
21
|
-
TLCommentThread,
|
|
22
|
-
TLRichText,
|
|
23
|
-
useContainer,
|
|
24
|
-
useEditor,
|
|
25
|
-
usePassThroughMouseOverEvents,
|
|
26
|
-
usePassThroughWheelEvents,
|
|
27
|
-
useTranslation,
|
|
28
|
-
useValue,
|
|
29
|
-
VecLike,
|
|
30
|
-
} from 'tldraw'
|
|
31
|
-
import { computeClusterTable } from '../clustering/computeClusterTable'
|
|
32
|
-
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
|
33
|
-
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
|
34
|
-
import { CommentComposer } from '../ui/comment-composer'
|
|
35
|
-
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
|
36
|
-
import { CommentPin } from '../ui/comment-pin'
|
|
37
|
-
import { CountBadge } from '../ui/count-badge'
|
|
1
|
+
import { isMentionPickerOpen } from '@tldraw/mentions'
|
|
2
|
+
import { Fragment, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
|
|
3
|
+
import { EditorPortal, TLCommentThread, useEditor, useValue } from 'tldraw'
|
|
4
|
+
import type { ClusterNode } from '../clustering/types'
|
|
38
5
|
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
|
39
|
-
import {
|
|
6
|
+
import { ClusterBadge } from './cluster-badge'
|
|
7
|
+
import { clusterFadeClassName } from './cluster-fade'
|
|
40
8
|
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} from './
|
|
46
|
-
import { commitCommentMutation, putRecordsInCommit } from './comment-mutations'
|
|
47
|
-
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
9
|
+
CLUSTER_EXPAND_ZOOM_MS,
|
|
10
|
+
revealThreadPin,
|
|
11
|
+
useClusterModel,
|
|
12
|
+
zoomToClusterSplit,
|
|
13
|
+
} from './cluster-model'
|
|
48
14
|
import { getCommentRecord } from './comment-store'
|
|
49
|
-
import { PendingComment } from './comment-tool'
|
|
50
15
|
import { type CommentingContext } from './context'
|
|
51
|
-
import { useCommentThreads
|
|
16
|
+
import { useCommentThreads } from './hooks'
|
|
52
17
|
import { useCommentingEnabled } from './license'
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
useCommentingOptions,
|
|
58
|
-
} from './options'
|
|
59
|
-
import { computePinStacks, pinStackKey } from './pin-stacking'
|
|
18
|
+
import { useCanComment, useCommentingOptions } from './options'
|
|
19
|
+
import { PendingComposer } from './pending-composer'
|
|
20
|
+
import { computePinStacks, isOpenStackKeyLive, pinStacksEqual } from './pin-stacking'
|
|
21
|
+
import { RegionBox, RegionDraftBox } from './region-box'
|
|
60
22
|
import {
|
|
61
23
|
commentsHidden,
|
|
62
24
|
openStackId,
|
|
63
25
|
openThreadId,
|
|
64
26
|
pendingComment,
|
|
65
|
-
regionDraft,
|
|
66
27
|
revealThreadRequest,
|
|
67
28
|
sidebarFilters,
|
|
68
29
|
toggleCommentsHidden,
|
|
69
30
|
usePendingComment,
|
|
70
31
|
} from './state'
|
|
71
|
-
import {
|
|
32
|
+
import { ThreadPin } from './thread-pin'
|
|
72
33
|
import { ThreadStackPin } from './thread-stack'
|
|
73
|
-
import {
|
|
74
|
-
anchorPagePoint,
|
|
75
|
-
commentCenterScreenOffset,
|
|
76
|
-
commentTargetShapeAt,
|
|
77
|
-
impreciseShapePinInset,
|
|
78
|
-
REGION_PIN_CORNER,
|
|
79
|
-
regionAnchorPinCorner,
|
|
80
|
-
regionPinPoint,
|
|
81
|
-
shapeAnchorAt,
|
|
82
|
-
} from './thread-state'
|
|
83
|
-
import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
|
84
34
|
|
|
85
35
|
/**
|
|
86
36
|
* The host wiring for {@link CanvasComments} — see {@link CommentingContext}, which the sidebar
|
|
@@ -90,121 +40,43 @@ import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
|
|
90
40
|
*/
|
|
91
41
|
export type CanvasCommentsProps = CommentingContext
|
|
92
42
|
|
|
93
|
-
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
|
94
|
-
|
|
95
|
-
/** A pointer-down that belongs to the camera, not the comment UI: any non-primary button
|
|
96
|
-
* (middle/right-button pans), or a primary press with the spacebar pan key held. */
|
|
97
|
-
const isCanvasPanGesture = (editor: Editor, e: ReactPointerEvent) =>
|
|
98
|
-
e.button !== 0 || editor.inputs.keys.has('Space')
|
|
99
|
-
|
|
100
|
-
/** Hand a pointer event to the canvas beneath the comments layer, marked the same way the
|
|
101
|
-
* pass-through wheel/hover hooks mark their re-dispatched events. */
|
|
102
|
-
function forwardPointerEventToCanvas(container: HTMLElement, e: ReactPointerEvent) {
|
|
103
|
-
const cvs = container.querySelector('.tl-canvas')
|
|
104
|
-
if (!cvs) return
|
|
105
|
-
const newEvent = new PointerEvent(e.type, e.nativeEvent as any)
|
|
106
|
-
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
107
|
-
cvs.dispatchEvent(newEvent)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const CLUSTER_FADE_MS = 150
|
|
111
|
-
/** Duration of the click-a-badge zoom-to-split animation. */
|
|
112
|
-
const CLUSTER_EXPAND_ZOOM_MS = 450
|
|
113
|
-
/** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
|
|
114
|
-
* lands clear of the threshold it just crossed rather than flickering on it. */
|
|
115
|
-
const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
|
|
116
|
-
/** Screen-pixel margin by which the viewport is inflated when culling cluster badges, so a badge
|
|
117
|
-
* just off-screen is already mounted when a pan brings it in. */
|
|
118
|
-
const CLUSTER_CULL_MARGIN_PX = 120
|
|
119
|
-
|
|
120
|
-
/** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
|
|
121
|
-
* first comment then lands where the preview's sat. Measured: the expanded first comment sits ~42px
|
|
122
|
-
* below the panel top (the 40px header, no gap) vs the preview's 4px (its panel padding).
|
|
123
|
-
* Re-measure if the header height or the preview panel padding changes. */
|
|
124
|
-
const THREAD_HEADER_BLOCK = 36
|
|
125
|
-
|
|
126
43
|
/**
|
|
127
44
|
* A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
|
|
128
45
|
* thread popover (with a reply composer) on click, and shows a composer where the comment tool
|
|
129
46
|
* placed a new thread. Reads/writes comment records straight from `editor.store`.
|
|
130
47
|
*
|
|
131
|
-
* It's
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* The host wiring — who the viewer is, how ids become names, read status, mentions — is the
|
|
137
|
-
* {@link CommentingContext}, which `CanvasCommentsSidebar` takes too, so a host mounting both can
|
|
138
|
-
* build it once and spread it into each.
|
|
48
|
+
* It's the batteries-included default: every visible piece is a slot on
|
|
49
|
+
* `CommentTool.configure({ components })`, and the pieces it composes (`CommentPin`,
|
|
50
|
+
* `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a consumer can
|
|
51
|
+
* rebuild it from parts instead. The host wiring is the {@link CommentingContext}, which
|
|
52
|
+
* `CanvasCommentsSidebar` takes too.
|
|
139
53
|
*
|
|
140
54
|
* @public @react
|
|
141
55
|
*/
|
|
142
56
|
export function CanvasComments(props: CanvasCommentsProps) {
|
|
143
|
-
//
|
|
144
|
-
//
|
|
57
|
+
// The inner component holds every other hook, so mounting it as the license resolves keeps hook
|
|
58
|
+
// order stable here.
|
|
145
59
|
const commentingEnabled = useCommentingEnabled()
|
|
146
60
|
if (!commentingEnabled) return null
|
|
147
61
|
return <CanvasCommentsLayer {...props} />
|
|
148
62
|
}
|
|
149
63
|
|
|
150
|
-
/**
|
|
151
|
-
* A mount point appended to the end of the editor container, for a portal that has to come last
|
|
152
|
-
* among the container's children.
|
|
153
|
-
*
|
|
154
|
-
* `createPortal(…, container)` doesn't get to say where its node lands: React places a portal
|
|
155
|
-
* during the same commit that mounts it, and a portal nested this deep in the tree is placed
|
|
156
|
-
* before the container's own, shallower children — so the layer ends up ahead of the UI and its
|
|
157
|
-
* "move focus to canvas" skip link. That link only works if nothing precedes it, and the pins are
|
|
158
|
-
* real buttons, so a single comment would take the first tab stop and leave no keyboard route to
|
|
159
|
-
* the canvas. A layout effect runs after the whole commit instead, by which point the container's
|
|
160
|
-
* children are all in place and appending is guaranteed to land at the end.
|
|
161
|
-
*
|
|
162
|
-
* Null until the effect has run, so the first render has nothing to portal into.
|
|
163
|
-
*/
|
|
164
|
-
function useTrailingPortalHost(container: HTMLElement) {
|
|
165
|
-
const [host, setHost] = useState<HTMLDivElement | null>(null)
|
|
166
|
-
useLayoutEffect(() => {
|
|
167
|
-
const elm = container.ownerDocument.createElement('div')
|
|
168
|
-
// The host is a position in the DOM, not a box — what it holds is positioned against the
|
|
169
|
-
// container, the same as it was when it hung off the container directly.
|
|
170
|
-
elm.style.display = 'contents'
|
|
171
|
-
container.appendChild(elm)
|
|
172
|
-
setHost(elm)
|
|
173
|
-
return () => {
|
|
174
|
-
elm.remove()
|
|
175
|
-
setHost(null)
|
|
176
|
-
}
|
|
177
|
-
}, [container])
|
|
178
|
-
return host
|
|
179
|
-
}
|
|
180
|
-
|
|
181
64
|
function CanvasCommentsLayer(props: CommentingContext) {
|
|
182
65
|
const editor = useEditor()
|
|
183
66
|
const options = useCommentingOptions()
|
|
184
|
-
const container = useContainer()
|
|
185
|
-
const portalHost = useTrailingPortalHost(container)
|
|
186
|
-
const layerRef = useRef<HTMLDivElement>(null)
|
|
187
|
-
// Over the pins and cluster badges, hover passes through to the canvas beneath (these events
|
|
188
|
-
// bubble up from the pointer-interactive markers to this layer root). Wheel pass-through is
|
|
189
|
-
// NOT on this root: it lives on each interactive element instead. The root spans the whole
|
|
190
|
-
// canvas, so any pin past its bottom/right edge inflates the root's scrollHeight — which the
|
|
191
|
-
// wheel hook's is-this-scrollable guard reads as scrollable, silently disabling pass-through.
|
|
192
|
-
usePassThroughMouseOverEvents(layerRef)
|
|
193
67
|
const allThreads = useCommentThreads(editor)
|
|
194
68
|
const pending = usePendingComment()
|
|
195
69
|
const canComment = useCanComment(props.currentUserId)
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// mount. Clear the atom instead of stranding it (a stale pending would pop a composer at the
|
|
199
|
-
// old click point if `canComment` later flips true).
|
|
70
|
+
// Nothing renders a pending comment when composing is blocked and there's no fallback slot, and
|
|
71
|
+
// the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it.
|
|
200
72
|
const canRenderComposer = canComment || options.components.ComposerFallback != null
|
|
201
73
|
const showPendingComposer = pending != null && canRenderComposer
|
|
202
74
|
useEffect(() => {
|
|
203
75
|
if (pending && !showPendingComposer) pendingComment.set(editor, null)
|
|
204
76
|
}, [editor, pending, showPendingComposer])
|
|
205
77
|
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
|
206
|
-
//
|
|
207
|
-
//
|
|
78
|
+
// Matches the sidebar's `showResolved` filter. The open thread stays in — resolving from its own
|
|
79
|
+
// popover shouldn't make the pin vanish under it.
|
|
208
80
|
const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
|
|
209
81
|
editor,
|
|
210
82
|
])
|
|
@@ -213,175 +85,35 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
213
85
|
[allThreads, showResolved, openId]
|
|
214
86
|
)
|
|
215
87
|
useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
() =>
|
|
224
|
-
collectClusterLeaves(
|
|
225
|
-
editor,
|
|
226
|
-
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
|
227
|
-
openThreadId.get(editor)
|
|
228
|
-
),
|
|
229
|
-
[editor, threads, heldThreadIds]
|
|
230
|
-
)
|
|
231
|
-
const clusterZoomBounds = useValue(
|
|
232
|
-
'comment cluster zoom bounds',
|
|
233
|
-
() => getClusterZoomBounds(editor),
|
|
234
|
-
[editor]
|
|
235
|
-
)
|
|
236
|
-
const latestModel = useMemo(() => {
|
|
237
|
-
const table = computeClusterTable(clusterLeaves, clusterZoomBounds)
|
|
238
|
-
const runtime = createClusterRuntime(table)
|
|
239
|
-
runtime.seed(editor.getZoomLevel())
|
|
240
|
-
return { runtime, table }
|
|
241
|
-
}, [clusterLeaves, clusterZoomBounds, editor])
|
|
242
|
-
// The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild
|
|
243
|
-
// (add / move / delete / open / pop-out) is computed immediately as `latestModel` — the MST
|
|
244
|
-
// stays correct — but the on-screen partition is `renderedModel`, and it only ever changes via
|
|
245
|
-
// (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
|
|
246
|
-
// (c) LOCAL detach patches: a leaf that left the input (deleted, opened, popped out) is
|
|
247
|
-
// detached from its own badge in place — count and centroid update for that badge alone,
|
|
248
|
-
// and nothing else on the canvas moves.
|
|
249
|
-
const [renderedModel, setRenderedModel] = useState(latestModel)
|
|
250
|
-
let clusterModel = renderedModel
|
|
251
|
-
// A page switch replaces the whole scene: hard-reset rather than detach the world.
|
|
252
|
-
const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
|
|
253
|
-
const pageRef = useRef(pageId)
|
|
254
|
-
if (pageRef.current !== pageId) {
|
|
255
|
-
pageRef.current = pageId
|
|
256
|
-
adoptOnRebuild.current = false
|
|
257
|
-
latestModel.runtime.seed(editor.getZoomLevel())
|
|
258
|
-
if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
|
|
259
|
-
setRenderedModel(latestModel)
|
|
260
|
-
clusterModel = latestModel
|
|
261
|
-
}
|
|
262
|
-
// adoptOnRebuild is set by the rejoin reaction below, outside React's render cycle, paired
|
|
263
|
-
// with clearing heldThreadIds. Only trust it once that pairing is actually visible here
|
|
264
|
-
// (heldThreadIds confirmed empty) — an unrelated re-render can land in the gap between the
|
|
265
|
-
// ref being set and the state update it was paired with being applied.
|
|
266
|
-
const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
|
|
267
|
-
if (renderedModel !== latestModel && rejoinPending) {
|
|
268
|
-
adoptOnRebuild.current = false
|
|
269
|
-
// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
|
|
270
|
-
// nothing changes state because of the swap alone. Idempotent, so safe during render.
|
|
271
|
-
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
|
272
|
-
setRenderedModel(latestModel)
|
|
273
|
-
clusterModel = latestModel
|
|
274
|
-
} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
|
|
275
|
-
// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
|
|
276
|
-
// survive to force-adopt a later, unrelated rebuild.
|
|
277
|
-
adoptOnRebuild.current = false
|
|
278
|
-
}
|
|
279
|
-
// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
|
|
280
|
-
// is baked into the model), so when its live position drifts from the baked one, hold it out.
|
|
281
|
-
// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
|
|
282
|
-
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
|
283
|
-
if (newlyMovedIds.length > 0) {
|
|
284
|
-
const next = new Set(heldThreadIds)
|
|
285
|
-
for (const id of newlyMovedIds) next.add(id)
|
|
286
|
-
setHeldThreadIds(next)
|
|
287
|
-
}
|
|
288
|
-
// Local partition maintenance — the only non-zoom visual change, and it is local by
|
|
289
|
-
// construction: any displayed leaf that has left the cluster input (deleted, thread opened,
|
|
290
|
-
// popped out above) is detached from its badge in place. The corrected rebuild is already
|
|
291
|
-
// sitting in latestModel awaiting the next zoom-out.
|
|
292
|
-
{
|
|
293
|
-
const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
294
|
-
for (const leaf of clusterModel.table.leaves) {
|
|
295
|
-
if (!latestLeafIds.has(leaf.id)) {
|
|
296
|
-
clusterModel.runtime.detachLeaf(leaf.id)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
|
301
|
-
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
|
302
|
-
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
|
303
|
-
useEffect(() => {
|
|
304
|
-
if (heldThreadIds.size === 0) return
|
|
305
|
-
let lastZoom = editor.getZoomLevel()
|
|
306
|
-
return react('rejoin moved comment pins on zoom out', () => {
|
|
307
|
-
const zoom = editor.getZoomLevel()
|
|
308
|
-
const prevZoom = lastZoom
|
|
309
|
-
lastZoom = zoom
|
|
310
|
-
if (zoom >= prevZoom) return
|
|
311
|
-
adoptOnRebuild.current = true
|
|
312
|
-
setHeldThreadIds(EMPTY_SET)
|
|
313
|
-
})
|
|
314
|
-
}, [heldThreadIds, editor])
|
|
315
|
-
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
|
316
|
-
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
|
317
|
-
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
|
318
|
-
useEffect(() => {
|
|
319
|
-
if (clusterModel === latestModel) return
|
|
320
|
-
let lastZoom = editor.getZoomLevel()
|
|
321
|
-
return react('adopt pending cluster model on zoom out', () => {
|
|
322
|
-
const zoom = editor.getZoomLevel()
|
|
323
|
-
const prevZoom = lastZoom
|
|
324
|
-
lastZoom = zoom
|
|
325
|
-
if (zoom >= prevZoom) return
|
|
326
|
-
latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
|
|
327
|
-
setRenderedModel(latestModel)
|
|
328
|
-
})
|
|
329
|
-
}, [clusterModel, latestModel, editor])
|
|
330
|
-
// Threads in the current input that the displayed partition doesn't show anywhere (new
|
|
331
|
-
// comments, reopened threads, undone deletions): render as plain pins until the next
|
|
332
|
-
// zoom-out folds them in. Membership is judged against the *displayed* partition (with
|
|
333
|
-
// detaches applied), not the rendered table, so a detached-then-restored leaf reappears.
|
|
334
|
-
const partitionVersion = clusterModel.runtime.version
|
|
335
|
-
const orphanThreads = useMemo(() => {
|
|
336
|
-
if (clusterModel === latestModel) return []
|
|
337
|
-
const displayed = new Set<string>()
|
|
338
|
-
for (const node of clusterModel.runtime.getVisible().values()) {
|
|
339
|
-
for (const member of node.members) displayed.add(member)
|
|
340
|
-
}
|
|
341
|
-
const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
342
|
-
return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
|
|
343
|
-
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
344
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
345
|
-
}, [clusterModel, latestModel, threads, partitionVersion])
|
|
346
|
-
const heldThreads = useMemo(
|
|
347
|
-
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
|
348
|
-
[threads, heldThreadIds, openId]
|
|
349
|
-
)
|
|
350
|
-
// Subscribe to the runtime's partition version, not the raw zoom: onCamera runs on every zoom
|
|
351
|
-
// tick (O(1) threshold checks) but the version only moves when the partition actually changes
|
|
352
|
-
// — so this component only re-renders on cluster changes, not on every camera frame. The memo
|
|
353
|
-
// below keys on a fresh inline read of the version rather than the subscribed value, because
|
|
354
|
-
// render-time detaches (above) bump it after the subscription's computed already evaluated.
|
|
355
|
-
useValue(
|
|
356
|
-
'comment cluster version',
|
|
357
|
-
() => {
|
|
358
|
-
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
|
359
|
-
return clusterModel.runtime.version
|
|
360
|
-
},
|
|
361
|
-
[clusterModel, editor]
|
|
362
|
-
)
|
|
363
|
-
const visibleNodes = useMemo(() => {
|
|
364
|
-
return Array.from(clusterModel.runtime.getVisible().values())
|
|
365
|
-
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
366
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
-
}, [clusterModel, partitionVersion])
|
|
368
|
-
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
|
88
|
+
const {
|
|
89
|
+
model: clusterModel,
|
|
90
|
+
zoomBounds: clusterZoomBounds,
|
|
91
|
+
fadeNodes,
|
|
92
|
+
orphanThreads,
|
|
93
|
+
heldThreads,
|
|
94
|
+
} = useClusterModel(editor, threads, openId)
|
|
369
95
|
const threadsById = useMemo(
|
|
370
96
|
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
|
371
97
|
[threads]
|
|
372
98
|
)
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
99
|
+
// Pins with the *same* anchor point coincide at every zoom, so they render as one count-badge
|
|
100
|
+
// stack. Keyed on page-space anchors, so camera moves never recompute this. Holding the map's
|
|
101
|
+
// identity while the grouping is unchanged keeps a reply from re-rendering every pin — but the
|
|
102
|
+
// map has no positions in it, so anything needing a stack's *point* must read the anchors itself.
|
|
103
|
+
const pinStacksRef = useRef<Map<string, readonly string[]>>(new Map())
|
|
104
|
+
const pinStacks = useValue(
|
|
105
|
+
'comment pin stacks',
|
|
106
|
+
() => {
|
|
107
|
+
const stacks = computePinStacks(editor, threads)
|
|
108
|
+
if (pinStacksEqual(pinStacksRef.current, stacks)) return pinStacksRef.current
|
|
109
|
+
pinStacksRef.current = stacks
|
|
110
|
+
return stacks
|
|
111
|
+
},
|
|
112
|
+
[editor, threads]
|
|
113
|
+
)
|
|
380
114
|
const openThread = openId ? threadsById.get(openId) : null
|
|
381
115
|
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
382
116
|
|
|
383
|
-
// Reset the transient UI state (open thread, open stack, half-placed comment, unserved reveal)
|
|
384
|
-
// when this unmounts.
|
|
385
117
|
useEffect(() => {
|
|
386
118
|
return () => {
|
|
387
119
|
openThreadId.set(editor, null)
|
|
@@ -391,23 +123,22 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
391
123
|
}
|
|
392
124
|
}, [editor])
|
|
393
125
|
|
|
394
|
-
// Clear a stale open-stack key
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
126
|
+
// Clear a stale open-stack key: only the stack's own mounted handlers clear it, so collapsing to
|
|
127
|
+
// a single pin strands it — and `useMarkerPreview` reads any non-null value as "a stack is open"
|
|
128
|
+
// and suppresses every hover preview. Kept while any live stack still sits at that key. Reads the
|
|
129
|
+
// anchors rather than keying off `pinStacks`, whose identity survives a stack moving as a whole.
|
|
130
|
+
const openStackKeyIsStale = useValue(
|
|
131
|
+
'open stack key stale',
|
|
132
|
+
() => {
|
|
133
|
+
const key = openStackId.get(editor)
|
|
134
|
+
if (!key) return false
|
|
135
|
+
return !isOpenStackKeyLive(editor, key, pinStacks, threadsById)
|
|
136
|
+
},
|
|
137
|
+
[editor, pinStacks, threadsById]
|
|
138
|
+
)
|
|
400
139
|
useEffect(() => {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
for (const id of pinStacks.keys()) {
|
|
404
|
-
const thread = threadsById.get(id)
|
|
405
|
-
if (!thread) continue
|
|
406
|
-
const point = anchorPagePoint(editor, thread.anchor)
|
|
407
|
-
if (point && pinStackKey(point) === key) return
|
|
408
|
-
}
|
|
409
|
-
openStackId.set(editor, null)
|
|
410
|
-
}, [editor, pinStacks, threadsById])
|
|
140
|
+
if (openStackKeyIsStale) openStackId.set(editor, null)
|
|
141
|
+
}, [editor, openStackKeyIsStale])
|
|
411
142
|
|
|
412
143
|
// The requested thread, once it (and, for a comment id, its parent thread) has synced into the
|
|
413
144
|
// store; null while records are still arriving or when no request is pending.
|
|
@@ -425,10 +156,8 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
425
156
|
[editor]
|
|
426
157
|
)
|
|
427
158
|
|
|
428
|
-
// Serve a pending reveal request
|
|
429
|
-
//
|
|
430
|
-
// the thread, so it also unhides pins — the popover opens on the on-canvas layer, which stays
|
|
431
|
-
// invisible while hidden.
|
|
159
|
+
// Serve a pending reveal request, zooming to the first cluster split that reveals the pin if it's
|
|
160
|
+
// folded into a badge. Also unhides pins: the popover opens on the layer that hiding withholds.
|
|
432
161
|
useEffect(() => {
|
|
433
162
|
if (!requestedRevealThread) return
|
|
434
163
|
revealThreadRequest.set(editor, null)
|
|
@@ -437,10 +166,8 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
437
166
|
openThreadId.set(editor, requestedRevealThread.id)
|
|
438
167
|
}, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
|
|
439
168
|
|
|
440
|
-
// Picking a thread out of a cluster's hover preview.
|
|
441
|
-
//
|
|
442
|
-
// from wherever the badge was. Zoom in on it first, the same move (and duration) the badge's
|
|
443
|
-
// own click makes, so the thread arrives instead of appearing.
|
|
169
|
+
// Picking a thread out of a cluster's hover preview. `openThreadId` alone would work, but would
|
|
170
|
+
// cut straight there from the badge — zoom in first, the same move the badge's own click makes.
|
|
444
171
|
const revealClusteredThread = useCallback(
|
|
445
172
|
(thread: TLCommentThread) => {
|
|
446
173
|
revealThreadPin(
|
|
@@ -456,29 +183,15 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
456
183
|
[clusterModel.table, clusterZoomBounds, editor, options]
|
|
457
184
|
)
|
|
458
185
|
|
|
459
|
-
|
|
460
|
-
// centered on its centroid. The event that created a visible cluster is the event that splits
|
|
461
|
-
// it, and (by the table's sort + monotone thresholds) it has the smallest zSplit of everything
|
|
462
|
-
// applied inside it — so its zSplit is exactly the first split within those comments. The
|
|
463
|
-
// animated zoom-in then drives the runtime cursor like any manual zoom, so the badge splits
|
|
464
|
-
// (and can be drilled into further) with no extra bookkeeping.
|
|
465
|
-
const zoomToClusterSplit = useCallback(
|
|
186
|
+
const expandCluster = useCallback(
|
|
466
187
|
(node: ClusterNode) => {
|
|
467
|
-
|
|
468
|
-
if (!event || !Number.isFinite(event.zSplit)) return
|
|
469
|
-
const zoom = clamp(
|
|
470
|
-
event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
471
|
-
clusterZoomBounds.minZoom,
|
|
472
|
-
clusterZoomBounds.maxZoom
|
|
473
|
-
)
|
|
474
|
-
centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
|
|
188
|
+
zoomToClusterSplit(editor, clusterModel.table, clusterZoomBounds, node)
|
|
475
189
|
},
|
|
476
|
-
[clusterModel, clusterZoomBounds, editor]
|
|
190
|
+
[clusterModel.table, clusterZoomBounds, editor]
|
|
477
191
|
)
|
|
478
192
|
|
|
479
|
-
// Escape collapses the open thread. Capture-phase
|
|
480
|
-
//
|
|
481
|
-
// being edited, let its own Escape handler exit edit mode first, keeping the thread open.
|
|
193
|
+
// Escape collapses the open thread. Capture-phase so it runs ahead of the editor, which would
|
|
194
|
+
// otherwise cancel the tool or clear the selection.
|
|
482
195
|
useEffect(() => {
|
|
483
196
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
484
197
|
if (e.key !== 'Escape' || openThreadId.get(editor) === null) return
|
|
@@ -494,8 +207,8 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
494
207
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
495
208
|
}, [editor])
|
|
496
209
|
|
|
497
|
-
// Shift+C toggles
|
|
498
|
-
//
|
|
210
|
+
// Shift+C toggles pin visibility. Physical `KeyC` so it's layout-independent, and skipped while
|
|
211
|
+
// typing so it never fires from inside a composer.
|
|
499
212
|
useEffect(() => {
|
|
500
213
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
501
214
|
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
@@ -508,31 +221,26 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
508
221
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
509
222
|
}, [editor])
|
|
510
223
|
|
|
511
|
-
//
|
|
512
|
-
// is read above so this component stays mounted and its shortcut/Escape effects keep running.
|
|
224
|
+
// The signal is read above so this stays mounted and its shortcut/Escape effects keep running.
|
|
513
225
|
if (hidden) return null
|
|
514
226
|
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
// can't be decided per-path.
|
|
519
|
-
// A cluster node that is exactly one coincident stack — every member shares a single pin-stack
|
|
520
|
-
// group, with no distinct-position comment mixed in. Such a node is a stack standing on its own
|
|
521
|
-
// (its neighbours have already split off as the view zoomed in), so it renders as the immediate
|
|
522
|
-
// cascading count-badge list rather than a zoom-to-split cluster badge. Returns the stack's full
|
|
523
|
-
// group — which can include an open or orphan member the node's own leaves omit — or null.
|
|
227
|
+
// The node's pin-stack group when every member shares one — a stack standing on its own, which
|
|
228
|
+
// renders as a cascading count-badge list rather than a zoom-to-split cluster badge. The group
|
|
229
|
+
// can include an open or orphan member the node's own leaves omit.
|
|
524
230
|
const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
|
|
525
231
|
const group = pinStacks.get(node.members[0])
|
|
526
232
|
if (!group) return null
|
|
527
233
|
return node.members.every((id) => group.includes(id)) ? group : null
|
|
528
234
|
}
|
|
529
235
|
|
|
236
|
+
// Which threads are on screen this render. A stack renders once, owned by its first on-screen
|
|
237
|
+
// member — members arrive by different paths, so ownership can't be decided per-path.
|
|
530
238
|
const renderedThreadIds = new Set<string>()
|
|
531
239
|
if (options.enableClustering) {
|
|
532
240
|
for (const { node } of fadeNodes) {
|
|
533
241
|
if (node.count === 1) renderedThreadIds.add(node.id)
|
|
534
|
-
// A pure-stack node
|
|
535
|
-
//
|
|
242
|
+
// A pure-stack node's members aren't count-1 leaves, so register them here for the owner
|
|
243
|
+
// logic to pick from.
|
|
536
244
|
else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
|
|
537
245
|
}
|
|
538
246
|
for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
|
|
@@ -557,977 +265,79 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
557
265
|
return <ThreadPin editor={editor} thread={thread} {...props} />
|
|
558
266
|
}
|
|
559
267
|
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
return createPortal(
|
|
565
|
-
<div ref={layerRef} className="tlui-cmt-canvas-layer">
|
|
566
|
-
{options.enableClustering ? (
|
|
567
|
-
<>
|
|
568
|
-
{fadeNodes.map(({ node, phase }) => {
|
|
569
|
-
let content: ReactNode
|
|
570
|
-
const stackGroup = node.count > 1 ? stackGroupOf(node) : null
|
|
571
|
-
if (node.count === 1) {
|
|
572
|
-
const thread = threadsById.get(node.id)
|
|
573
|
-
if (!thread) return null
|
|
574
|
-
content = renderThreadPin(thread)
|
|
575
|
-
} else if (stackGroup) {
|
|
576
|
-
// A coincident stack standing alone: draw the cascading count-badge list now
|
|
577
|
-
// instead of a zoom-to-split cluster badge. Route it through the stack's owner so
|
|
578
|
-
// the open/orphan/held slots stay deduped — when the owner is one of them, that
|
|
579
|
-
// slot draws the stack and this node draws nothing.
|
|
580
|
-
const owner = stackGroup.find((id) => renderedThreadIds.has(id))
|
|
581
|
-
content =
|
|
582
|
-
owner && node.members.includes(owner)
|
|
583
|
-
? renderThreadPin(threadsById.get(owner)!)
|
|
584
|
-
: null
|
|
585
|
-
} else {
|
|
586
|
-
content = (
|
|
587
|
-
<ClusterBadge
|
|
588
|
-
editor={editor}
|
|
589
|
-
node={node}
|
|
590
|
-
onExpand={zoomToClusterSplit}
|
|
591
|
-
onSelectThread={revealClusteredThread}
|
|
592
|
-
threadsById={threadsById}
|
|
593
|
-
currentUserId={props.currentUserId}
|
|
594
|
-
resolveAuthor={props.resolveAuthor}
|
|
595
|
-
/>
|
|
596
|
-
)
|
|
597
|
-
}
|
|
598
|
-
return (
|
|
599
|
-
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
|
600
|
-
{content}
|
|
601
|
-
</div>
|
|
602
|
-
)
|
|
603
|
-
})}
|
|
604
|
-
{orphanThreads.map((thread) => (
|
|
605
|
-
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
606
|
-
))}
|
|
607
|
-
{heldThreads.map((thread) => (
|
|
608
|
-
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
609
|
-
))}
|
|
610
|
-
</>
|
|
611
|
-
) : (
|
|
612
|
-
// Clustering off: every thread renders as its own live pin (each returns null when it's
|
|
613
|
-
// not on the current page or its anchor is missing). The open thread is excluded here and
|
|
614
|
-
// rendered once below, mirroring how the clustering path keeps it out of the cluster leaves —
|
|
615
|
-
// otherwise it would mount a second, stacked pin.
|
|
616
|
-
threads
|
|
617
|
-
.filter((thread) => thread.id !== openId)
|
|
618
|
-
.map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
|
|
619
|
-
)}
|
|
620
|
-
{openThread && (
|
|
621
|
-
<Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
|
|
622
|
-
)}
|
|
623
|
-
<RegionDraftBox editor={editor} />
|
|
624
|
-
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
|
625
|
-
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
|
626
|
-
{pending?.anchor.type === 'region' && showPendingComposer && (
|
|
627
|
-
<RegionBox editor={editor} box={pending.anchor} />
|
|
628
|
-
)}
|
|
629
|
-
{pending && showPendingComposer && (
|
|
630
|
-
<PendingComposer editor={editor} pending={pending} {...props} />
|
|
631
|
-
)}
|
|
632
|
-
</div>,
|
|
633
|
-
portalHost
|
|
634
|
-
)
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const EMPTY_SET: ReadonlySet<string> = new Set()
|
|
638
|
-
const MOVED_LEAF_EPSILON = 1e-6
|
|
639
|
-
type ClusterFadePhase = 'entering' | 'present' | 'exiting'
|
|
640
|
-
|
|
641
|
-
interface ClusterFadeNode {
|
|
642
|
-
node: ClusterNode
|
|
643
|
-
phase: ClusterFadePhase
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function useFadeVisibleNodes(
|
|
647
|
-
nodes: readonly ClusterNode[],
|
|
648
|
-
resetKey: { runtime: ClusterRuntime; table: ClusterTable }
|
|
649
|
-
): ClusterFadeNode[] {
|
|
650
|
-
const resetKeyRef = useRef(resetKey)
|
|
651
|
-
const didReset = resetKeyRef.current !== resetKey
|
|
652
|
-
if (didReset) {
|
|
653
|
-
resetKeyRef.current = resetKey
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
|
|
657
|
-
const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
|
|
658
|
-
|
|
659
|
-
useEffect(() => {
|
|
660
|
-
setFadeNodes(toPresentFadeNodes(nodes))
|
|
661
|
-
// Resets only on a new model (resetKey); node-list changes within the same model are
|
|
662
|
-
// handled by the reconcile effect below, which fades entries in/out instead of snapping.
|
|
663
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
664
|
-
}, [resetKey])
|
|
665
|
-
|
|
666
|
-
useEffect(() => {
|
|
667
|
-
if (didReset) return
|
|
668
|
-
setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
|
|
669
|
-
}, [didReset, nodes])
|
|
670
|
-
|
|
671
|
-
const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
|
|
672
|
-
useEffect(() => {
|
|
673
|
-
if (!hasEntering) return
|
|
674
|
-
const frame = requestClusterFadeFrame(() => {
|
|
675
|
-
setFadeNodes((previous) =>
|
|
676
|
-
previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
|
|
677
|
-
)
|
|
678
|
-
})
|
|
679
|
-
return () => cancelClusterFadeFrame(frame)
|
|
680
|
-
}, [hasEntering, renderedNodes])
|
|
681
|
-
|
|
682
|
-
const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
|
|
683
|
-
useEffect(() => {
|
|
684
|
-
if (!hasExiting) return
|
|
685
|
-
const timeout = window.setTimeout(() => {
|
|
686
|
-
setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
|
|
687
|
-
}, CLUSTER_FADE_MS)
|
|
688
|
-
return () => window.clearTimeout(timeout)
|
|
689
|
-
}, [hasExiting, renderedNodes])
|
|
690
|
-
|
|
691
|
-
return renderedNodes
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
|
|
695
|
-
return nodes.map((node) => ({ node, phase: 'present' }))
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
function reconcileFadeNodes(
|
|
699
|
-
previous: readonly ClusterFadeNode[],
|
|
700
|
-
nextNodes: readonly ClusterNode[]
|
|
701
|
-
): ClusterFadeNode[] {
|
|
702
|
-
const previousById = new Map(previous.map((item) => [item.node.id, item]))
|
|
703
|
-
const nextIds = new Set(nextNodes.map((node) => node.id))
|
|
704
|
-
const next: ClusterFadeNode[] = []
|
|
705
|
-
|
|
706
|
-
for (const node of nextNodes) {
|
|
707
|
-
const previousItem = previousById.get(node.id)
|
|
708
|
-
next.push({
|
|
709
|
-
node,
|
|
710
|
-
phase:
|
|
711
|
-
previousItem && previousItem.phase !== 'exiting'
|
|
712
|
-
? previousItem.phase
|
|
713
|
-
: previousItem
|
|
714
|
-
? 'present'
|
|
715
|
-
: 'entering',
|
|
716
|
-
})
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
for (const item of previous) {
|
|
720
|
-
if (nextIds.has(item.node.id)) continue
|
|
721
|
-
next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
return next
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
function requestClusterFadeFrame(callback: FrameRequestCallback): number {
|
|
728
|
-
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback)
|
|
729
|
-
return window.setTimeout(() => callback(0), 16)
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function cancelClusterFadeFrame(frame: number) {
|
|
733
|
-
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frame)
|
|
734
|
-
else window.clearTimeout(frame)
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function clusterFadeClassName(phase: ClusterFadePhase): string {
|
|
738
|
-
return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/**
|
|
742
|
-
* Leaves folded inside a badge whose live anchor no longer matches the position the rendered
|
|
743
|
-
* model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
|
|
744
|
-
* stay deferred; a badge can't follow a member, so these must pop out of clustering.
|
|
745
|
-
*/
|
|
746
|
-
function findMovedClusteredLeafIds(
|
|
747
|
-
rendered: { runtime: ClusterRuntime; table: ClusterTable },
|
|
748
|
-
latest: { table: ClusterTable }
|
|
749
|
-
): string[] {
|
|
750
|
-
if (rendered.table === latest.table) return []
|
|
751
|
-
const visible = rendered.runtime.getVisible()
|
|
752
|
-
const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
|
|
753
|
-
const moved: string[] = []
|
|
754
|
-
for (const leaf of rendered.table.leaves) {
|
|
755
|
-
if (visible.has(leaf.id)) continue
|
|
756
|
-
const current = latestById.get(leaf.id)
|
|
757
|
-
if (!current) continue
|
|
758
|
-
if (
|
|
759
|
-
Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
|
|
760
|
-
Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
|
|
761
|
-
) {
|
|
762
|
-
moved.push(leaf.id)
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
return moved
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
|
|
769
|
-
const cameraOptions = editor.getCameraOptions()
|
|
770
|
-
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
|
771
|
-
const zoomSteps = cameraOptions.zoomSteps
|
|
772
|
-
return {
|
|
773
|
-
minZoom: zoomSteps[0] * baseZoom,
|
|
774
|
-
maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function revealThreadPin(
|
|
779
|
-
editor: Editor,
|
|
780
|
-
thread: TLCommentThread,
|
|
781
|
-
table: ClusterTable,
|
|
782
|
-
zoomBounds: { minZoom: number; maxZoom: number },
|
|
783
|
-
options: CommentingOptions,
|
|
784
|
-
duration = 200
|
|
785
|
-
) {
|
|
786
|
-
if (thread.pageId !== editor.getCurrentPageId()) {
|
|
787
|
-
editor.setCurrentPage(thread.pageId as any)
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const point = anchorPagePoint(editor, thread.anchor)
|
|
791
|
-
if (!point) return
|
|
792
|
-
|
|
793
|
-
// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
|
|
794
|
-
// badge never exists) and just center on the pin.
|
|
795
|
-
if (options.enableClustering) {
|
|
796
|
-
const parentEvent = findDirectParentEvent(table, thread.id)
|
|
797
|
-
if (
|
|
798
|
-
parentEvent &&
|
|
799
|
-
Number.isFinite(parentEvent.zSplit) &&
|
|
800
|
-
parentEvent.zSplit <= zoomBounds.maxZoom
|
|
801
|
-
) {
|
|
802
|
-
const zoom = clamp(
|
|
803
|
-
parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
804
|
-
zoomBounds.minZoom,
|
|
805
|
-
zoomBounds.maxZoom
|
|
806
|
-
)
|
|
807
|
-
centerOnPointAtZoom(editor, point, zoom, duration)
|
|
808
|
-
return
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
|
813
|
-
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
|
817
|
-
return table.events.find((event) => event.children.some((child) => child.id === threadId))
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
function centerOnPointAtZoom(
|
|
821
|
-
editor: Editor,
|
|
822
|
-
point: { x: number; y: number },
|
|
823
|
-
zoom: number,
|
|
824
|
-
duration = 200
|
|
825
|
-
) {
|
|
826
|
-
const viewport = editor.getViewportScreenBounds()
|
|
827
|
-
// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
|
|
828
|
-
const offset = commentCenterScreenOffset(editor)
|
|
829
|
-
editor.setCamera(
|
|
830
|
-
{
|
|
831
|
-
x: (viewport.w / 2 - offset) / zoom - point.x,
|
|
832
|
-
y: viewport.h / (2 * zoom) - point.y,
|
|
833
|
-
z: zoom,
|
|
834
|
-
},
|
|
835
|
-
{ animation: { duration } }
|
|
836
|
-
)
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function clamp(value: number, min: number, max: number): number {
|
|
840
|
-
return Math.max(min, Math.min(max, value))
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
|
|
844
|
-
// badges skip re-rendering when the parent re-renders for reasons that don't concern them
|
|
845
|
-
// (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
|
|
846
|
-
// works — each component subscribes to its own viewport position via signals, not via props.
|
|
847
|
-
const ClusterBadge = memo(function ClusterBadge({
|
|
848
|
-
editor,
|
|
849
|
-
node,
|
|
850
|
-
onExpand,
|
|
851
|
-
onSelectThread,
|
|
852
|
-
threadsById,
|
|
853
|
-
...props
|
|
854
|
-
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
|
855
|
-
editor: Editor
|
|
856
|
-
node: ClusterNode
|
|
857
|
-
onExpand(node: ClusterNode): void
|
|
858
|
-
onSelectThread(thread: TLCommentThread): void
|
|
859
|
-
threadsById: ReadonlyMap<string, TLCommentThread>
|
|
860
|
-
}) {
|
|
861
|
-
const container = useContainer()
|
|
862
|
-
const msg = useTranslation()
|
|
863
|
-
const badgeRef = useRef<HTMLButtonElement>(null)
|
|
864
|
-
const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
|
|
865
|
-
// Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
|
|
866
|
-
// note on the layer.
|
|
867
|
-
usePassThroughWheelEvents(badgeRef)
|
|
868
|
-
const point = useValue(
|
|
869
|
-
'cluster badge point',
|
|
870
|
-
() => {
|
|
871
|
-
const pagePoint = editor.pageToViewport(node.centroid)
|
|
872
|
-
if (!isInInflatedViewport(editor, pagePoint)) return null
|
|
873
|
-
return pagePoint
|
|
874
|
-
},
|
|
875
|
-
[editor, node]
|
|
876
|
-
)
|
|
877
|
-
|
|
878
|
-
// `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
|
|
879
|
-
// the order a reader would expect. Only computed while the preview is up.
|
|
880
|
-
const previewThreads = useMemo(() => {
|
|
881
|
-
if (!previewShown) return []
|
|
882
|
-
const threads: TLCommentThread[] = []
|
|
883
|
-
for (const id of node.members) {
|
|
884
|
-
const thread = threadsById.get(id)
|
|
885
|
-
if (thread) threads.push(thread)
|
|
886
|
-
}
|
|
887
|
-
return sortThreadsForPreview(threads)
|
|
888
|
-
}, [previewShown, node.members, threadsById])
|
|
889
|
-
|
|
890
|
-
if (!point) return null
|
|
891
|
-
|
|
892
|
-
return (
|
|
893
|
-
<>
|
|
894
|
-
<button
|
|
895
|
-
ref={badgeRef}
|
|
896
|
-
type="button"
|
|
897
|
-
className="tlui-cmt-button tlui-cmt-canvas-cluster"
|
|
898
|
-
style={{ left: point.x, top: point.y }}
|
|
899
|
-
aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
|
|
900
|
-
onPointerDown={(e) => {
|
|
901
|
-
if (isCanvasPanGesture(editor, e)) {
|
|
902
|
-
forwardPointerEventToCanvas(container, e)
|
|
903
|
-
return
|
|
904
|
-
}
|
|
905
|
-
e.stopPropagation()
|
|
906
|
-
}}
|
|
907
|
-
onClick={(e) => {
|
|
908
|
-
e.stopPropagation()
|
|
909
|
-
onExpand(node)
|
|
910
|
-
}}
|
|
911
|
-
{...previewHandlers}
|
|
912
|
-
onFocus={previewHandlers.onPointerEnter}
|
|
913
|
-
onBlur={previewHandlers.onPointerLeave}
|
|
914
|
-
>
|
|
915
|
-
<CountBadge count={node.count} />
|
|
916
|
-
</button>
|
|
917
|
-
{previewShown && previewThreads.length > 0 && (
|
|
918
|
-
<ThreadPreview
|
|
919
|
-
editor={editor}
|
|
920
|
-
threads={previewThreads}
|
|
921
|
-
container={container}
|
|
922
|
-
variant="list"
|
|
923
|
-
point={point}
|
|
924
|
-
onSelectThread={onSelectThread}
|
|
925
|
-
{...previewHandlers}
|
|
926
|
-
{...props}
|
|
927
|
-
/>
|
|
928
|
-
)}
|
|
929
|
-
</>
|
|
930
|
-
)
|
|
931
|
-
})
|
|
932
|
-
|
|
933
|
-
function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
|
|
934
|
-
const viewport = editor.getViewportScreenBounds()
|
|
935
|
-
const margin = CLUSTER_CULL_MARGIN_PX
|
|
936
|
-
return (
|
|
937
|
-
point.x >= -margin &&
|
|
938
|
-
point.y >= -margin &&
|
|
939
|
-
point.x <= viewport.w + margin &&
|
|
940
|
-
point.y <= viewport.h + margin
|
|
941
|
-
)
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
|
|
945
|
-
* a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
|
|
946
|
-
* `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
|
|
947
|
-
/** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
|
|
948
|
-
* handles, so the box itself takes no pointer events. */
|
|
949
|
-
function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
|
|
950
|
-
const rect = useValue(
|
|
951
|
-
'region rect',
|
|
952
|
-
() => {
|
|
953
|
-
// Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
|
|
954
|
-
const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
|
|
955
|
-
const zoom = editor.getZoomLevel()
|
|
956
|
-
return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
|
|
957
|
-
},
|
|
958
|
-
[editor, box.x, box.y, box.w, box.h]
|
|
959
|
-
)
|
|
960
|
-
return <div className="tlui-cmt-canvas-region" style={rect} />
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
|
964
|
-
function RegionDraftBox({ editor }: { editor: Editor }) {
|
|
965
|
-
const box = useValue('region draft', () => regionDraft.get(editor), [editor])
|
|
966
|
-
if (!box) return null
|
|
967
|
-
return <RegionBox editor={editor} box={box} />
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
|
|
971
|
-
// controlled by that handle — the resize math reads the spot rather than special-casing corners.
|
|
972
|
-
interface RegionHandle {
|
|
973
|
-
x: number
|
|
974
|
-
y: number
|
|
975
|
-
cursor: string
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
// The four corners, each resizing both axes.
|
|
979
|
-
const REGION_CORNERS: readonly RegionHandle[] = [
|
|
980
|
-
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
|
981
|
-
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
|
982
|
-
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
|
983
|
-
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
|
984
|
-
]
|
|
985
|
-
|
|
986
|
-
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
|
987
|
-
// the handles (which sit on the edge) are comfortably reachable.
|
|
988
|
-
const REGION_HANDLE_MARGIN_PX = 12
|
|
989
|
-
|
|
990
|
-
/** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
|
|
991
|
-
* handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
|
|
992
|
-
* handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
|
|
993
|
-
function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
|
|
994
|
-
const controlsX = handle.x !== 0.5
|
|
995
|
-
const controlsY = handle.y !== 0.5
|
|
996
|
-
const fixedX = box.x + (1 - handle.x) * box.w
|
|
997
|
-
const fixedY = box.y + (1 - handle.y) * box.h
|
|
998
|
-
return {
|
|
999
|
-
x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
|
|
1000
|
-
y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
|
|
1001
|
-
w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
|
|
1002
|
-
h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
|
|
1007
|
-
* option. Previews live, commits on release. */
|
|
1008
|
-
function RegionResizeHandles({
|
|
1009
|
-
editor,
|
|
1010
|
-
box,
|
|
1011
|
-
handles,
|
|
1012
|
-
onPreview,
|
|
1013
|
-
onCommit,
|
|
1014
|
-
}: {
|
|
1015
|
-
editor: Editor
|
|
1016
|
-
box: BoxModel
|
|
1017
|
-
handles: readonly RegionHandle[]
|
|
1018
|
-
onPreview(bounds: BoxModel | null): void
|
|
1019
|
-
onCommit(bounds: BoxModel): void
|
|
1020
|
-
}) {
|
|
1021
|
-
// The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
|
|
1022
|
-
// the fixed edges mid-drag.
|
|
1023
|
-
const boxRef = useRef<BoxModel | null>(null)
|
|
1024
|
-
const points = useValue(
|
|
1025
|
-
'region handle points',
|
|
1026
|
-
() =>
|
|
1027
|
-
handles.map((h) => {
|
|
1028
|
-
const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
|
|
1029
|
-
return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
|
|
1030
|
-
}),
|
|
1031
|
-
[editor, box.x, box.y, box.w, box.h, handles]
|
|
1032
|
-
)
|
|
1033
|
-
const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1034
|
-
e.stopPropagation()
|
|
1035
|
-
boxRef.current = box
|
|
1036
|
-
e.currentTarget.setPointerCapture(e.pointerId)
|
|
1037
|
-
}
|
|
1038
|
-
const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
|
|
1039
|
-
resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
|
|
1040
|
-
const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1041
|
-
if (boxRef.current) onPreview(resizedTo(h, e))
|
|
1042
|
-
}
|
|
1043
|
-
const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1044
|
-
if (!boxRef.current) return
|
|
1045
|
-
const bounds = resizedTo(h, e)
|
|
1046
|
-
boxRef.current = null
|
|
1047
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
1048
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1049
|
-
onCommit(bounds)
|
|
1050
|
-
}
|
|
1051
|
-
return (
|
|
1052
|
-
<>
|
|
1053
|
-
{points.map((h) => (
|
|
1054
|
-
<div
|
|
1055
|
-
key={h.key}
|
|
1056
|
-
className="tlui-cmt-canvas-region-handle"
|
|
1057
|
-
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
|
1058
|
-
onPointerDown={startResize}
|
|
1059
|
-
onPointerMove={onResize(h)}
|
|
1060
|
-
onPointerUp={endResize(h)}
|
|
1061
|
-
/>
|
|
1062
|
-
))}
|
|
1063
|
-
</>
|
|
1064
|
-
)
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
const ThreadPin = memo(function ThreadPin({
|
|
1068
|
-
editor,
|
|
1069
|
-
thread,
|
|
1070
|
-
...props
|
|
1071
|
-
}: CommentingContext & {
|
|
1072
|
-
editor: Editor
|
|
1073
|
-
thread: TLCommentThread
|
|
1074
|
-
}) {
|
|
1075
|
-
const { resolveAuthor } = props
|
|
1076
|
-
const options = useCommentingOptions()
|
|
1077
|
-
const canComment = useCanComment(props.currentUserId)
|
|
1078
|
-
const container = useContainer()
|
|
1079
|
-
const msg = useTranslation()
|
|
1080
|
-
const comments = useThreadComments(editor, thread.id)
|
|
1081
|
-
// Only one thread's popover is open at a time — shared across pins via the atom.
|
|
1082
|
-
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
|
1083
|
-
editor,
|
|
1084
|
-
thread.id,
|
|
1085
|
-
])
|
|
1086
|
-
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
1087
|
-
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
|
1088
|
-
// The live bounds while a corner handle is resizing the region, else null.
|
|
1089
|
-
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
|
1090
|
-
// Hovering the marker previews the thread's opening comment, on the delay every marker uses.
|
|
1091
|
-
const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
|
|
1092
|
-
const previewThreads = useMemo(() => [thread], [thread])
|
|
1093
|
-
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
|
1094
|
-
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
|
1095
|
-
// handle never loses the affordance — the box stays `pointer-events: none`.
|
|
1096
|
-
const pointerInRegion = useValue(
|
|
1097
|
-
'pointer in region',
|
|
1098
|
-
() => {
|
|
1099
|
-
if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
|
|
1100
|
-
return false
|
|
1101
|
-
const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
|
|
1102
|
-
const p = editor.inputs.getCurrentPagePoint()
|
|
1103
|
-
const a = thread.anchor
|
|
1104
|
-
return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
|
|
1105
|
-
},
|
|
1106
|
-
[editor, thread.anchor, thread.pageId]
|
|
1107
|
-
)
|
|
1108
|
-
// A region's box and handles are revealed while open, mid-resize, or while the pointer is
|
|
1109
|
-
// within the region.
|
|
1110
|
-
const revealed = open || resizeBounds != null || pointerInRegion
|
|
1111
|
-
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
|
1112
|
-
// the default as the fallback for older records.
|
|
1113
|
-
const pinCorner =
|
|
1114
|
-
thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
|
|
1115
|
-
// A region resizes from its corners — every corner but the pin's own, which the pin owns.
|
|
1116
|
-
const resizeHandles = useMemo(
|
|
1117
|
-
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
|
1118
|
-
[pinCorner]
|
|
1119
|
-
)
|
|
1120
|
-
const dragRef = useRef<{
|
|
1121
|
-
startX: number
|
|
1122
|
-
startY: number
|
|
1123
|
-
moved: boolean
|
|
1124
|
-
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
|
1125
|
-
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
|
1126
|
-
offsetX: number
|
|
1127
|
-
offsetY: number
|
|
1128
|
-
} | null>(null)
|
|
1129
|
-
const markerRef = useRef<HTMLButtonElement>(null)
|
|
1130
|
-
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
|
1131
|
-
// see the note on the layer.
|
|
1132
|
-
usePassThroughWheelEvents(markerRef)
|
|
1133
|
-
|
|
1134
|
-
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
|
1135
|
-
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
|
1136
|
-
// clear the hint here or it stays on the shape indefinitely.
|
|
1137
|
-
useEffect(() => {
|
|
1138
|
-
return () => {
|
|
1139
|
-
if (dragRef.current) editor.setHintingShapes([])
|
|
1140
|
-
}
|
|
1141
|
-
}, [editor])
|
|
1142
|
-
|
|
1143
|
-
// Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
|
|
1144
|
-
// pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
|
|
1145
|
-
// popover portals elsewhere in the DOM. The pin marker is excluded so its own click-to-toggle
|
|
1146
|
-
// handles it instead of this closing then the toggle reopening.
|
|
1147
|
-
useEffect(() => {
|
|
1148
|
-
if (!open) return
|
|
1149
|
-
const onPointerDown = (e: PointerEvent) => {
|
|
1150
|
-
const target = e.target as HTMLElement | null
|
|
1151
|
-
if (!target) return
|
|
1152
|
-
if (target.closest('.tlui-cmt-canvas-popover')) return
|
|
1153
|
-
const marker = markerRef.current
|
|
1154
|
-
if (marker && marker.contains(target)) return
|
|
1155
|
-
// A press on a region's resize handle or movable body edits this thread — don't dismiss it.
|
|
1156
|
-
if (target.closest('.tlui-cmt-canvas-region-handle, .tlui-cmt-canvas-region--movable')) return
|
|
1157
|
-
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
|
1158
|
-
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
|
1159
|
-
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
|
1160
|
-
if (
|
|
1161
|
-
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
|
1162
|
-
)
|
|
1163
|
-
return
|
|
1164
|
-
openThreadId.set(editor, null)
|
|
1165
|
-
}
|
|
1166
|
-
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1167
|
-
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1168
|
-
}, [open, editor])
|
|
1169
|
-
|
|
1170
|
-
const point = useValue(
|
|
1171
|
-
'pin point',
|
|
1172
|
-
() => {
|
|
1173
|
-
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
1174
|
-
const pagePoint = anchorPagePoint(editor, thread.anchor)
|
|
1175
|
-
if (!pagePoint) return null
|
|
1176
|
-
const viewportPoint = editor.pageToViewport(pagePoint)
|
|
1177
|
-
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
1178
|
-
return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
|
|
1179
|
-
},
|
|
1180
|
-
[editor, thread.anchor, thread.pageId]
|
|
1181
|
-
)
|
|
1182
|
-
if (!point) return null
|
|
1183
|
-
|
|
1184
|
-
const PinContent = options.components.PinContent
|
|
1185
|
-
// The `PinContent` component slot overrides the built-in author-avatar default.
|
|
1186
|
-
const threadAuthor = resolveAuthor(thread.createdBy)
|
|
1187
|
-
const pinContent = PinContent ? (
|
|
1188
|
-
<PinContent thread={thread} comments={comments} />
|
|
1189
|
-
) : (
|
|
1190
|
-
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
1191
|
-
)
|
|
1192
|
-
const pinLabel = msg(
|
|
1193
|
-
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
|
1194
|
-
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
|
1195
|
-
|
|
1196
|
-
// Drag the marker to move the thread: its position is overridden locally while dragging, then
|
|
1197
|
-
// re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
|
|
1198
|
-
// a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
|
|
1199
|
-
// toggle the popover.
|
|
1200
|
-
const isRegion = thread.anchor.type === 'region'
|
|
1201
|
-
// The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
|
|
1202
|
-
const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1203
|
-
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
|
1204
|
-
// hand it to the canvas untouched.
|
|
1205
|
-
if (isCanvasPanGesture(editor, e)) {
|
|
1206
|
-
forwardPointerEventToCanvas(container, e)
|
|
1207
|
-
return
|
|
1208
|
-
}
|
|
1209
|
-
e.stopPropagation()
|
|
1210
|
-
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1211
|
-
const anchorPage = anchorPagePoint(editor, thread.anchor)
|
|
1212
|
-
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
|
1213
|
-
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
|
1214
|
-
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
1215
|
-
if (anchorPage && inset) {
|
|
1216
|
-
const zoom = editor.getZoomLevel()
|
|
1217
|
-
anchorPage.x += inset.x / zoom
|
|
1218
|
-
anchorPage.y += inset.y / zoom
|
|
1219
|
-
}
|
|
1220
|
-
dragRef.current = {
|
|
1221
|
-
startX: e.clientX,
|
|
1222
|
-
startY: e.clientY,
|
|
1223
|
-
moved: false,
|
|
1224
|
-
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
|
1225
|
-
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
|
1226
|
-
}
|
|
1227
|
-
e.currentTarget.setPointerCapture(e.pointerId)
|
|
1228
|
-
}
|
|
1229
|
-
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1230
|
-
const drag = dragRef.current
|
|
1231
|
-
if (!drag) return
|
|
1232
|
-
// Moving a pin re-anchors the thread record — a commenting write. Without the permission the
|
|
1233
|
-
// press stays a click (`moved` never sets, so release toggles the popover and never commits).
|
|
1234
|
-
if (!canComment) return
|
|
1235
|
-
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
1236
|
-
drag.moved = true
|
|
1237
|
-
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1238
|
-
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
1239
|
-
setDragPagePoint(pagePoint)
|
|
1240
|
-
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
|
1241
|
-
// with. Regions translate rather than re-anchor, so they never hint.
|
|
1242
|
-
if (!isRegion) {
|
|
1243
|
-
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
1244
|
-
editor.setHintingShapes(hit ? [hit.id] : [])
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
|
1248
|
-
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
|
1249
|
-
const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1250
|
-
const drag = dragRef.current
|
|
1251
|
-
dragRef.current = null
|
|
1252
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1253
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1254
|
-
}
|
|
1255
|
-
if (!drag) return
|
|
1256
|
-
setDragPagePoint(null)
|
|
1257
|
-
editor.setHintingShapes([])
|
|
1258
|
-
}
|
|
1259
|
-
const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1260
|
-
const drag = dragRef.current
|
|
1261
|
-
dragRef.current = null
|
|
1262
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1263
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1264
|
-
}
|
|
1265
|
-
if (!drag) return
|
|
1266
|
-
editor.setHintingShapes([])
|
|
1267
|
-
if (!drag.moved) {
|
|
1268
|
-
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
1269
|
-
return
|
|
1270
|
-
}
|
|
1271
|
-
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1272
|
-
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
1273
|
-
setDragPagePoint(null)
|
|
1274
|
-
let anchor: TLCommentThread['anchor']
|
|
1275
|
-
if (thread.anchor.type === 'region') {
|
|
1276
|
-
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
1277
|
-
anchor = {
|
|
1278
|
-
...thread.anchor,
|
|
1279
|
-
x: pagePoint.x - pinCorner.x * thread.anchor.w,
|
|
1280
|
-
y: pagePoint.y - pinCorner.y * thread.anchor.h,
|
|
1281
|
-
}
|
|
1282
|
-
} else {
|
|
1283
|
-
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
1284
|
-
anchor = hit
|
|
1285
|
-
? shapeAnchorAt(
|
|
1286
|
-
editor,
|
|
1287
|
-
hit.id,
|
|
1288
|
-
pagePoint,
|
|
1289
|
-
getCommentingOptions(editor).shouldBePrecise(editor, {
|
|
1290
|
-
shapeId: hit.id,
|
|
1291
|
-
point: pagePoint,
|
|
1292
|
-
altKey: e.altKey,
|
|
1293
|
-
})
|
|
1294
|
-
)
|
|
1295
|
-
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
1296
|
-
}
|
|
1297
|
-
commitCommentMutation(editor, () => putRecordsInCommit(editor, [{ ...thread, anchor }]), 'drag')
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
1301
|
-
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
1302
|
-
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
|
1303
|
-
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
1304
|
-
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
|
1305
|
-
// The marker anchors bottom-left, so step half its 34px size left and down (screen px).
|
|
1306
|
-
const renderPoint = isRegion
|
|
1307
|
-
? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
|
|
1308
|
-
: renderPointBase
|
|
1309
|
-
|
|
1310
|
-
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
1311
|
-
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
1312
|
-
const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
|
|
1313
|
-
const movedRegion =
|
|
1314
|
-
regionAnchor && dragPagePoint
|
|
1315
|
-
? {
|
|
1316
|
-
...regionAnchor,
|
|
1317
|
-
x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
|
|
1318
|
-
y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
|
|
1319
|
-
}
|
|
1320
|
-
: regionAnchor
|
|
1321
|
-
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
1322
|
-
const commitResize = (bounds: BoxModel) => {
|
|
1323
|
-
setResizeBounds(null)
|
|
1324
|
-
if (!canComment) return
|
|
1325
|
-
// Same commit path as a pin drag, so the configured `dragHistory` governs both — going
|
|
1326
|
-
// straight to `editor.run` here would make region resizes silently ignore the option.
|
|
1327
|
-
commitCommentMutation(
|
|
1328
|
-
editor,
|
|
1329
|
-
// Spread the existing anchor first so the region's pin corner survives a resize.
|
|
1330
|
-
() => putRecordsInCommit(editor, [{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
|
1331
|
-
'drag'
|
|
1332
|
-
)
|
|
1333
|
-
}
|
|
1334
|
-
|
|
268
|
+
// Portalled rather than rendered into this component's slot: pins sit below the collaborator
|
|
269
|
+
// cursors and popovers above the UI panels, and no single canvas layer spans both. The portal
|
|
270
|
+
// also fixes where the layer lands among the container's children, keeping it behind the UI's
|
|
271
|
+
// skip link in the tab order.
|
|
1335
272
|
return (
|
|
1336
|
-
|
|
1337
|
-
{
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
container={container}
|
|
1391
|
-
style={{
|
|
1392
|
-
left: renderPoint.x + POPOVER_OFFSET.thread.x,
|
|
1393
|
-
top: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
|
1394
|
-
}}
|
|
1395
|
-
>
|
|
1396
|
-
<ThreadView editor={editor} thread={thread} {...props} />
|
|
1397
|
-
</ThreadPopover>
|
|
273
|
+
<EditorPortal>
|
|
274
|
+
{/* Wheel pass-through lives on each interactive element, not this root: the root spans the
|
|
275
|
+
whole canvas, so a pin past its bottom/right edge inflates scrollHeight and the wheel
|
|
276
|
+
hook's is-this-scrollable guard silently disables pass-through. */}
|
|
277
|
+
<div className="tlui-cmt-canvas-layer">
|
|
278
|
+
{options.enableClustering ? (
|
|
279
|
+
<>
|
|
280
|
+
{fadeNodes.map(({ node, phase }) => {
|
|
281
|
+
let content: ReactNode
|
|
282
|
+
const stackGroup = node.count > 1 ? stackGroupOf(node) : null
|
|
283
|
+
if (node.count === 1) {
|
|
284
|
+
const thread = threadsById.get(node.id)
|
|
285
|
+
if (!thread) return null
|
|
286
|
+
content = renderThreadPin(thread)
|
|
287
|
+
} else if (stackGroup) {
|
|
288
|
+
// Routed through the stack's owner so the open/orphan/held slots stay deduped:
|
|
289
|
+
// when the owner is one of them, that slot draws the stack and this draws nothing.
|
|
290
|
+
const owner = stackGroup.find((id) => renderedThreadIds.has(id))
|
|
291
|
+
content =
|
|
292
|
+
owner && node.members.includes(owner)
|
|
293
|
+
? renderThreadPin(threadsById.get(owner)!)
|
|
294
|
+
: null
|
|
295
|
+
} else {
|
|
296
|
+
content = (
|
|
297
|
+
<ClusterBadge
|
|
298
|
+
editor={editor}
|
|
299
|
+
node={node}
|
|
300
|
+
onExpand={expandCluster}
|
|
301
|
+
onSelectThread={revealClusteredThread}
|
|
302
|
+
threadsById={threadsById}
|
|
303
|
+
currentUserId={props.currentUserId}
|
|
304
|
+
resolveAuthor={props.resolveAuthor}
|
|
305
|
+
/>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
return (
|
|
309
|
+
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
|
310
|
+
{content}
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
})}
|
|
314
|
+
{orphanThreads.map((thread) => (
|
|
315
|
+
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
316
|
+
))}
|
|
317
|
+
{heldThreads.map((thread) => (
|
|
318
|
+
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
319
|
+
))}
|
|
320
|
+
</>
|
|
321
|
+
) : (
|
|
322
|
+
// Clustering off: every thread renders its own pin. The open thread is excluded and
|
|
323
|
+
// rendered once below, or it would mount a second, stacked pin.
|
|
324
|
+
threads
|
|
325
|
+
.filter((thread) => thread.id !== openId)
|
|
326
|
+
.map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
|
|
1398
327
|
)}
|
|
1399
|
-
{
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
currentUserId={props.currentUserId}
|
|
1411
|
-
resolveAuthor={resolveAuthor}
|
|
1412
|
-
/>
|
|
328
|
+
{openThread && (
|
|
329
|
+
<Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
|
|
330
|
+
)}
|
|
331
|
+
<RegionDraftBox editor={editor} />
|
|
332
|
+
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
|
333
|
+
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
|
334
|
+
{pending?.anchor.type === 'region' && showPendingComposer && (
|
|
335
|
+
<RegionBox editor={editor} box={pending.anchor} />
|
|
336
|
+
)}
|
|
337
|
+
{pending && showPendingComposer && (
|
|
338
|
+
<PendingComposer editor={editor} pending={pending} {...props} />
|
|
1413
339
|
)}
|
|
1414
340
|
</div>
|
|
1415
|
-
|
|
1416
|
-
)
|
|
1417
|
-
})
|
|
1418
|
-
|
|
1419
|
-
function PendingComposer({
|
|
1420
|
-
editor,
|
|
1421
|
-
pending,
|
|
1422
|
-
currentUserId,
|
|
1423
|
-
resolveAuthor,
|
|
1424
|
-
onPostComment,
|
|
1425
|
-
getMentionSuggestions,
|
|
1426
|
-
renderMentionSuggestion,
|
|
1427
|
-
}: CommentingContext & { editor: Editor; pending: PendingComment }) {
|
|
1428
|
-
const ComposerFallback = useCommentingOptions().components.ComposerFallback
|
|
1429
|
-
const canComment = useCanComment(currentUserId)
|
|
1430
|
-
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
|
1431
|
-
// The leading pin previews the pin this draft becomes: a white pin holding the author's avatar.
|
|
1432
|
-
const draftAvatar = (
|
|
1433
|
-
<CommentPin>
|
|
1434
|
-
<Avatar author={me ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
1435
|
-
</CommentPin>
|
|
1436
|
-
)
|
|
1437
|
-
// Click-away keeps the draft (saved on every change) and the next placement composer
|
|
1438
|
-
// restores it — the flip side of dismissing without a discard warning.
|
|
1439
|
-
const [text, setText] = useState<TLRichText>(
|
|
1440
|
-
() => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
|
|
1441
|
-
)
|
|
1442
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
1443
|
-
const msg = useTranslation()
|
|
1444
|
-
const container = useContainer()
|
|
1445
|
-
// Over this floating panel, scroll and hover reach the canvas (except where it scrolls itself).
|
|
1446
|
-
usePassThroughWheelEvents(ref)
|
|
1447
|
-
usePassThroughMouseOverEvents(ref)
|
|
1448
|
-
|
|
1449
|
-
const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
|
|
1450
|
-
editor,
|
|
1451
|
-
pending.point,
|
|
1452
|
-
])
|
|
1453
|
-
|
|
1454
|
-
// Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
|
|
1455
|
-
useEffect(() => {
|
|
1456
|
-
const onPointerDown = (e: PointerEvent) => {
|
|
1457
|
-
const el = ref.current
|
|
1458
|
-
const target = e.target as HTMLElement | null
|
|
1459
|
-
if (!el || !target) return
|
|
1460
|
-
// A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
|
|
1461
|
-
// not "outside" — keep the draft open so the pick can insert.
|
|
1462
|
-
if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
|
|
1463
|
-
pendingComment.set(editor, null)
|
|
1464
|
-
}
|
|
1465
|
-
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1466
|
-
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1467
|
-
}, [editor])
|
|
1468
|
-
|
|
1469
|
-
const submit = () => {
|
|
1470
|
-
if (isCommentEmpty(text) || !currentUserId) return
|
|
1471
|
-
commitCommentMutation(editor, () => {
|
|
1472
|
-
const pageId = editor.getCurrentPageId()
|
|
1473
|
-
const thread = createCommentThread({
|
|
1474
|
-
pageId,
|
|
1475
|
-
anchor: pending.anchor,
|
|
1476
|
-
createdBy: currentUserId,
|
|
1477
|
-
})
|
|
1478
|
-
const comment = createComment({
|
|
1479
|
-
threadId: thread.id,
|
|
1480
|
-
pageId,
|
|
1481
|
-
authorId: currentUserId,
|
|
1482
|
-
body: text,
|
|
1483
|
-
})
|
|
1484
|
-
putRecordsInCommit(editor, [thread, comment])
|
|
1485
|
-
if (onPostComment) onPostComment(comment)
|
|
1486
|
-
})
|
|
1487
|
-
setText(EMPTY_COMMENT)
|
|
1488
|
-
clearCommentDraft(NEW_COMMENT_DRAFT)
|
|
1489
|
-
pendingComment.set(editor, null)
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
return createPortal(
|
|
1493
|
-
<div
|
|
1494
|
-
ref={ref}
|
|
1495
|
-
className={[
|
|
1496
|
-
'tlui-cmt-canvas-composer',
|
|
1497
|
-
pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
|
|
1498
|
-
!canComment && 'tlui-cmt-canvas-composer--fallback',
|
|
1499
|
-
]
|
|
1500
|
-
.filter(Boolean)
|
|
1501
|
-
.join(' ')}
|
|
1502
|
-
style={{ left: point.x, top: point.y }}
|
|
1503
|
-
onPointerDown={stop}
|
|
1504
|
-
onContextMenu={stop}
|
|
1505
|
-
onKeyDown={(e) => {
|
|
1506
|
-
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
|
1507
|
-
}}
|
|
1508
|
-
>
|
|
1509
|
-
{canComment ? (
|
|
1510
|
-
<CommentComposer
|
|
1511
|
-
author={me ?? UNKNOWN_COMMENT_AUTHOR}
|
|
1512
|
-
placeholder={msg('comments.add-placeholder')}
|
|
1513
|
-
sendLabel={msg('comments.send')}
|
|
1514
|
-
value={text}
|
|
1515
|
-
onChange={(value) => {
|
|
1516
|
-
setText(value)
|
|
1517
|
-
saveCommentDraft(NEW_COMMENT_DRAFT, value)
|
|
1518
|
-
}}
|
|
1519
|
-
onSubmit={submit}
|
|
1520
|
-
// No user, no author for the record — dead send button.
|
|
1521
|
-
disabled={isCommentEmpty(text) || !currentUserId}
|
|
1522
|
-
getMentionSuggestions={getMentionSuggestions}
|
|
1523
|
-
renderMentionSuggestion={renderMentionSuggestion}
|
|
1524
|
-
autoFocus
|
|
1525
|
-
leading={draftAvatar}
|
|
1526
|
-
/>
|
|
1527
|
-
) : (
|
|
1528
|
-
ComposerFallback && <ComposerFallback context="pending" />
|
|
1529
|
-
)}
|
|
1530
|
-
</div>,
|
|
1531
|
-
container
|
|
341
|
+
</EditorPortal>
|
|
1532
342
|
)
|
|
1533
343
|
}
|