@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
|
@@ -1,390 +1,204 @@
|
|
|
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'
|
|
5
|
+
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
|
6
|
+
import { ClusterBadge } from './cluster-badge'
|
|
7
|
+
import { clusterFadeClassName } from './cluster-fade'
|
|
1
8
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
useState,
|
|
11
|
-
} from 'react'
|
|
12
|
-
import { createPortal } from 'react-dom'
|
|
13
|
-
import {
|
|
14
|
-
type BoxModel,
|
|
15
|
-
createComment,
|
|
16
|
-
createCommentThread,
|
|
17
|
-
Editor,
|
|
18
|
-
getFirstCharacter,
|
|
19
|
-
react,
|
|
20
|
-
TLComment,
|
|
21
|
-
TLCommentId,
|
|
22
|
-
TLCommentThread,
|
|
23
|
-
TLRichText,
|
|
24
|
-
TldrawUiIcon,
|
|
25
|
-
useContainer,
|
|
26
|
-
useEditor,
|
|
27
|
-
usePassThroughMouseOverEvents,
|
|
28
|
-
usePassThroughWheelEvents,
|
|
29
|
-
useTranslation,
|
|
30
|
-
useValue,
|
|
31
|
-
VecLike,
|
|
32
|
-
} from 'tldraw'
|
|
33
|
-
import { computeClusterTable } from '../clustering/computeClusterTable'
|
|
34
|
-
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
|
35
|
-
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
|
36
|
-
import { CommentCard, CommentCardProps } from '../ui/comment-card'
|
|
37
|
-
import { CommentComposer } from '../ui/comment-composer'
|
|
38
|
-
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
|
39
|
-
import { CommentPin } from '../ui/comment-pin'
|
|
40
|
-
import { CommentThread } from '../ui/comment-thread'
|
|
41
|
-
import { CountBadge } from '../ui/count-badge'
|
|
42
|
-
import { MentionMember } from '../ui/mention-list'
|
|
43
|
-
import { isMentionPickerOpen } from '../ui/mention-suggestion'
|
|
44
|
-
import { collectClusterLeaves } from './cluster-input'
|
|
45
|
-
import { CommentBody } from './comment-body'
|
|
46
|
-
import { UNKNOWN_AUTHOR } from './comment-render'
|
|
47
|
-
import { getCommentRecord, putCommentRecords, removeCommentRecords } from './comment-store'
|
|
48
|
-
import { PendingComment } from './comment-tool'
|
|
49
|
-
import { useCommentThreads, useThreadComments } from './hooks'
|
|
9
|
+
CLUSTER_EXPAND_ZOOM_MS,
|
|
10
|
+
revealThreadPin,
|
|
11
|
+
useClusterModel,
|
|
12
|
+
zoomToClusterSplit,
|
|
13
|
+
} from './cluster-model'
|
|
14
|
+
import { getCommentRecord } from './comment-store'
|
|
15
|
+
import { type CommentingContext } from './context'
|
|
16
|
+
import { useCommentThreads } from './hooks'
|
|
50
17
|
import { useCommentingEnabled } from './license'
|
|
51
|
-
import {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
useCommentingOptions,
|
|
56
|
-
} from './options'
|
|
57
|
-
import {
|
|
58
|
-
DEFAULT_REGION_COMMENT_OPTIONS,
|
|
59
|
-
RegionCommentOptions,
|
|
60
|
-
setRegionCommentOptions,
|
|
61
|
-
} from './region-options'
|
|
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'
|
|
62
22
|
import {
|
|
63
23
|
commentsHidden,
|
|
64
|
-
|
|
24
|
+
openStackId,
|
|
65
25
|
openThreadId,
|
|
66
26
|
pendingComment,
|
|
67
|
-
|
|
27
|
+
revealThreadRequest,
|
|
28
|
+
sidebarFilters,
|
|
68
29
|
toggleCommentsHidden,
|
|
69
30
|
usePendingComment,
|
|
70
31
|
} from './state'
|
|
71
|
-
import {
|
|
32
|
+
import { ThreadPin } from './thread-pin'
|
|
33
|
+
import { ThreadStackPin } from './thread-stack'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The host wiring for {@link CanvasComments} — see {@link CommentingContext}, which the sidebar
|
|
37
|
+
* takes the same fields from.
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export type CanvasCommentsProps = CommentingContext
|
|
72
42
|
|
|
73
43
|
/**
|
|
74
44
|
* A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
|
|
75
45
|
* thread popover (with a reply composer) on click, and shows a composer where the comment tool
|
|
76
46
|
* placed a new thread. Reads/writes comment records straight from `editor.store`.
|
|
77
47
|
*
|
|
78
|
-
* It's
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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.
|
|
53
|
+
*
|
|
54
|
+
* @public @react
|
|
83
55
|
*/
|
|
84
|
-
export interface CanvasCommentsProps {
|
|
85
|
-
/** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
|
|
86
|
-
currentUserId: string | null
|
|
87
|
-
/** Map an author id to a display name, or `undefined` when the id can't be named. */
|
|
88
|
-
resolveName(id: string): string | undefined
|
|
89
|
-
/** Called after any comment (a new thread's first comment, or a reply) is posted. */
|
|
90
|
-
onPostComment?(comment: TLComment): void
|
|
91
|
-
/** Whether a comment is unread for the current user (return true for unread). */
|
|
92
|
-
isCommentUnread?(commentId: TLCommentId): boolean
|
|
93
|
-
/**
|
|
94
|
-
* Called for each unread comment shown to the user in an open thread popover, so hosts can
|
|
95
|
-
* record a read receipt. Needs `isCommentUnread` to know what's unread.
|
|
96
|
-
*/
|
|
97
|
-
onCommentRead?(commentId: TLCommentId): void
|
|
98
|
-
/** Resolve the members matching an `@`-query in the composers (sync or async). */
|
|
99
|
-
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
|
100
|
-
/** Override a mention-picker row's content. */
|
|
101
|
-
renderMentionSuggestion?(member: MentionMember): ReactNode
|
|
102
|
-
/** Where imprecise shape pins sit — a normalized (0–1) spot within the shape. Default top-right. */
|
|
103
|
-
impreciseShapeAnchor?: { x: number; y: number }
|
|
104
|
-
/** Region comment behaviour. Region is off by default — omit this for click-only point/shape
|
|
105
|
-
* comments. Anything unset falls back to {@link DEFAULT_REGION_COMMENT_OPTIONS}. */
|
|
106
|
-
regionOptions?: Partial<RegionCommentOptions>
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
|
110
|
-
|
|
111
|
-
const initialOf = (name: string): string => (getFirstCharacter(name.trim()) || '?').toUpperCase()
|
|
112
|
-
const CLUSTER_FADE_MS = 150
|
|
113
|
-
/** Duration of the click-a-badge zoom-to-split animation. */
|
|
114
|
-
const CLUSTER_EXPAND_ZOOM_MS = 450
|
|
115
|
-
|
|
116
|
-
/** The leading element for the placement composer — the comment pin's shape, but a pencil
|
|
117
|
-
* instead of an initial, marking an unsent draft. */
|
|
118
|
-
const draftAvatar = (
|
|
119
|
-
<CommentPin>
|
|
120
|
-
<svg
|
|
121
|
-
viewBox="0 0 24 24"
|
|
122
|
-
width="15"
|
|
123
|
-
height="15"
|
|
124
|
-
fill="none"
|
|
125
|
-
stroke="currentColor"
|
|
126
|
-
strokeWidth="2"
|
|
127
|
-
strokeLinecap="round"
|
|
128
|
-
strokeLinejoin="round"
|
|
129
|
-
aria-hidden="true"
|
|
130
|
-
>
|
|
131
|
-
<path d="M12 20h9" />
|
|
132
|
-
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
|
133
|
-
</svg>
|
|
134
|
-
</CommentPin>
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
function toCardProps(
|
|
138
|
-
comment: TLComment,
|
|
139
|
-
props: CanvasCommentsProps,
|
|
140
|
-
components: CommentingComponents
|
|
141
|
-
): CommentCardProps {
|
|
142
|
-
const Body = components.CommentBody
|
|
143
|
-
// The `CommentBody` component slot overrides the built-in rich-text default (which resolves
|
|
144
|
-
// mention ids to names).
|
|
145
|
-
const body = Body ? (
|
|
146
|
-
<Body comment={comment} />
|
|
147
|
-
) : (
|
|
148
|
-
<CommentBody richText={comment.body} resolveName={props.resolveName} />
|
|
149
|
-
)
|
|
150
|
-
return {
|
|
151
|
-
author: props.resolveName(comment.authorId) ?? UNKNOWN_AUTHOR,
|
|
152
|
-
body,
|
|
153
|
-
date: new Date(comment.createdAt).toISOString(),
|
|
154
|
-
you: comment.authorId === props.currentUserId,
|
|
155
|
-
edited: comment.editedAt != null,
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** @public @react */
|
|
160
56
|
export function CanvasComments(props: CanvasCommentsProps) {
|
|
161
|
-
//
|
|
162
|
-
//
|
|
57
|
+
// The inner component holds every other hook, so mounting it as the license resolves keeps hook
|
|
58
|
+
// order stable here.
|
|
163
59
|
const commentingEnabled = useCommentingEnabled()
|
|
164
60
|
if (!commentingEnabled) return null
|
|
165
61
|
return <CanvasCommentsLayer {...props} />
|
|
166
62
|
}
|
|
167
63
|
|
|
168
|
-
function CanvasCommentsLayer(props:
|
|
64
|
+
function CanvasCommentsLayer(props: CommentingContext) {
|
|
169
65
|
const editor = useEditor()
|
|
170
66
|
const options = useCommentingOptions()
|
|
171
|
-
const
|
|
172
|
-
// Merge the consumer's region options over the disabled defaults and publish them for this editor,
|
|
173
|
-
// so the comment tool (which has no props) reads the same per-instance config.
|
|
174
|
-
const regionOptions = useMemo(
|
|
175
|
-
() => ({ ...DEFAULT_REGION_COMMENT_OPTIONS, ...props.regionOptions }),
|
|
176
|
-
[props.regionOptions]
|
|
177
|
-
)
|
|
178
|
-
useEffect(() => setRegionCommentOptions(editor, regionOptions), [editor, regionOptions])
|
|
179
|
-
const layerRef = useRef<HTMLDivElement>(null)
|
|
180
|
-
// Over the pins and cluster badges, wheel and hover pass through to the canvas beneath (these
|
|
181
|
-
// events bubble up from the pointer-interactive markers to this layer root).
|
|
182
|
-
usePassThroughWheelEvents(layerRef)
|
|
183
|
-
usePassThroughMouseOverEvents(layerRef)
|
|
184
|
-
const deepLinkHandled = useRef(false)
|
|
185
|
-
const threads = useCommentThreads(editor)
|
|
67
|
+
const allThreads = useCommentThreads(editor)
|
|
186
68
|
const pending = usePendingComment()
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const [movedThreadIds, setMovedThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
|
|
193
|
-
const adoptOnRebuild = useRef(false)
|
|
194
|
-
const clusterLeaves = useValue(
|
|
195
|
-
'comment cluster leaves',
|
|
196
|
-
() =>
|
|
197
|
-
collectClusterLeaves(
|
|
198
|
-
editor,
|
|
199
|
-
threads.filter((thread) => !movedThreadIds.has(thread.id)),
|
|
200
|
-
openThreadId.get(editor),
|
|
201
|
-
impreciseShapeAnchor
|
|
202
|
-
),
|
|
203
|
-
[editor, threads, impreciseShapeAnchor, movedThreadIds]
|
|
204
|
-
)
|
|
205
|
-
const clusterZoomBounds = useValue(
|
|
206
|
-
'comment cluster zoom bounds',
|
|
207
|
-
() => getClusterZoomBounds(editor),
|
|
208
|
-
[editor]
|
|
209
|
-
)
|
|
210
|
-
const latestModel = useMemo(() => {
|
|
211
|
-
const table = computeClusterTable(clusterLeaves, clusterZoomBounds)
|
|
212
|
-
const runtime = createClusterRuntime(table)
|
|
213
|
-
runtime.seed(editor.getZoomLevel())
|
|
214
|
-
return { runtime, table }
|
|
215
|
-
}, [clusterLeaves, clusterZoomBounds, editor])
|
|
216
|
-
// Re-clustering only applies while zooming: a rebuilt model (thread added, moved, or closed)
|
|
217
|
-
// is held as `latestModel` and adopted on the next zoom change, so pins never re-flow into
|
|
218
|
-
// clusters under a static camera. Until adoption, threads the rendered model doesn't know
|
|
219
|
-
// about show as plain unclustered pins (`orphanThreads`). Exception: a rebuild that *removed*
|
|
220
|
-
// leaves (thread deleted or opened, page changed) is adopted immediately, so stale pins and
|
|
221
|
-
// badge counts never linger.
|
|
222
|
-
const [renderedModel, setRenderedModel] = useState(latestModel)
|
|
223
|
-
let clusterModel = renderedModel
|
|
224
|
-
if (
|
|
225
|
-
renderedModel !== latestModel &&
|
|
226
|
-
(adoptOnRebuild.current || hasRemovedLeaves(renderedModel.table, latestModel.table))
|
|
227
|
-
) {
|
|
228
|
-
adoptOnRebuild.current = false
|
|
229
|
-
// Carryover seed: events inside their hysteresis band inherit the outgoing partition's
|
|
230
|
-
// merged/unmerged state instead of the geometric-mean tiebreak, so untouched pins never
|
|
231
|
-
// snap together (or apart) just because the model was swapped. Idempotent, so safe to
|
|
232
|
-
// run during render.
|
|
233
|
-
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
|
234
|
-
setRenderedModel(latestModel)
|
|
235
|
-
clusterModel = latestModel
|
|
236
|
-
}
|
|
237
|
-
// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
|
|
238
|
-
// is baked into the model), so when its live position drifts from the baked one it ghosts.
|
|
239
|
-
// Marking it moved excludes it from the cluster input, which reads as a removal above and
|
|
240
|
-
// re-clusters the rest of its pile immediately; the pin itself renders live below.
|
|
241
|
-
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
|
242
|
-
if (newlyMovedIds.length > 0) {
|
|
243
|
-
// eslint-disable-next-line no-console
|
|
244
|
-
console.debug(`[comments] pins popped out of clustering: ${newlyMovedIds.join(', ')}`)
|
|
245
|
-
const next = new Set(movedThreadIds)
|
|
246
|
-
for (const id of newlyMovedIds) next.add(id)
|
|
247
|
-
setMovedThreadIds(next)
|
|
248
|
-
}
|
|
249
|
-
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
|
250
|
-
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
|
251
|
-
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
|
252
|
-
useEffect(() => {
|
|
253
|
-
if (movedThreadIds.size === 0) return
|
|
254
|
-
let lastZoom = editor.getZoomLevel()
|
|
255
|
-
return react('rejoin moved comment pins on zoom out', () => {
|
|
256
|
-
const zoom = editor.getZoomLevel()
|
|
257
|
-
const prevZoom = lastZoom
|
|
258
|
-
lastZoom = zoom
|
|
259
|
-
if (zoom >= prevZoom) return
|
|
260
|
-
adoptOnRebuild.current = true
|
|
261
|
-
setMovedThreadIds(EMPTY_SET)
|
|
262
|
-
})
|
|
263
|
-
}, [movedThreadIds, editor])
|
|
264
|
-
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
|
265
|
-
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
|
266
|
-
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
|
69
|
+
const canComment = useCanComment(props.currentUserId)
|
|
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.
|
|
72
|
+
const canRenderComposer = canComment || options.components.ComposerFallback != null
|
|
73
|
+
const showPendingComposer = pending != null && canRenderComposer
|
|
267
74
|
useEffect(() => {
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const orphanThreads = useMemo(() => {
|
|
280
|
-
if (clusterModel === latestModel) return []
|
|
281
|
-
const renderedIds = new Set(clusterModel.table.leaves.map((leaf) => leaf.id))
|
|
282
|
-
const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
283
|
-
return threads.filter((thread) => latestIds.has(thread.id) && !renderedIds.has(thread.id))
|
|
284
|
-
}, [clusterModel, latestModel, threads])
|
|
285
|
-
const movedThreads = useMemo(
|
|
286
|
-
() => threads.filter((thread) => movedThreadIds.has(thread.id) && thread.id !== openId),
|
|
287
|
-
[threads, movedThreadIds, openId]
|
|
288
|
-
)
|
|
289
|
-
// Subscribe to the runtime cursor, not the raw zoom: onCamera runs on every zoom tick (two
|
|
290
|
-
// O(1) threshold checks against the event table) but returns the same integer until a merge
|
|
291
|
-
// or split event actually fires — so this component only re-renders on cluster changes, not
|
|
292
|
-
// on every camera frame.
|
|
293
|
-
const clusterCursor = useValue(
|
|
294
|
-
'comment cluster cursor',
|
|
295
|
-
() => {
|
|
296
|
-
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
|
297
|
-
return clusterModel.runtime.k
|
|
298
|
-
},
|
|
299
|
-
[clusterModel, editor]
|
|
75
|
+
if (pending && !showPendingComposer) pendingComment.set(editor, null)
|
|
76
|
+
}, [editor, pending, showPendingComposer])
|
|
77
|
+
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
|
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.
|
|
80
|
+
const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
|
|
81
|
+
editor,
|
|
82
|
+
])
|
|
83
|
+
const threads = useMemo(
|
|
84
|
+
() => allThreads.filter((t) => showResolved || t.resolved == null || t.id === openId),
|
|
85
|
+
[allThreads, showResolved, openId]
|
|
300
86
|
)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
},
|
|
309
|
-
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
|
87
|
+
useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
|
|
88
|
+
const {
|
|
89
|
+
model: clusterModel,
|
|
90
|
+
zoomBounds: clusterZoomBounds,
|
|
91
|
+
fadeNodes,
|
|
92
|
+
orphanThreads,
|
|
93
|
+
heldThreads,
|
|
94
|
+
} = useClusterModel(editor, threads, openId)
|
|
310
95
|
const threadsById = useMemo(
|
|
311
96
|
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
|
312
97
|
[threads]
|
|
313
98
|
)
|
|
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
|
+
)
|
|
314
114
|
const openThread = openId ? threadsById.get(openId) : null
|
|
315
115
|
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
316
116
|
|
|
317
|
-
// Reset the transient UI state (open thread, half-placed comment) when this unmounts.
|
|
318
117
|
useEffect(() => {
|
|
319
118
|
return () => {
|
|
320
119
|
openThreadId.set(editor, null)
|
|
120
|
+
openStackId.set(editor, null)
|
|
321
121
|
pendingComment.set(editor, null)
|
|
122
|
+
revealThreadRequest.set(editor, null)
|
|
322
123
|
}
|
|
323
124
|
}, [editor])
|
|
324
125
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
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
|
+
)
|
|
327
139
|
useEffect(() => {
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
if (!id) {
|
|
331
|
-
deepLinkHandled.current = true
|
|
332
|
-
return
|
|
333
|
-
}
|
|
140
|
+
if (openStackKeyIsStale) openStackId.set(editor, null)
|
|
141
|
+
}, [editor, openStackKeyIsStale])
|
|
334
142
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
143
|
+
// The requested thread, once it (and, for a comment id, its parent thread) has synced into the
|
|
144
|
+
// store; null while records are still arriving or when no request is pending.
|
|
145
|
+
const requestedRevealThread = useValue(
|
|
146
|
+
'requested reveal thread',
|
|
147
|
+
() => {
|
|
148
|
+
const id = revealThreadRequest.get(editor)
|
|
149
|
+
if (!id) return null
|
|
150
|
+
const record = getCommentRecord(editor, id)
|
|
151
|
+
if (!record) return null
|
|
152
|
+
const thread =
|
|
153
|
+
record.typeName === 'comment' ? getCommentRecord(editor, record.threadId) : record
|
|
154
|
+
return thread?.typeName === 'comment-thread' ? thread : null
|
|
155
|
+
},
|
|
156
|
+
[editor]
|
|
157
|
+
)
|
|
345
158
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!requestedRevealThread) return
|
|
163
|
+
revealThreadRequest.set(editor, null)
|
|
164
|
+
commentsHidden.set(editor, false)
|
|
165
|
+
revealThreadPin(editor, requestedRevealThread, clusterModel.table, clusterZoomBounds, options)
|
|
166
|
+
openThreadId.set(editor, requestedRevealThread.id)
|
|
167
|
+
}, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
|
|
168
|
+
|
|
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.
|
|
171
|
+
const revealClusteredThread = useCallback(
|
|
172
|
+
(thread: TLCommentThread) => {
|
|
173
|
+
revealThreadPin(
|
|
174
|
+
editor,
|
|
175
|
+
thread,
|
|
176
|
+
clusterModel.table,
|
|
177
|
+
clusterZoomBounds,
|
|
178
|
+
options,
|
|
179
|
+
CLUSTER_EXPAND_ZOOM_MS
|
|
180
|
+
)
|
|
181
|
+
openThreadId.set(editor, thread.id)
|
|
182
|
+
},
|
|
183
|
+
[clusterModel.table, clusterZoomBounds, editor, options]
|
|
184
|
+
)
|
|
357
185
|
|
|
358
|
-
|
|
359
|
-
// centered on its centroid. The event that created a visible cluster is the event that splits
|
|
360
|
-
// it, and (by the table's sort + monotone thresholds) it has the smallest zSplit of everything
|
|
361
|
-
// applied inside it — so its zSplit is exactly the first split within those comments. The
|
|
362
|
-
// animated zoom-in then drives the runtime cursor like any manual zoom, so the badge splits
|
|
363
|
-
// (and can be drilled into further) with no extra bookkeeping.
|
|
364
|
-
const zoomToClusterSplit = useCallback(
|
|
186
|
+
const expandCluster = useCallback(
|
|
365
187
|
(node: ClusterNode) => {
|
|
366
|
-
|
|
367
|
-
if (!event || !Number.isFinite(event.zSplit)) return
|
|
368
|
-
const zoom = clamp(
|
|
369
|
-
event.zSplit * options.clusterSplitZoomFactor,
|
|
370
|
-
clusterZoomBounds.minZoom,
|
|
371
|
-
clusterZoomBounds.maxZoom
|
|
372
|
-
)
|
|
373
|
-
centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
|
|
188
|
+
zoomToClusterSplit(editor, clusterModel.table, clusterZoomBounds, node)
|
|
374
189
|
},
|
|
375
|
-
[clusterModel, clusterZoomBounds, editor
|
|
190
|
+
[clusterModel.table, clusterZoomBounds, editor]
|
|
376
191
|
)
|
|
377
192
|
|
|
378
|
-
// Escape collapses the open thread. Capture-phase
|
|
379
|
-
//
|
|
380
|
-
// 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.
|
|
381
195
|
useEffect(() => {
|
|
382
196
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
383
197
|
if (e.key !== 'Escape' || openThreadId.get(editor) === null) return
|
|
384
198
|
// The mention picker owns Escape while it's open — let it dismiss the roster alone.
|
|
385
199
|
if (isMentionPickerOpen()) return
|
|
386
200
|
const target = e.target as HTMLElement | null
|
|
387
|
-
if (target && target.closest('.cmt-editing')) return
|
|
201
|
+
if (target && target.closest('.tlui-cmt-editing')) return
|
|
388
202
|
openThreadId.set(editor, null)
|
|
389
203
|
e.preventDefault()
|
|
390
204
|
e.stopPropagation()
|
|
@@ -393,8 +207,8 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
393
207
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
394
208
|
}, [editor])
|
|
395
209
|
|
|
396
|
-
// Shift+C toggles
|
|
397
|
-
// it never fires from inside a composer.
|
|
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.
|
|
398
212
|
useEffect(() => {
|
|
399
213
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
400
214
|
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
@@ -407,1054 +221,123 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
407
221
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
408
222
|
}, [editor])
|
|
409
223
|
|
|
410
|
-
//
|
|
411
|
-
// 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.
|
|
412
225
|
if (hidden) return null
|
|
413
226
|
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
let content: ReactNode
|
|
422
|
-
if (node.count === 1) {
|
|
423
|
-
const thread = threadsById.get(node.id)
|
|
424
|
-
if (!thread) return null
|
|
425
|
-
content = (
|
|
426
|
-
<ThreadPin
|
|
427
|
-
editor={editor}
|
|
428
|
-
thread={thread}
|
|
429
|
-
{...props}
|
|
430
|
-
regionOptions={regionOptions}
|
|
431
|
-
/>
|
|
432
|
-
)
|
|
433
|
-
} else {
|
|
434
|
-
content = <ClusterBadge editor={editor} node={node} onExpand={zoomToClusterSplit} />
|
|
435
|
-
}
|
|
436
|
-
return (
|
|
437
|
-
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
|
438
|
-
{content}
|
|
439
|
-
</div>
|
|
440
|
-
)
|
|
441
|
-
})}
|
|
442
|
-
{orphanThreads.map((thread) => (
|
|
443
|
-
<ThreadPin
|
|
444
|
-
key={thread.id}
|
|
445
|
-
editor={editor}
|
|
446
|
-
thread={thread}
|
|
447
|
-
{...props}
|
|
448
|
-
regionOptions={regionOptions}
|
|
449
|
-
/>
|
|
450
|
-
))}
|
|
451
|
-
{movedThreads.map((thread) => (
|
|
452
|
-
<ThreadPin
|
|
453
|
-
key={thread.id}
|
|
454
|
-
editor={editor}
|
|
455
|
-
thread={thread}
|
|
456
|
-
{...props}
|
|
457
|
-
regionOptions={regionOptions}
|
|
458
|
-
/>
|
|
459
|
-
))}
|
|
460
|
-
</>
|
|
461
|
-
) : (
|
|
462
|
-
// Clustering off: every thread renders as its own live pin (each returns null when it's
|
|
463
|
-
// not on the current page or its anchor is missing). The open thread is excluded here and
|
|
464
|
-
// rendered once below, mirroring how the clustering path keeps it out of the cluster leaves —
|
|
465
|
-
// otherwise it would mount a second, stacked pin.
|
|
466
|
-
threads
|
|
467
|
-
.filter((thread) => thread.id !== openId)
|
|
468
|
-
.map((thread) => (
|
|
469
|
-
<ThreadPin
|
|
470
|
-
key={thread.id}
|
|
471
|
-
editor={editor}
|
|
472
|
-
thread={thread}
|
|
473
|
-
{...props}
|
|
474
|
-
regionOptions={regionOptions}
|
|
475
|
-
/>
|
|
476
|
-
))
|
|
477
|
-
)}
|
|
478
|
-
{openThread && (
|
|
479
|
-
<ThreadPin
|
|
480
|
-
key={`open:${openThread.id}`}
|
|
481
|
-
editor={editor}
|
|
482
|
-
thread={openThread}
|
|
483
|
-
{...props}
|
|
484
|
-
regionOptions={regionOptions}
|
|
485
|
-
/>
|
|
486
|
-
)}
|
|
487
|
-
<RegionDraftBox editor={editor} />
|
|
488
|
-
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
|
489
|
-
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
|
490
|
-
{pending?.anchor.type === 'region' && <RegionBox editor={editor} box={pending.anchor} />}
|
|
491
|
-
{pending && props.currentUserId && (
|
|
492
|
-
<PendingComposer editor={editor} pending={pending} {...props} />
|
|
493
|
-
)}
|
|
494
|
-
</div>,
|
|
495
|
-
container
|
|
496
|
-
)
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const EMPTY_SET: ReadonlySet<string> = new Set()
|
|
500
|
-
const MOVED_LEAF_EPSILON = 1e-6
|
|
501
|
-
type ClusterFadePhase = 'entering' | 'present' | 'exiting'
|
|
502
|
-
|
|
503
|
-
interface ClusterFadeNode {
|
|
504
|
-
node: ClusterNode
|
|
505
|
-
phase: ClusterFadePhase
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function useFadeVisibleNodes(
|
|
509
|
-
nodes: readonly ClusterNode[],
|
|
510
|
-
resetKey: { runtime: ClusterRuntime; table: ClusterTable }
|
|
511
|
-
): ClusterFadeNode[] {
|
|
512
|
-
const resetKeyRef = useRef(resetKey)
|
|
513
|
-
const didReset = resetKeyRef.current !== resetKey
|
|
514
|
-
if (didReset) {
|
|
515
|
-
resetKeyRef.current = resetKey
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
|
|
519
|
-
const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
|
|
520
|
-
|
|
521
|
-
useEffect(() => {
|
|
522
|
-
setFadeNodes(toPresentFadeNodes(nodes))
|
|
523
|
-
// Resets only on a new model (resetKey); node-list changes within the same model are
|
|
524
|
-
// handled by the reconcile effect below, which fades entries in/out instead of snapping.
|
|
525
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
526
|
-
}, [resetKey])
|
|
527
|
-
|
|
528
|
-
useEffect(() => {
|
|
529
|
-
if (didReset) return
|
|
530
|
-
setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
|
|
531
|
-
}, [didReset, nodes])
|
|
532
|
-
|
|
533
|
-
const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
|
|
534
|
-
useEffect(() => {
|
|
535
|
-
if (!hasEntering) return
|
|
536
|
-
const frame = requestClusterFadeFrame(() => {
|
|
537
|
-
setFadeNodes((previous) =>
|
|
538
|
-
previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
|
|
539
|
-
)
|
|
540
|
-
})
|
|
541
|
-
return () => cancelClusterFadeFrame(frame)
|
|
542
|
-
}, [hasEntering, renderedNodes])
|
|
543
|
-
|
|
544
|
-
const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
|
|
545
|
-
useEffect(() => {
|
|
546
|
-
if (!hasExiting) return
|
|
547
|
-
const timeout = window.setTimeout(() => {
|
|
548
|
-
setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
|
|
549
|
-
}, CLUSTER_FADE_MS)
|
|
550
|
-
return () => window.clearTimeout(timeout)
|
|
551
|
-
}, [hasExiting, renderedNodes])
|
|
552
|
-
|
|
553
|
-
return renderedNodes
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
|
|
557
|
-
return nodes.map((node) => ({ node, phase: 'present' }))
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function reconcileFadeNodes(
|
|
561
|
-
previous: readonly ClusterFadeNode[],
|
|
562
|
-
nextNodes: readonly ClusterNode[]
|
|
563
|
-
): ClusterFadeNode[] {
|
|
564
|
-
const previousById = new Map(previous.map((item) => [item.node.id, item]))
|
|
565
|
-
const nextIds = new Set(nextNodes.map((node) => node.id))
|
|
566
|
-
const next: ClusterFadeNode[] = []
|
|
567
|
-
|
|
568
|
-
for (const node of nextNodes) {
|
|
569
|
-
const previousItem = previousById.get(node.id)
|
|
570
|
-
next.push({
|
|
571
|
-
node,
|
|
572
|
-
phase:
|
|
573
|
-
previousItem && previousItem.phase !== 'exiting'
|
|
574
|
-
? previousItem.phase
|
|
575
|
-
: previousItem
|
|
576
|
-
? 'present'
|
|
577
|
-
: 'entering',
|
|
578
|
-
})
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
for (const item of previous) {
|
|
582
|
-
if (nextIds.has(item.node.id)) continue
|
|
583
|
-
next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
return next
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
function requestClusterFadeFrame(callback: FrameRequestCallback): number {
|
|
590
|
-
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback)
|
|
591
|
-
return window.setTimeout(() => callback(0), 16)
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function cancelClusterFadeFrame(frame: number) {
|
|
595
|
-
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frame)
|
|
596
|
-
else window.clearTimeout(frame)
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function clusterFadeClassName(phase: ClusterFadePhase): string {
|
|
600
|
-
return `cmt-cluster-fade cmt-cluster-fade--${phase}`
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Leaves folded inside a badge whose live anchor no longer matches the position the rendered
|
|
605
|
-
* model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
|
|
606
|
-
* stay deferred; a badge can't follow a member, so these must pop out of clustering.
|
|
607
|
-
*/
|
|
608
|
-
function findMovedClusteredLeafIds(
|
|
609
|
-
rendered: { runtime: ClusterRuntime; table: ClusterTable },
|
|
610
|
-
latest: { table: ClusterTable }
|
|
611
|
-
): string[] {
|
|
612
|
-
if (rendered.table === latest.table) return []
|
|
613
|
-
const visible = rendered.runtime.getVisible()
|
|
614
|
-
const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
|
|
615
|
-
const moved: string[] = []
|
|
616
|
-
for (const leaf of rendered.table.leaves) {
|
|
617
|
-
if (visible.has(leaf.id)) continue
|
|
618
|
-
const current = latestById.get(leaf.id)
|
|
619
|
-
if (!current) continue
|
|
620
|
-
if (
|
|
621
|
-
Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
|
|
622
|
-
Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
|
|
623
|
-
) {
|
|
624
|
-
moved.push(leaf.id)
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
return moved
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function hasRemovedLeaves(rendered: ClusterTable, latest: ClusterTable): boolean {
|
|
631
|
-
if (rendered.leaves.length === 0) return false
|
|
632
|
-
const latestIds = new Set(latest.leaves.map((leaf) => leaf.id))
|
|
633
|
-
return rendered.leaves.some((leaf) => !latestIds.has(leaf.id))
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
|
|
637
|
-
const cameraOptions = editor.getCameraOptions()
|
|
638
|
-
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
|
639
|
-
const zoomSteps = cameraOptions.zoomSteps
|
|
640
|
-
return {
|
|
641
|
-
minZoom: zoomSteps[0] * baseZoom,
|
|
642
|
-
maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
|
|
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.
|
|
230
|
+
const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
|
|
231
|
+
const group = pinStacks.get(node.members[0])
|
|
232
|
+
if (!group) return null
|
|
233
|
+
return node.members.every((id) => group.includes(id)) ? group : null
|
|
643
234
|
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function revealDeepLinkedThread(
|
|
647
|
-
editor: Editor,
|
|
648
|
-
thread: TLCommentThread,
|
|
649
|
-
table: ClusterTable,
|
|
650
|
-
zoomBounds: { minZoom: number; maxZoom: number },
|
|
651
|
-
options: CommentingOptions,
|
|
652
|
-
impreciseShapeAnchor: { x: number; y: number }
|
|
653
|
-
) {
|
|
654
|
-
if (thread.pageId !== editor.getCurrentPageId()) {
|
|
655
|
-
editor.setCurrentPage(thread.pageId as any)
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Match where the rendered pin sits (resolved prop-or-option), so the camera centers on the pin.
|
|
659
|
-
const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
660
|
-
if (!point) return
|
|
661
235
|
|
|
662
|
-
//
|
|
663
|
-
//
|
|
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.
|
|
238
|
+
const renderedThreadIds = new Set<string>()
|
|
664
239
|
if (options.enableClustering) {
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
) {
|
|
671
|
-
const zoom = clamp(
|
|
672
|
-
parentEvent.zSplit * options.clusterSplitZoomFactor,
|
|
673
|
-
zoomBounds.minZoom,
|
|
674
|
-
zoomBounds.maxZoom
|
|
675
|
-
)
|
|
676
|
-
centerOnPointAtZoom(editor, point, zoom)
|
|
677
|
-
return
|
|
240
|
+
for (const { node } of fadeNodes) {
|
|
241
|
+
if (node.count === 1) renderedThreadIds.add(node.id)
|
|
242
|
+
// A pure-stack node's members aren't count-1 leaves, so register them here for the owner
|
|
243
|
+
// logic to pick from.
|
|
244
|
+
else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
|
|
678
245
|
}
|
|
246
|
+
for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
|
|
247
|
+
for (const thread of heldThreads) renderedThreadIds.add(thread.id)
|
|
248
|
+
} else {
|
|
249
|
+
for (const thread of threads) renderedThreadIds.add(thread.id)
|
|
679
250
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
) {
|
|
694
|
-
const viewport = editor.getViewportScreenBounds()
|
|
695
|
-
editor.setCamera(
|
|
696
|
-
{
|
|
697
|
-
x: viewport.w / (2 * zoom) - point.x,
|
|
698
|
-
y: viewport.h / (2 * zoom) - point.y,
|
|
699
|
-
z: zoom,
|
|
700
|
-
},
|
|
701
|
-
{ animation: { duration } }
|
|
702
|
-
)
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function clamp(value: number, min: number, max: number): number {
|
|
706
|
-
return Math.max(min, Math.min(max, value))
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
|
|
710
|
-
// badges skip re-rendering when the parent re-renders for reasons that don't concern them
|
|
711
|
-
// (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
|
|
712
|
-
// works — each component subscribes to its own viewport position via signals, not via props.
|
|
713
|
-
const ClusterBadge = memo(function ClusterBadge({
|
|
714
|
-
editor,
|
|
715
|
-
node,
|
|
716
|
-
onExpand,
|
|
717
|
-
}: {
|
|
718
|
-
editor: Editor
|
|
719
|
-
node: ClusterNode
|
|
720
|
-
onExpand(node: ClusterNode): void
|
|
721
|
-
}) {
|
|
722
|
-
const point = useValue(
|
|
723
|
-
'cluster badge point',
|
|
724
|
-
() => {
|
|
725
|
-
const pagePoint = editor.pageToViewport(node.centroid)
|
|
726
|
-
if (!isInInflatedViewport(editor, pagePoint)) return null
|
|
727
|
-
return pagePoint
|
|
728
|
-
},
|
|
729
|
-
[editor, node]
|
|
730
|
-
)
|
|
731
|
-
|
|
732
|
-
if (!point) return null
|
|
733
|
-
|
|
734
|
-
return (
|
|
735
|
-
<div
|
|
736
|
-
className="cmt-canvas-cluster"
|
|
737
|
-
style={{ left: point.x, top: point.y }}
|
|
738
|
-
onPointerDown={stop}
|
|
739
|
-
onClick={(e) => {
|
|
740
|
-
e.stopPropagation()
|
|
741
|
-
onExpand(node)
|
|
742
|
-
}}
|
|
743
|
-
>
|
|
744
|
-
<CountBadge count={node.count} />
|
|
745
|
-
</div>
|
|
746
|
-
)
|
|
747
|
-
})
|
|
748
|
-
|
|
749
|
-
function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
|
|
750
|
-
const viewport = editor.getViewportScreenBounds()
|
|
751
|
-
const margin = getCommentingOptions(editor).clusterCullMargin
|
|
752
|
-
return (
|
|
753
|
-
point.x >= -margin &&
|
|
754
|
-
point.y >= -margin &&
|
|
755
|
-
point.x <= viewport.w + margin &&
|
|
756
|
-
point.y <= viewport.h + margin
|
|
757
|
-
)
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/** The open thread's popover, portaled above the UI panels. Over it, wheel and hover events pass
|
|
761
|
-
* through to the canvas (unless the popover is scrolling its own content), like tldraw's panels. */
|
|
762
|
-
function ThreadPopover({
|
|
763
|
-
container,
|
|
764
|
-
style,
|
|
765
|
-
children,
|
|
766
|
-
}: {
|
|
767
|
-
container: HTMLElement
|
|
768
|
-
style: CSSProperties
|
|
769
|
-
children: ReactNode
|
|
770
|
-
}) {
|
|
771
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
772
|
-
usePassThroughWheelEvents(ref)
|
|
773
|
-
usePassThroughMouseOverEvents(ref)
|
|
774
|
-
return createPortal(
|
|
775
|
-
<div ref={ref} className="cmt-canvas-popover" style={style} onPointerDown={stop}>
|
|
776
|
-
{children}
|
|
777
|
-
</div>,
|
|
778
|
-
container
|
|
779
|
-
)
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
|
|
783
|
-
* a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
|
|
784
|
-
* `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
|
|
785
|
-
function RegionBox({
|
|
786
|
-
editor,
|
|
787
|
-
box,
|
|
788
|
-
movable,
|
|
789
|
-
onPreview,
|
|
790
|
-
onCommit,
|
|
791
|
-
}: {
|
|
792
|
-
editor: Editor
|
|
793
|
-
box: BoxModel
|
|
794
|
-
movable?: boolean
|
|
795
|
-
onPreview?(bounds: BoxModel | null): void
|
|
796
|
-
onCommit?(bounds: BoxModel): void
|
|
797
|
-
}) {
|
|
798
|
-
const rect = useValue(
|
|
799
|
-
'region rect',
|
|
800
|
-
() => {
|
|
801
|
-
// Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
|
|
802
|
-
const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
|
|
803
|
-
const zoom = editor.getZoomLevel()
|
|
804
|
-
return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
|
|
805
|
-
},
|
|
806
|
-
[editor, box.x, box.y, box.w, box.h]
|
|
807
|
-
)
|
|
808
|
-
// The grab point and the box at grab time, captured so the drag translates by a stable delta even
|
|
809
|
-
// as the box prop reflows under the live preview.
|
|
810
|
-
const grabRef = useRef<{ page: VecLike; box: BoxModel } | null>(null)
|
|
811
|
-
const translated = (e: ReactPointerEvent<HTMLDivElement>): BoxModel => {
|
|
812
|
-
const g = grabRef.current!
|
|
813
|
-
const p = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
814
|
-
return { ...g.box, x: g.box.x + (p.x - g.page.x), y: g.box.y + (p.y - g.page.y) }
|
|
815
|
-
}
|
|
816
|
-
const startMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
817
|
-
e.stopPropagation()
|
|
818
|
-
grabRef.current = { page: editor.screenToPage({ x: e.clientX, y: e.clientY }), box }
|
|
819
|
-
e.currentTarget.setPointerCapture(e.pointerId)
|
|
820
|
-
}
|
|
821
|
-
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
822
|
-
if (grabRef.current) onPreview?.(translated(e))
|
|
823
|
-
}
|
|
824
|
-
const endMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
825
|
-
if (!grabRef.current) return
|
|
826
|
-
const bounds = translated(e)
|
|
827
|
-
grabRef.current = null
|
|
828
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
829
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
830
|
-
onCommit?.(bounds)
|
|
831
|
-
}
|
|
832
|
-
return (
|
|
833
|
-
<div
|
|
834
|
-
className={movable ? 'cmt-canvas-region cmt-canvas-region--movable' : 'cmt-canvas-region'}
|
|
835
|
-
style={rect}
|
|
836
|
-
onPointerDown={movable ? startMove : undefined}
|
|
837
|
-
onPointerMove={movable ? onMove : undefined}
|
|
838
|
-
onPointerUp={movable ? endMove : undefined}
|
|
839
|
-
/>
|
|
840
|
-
)
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
|
844
|
-
function RegionDraftBox({ editor }: { editor: Editor }) {
|
|
845
|
-
const box = useValue('region draft', () => regionDraft.get(editor), [editor])
|
|
846
|
-
if (!box) return null
|
|
847
|
-
return <RegionBox editor={editor} box={box} />
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 (a side midpoint)
|
|
851
|
-
// is *not* controlled by that handle: corners resize both axes, edges resize only their own.
|
|
852
|
-
interface RegionHandle {
|
|
853
|
-
x: number
|
|
854
|
-
y: number
|
|
855
|
-
cursor: string
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// The four corners (both axes) and the four side midpoints (one axis each).
|
|
859
|
-
const REGION_CORNERS: readonly RegionHandle[] = [
|
|
860
|
-
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
|
861
|
-
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
|
862
|
-
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
|
863
|
-
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
|
864
|
-
]
|
|
865
|
-
const REGION_EDGES: readonly RegionHandle[] = [
|
|
866
|
-
{ x: 0.5, y: 0, cursor: 'ns-resize' },
|
|
867
|
-
{ x: 1, y: 0.5, cursor: 'ew-resize' },
|
|
868
|
-
{ x: 0.5, y: 1, cursor: 'ns-resize' },
|
|
869
|
-
{ x: 0, y: 0.5, cursor: 'ew-resize' },
|
|
870
|
-
]
|
|
871
|
-
|
|
872
|
-
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
|
873
|
-
// the handles (which sit on the edge) are comfortably reachable.
|
|
874
|
-
const REGION_HANDLE_MARGIN_PX = 12
|
|
875
|
-
|
|
876
|
-
/** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
|
|
877
|
-
* handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
|
|
878
|
-
* handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
|
|
879
|
-
function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
|
|
880
|
-
const controlsX = handle.x !== 0.5
|
|
881
|
-
const controlsY = handle.y !== 0.5
|
|
882
|
-
const fixedX = box.x + (1 - handle.x) * box.w
|
|
883
|
-
const fixedY = box.y + (1 - handle.y) * box.h
|
|
884
|
-
return {
|
|
885
|
-
x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
|
|
886
|
-
y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
|
|
887
|
-
w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
|
|
888
|
-
h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
/** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
|
|
893
|
-
* option. Previews live, commits on release. */
|
|
894
|
-
function RegionResizeHandles({
|
|
895
|
-
editor,
|
|
896
|
-
box,
|
|
897
|
-
handles,
|
|
898
|
-
onPreview,
|
|
899
|
-
onCommit,
|
|
900
|
-
}: {
|
|
901
|
-
editor: Editor
|
|
902
|
-
box: BoxModel
|
|
903
|
-
handles: readonly RegionHandle[]
|
|
904
|
-
onPreview(bounds: BoxModel | null): void
|
|
905
|
-
onCommit(bounds: BoxModel): void
|
|
906
|
-
}) {
|
|
907
|
-
// The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
|
|
908
|
-
// the fixed edges mid-drag.
|
|
909
|
-
const boxRef = useRef<BoxModel | null>(null)
|
|
910
|
-
const points = useValue(
|
|
911
|
-
'region handle points',
|
|
912
|
-
() =>
|
|
913
|
-
handles.map((h) => {
|
|
914
|
-
const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
|
|
915
|
-
return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
|
|
916
|
-
}),
|
|
917
|
-
[editor, box.x, box.y, box.w, box.h, handles]
|
|
918
|
-
)
|
|
919
|
-
const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
920
|
-
e.stopPropagation()
|
|
921
|
-
boxRef.current = box
|
|
922
|
-
e.currentTarget.setPointerCapture(e.pointerId)
|
|
923
|
-
}
|
|
924
|
-
const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
|
|
925
|
-
resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
|
|
926
|
-
const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
927
|
-
if (boxRef.current) onPreview(resizedTo(h, e))
|
|
928
|
-
}
|
|
929
|
-
const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
930
|
-
if (!boxRef.current) return
|
|
931
|
-
const bounds = resizedTo(h, e)
|
|
932
|
-
boxRef.current = null
|
|
933
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
934
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
935
|
-
onCommit(bounds)
|
|
936
|
-
}
|
|
937
|
-
return (
|
|
938
|
-
<>
|
|
939
|
-
{points.map((h) => (
|
|
940
|
-
<div
|
|
941
|
-
key={h.key}
|
|
942
|
-
className="cmt-canvas-region-handle"
|
|
943
|
-
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
|
944
|
-
onPointerDown={startResize}
|
|
945
|
-
onPointerMove={onResize(h)}
|
|
946
|
-
onPointerUp={endResize(h)}
|
|
947
|
-
/>
|
|
948
|
-
))}
|
|
949
|
-
</>
|
|
950
|
-
)
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
const ThreadPin = memo(function ThreadPin({
|
|
954
|
-
editor,
|
|
955
|
-
thread,
|
|
956
|
-
regionOptions,
|
|
957
|
-
...props
|
|
958
|
-
}: Omit<CanvasCommentsProps, 'regionOptions'> & {
|
|
959
|
-
editor: Editor
|
|
960
|
-
thread: TLCommentThread
|
|
961
|
-
regionOptions: RegionCommentOptions
|
|
962
|
-
}) {
|
|
963
|
-
const {
|
|
964
|
-
currentUserId,
|
|
965
|
-
resolveName,
|
|
966
|
-
onPostComment,
|
|
967
|
-
isCommentUnread,
|
|
968
|
-
onCommentRead,
|
|
969
|
-
getMentionSuggestions,
|
|
970
|
-
renderMentionSuggestion,
|
|
971
|
-
} = props
|
|
972
|
-
const options = useCommentingOptions()
|
|
973
|
-
const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
|
|
974
|
-
const container = useContainer()
|
|
975
|
-
const comments = useThreadComments(editor, thread.id)
|
|
976
|
-
const msg = useTranslation()
|
|
977
|
-
// Only one thread's popover is open at a time — shared across pins via the atom.
|
|
978
|
-
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
|
979
|
-
editor,
|
|
980
|
-
thread.id,
|
|
981
|
-
])
|
|
982
|
-
const [reply, setReply] = useState<TLRichText>(EMPTY_COMMENT)
|
|
983
|
-
const [editingId, setEditingId] = useState<string | null>(null)
|
|
984
|
-
const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
|
|
985
|
-
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
986
|
-
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
|
987
|
-
// The live bounds while a corner handle is resizing the region, else null.
|
|
988
|
-
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
|
989
|
-
// Whether the pin marker is hovered — only consulted by the 'pin-hover' reveal mode.
|
|
990
|
-
const [pinHovered, setPinHovered] = useState(false)
|
|
991
|
-
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
|
992
|
-
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
|
993
|
-
// handle never loses the affordance — the box stays `pointer-events: none`.
|
|
994
|
-
const pointerInRegion = useValue(
|
|
995
|
-
'pointer in region',
|
|
996
|
-
() => {
|
|
997
|
-
if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
|
|
998
|
-
return false
|
|
999
|
-
const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
|
|
1000
|
-
const p = editor.inputs.getCurrentPagePoint()
|
|
1001
|
-
const a = thread.anchor
|
|
1002
|
-
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
|
|
1003
|
-
},
|
|
1004
|
-
[editor, thread.anchor, thread.pageId]
|
|
1005
|
-
)
|
|
1006
|
-
// A region's box and handles are revealed while open or mid-resize, plus — per the reveal mode —
|
|
1007
|
-
// while the pointer is within the region ('pointer') or the pin is hovered ('pin-hover').
|
|
1008
|
-
const revealed =
|
|
1009
|
-
open ||
|
|
1010
|
-
resizeBounds != null ||
|
|
1011
|
-
(regionOptions.reveal === 'pointer' && pointerInRegion) ||
|
|
1012
|
-
(regionOptions.reveal === 'pin-hover' && pinHovered)
|
|
1013
|
-
// The resize handles: side midpoints ('edges'), or the corners other than the pin's ('corners').
|
|
1014
|
-
const resizeHandles = useMemo(
|
|
1015
|
-
() =>
|
|
1016
|
-
regionOptions.resize === 'edges'
|
|
1017
|
-
? REGION_EDGES
|
|
1018
|
-
: REGION_CORNERS.filter(
|
|
1019
|
-
(c) => c.x !== regionOptions.pinCorner.x || c.y !== regionOptions.pinCorner.y
|
|
1020
|
-
),
|
|
1021
|
-
[regionOptions.resize, regionOptions.pinCorner]
|
|
1022
|
-
)
|
|
1023
|
-
const dragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null)
|
|
1024
|
-
const markerRef = useRef<HTMLDivElement>(null)
|
|
1025
|
-
|
|
1026
|
-
// Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
|
|
1027
|
-
// pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
|
|
1028
|
-
// popover portals elsewhere in the DOM. The pin marker is excluded so its own click-to-toggle
|
|
1029
|
-
// handles it instead of this closing then the toggle reopening.
|
|
1030
|
-
useEffect(() => {
|
|
1031
|
-
if (!open) return
|
|
1032
|
-
const onPointerDown = (e: PointerEvent) => {
|
|
1033
|
-
const target = e.target as HTMLElement | null
|
|
1034
|
-
if (!target) return
|
|
1035
|
-
if (target.closest('.cmt-canvas-popover')) return
|
|
1036
|
-
const marker = markerRef.current
|
|
1037
|
-
if (marker && marker.contains(target)) return
|
|
1038
|
-
// A press on a region's resize handle or movable body edits this thread — don't dismiss it.
|
|
1039
|
-
if (target.closest('.cmt-canvas-region-handle, .cmt-canvas-region--movable')) return
|
|
1040
|
-
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
|
1041
|
-
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
|
1042
|
-
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
|
1043
|
-
if (target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .cmt-mention-popup'))
|
|
1044
|
-
return
|
|
1045
|
-
openThreadId.set(editor, null)
|
|
1046
|
-
}
|
|
1047
|
-
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1048
|
-
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1049
|
-
}, [open, editor])
|
|
1050
|
-
|
|
1051
|
-
const point = useValue(
|
|
1052
|
-
'pin point',
|
|
1053
|
-
() => {
|
|
1054
|
-
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
1055
|
-
const pagePoint = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
1056
|
-
return pagePoint ? editor.pageToViewport(pagePoint) : null
|
|
1057
|
-
},
|
|
1058
|
-
[editor, thread.anchor, thread.pageId, impreciseShapeAnchor]
|
|
1059
|
-
)
|
|
1060
|
-
const visible = point !== null
|
|
1061
|
-
|
|
1062
|
-
// While the popover is open, every unread comment on display gets reported read — including
|
|
1063
|
-
// replies that arrive while it stays open, since the effect re-runs as `comments` changes.
|
|
1064
|
-
// The host's receipt write flips isCommentUnread to false, so re-runs find nothing to report.
|
|
1065
|
-
useEffect(() => {
|
|
1066
|
-
if (!open || !visible || !isCommentUnread || !onCommentRead) return
|
|
1067
|
-
for (const comment of comments) {
|
|
1068
|
-
if (isCommentUnread(comment.id)) {
|
|
1069
|
-
onCommentRead(comment.id)
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}, [open, visible, comments, isCommentUnread, onCommentRead])
|
|
1073
|
-
|
|
1074
|
-
if (!point) return null
|
|
1075
|
-
|
|
1076
|
-
const postReply = () => {
|
|
1077
|
-
if (isCommentEmpty(reply) || !currentUserId) return
|
|
1078
|
-
commitCommentMutation(editor, () => {
|
|
1079
|
-
const comment = createComment({
|
|
1080
|
-
threadId: thread.id,
|
|
1081
|
-
pageId: thread.pageId,
|
|
1082
|
-
authorId: currentUserId,
|
|
1083
|
-
body: reply,
|
|
1084
|
-
})
|
|
1085
|
-
putCommentRecords(editor, [comment])
|
|
1086
|
-
if (onPostComment) onPostComment(comment)
|
|
1087
|
-
})
|
|
1088
|
-
setReply(EMPTY_COMMENT)
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
const toggleResolve = () => {
|
|
1092
|
-
if (!currentUserId) return
|
|
1093
|
-
commitCommentMutation(editor, () => {
|
|
1094
|
-
putCommentRecords(editor, [
|
|
1095
|
-
{
|
|
1096
|
-
...thread,
|
|
1097
|
-
resolved: thread.resolved ? null : { at: Date.now(), by: currentUserId },
|
|
1098
|
-
},
|
|
1099
|
-
])
|
|
1100
|
-
})
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const deleteThread = () => {
|
|
1104
|
-
openThreadId.set(editor, null)
|
|
1105
|
-
commitCommentMutation(editor, () =>
|
|
1106
|
-
removeCommentRecords(editor, [thread.id, ...comments.map((c) => c.id)])
|
|
1107
|
-
)
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
const startEdit = (comment: TLComment) => {
|
|
1111
|
-
setEditingId(comment.id)
|
|
1112
|
-
setEditText(comment.body)
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
const saveEdit = () => {
|
|
1116
|
-
const comment = comments.find((c) => c.id === editingId)
|
|
1117
|
-
if (!comment || isCommentEmpty(editText)) return
|
|
1118
|
-
commitCommentMutation(editor, () => {
|
|
1119
|
-
putCommentRecords(editor, [{ ...comment, body: editText, editedAt: Date.now() }])
|
|
1120
|
-
})
|
|
1121
|
-
setEditingId(null)
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
|
|
1125
|
-
// with an edit affordance on your own comments.
|
|
1126
|
-
const renderComment = (card: CommentCardProps, index: number): ReactNode => {
|
|
1127
|
-
const comment = comments[index]
|
|
1128
|
-
if (editingId === comment.id) {
|
|
1129
|
-
return (
|
|
1130
|
-
<div
|
|
1131
|
-
className="cmt-editing"
|
|
1132
|
-
onKeyDown={(e) => {
|
|
1133
|
-
if (e.key === 'Escape') {
|
|
1134
|
-
setEditingId(null)
|
|
1135
|
-
e.stopPropagation()
|
|
1136
|
-
}
|
|
1137
|
-
}}
|
|
1138
|
-
>
|
|
1139
|
-
<CommentComposer
|
|
1140
|
-
author={card.author}
|
|
1141
|
-
placeholder={msg('comments.edit-placeholder')}
|
|
1142
|
-
value={editText}
|
|
1143
|
-
onChange={setEditText}
|
|
1144
|
-
onSubmit={saveEdit}
|
|
1145
|
-
sendLabel={msg('comments.save')}
|
|
1146
|
-
disabled={isCommentEmpty(editText)}
|
|
1147
|
-
getMentionSuggestions={getMentionSuggestions}
|
|
1148
|
-
renderMentionSuggestion={renderMentionSuggestion}
|
|
1149
|
-
autoFocus
|
|
1150
|
-
/>
|
|
1151
|
-
</div>
|
|
1152
|
-
)
|
|
251
|
+
if (openThread) renderedThreadIds.add(openThread.id)
|
|
252
|
+
|
|
253
|
+
// A coincident-stack member renders as the group's single count-badge stack (if it owns it)
|
|
254
|
+
// or not at all; everything else is an ordinary pin.
|
|
255
|
+
const renderThreadPin = (thread: TLCommentThread): ReactNode => {
|
|
256
|
+
const group = pinStacks.get(thread.id)
|
|
257
|
+
if (group) {
|
|
258
|
+
const owner = group.find((id) => renderedThreadIds.has(id))
|
|
259
|
+
if (owner !== thread.id) return null
|
|
260
|
+
const stackThreads = group
|
|
261
|
+
.map((id) => threadsById.get(id))
|
|
262
|
+
.filter((t): t is TLCommentThread => t !== undefined)
|
|
263
|
+
return <ThreadStackPin editor={editor} threads={stackThreads} {...props} />
|
|
1153
264
|
}
|
|
1154
|
-
return
|
|
1155
|
-
<CommentCard
|
|
1156
|
-
{...card}
|
|
1157
|
-
actions={
|
|
1158
|
-
comment.authorId === currentUserId ? (
|
|
1159
|
-
<button
|
|
1160
|
-
className="cmt-thread__action"
|
|
1161
|
-
title={msg('comments.edit')}
|
|
1162
|
-
onClick={() => startEdit(comment)}
|
|
1163
|
-
>
|
|
1164
|
-
<TldrawUiIcon icon="dots-horizontal" label={msg('comments.edit')} small />
|
|
1165
|
-
</button>
|
|
1166
|
-
) : undefined
|
|
1167
|
-
}
|
|
1168
|
-
/>
|
|
1169
|
-
)
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
const headerActions = (
|
|
1173
|
-
<>
|
|
1174
|
-
{currentUserId && (
|
|
1175
|
-
<button
|
|
1176
|
-
className="cmt-thread__action"
|
|
1177
|
-
title={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
1178
|
-
onClick={toggleResolve}
|
|
1179
|
-
>
|
|
1180
|
-
<TldrawUiIcon
|
|
1181
|
-
icon="check"
|
|
1182
|
-
label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
1183
|
-
small
|
|
1184
|
-
/>
|
|
1185
|
-
</button>
|
|
1186
|
-
)}
|
|
1187
|
-
{currentUserId && (
|
|
1188
|
-
<button
|
|
1189
|
-
className="cmt-thread__action"
|
|
1190
|
-
title={msg('comments.delete')}
|
|
1191
|
-
onClick={deleteThread}
|
|
1192
|
-
>
|
|
1193
|
-
<TldrawUiIcon icon="trash" label={msg('comments.delete')} small />
|
|
1194
|
-
</button>
|
|
1195
|
-
)}
|
|
1196
|
-
<button
|
|
1197
|
-
className="cmt-thread__action"
|
|
1198
|
-
title={msg('comments.dismiss')}
|
|
1199
|
-
onClick={() => openThreadId.set(editor, null)}
|
|
1200
|
-
>
|
|
1201
|
-
<TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
|
|
1202
|
-
</button>
|
|
1203
|
-
</>
|
|
1204
|
-
)
|
|
1205
|
-
|
|
1206
|
-
const PinContent = options.components.PinContent
|
|
1207
|
-
// The `PinContent` component slot overrides the built-in author-initial default.
|
|
1208
|
-
const pinContent = PinContent ? (
|
|
1209
|
-
<PinContent thread={thread} comments={comments} />
|
|
1210
|
-
) : (
|
|
1211
|
-
initialOf(resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR)
|
|
1212
|
-
)
|
|
1213
|
-
|
|
1214
|
-
// Drag the marker to move the thread: its position is overridden locally while dragging, then
|
|
1215
|
-
// re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
|
|
1216
|
-
// a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
|
|
1217
|
-
// toggle the popover.
|
|
1218
|
-
// Which affordances move a region, per the option: 'pin' → pin only, 'body' → body only, 'both'.
|
|
1219
|
-
const isRegion = thread.anchor.type === 'region'
|
|
1220
|
-
const pinMovable = regionOptions.move !== 'body'
|
|
1221
|
-
const bodyMovable = regionOptions.move !== 'pin'
|
|
1222
|
-
const startDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1223
|
-
e.stopPropagation()
|
|
1224
|
-
dragRef.current = { startX: e.clientX, startY: e.clientY, moved: false }
|
|
1225
|
-
e.currentTarget.setPointerCapture(e.pointerId)
|
|
1226
|
-
}
|
|
1227
|
-
const onDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1228
|
-
const drag = dragRef.current
|
|
1229
|
-
if (!drag) return
|
|
1230
|
-
// A region that moves by its body ignores pin drags (the pin only toggles the thread).
|
|
1231
|
-
if (isRegion && !pinMovable) return
|
|
1232
|
-
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
1233
|
-
drag.moved = true
|
|
1234
|
-
setDragPagePoint(editor.screenToPage({ x: e.clientX, y: e.clientY }))
|
|
1235
|
-
}
|
|
1236
|
-
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1237
|
-
const drag = dragRef.current
|
|
1238
|
-
dragRef.current = null
|
|
1239
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1240
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1241
|
-
}
|
|
1242
|
-
if (!drag) return
|
|
1243
|
-
if (!drag.moved) {
|
|
1244
|
-
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
1245
|
-
return
|
|
1246
|
-
}
|
|
1247
|
-
const pagePoint = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1248
|
-
setDragPagePoint(null)
|
|
1249
|
-
let anchor: TLCommentThread['anchor']
|
|
1250
|
-
if (thread.anchor.type === 'region') {
|
|
1251
|
-
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
1252
|
-
anchor = {
|
|
1253
|
-
...thread.anchor,
|
|
1254
|
-
x: pagePoint.x - regionOptions.pinCorner.x * thread.anchor.w,
|
|
1255
|
-
y: pagePoint.y - regionOptions.pinCorner.y * thread.anchor.h,
|
|
1256
|
-
}
|
|
1257
|
-
} else {
|
|
1258
|
-
const hit = editor.getShapeAtPoint(pagePoint, { hitInside: true })
|
|
1259
|
-
anchor = hit
|
|
1260
|
-
? shapeAnchorAt(editor, hit.id, pagePoint, e.altKey)
|
|
1261
|
-
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
1262
|
-
}
|
|
1263
|
-
commitCommentMutation(editor, () => putCommentRecords(editor, [{ ...thread, anchor }]), 'drag')
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
1267
|
-
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
1268
|
-
const livePinPage = resizeBounds
|
|
1269
|
-
? regionPinPoint(resizeBounds, regionOptions.pinCorner)
|
|
1270
|
-
: dragPagePoint
|
|
1271
|
-
const renderPoint = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
1272
|
-
|
|
1273
|
-
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
1274
|
-
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
1275
|
-
const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
|
|
1276
|
-
const movedRegion =
|
|
1277
|
-
regionAnchor && dragPagePoint
|
|
1278
|
-
? {
|
|
1279
|
-
...regionAnchor,
|
|
1280
|
-
x: dragPagePoint.x - regionOptions.pinCorner.x * regionAnchor.w,
|
|
1281
|
-
y: dragPagePoint.y - regionOptions.pinCorner.y * regionAnchor.h,
|
|
1282
|
-
}
|
|
1283
|
-
: regionAnchor
|
|
1284
|
-
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
1285
|
-
const commitResize = (bounds: BoxModel) => {
|
|
1286
|
-
setResizeBounds(null)
|
|
1287
|
-
editor.run(
|
|
1288
|
-
() => putCommentRecords(editor, [{ ...thread, anchor: { type: 'region', ...bounds } }]),
|
|
1289
|
-
{
|
|
1290
|
-
history: 'ignore',
|
|
1291
|
-
}
|
|
1292
|
-
)
|
|
265
|
+
return <ThreadPin editor={editor} thread={thread} {...props} />
|
|
1293
266
|
}
|
|
1294
267
|
|
|
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.
|
|
1295
272
|
return (
|
|
1296
|
-
|
|
1297
|
-
{
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
</CommentPin>
|
|
1331
|
-
</div>
|
|
1332
|
-
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
|
1333
|
-
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
|
1334
|
-
{open && (
|
|
1335
|
-
<ThreadPopover
|
|
1336
|
-
container={container}
|
|
1337
|
-
style={{ left: renderPoint.x + 36, top: renderPoint.y - 28 }}
|
|
1338
|
-
>
|
|
1339
|
-
<CommentThread
|
|
1340
|
-
header={msg('comments.thread-title')}
|
|
1341
|
-
headerActions={headerActions}
|
|
1342
|
-
renderComment={renderComment}
|
|
1343
|
-
comments={comments.map((c) => toCardProps(c, props, options.components))}
|
|
1344
|
-
resolvedBanner={
|
|
1345
|
-
thread.resolved
|
|
1346
|
-
? msg('comments.resolved-by').replace(
|
|
1347
|
-
'{name}',
|
|
1348
|
-
resolveName(thread.resolved.by) ?? UNKNOWN_AUTHOR
|
|
1349
|
-
)
|
|
1350
|
-
: undefined
|
|
1351
|
-
}
|
|
1352
|
-
composer={
|
|
1353
|
-
currentUserId && !thread.resolved
|
|
1354
|
-
? {
|
|
1355
|
-
author: resolveName(currentUserId) ?? UNKNOWN_AUTHOR,
|
|
1356
|
-
placeholder: msg('comments.reply-placeholder'),
|
|
1357
|
-
sendLabel: msg('comments.send'),
|
|
1358
|
-
value: reply,
|
|
1359
|
-
onChange: setReply,
|
|
1360
|
-
onSubmit: postReply,
|
|
1361
|
-
disabled: isCommentEmpty(reply),
|
|
1362
|
-
getMentionSuggestions,
|
|
1363
|
-
renderMentionSuggestion,
|
|
1364
|
-
}
|
|
1365
|
-
: undefined
|
|
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
|
+
)
|
|
1366
307
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
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>)
|
|
327
|
+
)}
|
|
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} />
|
|
1369
339
|
)}
|
|
1370
340
|
</div>
|
|
1371
|
-
|
|
1372
|
-
)
|
|
1373
|
-
})
|
|
1374
|
-
|
|
1375
|
-
function PendingComposer({
|
|
1376
|
-
editor,
|
|
1377
|
-
pending,
|
|
1378
|
-
currentUserId,
|
|
1379
|
-
resolveName,
|
|
1380
|
-
onPostComment,
|
|
1381
|
-
getMentionSuggestions,
|
|
1382
|
-
renderMentionSuggestion,
|
|
1383
|
-
}: CanvasCommentsProps & { editor: Editor; pending: PendingComment }) {
|
|
1384
|
-
const [text, setText] = useState<TLRichText>(EMPTY_COMMENT)
|
|
1385
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
1386
|
-
const msg = useTranslation()
|
|
1387
|
-
const container = useContainer()
|
|
1388
|
-
// Over this floating panel, scroll and hover reach the canvas (except where it scrolls itself).
|
|
1389
|
-
usePassThroughWheelEvents(ref)
|
|
1390
|
-
usePassThroughMouseOverEvents(ref)
|
|
1391
|
-
|
|
1392
|
-
const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
|
|
1393
|
-
editor,
|
|
1394
|
-
pending.point,
|
|
1395
|
-
])
|
|
1396
|
-
|
|
1397
|
-
// Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
|
|
1398
|
-
useEffect(() => {
|
|
1399
|
-
const onPointerDown = (e: PointerEvent) => {
|
|
1400
|
-
const el = ref.current
|
|
1401
|
-
const target = e.target as HTMLElement | null
|
|
1402
|
-
if (!el || !target) return
|
|
1403
|
-
// A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
|
|
1404
|
-
// not "outside" — keep the draft open so the pick can insert.
|
|
1405
|
-
if (el.contains(target) || target.closest('.cmt-mention-popup')) return
|
|
1406
|
-
pendingComment.set(editor, null)
|
|
1407
|
-
}
|
|
1408
|
-
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1409
|
-
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1410
|
-
}, [editor])
|
|
1411
|
-
|
|
1412
|
-
const submit = () => {
|
|
1413
|
-
if (isCommentEmpty(text) || !currentUserId) return
|
|
1414
|
-
commitCommentMutation(editor, () => {
|
|
1415
|
-
const pageId = editor.getCurrentPageId()
|
|
1416
|
-
const thread = createCommentThread({
|
|
1417
|
-
pageId,
|
|
1418
|
-
anchor: pending.anchor,
|
|
1419
|
-
createdBy: currentUserId,
|
|
1420
|
-
})
|
|
1421
|
-
const comment = createComment({
|
|
1422
|
-
threadId: thread.id,
|
|
1423
|
-
pageId,
|
|
1424
|
-
authorId: currentUserId,
|
|
1425
|
-
body: text,
|
|
1426
|
-
})
|
|
1427
|
-
putCommentRecords(editor, [thread, comment])
|
|
1428
|
-
if (onPostComment) onPostComment(comment)
|
|
1429
|
-
})
|
|
1430
|
-
setText(EMPTY_COMMENT)
|
|
1431
|
-
pendingComment.set(editor, null)
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
return createPortal(
|
|
1435
|
-
<div
|
|
1436
|
-
ref={ref}
|
|
1437
|
-
className="cmt-canvas-composer"
|
|
1438
|
-
style={{ left: point.x, top: point.y }}
|
|
1439
|
-
onPointerDown={stop}
|
|
1440
|
-
onKeyDown={(e) => {
|
|
1441
|
-
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
|
1442
|
-
}}
|
|
1443
|
-
>
|
|
1444
|
-
<CommentComposer
|
|
1445
|
-
author={currentUserId ? (resolveName(currentUserId) ?? UNKNOWN_AUTHOR) : ''}
|
|
1446
|
-
placeholder={msg('comments.add-placeholder')}
|
|
1447
|
-
sendLabel={msg('comments.send')}
|
|
1448
|
-
value={text}
|
|
1449
|
-
onChange={setText}
|
|
1450
|
-
onSubmit={submit}
|
|
1451
|
-
disabled={isCommentEmpty(text)}
|
|
1452
|
-
getMentionSuggestions={getMentionSuggestions}
|
|
1453
|
-
renderMentionSuggestion={renderMentionSuggestion}
|
|
1454
|
-
autoFocus
|
|
1455
|
-
leading={draftAvatar}
|
|
1456
|
-
/>
|
|
1457
|
-
</div>,
|
|
1458
|
-
container
|
|
341
|
+
</EditorPortal>
|
|
1459
342
|
)
|
|
1460
343
|
}
|