@tldraw/commenting 5.3.0-internal.c5c7f1d817d0 → 5.3.0-next.2fa9c61a8de6
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 +1040 -230
- package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
- package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
- package/dist-cjs/canvas/cluster-input.js +2 -2
- package/dist-cjs/canvas/cluster-input.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 +123 -0
- package/dist-cjs/canvas/comment-mutations.js.map +7 -0
- package/dist-cjs/canvas/comment-reactions.js +163 -0
- package/dist-cjs/canvas/comment-reactions.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +5 -3
- 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 +64 -14
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +20 -38
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +377 -437
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +34 -31
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +65 -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 +22 -3
- package/dist-cjs/canvas/hooks.js.map +2 -2
- package/dist-cjs/canvas/options.js +19 -2
- package/dist-cjs/canvas/options.js.map +2 -2
- package/dist-cjs/canvas/pin-stacking.js +56 -0
- package/dist-cjs/canvas/pin-stacking.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-preview.js +185 -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 +67 -21
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +397 -0
- package/dist-cjs/canvas/thread-view.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +4 -4
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/runtime.js +9 -9
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +763 -353
- package/dist-cjs/index.js +46 -28
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +3 -1
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-card.js +12 -5
- package/dist-cjs/ui/comment-card.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +112 -17
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +20 -2
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comment-thread.js +4 -2
- package/dist-cjs/ui/comment-thread.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +22 -10
- 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/format-time.js +4 -1
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/reaction-picker.js +73 -0
- package/dist-cjs/ui/reaction-picker.js.map +7 -0
- package/dist-cjs/ui/reaction.js +50 -4
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/reactions.js +20 -15
- 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 +20 -1
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
- package/dist-cjs/ui/tooltip-button.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/cluster-input.mjs +2 -2
- package/dist-esm/canvas/cluster-input.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 +103 -0
- package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
- package/dist-esm/canvas/comment-reactions.mjs +152 -0
- package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +5 -3
- 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 +67 -17
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +398 -442
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +37 -34
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -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 +28 -5
- package/dist-esm/canvas/hooks.mjs.map +2 -2
- package/dist-esm/canvas/options.mjs +21 -3
- package/dist-esm/canvas/options.mjs.map +2 -2
- package/dist-esm/canvas/pin-stacking.mjs +36 -0
- package/dist-esm/canvas/pin-stacking.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-preview.mjs +170 -0
- package/dist-esm/canvas/thread-preview.mjs.map +7 -0
- package/dist-esm/canvas/thread-stack.mjs +190 -0
- package/dist-esm/canvas/thread-stack.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +71 -22
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +402 -0
- package/dist-esm/canvas/thread-view.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +4 -4
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/runtime.mjs +9 -9
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +763 -353
- package/dist-esm/index.mjs +87 -41
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +3 -1
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-card.mjs +12 -5
- package/dist-esm/ui/comment-card.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +122 -16
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +20 -2
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comment-thread.mjs +4 -2
- package/dist-esm/ui/comment-thread.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +39 -25
- 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/format-time.mjs +4 -1
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/reaction-picker.mjs +60 -0
- package/dist-esm/ui/reaction-picker.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +51 -5
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/reactions.mjs +21 -16
- 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 +20 -1
- package/dist-esm/ui/send-button.mjs.map +2 -2
- package/dist-esm/ui/tooltip-button.mjs +12 -0
- package/dist-esm/ui/tooltip-button.mjs.map +7 -0
- package/package.json +6 -6
- package/src/canvas/anchor-lifecycle.test.ts +290 -0
- package/src/canvas/anchor-lifecycle.ts +205 -0
- package/src/canvas/canvas.css +439 -24
- package/src/canvas/cluster-input.test.ts +31 -23
- package/src/canvas/cluster-input.ts +3 -4
- package/src/canvas/comment-drafts.test.ts +55 -0
- package/src/canvas/comment-drafts.ts +46 -0
- package/src/canvas/comment-mutations.test.ts +410 -0
- package/src/canvas/comment-mutations.ts +288 -0
- package/src/canvas/comment-reactions.test.ts +132 -0
- package/src/canvas/comment-reactions.tsx +245 -0
- package/src/canvas/comment-render.ts +9 -5
- package/src/canvas/comment-store.ts +67 -27
- package/src/canvas/comment-tool.test.ts +162 -0
- package/src/canvas/comment-tool.tsx +105 -23
- package/src/canvas/comments-filter-menu.tsx +23 -32
- package/src/canvas/comments-overflow-menu.tsx +12 -19
- package/src/canvas/comments-overlay.tsx +602 -562
- package/src/canvas/comments-sidebar.test.ts +45 -0
- package/src/canvas/comments-sidebar.tsx +75 -57
- package/src/canvas/comments-visibility-toggle.tsx +49 -0
- package/src/canvas/context.ts +47 -0
- package/src/canvas/hooks.ts +52 -11
- package/src/canvas/options.test.ts +52 -2
- package/src/canvas/options.ts +133 -9
- package/src/canvas/pin-stacking.test.ts +114 -0
- package/src/canvas/pin-stacking.ts +57 -0
- package/src/canvas/sidebar-filters.ts +4 -3
- package/src/canvas/state.ts +92 -35
- package/src/canvas/thread-preview.test.ts +91 -0
- package/src/canvas/thread-preview.tsx +304 -0
- package/src/canvas/thread-stack.tsx +252 -0
- package/src/canvas/thread-state.test.ts +209 -0
- package/src/canvas/thread-state.ts +137 -36
- package/src/canvas/thread-view.tsx +516 -0
- package/src/clustering/computeClusterTable.test.ts +24 -23
- package/src/clustering/computeClusterTable.ts +5 -5
- package/src/clustering/runtime.test.ts +34 -0
- package/src/clustering/runtime.ts +20 -15
- package/src/clustering/types.ts +9 -9
- package/src/index.ts +76 -40
- package/src/ui/byline.tsx +4 -2
- package/src/ui/comment-card.tsx +17 -7
- package/src/ui/comment-composer.tsx +160 -13
- package/src/ui/comment-pin.tsx +22 -2
- package/src/ui/comment-thread.tsx +6 -1
- package/src/ui/comments-list.test.ts +31 -0
- package/src/ui/comments-list.tsx +32 -12
- package/src/ui/comments.css +489 -204
- 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/format-time.ts +7 -2
- package/src/ui/reaction-picker.tsx +109 -0
- package/src/ui/reaction.tsx +116 -4
- package/src/ui/reactions.tsx +68 -13
- package/src/ui/reply-count.ts +16 -0
- package/src/ui/send-button.tsx +18 -3
- package/src/ui/tooltip-button.tsx +20 -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 +0 -51
- 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 -31
- 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 -38
- 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,10 +1,12 @@
|
|
|
1
|
+
import { Avatar, isMentionPickerOpen } from '@tldraw/mentions'
|
|
1
2
|
import {
|
|
3
|
+
Fragment,
|
|
2
4
|
memo,
|
|
3
|
-
type CSSProperties,
|
|
4
5
|
type PointerEvent as ReactPointerEvent,
|
|
5
6
|
ReactNode,
|
|
6
7
|
useCallback,
|
|
7
8
|
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
8
10
|
useMemo,
|
|
9
11
|
useRef,
|
|
10
12
|
useState,
|
|
@@ -15,13 +17,9 @@ import {
|
|
|
15
17
|
createComment,
|
|
16
18
|
createCommentThread,
|
|
17
19
|
Editor,
|
|
18
|
-
getFirstCharacter,
|
|
19
20
|
react,
|
|
20
|
-
TLComment,
|
|
21
|
-
TLCommentId,
|
|
22
21
|
TLCommentThread,
|
|
23
22
|
TLRichText,
|
|
24
|
-
TldrawUiIcon,
|
|
25
23
|
useContainer,
|
|
26
24
|
useEditor,
|
|
27
25
|
usePassThroughMouseOverEvents,
|
|
@@ -33,130 +31,114 @@ import {
|
|
|
33
31
|
import { computeClusterTable } from '../clustering/computeClusterTable'
|
|
34
32
|
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
|
35
33
|
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
|
36
|
-
import { CommentCard, CommentCardProps } from '../ui/comment-card'
|
|
37
34
|
import { CommentComposer } from '../ui/comment-composer'
|
|
38
35
|
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
|
39
36
|
import { CommentPin } from '../ui/comment-pin'
|
|
40
|
-
import { CommentThread } from '../ui/comment-thread'
|
|
41
37
|
import { CountBadge } from '../ui/count-badge'
|
|
42
|
-
import {
|
|
43
|
-
import { isMentionPickerOpen } from '../ui/mention-suggestion'
|
|
38
|
+
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
|
44
39
|
import { collectClusterLeaves } from './cluster-input'
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
import {
|
|
41
|
+
clearCommentDraft,
|
|
42
|
+
getCommentDraft,
|
|
43
|
+
NEW_COMMENT_DRAFT,
|
|
44
|
+
saveCommentDraft,
|
|
45
|
+
} from './comment-drafts'
|
|
46
|
+
import { commitCommentMutation, putRecordsInCommit } from './comment-mutations'
|
|
47
|
+
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
48
|
+
import { getCommentRecord } from './comment-store'
|
|
48
49
|
import { PendingComment } from './comment-tool'
|
|
50
|
+
import { type CommentingContext } from './context'
|
|
49
51
|
import { useCommentThreads, useThreadComments } from './hooks'
|
|
50
52
|
import { useCommentingEnabled } from './license'
|
|
51
53
|
import {
|
|
52
|
-
type CommentingComponents,
|
|
53
54
|
type CommentingOptions,
|
|
54
55
|
getCommentingOptions,
|
|
56
|
+
useCanComment,
|
|
55
57
|
useCommentingOptions,
|
|
56
58
|
} from './options'
|
|
57
|
-
import {
|
|
58
|
-
DEFAULT_REGION_COMMENT_OPTIONS,
|
|
59
|
-
RegionCommentOptions,
|
|
60
|
-
setRegionCommentOptions,
|
|
61
|
-
} from './region-options'
|
|
59
|
+
import { computePinStacks, pinStackKey } from './pin-stacking'
|
|
62
60
|
import {
|
|
63
61
|
commentsHidden,
|
|
64
|
-
|
|
62
|
+
openStackId,
|
|
65
63
|
openThreadId,
|
|
66
64
|
pendingComment,
|
|
67
65
|
regionDraft,
|
|
66
|
+
revealThreadRequest,
|
|
67
|
+
sidebarFilters,
|
|
68
68
|
toggleCommentsHidden,
|
|
69
69
|
usePendingComment,
|
|
70
70
|
} from './state'
|
|
71
|
-
import {
|
|
71
|
+
import { ThreadPreview, sortThreadsForPreview, useMarkerPreview } from './thread-preview'
|
|
72
|
+
import { ThreadStackPin } from './thread-stack'
|
|
73
|
+
import {
|
|
74
|
+
anchorPagePoint,
|
|
75
|
+
commentCenterScreenOffset,
|
|
76
|
+
commentTargetShapeAt,
|
|
77
|
+
impreciseShapePinInset,
|
|
78
|
+
REGION_PIN_CORNER,
|
|
79
|
+
regionAnchorPinCorner,
|
|
80
|
+
regionPinPoint,
|
|
81
|
+
shapeAnchorAt,
|
|
82
|
+
} from './thread-state'
|
|
83
|
+
import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
|
72
84
|
|
|
73
85
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* placed a new thread. Reads/writes comment records straight from `editor.store`.
|
|
86
|
+
* The host wiring for {@link CanvasComments} — see {@link CommentingContext}, which the sidebar
|
|
87
|
+
* takes the same fields from.
|
|
77
88
|
*
|
|
78
|
-
* It's meant as the batteries-included default — every visible piece is a lever (the `CommentBody`
|
|
79
|
-
* and `PinContent` slots on `CommentTool.configure({ components })`), and the pieces it composes
|
|
80
|
-
* (`CommentPin`, `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a
|
|
81
|
-
* consumer can rebuild this from parts instead.
|
|
82
89
|
* @public
|
|
83
90
|
*/
|
|
84
|
-
export
|
|
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
|
-
}
|
|
91
|
+
export type CanvasCommentsProps = CommentingContext
|
|
108
92
|
|
|
109
93
|
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
|
110
94
|
|
|
111
|
-
|
|
95
|
+
/** A pointer-down that belongs to the camera, not the comment UI: any non-primary button
|
|
96
|
+
* (middle/right-button pans), or a primary press with the spacebar pan key held. */
|
|
97
|
+
const isCanvasPanGesture = (editor: Editor, e: ReactPointerEvent) =>
|
|
98
|
+
e.button !== 0 || editor.inputs.keys.has('Space')
|
|
99
|
+
|
|
100
|
+
/** Hand a pointer event to the canvas beneath the comments layer, marked the same way the
|
|
101
|
+
* pass-through wheel/hover hooks mark their re-dispatched events. */
|
|
102
|
+
function forwardPointerEventToCanvas(container: HTMLElement, e: ReactPointerEvent) {
|
|
103
|
+
const cvs = container.querySelector('.tl-canvas')
|
|
104
|
+
if (!cvs) return
|
|
105
|
+
const newEvent = new PointerEvent(e.type, e.nativeEvent as any)
|
|
106
|
+
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
107
|
+
cvs.dispatchEvent(newEvent)
|
|
108
|
+
}
|
|
109
|
+
|
|
112
110
|
const CLUSTER_FADE_MS = 150
|
|
113
111
|
/** Duration of the click-a-badge zoom-to-split animation. */
|
|
114
112
|
const CLUSTER_EXPAND_ZOOM_MS = 450
|
|
113
|
+
/** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
|
|
114
|
+
* lands clear of the threshold it just crossed rather than flickering on it. */
|
|
115
|
+
const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
|
|
116
|
+
/** Screen-pixel margin by which the viewport is inflated when culling cluster badges, so a badge
|
|
117
|
+
* just off-screen is already mounted when a pan brings it in. */
|
|
118
|
+
const CLUSTER_CULL_MARGIN_PX = 120
|
|
119
|
+
|
|
120
|
+
/** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
|
|
121
|
+
* first comment then lands where the preview's sat. Measured: the expanded first comment sits ~42px
|
|
122
|
+
* below the panel top (the 40px header, no gap) vs the preview's 4px (its panel padding).
|
|
123
|
+
* Re-measure if the header height or the preview panel padding changes. */
|
|
124
|
+
const THREAD_HEADER_BLOCK = 36
|
|
115
125
|
|
|
116
|
-
/**
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 */
|
|
126
|
+
/**
|
|
127
|
+
* A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
|
|
128
|
+
* thread popover (with a reply composer) on click, and shows a composer where the comment tool
|
|
129
|
+
* placed a new thread. Reads/writes comment records straight from `editor.store`.
|
|
130
|
+
*
|
|
131
|
+
* It's meant as the batteries-included default — every visible piece is a lever (the `CommentBody`
|
|
132
|
+
* and `PinContent` slots on `CommentTool.configure({ components })`), and the pieces it composes
|
|
133
|
+
* (`CommentPin`, `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a
|
|
134
|
+
* consumer can rebuild this from parts instead.
|
|
135
|
+
*
|
|
136
|
+
* The host wiring — who the viewer is, how ids become names, read status, mentions — is the
|
|
137
|
+
* {@link CommentingContext}, which `CanvasCommentsSidebar` takes too, so a host mounting both can
|
|
138
|
+
* build it once and spread it into each.
|
|
139
|
+
*
|
|
140
|
+
* @public @react
|
|
141
|
+
*/
|
|
160
142
|
export function CanvasComments(props: CanvasCommentsProps) {
|
|
161
143
|
// Gate the whole layer on the license before doing any work. The inner component holds all the
|
|
162
144
|
// other hooks, so mounting/unmounting it as the license resolves keeps hook order stable here.
|
|
@@ -165,27 +147,72 @@ export function CanvasComments(props: CanvasCommentsProps) {
|
|
|
165
147
|
return <CanvasCommentsLayer {...props} />
|
|
166
148
|
}
|
|
167
149
|
|
|
168
|
-
|
|
150
|
+
/**
|
|
151
|
+
* A mount point appended to the end of the editor container, for a portal that has to come last
|
|
152
|
+
* among the container's children.
|
|
153
|
+
*
|
|
154
|
+
* `createPortal(…, container)` doesn't get to say where its node lands: React places a portal
|
|
155
|
+
* during the same commit that mounts it, and a portal nested this deep in the tree is placed
|
|
156
|
+
* before the container's own, shallower children — so the layer ends up ahead of the UI and its
|
|
157
|
+
* "move focus to canvas" skip link. That link only works if nothing precedes it, and the pins are
|
|
158
|
+
* real buttons, so a single comment would take the first tab stop and leave no keyboard route to
|
|
159
|
+
* the canvas. A layout effect runs after the whole commit instead, by which point the container's
|
|
160
|
+
* children are all in place and appending is guaranteed to land at the end.
|
|
161
|
+
*
|
|
162
|
+
* Null until the effect has run, so the first render has nothing to portal into.
|
|
163
|
+
*/
|
|
164
|
+
function useTrailingPortalHost(container: HTMLElement) {
|
|
165
|
+
const [host, setHost] = useState<HTMLDivElement | null>(null)
|
|
166
|
+
useLayoutEffect(() => {
|
|
167
|
+
const elm = container.ownerDocument.createElement('div')
|
|
168
|
+
// The host is a position in the DOM, not a box — what it holds is positioned against the
|
|
169
|
+
// container, the same as it was when it hung off the container directly.
|
|
170
|
+
elm.style.display = 'contents'
|
|
171
|
+
container.appendChild(elm)
|
|
172
|
+
setHost(elm)
|
|
173
|
+
return () => {
|
|
174
|
+
elm.remove()
|
|
175
|
+
setHost(null)
|
|
176
|
+
}
|
|
177
|
+
}, [container])
|
|
178
|
+
return host
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function CanvasCommentsLayer(props: CommentingContext) {
|
|
169
182
|
const editor = useEditor()
|
|
170
183
|
const options = useCommentingOptions()
|
|
171
184
|
const container = useContainer()
|
|
172
|
-
|
|
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])
|
|
185
|
+
const portalHost = useTrailingPortalHost(container)
|
|
179
186
|
const layerRef = useRef<HTMLDivElement>(null)
|
|
180
|
-
// Over the pins and cluster badges,
|
|
181
|
-
//
|
|
182
|
-
|
|
187
|
+
// Over the pins and cluster badges, hover passes through to the canvas beneath (these events
|
|
188
|
+
// bubble up from the pointer-interactive markers to this layer root). Wheel pass-through is
|
|
189
|
+
// NOT on this root: it lives on each interactive element instead. The root spans the whole
|
|
190
|
+
// canvas, so any pin past its bottom/right edge inflates the root's scrollHeight — which the
|
|
191
|
+
// wheel hook's is-this-scrollable guard reads as scrollable, silently disabling pass-through.
|
|
183
192
|
usePassThroughMouseOverEvents(layerRef)
|
|
184
|
-
const
|
|
185
|
-
const threads = useCommentThreads(editor)
|
|
193
|
+
const allThreads = useCommentThreads(editor)
|
|
186
194
|
const pending = usePendingComment()
|
|
195
|
+
const canComment = useCanComment(props.currentUserId)
|
|
196
|
+
// With composing blocked and no fallback slot there's nothing to render for a pending comment —
|
|
197
|
+
// and the dismiss handlers (Escape, click-away) live inside PendingComposer, which would never
|
|
198
|
+
// mount. Clear the atom instead of stranding it (a stale pending would pop a composer at the
|
|
199
|
+
// old click point if `canComment` later flips true).
|
|
200
|
+
const canRenderComposer = canComment || options.components.ComposerFallback != null
|
|
201
|
+
const showPendingComposer = pending != null && canRenderComposer
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (pending && !showPendingComposer) pendingComment.set(editor, null)
|
|
204
|
+
}, [editor, pending, showPendingComposer])
|
|
187
205
|
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
|
188
|
-
|
|
206
|
+
// Hide resolved threads' pins by default, matching the sidebar's `showResolved` filter. The open
|
|
207
|
+
// thread stays in — resolving from its own popover shouldn't make the pin vanish under it.
|
|
208
|
+
const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
|
|
209
|
+
editor,
|
|
210
|
+
])
|
|
211
|
+
const threads = useMemo(
|
|
212
|
+
() => allThreads.filter((t) => showResolved || t.resolved == null || t.id === openId),
|
|
213
|
+
[allThreads, showResolved, openId]
|
|
214
|
+
)
|
|
215
|
+
useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
|
|
189
216
|
// Threads held out of clustering because their anchor moved while folded inside a badge
|
|
190
217
|
// (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
|
|
191
218
|
// as live pins riding their anchor and rejoin clustering on the next zoom-out.
|
|
@@ -197,10 +224,9 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
197
224
|
collectClusterLeaves(
|
|
198
225
|
editor,
|
|
199
226
|
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
|
200
|
-
openThreadId.get(editor)
|
|
201
|
-
impreciseShapeAnchor
|
|
227
|
+
openThreadId.get(editor)
|
|
202
228
|
),
|
|
203
|
-
[editor, threads,
|
|
229
|
+
[editor, threads, heldThreadIds]
|
|
204
230
|
)
|
|
205
231
|
const clusterZoomBounds = useValue(
|
|
206
232
|
'comment cluster zoom bounds',
|
|
@@ -344,49 +370,91 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
344
370
|
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
|
345
371
|
[threads]
|
|
346
372
|
)
|
|
373
|
+
// Zooming separates near pins, but pins with the *same* anchor point (several imprecise
|
|
374
|
+
// comments on one shape) coincide at every zoom — those render as one count-badge stack that
|
|
375
|
+
// opens the threads as a list. Keyed on page-space anchors, so camera moves never recompute this.
|
|
376
|
+
const pinStacks = useValue('comment pin stacks', () => computePinStacks(editor, threads), [
|
|
377
|
+
editor,
|
|
378
|
+
threads,
|
|
379
|
+
])
|
|
347
380
|
const openThread = openId ? threadsById.get(openId) : null
|
|
348
381
|
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
349
382
|
|
|
350
|
-
// Reset the transient UI state (open thread, half-placed comment
|
|
383
|
+
// Reset the transient UI state (open thread, open stack, half-placed comment, unserved reveal)
|
|
384
|
+
// when this unmounts.
|
|
351
385
|
useEffect(() => {
|
|
352
386
|
return () => {
|
|
353
387
|
openThreadId.set(editor, null)
|
|
388
|
+
openStackId.set(editor, null)
|
|
354
389
|
pendingComment.set(editor, null)
|
|
390
|
+
revealThreadRequest.set(editor, null)
|
|
355
391
|
}
|
|
356
392
|
}, [editor])
|
|
357
393
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
394
|
+
// Clear a stale open-stack key. `openStackId` is a stack's coincident point key, and only the
|
|
395
|
+
// stack's own (mounted) handlers clear it — so collapsing the stack to a single pin unmounts the
|
|
396
|
+
// `ThreadStackPin` and strands the key. A dangling `openStackId` is not harmless: `useMarkerPreview`
|
|
397
|
+
// treats any non-null value as "a stack is open" and suppresses every hover preview until it's
|
|
398
|
+
// cleared. Keep it while any live stack still sits at that key (so losing a member — even the
|
|
399
|
+
// oldest — keeps the list open under the survivors), and clear it once none does.
|
|
360
400
|
useEffect(() => {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
401
|
+
const key = openStackId.get(editor)
|
|
402
|
+
if (!key) return
|
|
403
|
+
for (const id of pinStacks.keys()) {
|
|
404
|
+
const thread = threadsById.get(id)
|
|
405
|
+
if (!thread) continue
|
|
406
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
407
|
+
if (point && pinStackKey(point) === key) return
|
|
366
408
|
}
|
|
409
|
+
openStackId.set(editor, null)
|
|
410
|
+
}, [editor, pinStacks, threadsById])
|
|
367
411
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
412
|
+
// The requested thread, once it (and, for a comment id, its parent thread) has synced into the
|
|
413
|
+
// store; null while records are still arriving or when no request is pending.
|
|
414
|
+
const requestedRevealThread = useValue(
|
|
415
|
+
'requested reveal thread',
|
|
416
|
+
() => {
|
|
417
|
+
const id = revealThreadRequest.get(editor)
|
|
418
|
+
if (!id) return null
|
|
419
|
+
const record = getCommentRecord(editor, id)
|
|
420
|
+
if (!record) return null
|
|
421
|
+
const thread =
|
|
422
|
+
record.typeName === 'comment' ? getCommentRecord(editor, record.threadId) : record
|
|
423
|
+
return thread?.typeName === 'comment-thread' ? thread : null
|
|
424
|
+
},
|
|
425
|
+
[editor]
|
|
426
|
+
)
|
|
378
427
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
)
|
|
388
|
-
openThreadId.set(editor,
|
|
389
|
-
}, [clusterModel.table, clusterZoomBounds, editor,
|
|
428
|
+
// Serve a pending reveal request: open the thread, zooming to the first cluster split that
|
|
429
|
+
// reveals its pin when it's currently folded into a badge. A reveal is an explicit ask to see
|
|
430
|
+
// the thread, so it also unhides pins — the popover opens on the on-canvas layer, which stays
|
|
431
|
+
// invisible while hidden.
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (!requestedRevealThread) return
|
|
434
|
+
revealThreadRequest.set(editor, null)
|
|
435
|
+
commentsHidden.set(editor, false)
|
|
436
|
+
revealThreadPin(editor, requestedRevealThread, clusterModel.table, clusterZoomBounds, options)
|
|
437
|
+
openThreadId.set(editor, requestedRevealThread.id)
|
|
438
|
+
}, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
|
|
439
|
+
|
|
440
|
+
// Picking a thread out of a cluster's hover preview. Setting `openThreadId` alone would work —
|
|
441
|
+
// the thread leaves the cluster input and renders its own pin — but it would cut straight there
|
|
442
|
+
// from wherever the badge was. Zoom in on it first, the same move (and duration) the badge's
|
|
443
|
+
// own click makes, so the thread arrives instead of appearing.
|
|
444
|
+
const revealClusteredThread = useCallback(
|
|
445
|
+
(thread: TLCommentThread) => {
|
|
446
|
+
revealThreadPin(
|
|
447
|
+
editor,
|
|
448
|
+
thread,
|
|
449
|
+
clusterModel.table,
|
|
450
|
+
clusterZoomBounds,
|
|
451
|
+
options,
|
|
452
|
+
CLUSTER_EXPAND_ZOOM_MS
|
|
453
|
+
)
|
|
454
|
+
openThreadId.set(editor, thread.id)
|
|
455
|
+
},
|
|
456
|
+
[clusterModel.table, clusterZoomBounds, editor, options]
|
|
457
|
+
)
|
|
390
458
|
|
|
391
459
|
// Clicking a badge zooms to just past the zoom at which that cluster first unclusters,
|
|
392
460
|
// centered on its centroid. The event that created a visible cluster is the event that splits
|
|
@@ -399,13 +467,13 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
399
467
|
const event = clusterModel.table.events.find((e) => e.result.id === node.id)
|
|
400
468
|
if (!event || !Number.isFinite(event.zSplit)) return
|
|
401
469
|
const zoom = clamp(
|
|
402
|
-
event.zSplit *
|
|
470
|
+
event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
403
471
|
clusterZoomBounds.minZoom,
|
|
404
472
|
clusterZoomBounds.maxZoom
|
|
405
473
|
)
|
|
406
474
|
centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
|
|
407
475
|
},
|
|
408
|
-
[clusterModel, clusterZoomBounds, editor
|
|
476
|
+
[clusterModel, clusterZoomBounds, editor]
|
|
409
477
|
)
|
|
410
478
|
|
|
411
479
|
// Escape collapses the open thread. Capture-phase + stopPropagation so it runs ahead of the
|
|
@@ -426,8 +494,8 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
426
494
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
427
495
|
}, [editor])
|
|
428
496
|
|
|
429
|
-
// Shift+C toggles comment-pin visibility on the canvas
|
|
430
|
-
//
|
|
497
|
+
// Shift+C toggles comment-pin visibility on the canvas. Skipped while typing so it never fires
|
|
498
|
+
// from inside a composer. Physical `KeyC` (layout-independent) with shift only.
|
|
431
499
|
useEffect(() => {
|
|
432
500
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
433
501
|
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
@@ -444,27 +512,88 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
444
512
|
// is read above so this component stays mounted and its shortcut/Escape effects keep running.
|
|
445
513
|
if (hidden) return null
|
|
446
514
|
|
|
515
|
+
// Which threads are on screen this render, across every path below. A stack renders exactly
|
|
516
|
+
// once, owned by its first member that is actually on screen — members can arrive by different
|
|
517
|
+
// paths (a leaf via clustering while its open sibling renders via the open slot), so ownership
|
|
518
|
+
// can't be decided per-path.
|
|
519
|
+
// A cluster node that is exactly one coincident stack — every member shares a single pin-stack
|
|
520
|
+
// group, with no distinct-position comment mixed in. Such a node is a stack standing on its own
|
|
521
|
+
// (its neighbours have already split off as the view zoomed in), so it renders as the immediate
|
|
522
|
+
// cascading count-badge list rather than a zoom-to-split cluster badge. Returns the stack's full
|
|
523
|
+
// group — which can include an open or orphan member the node's own leaves omit — or null.
|
|
524
|
+
const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
|
|
525
|
+
const group = pinStacks.get(node.members[0])
|
|
526
|
+
if (!group) return null
|
|
527
|
+
return node.members.every((id) => group.includes(id)) ? group : null
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const renderedThreadIds = new Set<string>()
|
|
531
|
+
if (options.enableClustering) {
|
|
532
|
+
for (const { node } of fadeNodes) {
|
|
533
|
+
if (node.count === 1) renderedThreadIds.add(node.id)
|
|
534
|
+
// A pure-stack node owns its members here (they aren't count-1 leaves), so register them so
|
|
535
|
+
// the owner logic can pick one — mirroring how count-1 leaves are added above.
|
|
536
|
+
else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
|
|
537
|
+
}
|
|
538
|
+
for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
|
|
539
|
+
for (const thread of heldThreads) renderedThreadIds.add(thread.id)
|
|
540
|
+
} else {
|
|
541
|
+
for (const thread of threads) renderedThreadIds.add(thread.id)
|
|
542
|
+
}
|
|
543
|
+
if (openThread) renderedThreadIds.add(openThread.id)
|
|
544
|
+
|
|
545
|
+
// A coincident-stack member renders as the group's single count-badge stack (if it owns it)
|
|
546
|
+
// or not at all; everything else is an ordinary pin.
|
|
547
|
+
const renderThreadPin = (thread: TLCommentThread): ReactNode => {
|
|
548
|
+
const group = pinStacks.get(thread.id)
|
|
549
|
+
if (group) {
|
|
550
|
+
const owner = group.find((id) => renderedThreadIds.has(id))
|
|
551
|
+
if (owner !== thread.id) return null
|
|
552
|
+
const stackThreads = group
|
|
553
|
+
.map((id) => threadsById.get(id))
|
|
554
|
+
.filter((t): t is TLCommentThread => t !== undefined)
|
|
555
|
+
return <ThreadStackPin editor={editor} threads={stackThreads} {...props} />
|
|
556
|
+
}
|
|
557
|
+
return <ThreadPin editor={editor} thread={thread} {...props} />
|
|
558
|
+
}
|
|
559
|
+
|
|
447
560
|
// Render into the container (above the panels' stacking context) so the pins and popovers
|
|
448
|
-
// live in the UI layer rather than being clipped by the canvas layer
|
|
561
|
+
// live in the UI layer rather than being clipped by the canvas layer — but at the end of it,
|
|
562
|
+
// behind the editor's own children in the tab order. See `useTrailingPortalHost`.
|
|
563
|
+
if (!portalHost) return null
|
|
449
564
|
return createPortal(
|
|
450
565
|
<div ref={layerRef} className="tlui-cmt-canvas-layer">
|
|
451
566
|
{options.enableClustering ? (
|
|
452
567
|
<>
|
|
453
568
|
{fadeNodes.map(({ node, phase }) => {
|
|
454
569
|
let content: ReactNode
|
|
570
|
+
const stackGroup = node.count > 1 ? stackGroupOf(node) : null
|
|
455
571
|
if (node.count === 1) {
|
|
456
572
|
const thread = threadsById.get(node.id)
|
|
457
573
|
if (!thread) return null
|
|
574
|
+
content = renderThreadPin(thread)
|
|
575
|
+
} else if (stackGroup) {
|
|
576
|
+
// A coincident stack standing alone: draw the cascading count-badge list now
|
|
577
|
+
// instead of a zoom-to-split cluster badge. Route it through the stack's owner so
|
|
578
|
+
// the open/orphan/held slots stay deduped — when the owner is one of them, that
|
|
579
|
+
// slot draws the stack and this node draws nothing.
|
|
580
|
+
const owner = stackGroup.find((id) => renderedThreadIds.has(id))
|
|
581
|
+
content =
|
|
582
|
+
owner && node.members.includes(owner)
|
|
583
|
+
? renderThreadPin(threadsById.get(owner)!)
|
|
584
|
+
: null
|
|
585
|
+
} else {
|
|
458
586
|
content = (
|
|
459
|
-
<
|
|
587
|
+
<ClusterBadge
|
|
460
588
|
editor={editor}
|
|
461
|
-
|
|
462
|
-
{
|
|
463
|
-
|
|
589
|
+
node={node}
|
|
590
|
+
onExpand={zoomToClusterSplit}
|
|
591
|
+
onSelectThread={revealClusteredThread}
|
|
592
|
+
threadsById={threadsById}
|
|
593
|
+
currentUserId={props.currentUserId}
|
|
594
|
+
resolveAuthor={props.resolveAuthor}
|
|
464
595
|
/>
|
|
465
596
|
)
|
|
466
|
-
} else {
|
|
467
|
-
content = <ClusterBadge editor={editor} node={node} onExpand={zoomToClusterSplit} />
|
|
468
597
|
}
|
|
469
598
|
return (
|
|
470
599
|
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
|
@@ -473,22 +602,10 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
473
602
|
)
|
|
474
603
|
})}
|
|
475
604
|
{orphanThreads.map((thread) => (
|
|
476
|
-
<
|
|
477
|
-
key={thread.id}
|
|
478
|
-
editor={editor}
|
|
479
|
-
thread={thread}
|
|
480
|
-
{...props}
|
|
481
|
-
regionOptions={regionOptions}
|
|
482
|
-
/>
|
|
605
|
+
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
483
606
|
))}
|
|
484
607
|
{heldThreads.map((thread) => (
|
|
485
|
-
<
|
|
486
|
-
key={thread.id}
|
|
487
|
-
editor={editor}
|
|
488
|
-
thread={thread}
|
|
489
|
-
{...props}
|
|
490
|
-
regionOptions={regionOptions}
|
|
491
|
-
/>
|
|
608
|
+
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
492
609
|
))}
|
|
493
610
|
</>
|
|
494
611
|
) : (
|
|
@@ -498,34 +615,22 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
498
615
|
// otherwise it would mount a second, stacked pin.
|
|
499
616
|
threads
|
|
500
617
|
.filter((thread) => thread.id !== openId)
|
|
501
|
-
.map((thread) => (
|
|
502
|
-
<ThreadPin
|
|
503
|
-
key={thread.id}
|
|
504
|
-
editor={editor}
|
|
505
|
-
thread={thread}
|
|
506
|
-
{...props}
|
|
507
|
-
regionOptions={regionOptions}
|
|
508
|
-
/>
|
|
509
|
-
))
|
|
618
|
+
.map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
|
|
510
619
|
)}
|
|
511
620
|
{openThread && (
|
|
512
|
-
<
|
|
513
|
-
key={`open:${openThread.id}`}
|
|
514
|
-
editor={editor}
|
|
515
|
-
thread={openThread}
|
|
516
|
-
{...props}
|
|
517
|
-
regionOptions={regionOptions}
|
|
518
|
-
/>
|
|
621
|
+
<Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
|
|
519
622
|
)}
|
|
520
623
|
<RegionDraftBox editor={editor} />
|
|
521
624
|
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
|
522
625
|
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
|
523
|
-
{pending?.anchor.type === 'region' &&
|
|
524
|
-
|
|
626
|
+
{pending?.anchor.type === 'region' && showPendingComposer && (
|
|
627
|
+
<RegionBox editor={editor} box={pending.anchor} />
|
|
628
|
+
)}
|
|
629
|
+
{pending && showPendingComposer && (
|
|
525
630
|
<PendingComposer editor={editor} pending={pending} {...props} />
|
|
526
631
|
)}
|
|
527
632
|
</div>,
|
|
528
|
-
|
|
633
|
+
portalHost
|
|
529
634
|
)
|
|
530
635
|
}
|
|
531
636
|
|
|
@@ -670,20 +775,19 @@ function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: numbe
|
|
|
670
775
|
}
|
|
671
776
|
}
|
|
672
777
|
|
|
673
|
-
function
|
|
778
|
+
function revealThreadPin(
|
|
674
779
|
editor: Editor,
|
|
675
780
|
thread: TLCommentThread,
|
|
676
781
|
table: ClusterTable,
|
|
677
782
|
zoomBounds: { minZoom: number; maxZoom: number },
|
|
678
783
|
options: CommentingOptions,
|
|
679
|
-
|
|
784
|
+
duration = 200
|
|
680
785
|
) {
|
|
681
786
|
if (thread.pageId !== editor.getCurrentPageId()) {
|
|
682
787
|
editor.setCurrentPage(thread.pageId as any)
|
|
683
788
|
}
|
|
684
789
|
|
|
685
|
-
|
|
686
|
-
const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
790
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
687
791
|
if (!point) return
|
|
688
792
|
|
|
689
793
|
// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
|
|
@@ -696,16 +800,17 @@ function revealDeepLinkedThread(
|
|
|
696
800
|
parentEvent.zSplit <= zoomBounds.maxZoom
|
|
697
801
|
) {
|
|
698
802
|
const zoom = clamp(
|
|
699
|
-
parentEvent.zSplit *
|
|
803
|
+
parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
700
804
|
zoomBounds.minZoom,
|
|
701
805
|
zoomBounds.maxZoom
|
|
702
806
|
)
|
|
703
|
-
centerOnPointAtZoom(editor, point, zoom)
|
|
807
|
+
centerOnPointAtZoom(editor, point, zoom, duration)
|
|
704
808
|
return
|
|
705
809
|
}
|
|
706
810
|
}
|
|
707
811
|
|
|
708
|
-
|
|
812
|
+
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
|
813
|
+
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
|
|
709
814
|
}
|
|
710
815
|
|
|
711
816
|
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
|
@@ -719,9 +824,11 @@ function centerOnPointAtZoom(
|
|
|
719
824
|
duration = 200
|
|
720
825
|
) {
|
|
721
826
|
const viewport = editor.getViewportScreenBounds()
|
|
827
|
+
// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
|
|
828
|
+
const offset = commentCenterScreenOffset(editor)
|
|
722
829
|
editor.setCamera(
|
|
723
830
|
{
|
|
724
|
-
x: viewport.w /
|
|
831
|
+
x: (viewport.w / 2 - offset) / zoom - point.x,
|
|
725
832
|
y: viewport.h / (2 * zoom) - point.y,
|
|
726
833
|
z: zoom,
|
|
727
834
|
},
|
|
@@ -741,11 +848,23 @@ const ClusterBadge = memo(function ClusterBadge({
|
|
|
741
848
|
editor,
|
|
742
849
|
node,
|
|
743
850
|
onExpand,
|
|
744
|
-
|
|
851
|
+
onSelectThread,
|
|
852
|
+
threadsById,
|
|
853
|
+
...props
|
|
854
|
+
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
|
745
855
|
editor: Editor
|
|
746
856
|
node: ClusterNode
|
|
747
857
|
onExpand(node: ClusterNode): void
|
|
858
|
+
onSelectThread(thread: TLCommentThread): void
|
|
859
|
+
threadsById: ReadonlyMap<string, TLCommentThread>
|
|
748
860
|
}) {
|
|
861
|
+
const container = useContainer()
|
|
862
|
+
const msg = useTranslation()
|
|
863
|
+
const badgeRef = useRef<HTMLButtonElement>(null)
|
|
864
|
+
const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
|
|
865
|
+
// Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
|
|
866
|
+
// note on the layer.
|
|
867
|
+
usePassThroughWheelEvents(badgeRef)
|
|
749
868
|
const point = useValue(
|
|
750
869
|
'cluster badge point',
|
|
751
870
|
() => {
|
|
@@ -756,26 +875,64 @@ const ClusterBadge = memo(function ClusterBadge({
|
|
|
756
875
|
[editor, node]
|
|
757
876
|
)
|
|
758
877
|
|
|
878
|
+
// `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
|
|
879
|
+
// the order a reader would expect. Only computed while the preview is up.
|
|
880
|
+
const previewThreads = useMemo(() => {
|
|
881
|
+
if (!previewShown) return []
|
|
882
|
+
const threads: TLCommentThread[] = []
|
|
883
|
+
for (const id of node.members) {
|
|
884
|
+
const thread = threadsById.get(id)
|
|
885
|
+
if (thread) threads.push(thread)
|
|
886
|
+
}
|
|
887
|
+
return sortThreadsForPreview(threads)
|
|
888
|
+
}, [previewShown, node.members, threadsById])
|
|
889
|
+
|
|
759
890
|
if (!point) return null
|
|
760
891
|
|
|
761
892
|
return (
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
893
|
+
<>
|
|
894
|
+
<button
|
|
895
|
+
ref={badgeRef}
|
|
896
|
+
type="button"
|
|
897
|
+
className="tlui-cmt-button tlui-cmt-canvas-cluster"
|
|
898
|
+
style={{ left: point.x, top: point.y }}
|
|
899
|
+
aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
|
|
900
|
+
onPointerDown={(e) => {
|
|
901
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
902
|
+
forwardPointerEventToCanvas(container, e)
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
e.stopPropagation()
|
|
906
|
+
}}
|
|
907
|
+
onClick={(e) => {
|
|
908
|
+
e.stopPropagation()
|
|
909
|
+
onExpand(node)
|
|
910
|
+
}}
|
|
911
|
+
{...previewHandlers}
|
|
912
|
+
onFocus={previewHandlers.onPointerEnter}
|
|
913
|
+
onBlur={previewHandlers.onPointerLeave}
|
|
914
|
+
>
|
|
915
|
+
<CountBadge count={node.count} />
|
|
916
|
+
</button>
|
|
917
|
+
{previewShown && previewThreads.length > 0 && (
|
|
918
|
+
<ThreadPreview
|
|
919
|
+
editor={editor}
|
|
920
|
+
threads={previewThreads}
|
|
921
|
+
container={container}
|
|
922
|
+
variant="list"
|
|
923
|
+
point={point}
|
|
924
|
+
onSelectThread={onSelectThread}
|
|
925
|
+
{...previewHandlers}
|
|
926
|
+
{...props}
|
|
927
|
+
/>
|
|
928
|
+
)}
|
|
929
|
+
</>
|
|
773
930
|
)
|
|
774
931
|
})
|
|
775
932
|
|
|
776
933
|
function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
|
|
777
934
|
const viewport = editor.getViewportScreenBounds()
|
|
778
|
-
const margin =
|
|
935
|
+
const margin = CLUSTER_CULL_MARGIN_PX
|
|
779
936
|
return (
|
|
780
937
|
point.x >= -margin &&
|
|
781
938
|
point.y >= -margin &&
|
|
@@ -784,44 +941,12 @@ function isInInflatedViewport(editor: Editor, point: { x: number; y: number }):
|
|
|
784
941
|
)
|
|
785
942
|
}
|
|
786
943
|
|
|
787
|
-
/** The open thread's popover, portaled above the UI panels. Over it, wheel and hover events pass
|
|
788
|
-
* through to the canvas (unless the popover is scrolling its own content), like tldraw's panels. */
|
|
789
|
-
function ThreadPopover({
|
|
790
|
-
container,
|
|
791
|
-
style,
|
|
792
|
-
children,
|
|
793
|
-
}: {
|
|
794
|
-
container: HTMLElement
|
|
795
|
-
style: CSSProperties
|
|
796
|
-
children: ReactNode
|
|
797
|
-
}) {
|
|
798
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
799
|
-
usePassThroughWheelEvents(ref)
|
|
800
|
-
usePassThroughMouseOverEvents(ref)
|
|
801
|
-
return createPortal(
|
|
802
|
-
<div ref={ref} className="tlui-cmt-canvas-popover" style={style} onPointerDown={stop}>
|
|
803
|
-
{children}
|
|
804
|
-
</div>,
|
|
805
|
-
container
|
|
806
|
-
)
|
|
807
|
-
}
|
|
808
|
-
|
|
809
944
|
/** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
|
|
810
945
|
* a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
|
|
811
946
|
* `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
movable,
|
|
816
|
-
onPreview,
|
|
817
|
-
onCommit,
|
|
818
|
-
}: {
|
|
819
|
-
editor: Editor
|
|
820
|
-
box: BoxModel
|
|
821
|
-
movable?: boolean
|
|
822
|
-
onPreview?(bounds: BoxModel | null): void
|
|
823
|
-
onCommit?(bounds: BoxModel): void
|
|
824
|
-
}) {
|
|
947
|
+
/** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
|
|
948
|
+
* handles, so the box itself takes no pointer events. */
|
|
949
|
+
function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
|
|
825
950
|
const rect = useValue(
|
|
826
951
|
'region rect',
|
|
827
952
|
() => {
|
|
@@ -832,43 +957,7 @@ function RegionBox({
|
|
|
832
957
|
},
|
|
833
958
|
[editor, box.x, box.y, box.w, box.h]
|
|
834
959
|
)
|
|
835
|
-
|
|
836
|
-
// as the box prop reflows under the live preview.
|
|
837
|
-
const grabRef = useRef<{ page: VecLike; box: BoxModel } | null>(null)
|
|
838
|
-
const translated = (e: ReactPointerEvent<HTMLDivElement>): BoxModel => {
|
|
839
|
-
const g = grabRef.current!
|
|
840
|
-
const p = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
841
|
-
return { ...g.box, x: g.box.x + (p.x - g.page.x), y: g.box.y + (p.y - g.page.y) }
|
|
842
|
-
}
|
|
843
|
-
const startMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
844
|
-
e.stopPropagation()
|
|
845
|
-
grabRef.current = { page: editor.screenToPage({ x: e.clientX, y: e.clientY }), box }
|
|
846
|
-
e.currentTarget.setPointerCapture(e.pointerId)
|
|
847
|
-
}
|
|
848
|
-
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
849
|
-
if (grabRef.current) onPreview?.(translated(e))
|
|
850
|
-
}
|
|
851
|
-
const endMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
852
|
-
if (!grabRef.current) return
|
|
853
|
-
const bounds = translated(e)
|
|
854
|
-
grabRef.current = null
|
|
855
|
-
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
856
|
-
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
857
|
-
onCommit?.(bounds)
|
|
858
|
-
}
|
|
859
|
-
return (
|
|
860
|
-
<div
|
|
861
|
-
className={
|
|
862
|
-
movable
|
|
863
|
-
? 'tlui-cmt-canvas-region tlui-cmt-canvas-region--movable'
|
|
864
|
-
: 'tlui-cmt-canvas-region'
|
|
865
|
-
}
|
|
866
|
-
style={rect}
|
|
867
|
-
onPointerDown={movable ? startMove : undefined}
|
|
868
|
-
onPointerMove={movable ? onMove : undefined}
|
|
869
|
-
onPointerUp={movable ? endMove : undefined}
|
|
870
|
-
/>
|
|
871
|
-
)
|
|
960
|
+
return <div className="tlui-cmt-canvas-region" style={rect} />
|
|
872
961
|
}
|
|
873
962
|
|
|
874
963
|
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
|
@@ -878,27 +967,21 @@ function RegionDraftBox({ editor }: { editor: Editor }) {
|
|
|
878
967
|
return <RegionBox editor={editor} box={box} />
|
|
879
968
|
}
|
|
880
969
|
|
|
881
|
-
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5
|
|
882
|
-
//
|
|
970
|
+
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
|
|
971
|
+
// controlled by that handle — the resize math reads the spot rather than special-casing corners.
|
|
883
972
|
interface RegionHandle {
|
|
884
973
|
x: number
|
|
885
974
|
y: number
|
|
886
975
|
cursor: string
|
|
887
976
|
}
|
|
888
977
|
|
|
889
|
-
// The four corners
|
|
978
|
+
// The four corners, each resizing both axes.
|
|
890
979
|
const REGION_CORNERS: readonly RegionHandle[] = [
|
|
891
980
|
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
|
892
981
|
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
|
893
982
|
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
|
894
983
|
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
|
895
984
|
]
|
|
896
|
-
const REGION_EDGES: readonly RegionHandle[] = [
|
|
897
|
-
{ x: 0.5, y: 0, cursor: 'ns-resize' },
|
|
898
|
-
{ x: 1, y: 0.5, cursor: 'ew-resize' },
|
|
899
|
-
{ x: 0.5, y: 1, cursor: 'ns-resize' },
|
|
900
|
-
{ x: 0, y: 0.5, cursor: 'ew-resize' },
|
|
901
|
-
]
|
|
902
985
|
|
|
903
986
|
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
|
904
987
|
// the handles (which sit on the edge) are comfortably reachable.
|
|
@@ -984,41 +1067,29 @@ function RegionResizeHandles({
|
|
|
984
1067
|
const ThreadPin = memo(function ThreadPin({
|
|
985
1068
|
editor,
|
|
986
1069
|
thread,
|
|
987
|
-
regionOptions,
|
|
988
1070
|
...props
|
|
989
|
-
}:
|
|
1071
|
+
}: CommentingContext & {
|
|
990
1072
|
editor: Editor
|
|
991
1073
|
thread: TLCommentThread
|
|
992
|
-
regionOptions: RegionCommentOptions
|
|
993
1074
|
}) {
|
|
994
|
-
const {
|
|
995
|
-
currentUserId,
|
|
996
|
-
resolveName,
|
|
997
|
-
onPostComment,
|
|
998
|
-
isCommentUnread,
|
|
999
|
-
onCommentRead,
|
|
1000
|
-
getMentionSuggestions,
|
|
1001
|
-
renderMentionSuggestion,
|
|
1002
|
-
} = props
|
|
1075
|
+
const { resolveAuthor } = props
|
|
1003
1076
|
const options = useCommentingOptions()
|
|
1004
|
-
const
|
|
1077
|
+
const canComment = useCanComment(props.currentUserId)
|
|
1005
1078
|
const container = useContainer()
|
|
1006
|
-
const comments = useThreadComments(editor, thread.id)
|
|
1007
1079
|
const msg = useTranslation()
|
|
1080
|
+
const comments = useThreadComments(editor, thread.id)
|
|
1008
1081
|
// Only one thread's popover is open at a time — shared across pins via the atom.
|
|
1009
1082
|
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
|
1010
1083
|
editor,
|
|
1011
1084
|
thread.id,
|
|
1012
1085
|
])
|
|
1013
|
-
const [reply, setReply] = useState<TLRichText>(EMPTY_COMMENT)
|
|
1014
|
-
const [editingId, setEditingId] = useState<string | null>(null)
|
|
1015
|
-
const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
|
|
1016
1086
|
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
1017
1087
|
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
|
1018
1088
|
// The live bounds while a corner handle is resizing the region, else null.
|
|
1019
1089
|
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
|
1020
|
-
//
|
|
1021
|
-
const
|
|
1090
|
+
// Hovering the marker previews the thread's opening comment, on the delay every marker uses.
|
|
1091
|
+
const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
|
|
1092
|
+
const previewThreads = useMemo(() => [thread], [thread])
|
|
1022
1093
|
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
|
1023
1094
|
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
|
1024
1095
|
// handle never loses the affordance — the box stays `pointer-events: none`.
|
|
@@ -1034,25 +1105,40 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1034
1105
|
},
|
|
1035
1106
|
[editor, thread.anchor, thread.pageId]
|
|
1036
1107
|
)
|
|
1037
|
-
// A region's box and handles are revealed while open
|
|
1038
|
-
//
|
|
1039
|
-
const revealed =
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
//
|
|
1108
|
+
// A region's box and handles are revealed while open, mid-resize, or while the pointer is
|
|
1109
|
+
// within the region.
|
|
1110
|
+
const revealed = open || resizeBounds != null || pointerInRegion
|
|
1111
|
+
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
|
1112
|
+
// the default as the fallback for older records.
|
|
1113
|
+
const pinCorner =
|
|
1114
|
+
thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
|
|
1115
|
+
// A region resizes from its corners — every corner but the pin's own, which the pin owns.
|
|
1045
1116
|
const resizeHandles = useMemo(
|
|
1046
|
-
() =>
|
|
1047
|
-
|
|
1048
|
-
? REGION_EDGES
|
|
1049
|
-
: REGION_CORNERS.filter(
|
|
1050
|
-
(c) => c.x !== regionOptions.pinCorner.x || c.y !== regionOptions.pinCorner.y
|
|
1051
|
-
),
|
|
1052
|
-
[regionOptions.resize, regionOptions.pinCorner]
|
|
1117
|
+
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
|
1118
|
+
[pinCorner]
|
|
1053
1119
|
)
|
|
1054
|
-
const dragRef = useRef<{
|
|
1055
|
-
|
|
1120
|
+
const dragRef = useRef<{
|
|
1121
|
+
startX: number
|
|
1122
|
+
startY: number
|
|
1123
|
+
moved: boolean
|
|
1124
|
+
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
|
1125
|
+
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
|
1126
|
+
offsetX: number
|
|
1127
|
+
offsetY: number
|
|
1128
|
+
} | null>(null)
|
|
1129
|
+
const markerRef = useRef<HTMLButtonElement>(null)
|
|
1130
|
+
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
|
1131
|
+
// see the note on the layer.
|
|
1132
|
+
usePassThroughWheelEvents(markerRef)
|
|
1133
|
+
|
|
1134
|
+
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
|
1135
|
+
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
|
1136
|
+
// clear the hint here or it stays on the shape indefinitely.
|
|
1137
|
+
useEffect(() => {
|
|
1138
|
+
return () => {
|
|
1139
|
+
if (dragRef.current) editor.setHintingShapes([])
|
|
1140
|
+
}
|
|
1141
|
+
}, [editor])
|
|
1056
1142
|
|
|
1057
1143
|
// Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
|
|
1058
1144
|
// pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
|
|
@@ -1085,223 +1171,141 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1085
1171
|
'pin point',
|
|
1086
1172
|
() => {
|
|
1087
1173
|
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
1088
|
-
const pagePoint = anchorPagePoint(editor, thread.anchor
|
|
1089
|
-
|
|
1174
|
+
const pagePoint = anchorPagePoint(editor, thread.anchor)
|
|
1175
|
+
if (!pagePoint) return null
|
|
1176
|
+
const viewportPoint = editor.pageToViewport(pagePoint)
|
|
1177
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
1178
|
+
return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
|
|
1090
1179
|
},
|
|
1091
|
-
[editor, thread.anchor, thread.pageId
|
|
1180
|
+
[editor, thread.anchor, thread.pageId]
|
|
1092
1181
|
)
|
|
1093
|
-
const visible = point !== null
|
|
1094
|
-
|
|
1095
|
-
// While the popover is open, every unread comment on display gets reported read — including
|
|
1096
|
-
// replies that arrive while it stays open, since the effect re-runs as `comments` changes.
|
|
1097
|
-
// The host's receipt write flips isCommentUnread to false, so re-runs find nothing to report.
|
|
1098
|
-
useEffect(() => {
|
|
1099
|
-
if (!open || !visible || !isCommentUnread || !onCommentRead) return
|
|
1100
|
-
for (const comment of comments) {
|
|
1101
|
-
if (isCommentUnread(comment.id)) {
|
|
1102
|
-
onCommentRead(comment.id)
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
}, [open, visible, comments, isCommentUnread, onCommentRead])
|
|
1106
|
-
|
|
1107
1182
|
if (!point) return null
|
|
1108
1183
|
|
|
1109
|
-
const postReply = () => {
|
|
1110
|
-
if (isCommentEmpty(reply) || !currentUserId) return
|
|
1111
|
-
commitCommentMutation(editor, () => {
|
|
1112
|
-
const comment = createComment({
|
|
1113
|
-
threadId: thread.id,
|
|
1114
|
-
pageId: thread.pageId,
|
|
1115
|
-
authorId: currentUserId,
|
|
1116
|
-
body: reply,
|
|
1117
|
-
})
|
|
1118
|
-
putCommentRecords(editor, [comment])
|
|
1119
|
-
if (onPostComment) onPostComment(comment)
|
|
1120
|
-
})
|
|
1121
|
-
setReply(EMPTY_COMMENT)
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
const toggleResolve = () => {
|
|
1125
|
-
if (!currentUserId) return
|
|
1126
|
-
commitCommentMutation(editor, () => {
|
|
1127
|
-
putCommentRecords(editor, [
|
|
1128
|
-
{
|
|
1129
|
-
...thread,
|
|
1130
|
-
resolved: thread.resolved ? null : { at: Date.now(), by: currentUserId },
|
|
1131
|
-
},
|
|
1132
|
-
])
|
|
1133
|
-
})
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
const deleteThread = () => {
|
|
1137
|
-
openThreadId.set(editor, null)
|
|
1138
|
-
commitCommentMutation(editor, () =>
|
|
1139
|
-
removeCommentRecords(editor, [thread.id, ...comments.map((c) => c.id)])
|
|
1140
|
-
)
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
const startEdit = (comment: TLComment) => {
|
|
1144
|
-
setEditingId(comment.id)
|
|
1145
|
-
setEditText(comment.body)
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
const saveEdit = () => {
|
|
1149
|
-
const comment = comments.find((c) => c.id === editingId)
|
|
1150
|
-
if (!comment || isCommentEmpty(editText)) return
|
|
1151
|
-
commitCommentMutation(editor, () => {
|
|
1152
|
-
putCommentRecords(editor, [{ ...comment, body: editText, editedAt: Date.now() }])
|
|
1153
|
-
})
|
|
1154
|
-
setEditingId(null)
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
|
|
1158
|
-
// with an edit affordance on your own comments.
|
|
1159
|
-
const renderComment = (card: CommentCardProps, index: number): ReactNode => {
|
|
1160
|
-
const comment = comments[index]
|
|
1161
|
-
if (editingId === comment.id) {
|
|
1162
|
-
return (
|
|
1163
|
-
<div
|
|
1164
|
-
className="tlui-cmt-editing"
|
|
1165
|
-
onKeyDown={(e) => {
|
|
1166
|
-
if (e.key === 'Escape') {
|
|
1167
|
-
setEditingId(null)
|
|
1168
|
-
e.stopPropagation()
|
|
1169
|
-
}
|
|
1170
|
-
}}
|
|
1171
|
-
>
|
|
1172
|
-
<CommentComposer
|
|
1173
|
-
author={card.author}
|
|
1174
|
-
placeholder={msg('comments.edit-placeholder')}
|
|
1175
|
-
value={editText}
|
|
1176
|
-
onChange={setEditText}
|
|
1177
|
-
onSubmit={saveEdit}
|
|
1178
|
-
sendLabel={msg('comments.save')}
|
|
1179
|
-
disabled={isCommentEmpty(editText)}
|
|
1180
|
-
getMentionSuggestions={getMentionSuggestions}
|
|
1181
|
-
renderMentionSuggestion={renderMentionSuggestion}
|
|
1182
|
-
autoFocus
|
|
1183
|
-
/>
|
|
1184
|
-
</div>
|
|
1185
|
-
)
|
|
1186
|
-
}
|
|
1187
|
-
return (
|
|
1188
|
-
<CommentCard
|
|
1189
|
-
{...card}
|
|
1190
|
-
actions={
|
|
1191
|
-
comment.authorId === currentUserId ? (
|
|
1192
|
-
<button
|
|
1193
|
-
className="tlui-cmt-thread__action"
|
|
1194
|
-
title={msg('comments.edit')}
|
|
1195
|
-
onClick={() => startEdit(comment)}
|
|
1196
|
-
>
|
|
1197
|
-
<TldrawUiIcon icon="dots-horizontal" label={msg('comments.edit')} small />
|
|
1198
|
-
</button>
|
|
1199
|
-
) : undefined
|
|
1200
|
-
}
|
|
1201
|
-
/>
|
|
1202
|
-
)
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
const headerActions = (
|
|
1206
|
-
<>
|
|
1207
|
-
{currentUserId && (
|
|
1208
|
-
<button
|
|
1209
|
-
className="tlui-cmt-thread__action"
|
|
1210
|
-
title={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
1211
|
-
onClick={toggleResolve}
|
|
1212
|
-
>
|
|
1213
|
-
<TldrawUiIcon
|
|
1214
|
-
icon="check"
|
|
1215
|
-
label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
1216
|
-
small
|
|
1217
|
-
/>
|
|
1218
|
-
</button>
|
|
1219
|
-
)}
|
|
1220
|
-
{currentUserId && (
|
|
1221
|
-
<button
|
|
1222
|
-
className="tlui-cmt-thread__action"
|
|
1223
|
-
title={msg('comments.delete')}
|
|
1224
|
-
onClick={deleteThread}
|
|
1225
|
-
>
|
|
1226
|
-
<TldrawUiIcon icon="trash" label={msg('comments.delete')} small />
|
|
1227
|
-
</button>
|
|
1228
|
-
)}
|
|
1229
|
-
<button
|
|
1230
|
-
className="tlui-cmt-thread__action"
|
|
1231
|
-
title={msg('comments.dismiss')}
|
|
1232
|
-
onClick={() => openThreadId.set(editor, null)}
|
|
1233
|
-
>
|
|
1234
|
-
<TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
|
|
1235
|
-
</button>
|
|
1236
|
-
</>
|
|
1237
|
-
)
|
|
1238
|
-
|
|
1239
1184
|
const PinContent = options.components.PinContent
|
|
1240
|
-
// The `PinContent` component slot overrides the built-in author-
|
|
1185
|
+
// The `PinContent` component slot overrides the built-in author-avatar default.
|
|
1186
|
+
const threadAuthor = resolveAuthor(thread.createdBy)
|
|
1241
1187
|
const pinContent = PinContent ? (
|
|
1242
1188
|
<PinContent thread={thread} comments={comments} />
|
|
1243
1189
|
) : (
|
|
1244
|
-
|
|
1190
|
+
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
1245
1191
|
)
|
|
1192
|
+
const pinLabel = msg(
|
|
1193
|
+
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
|
1194
|
+
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
|
1246
1195
|
|
|
1247
1196
|
// Drag the marker to move the thread: its position is overridden locally while dragging, then
|
|
1248
1197
|
// re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
|
|
1249
1198
|
// a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
|
|
1250
1199
|
// toggle the popover.
|
|
1251
|
-
// Which affordances move a region, per the option: 'pin' → pin only, 'body' → body only, 'both'.
|
|
1252
1200
|
const isRegion = thread.anchor.type === 'region'
|
|
1253
|
-
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1201
|
+
// The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
|
|
1202
|
+
const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1203
|
+
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
|
1204
|
+
// hand it to the canvas untouched.
|
|
1205
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
1206
|
+
forwardPointerEventToCanvas(container, e)
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1256
1209
|
e.stopPropagation()
|
|
1257
|
-
|
|
1210
|
+
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1211
|
+
const anchorPage = anchorPagePoint(editor, thread.anchor)
|
|
1212
|
+
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
|
1213
|
+
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
|
1214
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
1215
|
+
if (anchorPage && inset) {
|
|
1216
|
+
const zoom = editor.getZoomLevel()
|
|
1217
|
+
anchorPage.x += inset.x / zoom
|
|
1218
|
+
anchorPage.y += inset.y / zoom
|
|
1219
|
+
}
|
|
1220
|
+
dragRef.current = {
|
|
1221
|
+
startX: e.clientX,
|
|
1222
|
+
startY: e.clientY,
|
|
1223
|
+
moved: false,
|
|
1224
|
+
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
|
1225
|
+
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
|
1226
|
+
}
|
|
1258
1227
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
1259
1228
|
}
|
|
1260
|
-
const onDrag = (e: ReactPointerEvent<
|
|
1229
|
+
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1261
1230
|
const drag = dragRef.current
|
|
1262
1231
|
if (!drag) return
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1232
|
+
// Moving a pin re-anchors the thread record — a commenting write. Without the permission the
|
|
1233
|
+
// press stays a click (`moved` never sets, so release toggles the popover and never commits).
|
|
1234
|
+
if (!canComment) return
|
|
1265
1235
|
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
1266
1236
|
drag.moved = true
|
|
1267
|
-
|
|
1237
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1238
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
1239
|
+
setDragPagePoint(pagePoint)
|
|
1240
|
+
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
|
1241
|
+
// with. Regions translate rather than re-anchor, so they never hint.
|
|
1242
|
+
if (!isRegion) {
|
|
1243
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
1244
|
+
editor.setHintingShapes(hit ? [hit.id] : [])
|
|
1245
|
+
}
|
|
1268
1246
|
}
|
|
1269
|
-
|
|
1247
|
+
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
|
1248
|
+
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
|
1249
|
+
const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1270
1250
|
const drag = dragRef.current
|
|
1271
1251
|
dragRef.current = null
|
|
1272
1252
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1273
1253
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1274
1254
|
}
|
|
1275
1255
|
if (!drag) return
|
|
1256
|
+
setDragPagePoint(null)
|
|
1257
|
+
editor.setHintingShapes([])
|
|
1258
|
+
}
|
|
1259
|
+
const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1260
|
+
const drag = dragRef.current
|
|
1261
|
+
dragRef.current = null
|
|
1262
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1263
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1264
|
+
}
|
|
1265
|
+
if (!drag) return
|
|
1266
|
+
editor.setHintingShapes([])
|
|
1276
1267
|
if (!drag.moved) {
|
|
1277
1268
|
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
1278
1269
|
return
|
|
1279
1270
|
}
|
|
1280
|
-
const
|
|
1271
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1272
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
1281
1273
|
setDragPagePoint(null)
|
|
1282
1274
|
let anchor: TLCommentThread['anchor']
|
|
1283
1275
|
if (thread.anchor.type === 'region') {
|
|
1284
1276
|
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
1285
1277
|
anchor = {
|
|
1286
1278
|
...thread.anchor,
|
|
1287
|
-
x: pagePoint.x -
|
|
1288
|
-
y: pagePoint.y -
|
|
1279
|
+
x: pagePoint.x - pinCorner.x * thread.anchor.w,
|
|
1280
|
+
y: pagePoint.y - pinCorner.y * thread.anchor.h,
|
|
1289
1281
|
}
|
|
1290
1282
|
} else {
|
|
1291
|
-
const hit = editor
|
|
1283
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
1292
1284
|
anchor = hit
|
|
1293
|
-
? shapeAnchorAt(
|
|
1285
|
+
? shapeAnchorAt(
|
|
1286
|
+
editor,
|
|
1287
|
+
hit.id,
|
|
1288
|
+
pagePoint,
|
|
1289
|
+
getCommentingOptions(editor).shouldBePrecise(editor, {
|
|
1290
|
+
shapeId: hit.id,
|
|
1291
|
+
point: pagePoint,
|
|
1292
|
+
altKey: e.altKey,
|
|
1293
|
+
})
|
|
1294
|
+
)
|
|
1294
1295
|
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
1295
1296
|
}
|
|
1296
|
-
commitCommentMutation(editor, () =>
|
|
1297
|
+
commitCommentMutation(editor, () => putRecordsInCommit(editor, [{ ...thread, anchor }]), 'drag')
|
|
1297
1298
|
}
|
|
1298
1299
|
|
|
1299
1300
|
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
1300
1301
|
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
1301
|
-
const livePinPage = resizeBounds
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1302
|
+
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
|
1303
|
+
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
1304
|
+
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
|
1305
|
+
// The marker anchors bottom-left, so step half its 34px size left and down (screen px).
|
|
1306
|
+
const renderPoint = isRegion
|
|
1307
|
+
? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
|
|
1308
|
+
: renderPointBase
|
|
1305
1309
|
|
|
1306
1310
|
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
1307
1311
|
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
@@ -1310,33 +1314,30 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1310
1314
|
regionAnchor && dragPagePoint
|
|
1311
1315
|
? {
|
|
1312
1316
|
...regionAnchor,
|
|
1313
|
-
x: dragPagePoint.x -
|
|
1314
|
-
y: dragPagePoint.y -
|
|
1317
|
+
x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
|
|
1318
|
+
y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
|
|
1315
1319
|
}
|
|
1316
1320
|
: regionAnchor
|
|
1317
1321
|
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
1318
1322
|
const commitResize = (bounds: BoxModel) => {
|
|
1319
1323
|
setResizeBounds(null)
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1324
|
+
if (!canComment) return
|
|
1325
|
+
// Same commit path as a pin drag, so the configured `dragHistory` governs both — going
|
|
1326
|
+
// straight to `editor.run` here would make region resizes silently ignore the option.
|
|
1327
|
+
commitCommentMutation(
|
|
1328
|
+
editor,
|
|
1329
|
+
// Spread the existing anchor first so the region's pin corner survives a resize.
|
|
1330
|
+
() => putRecordsInCommit(editor, [{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
|
1331
|
+
'drag'
|
|
1325
1332
|
)
|
|
1326
1333
|
}
|
|
1327
1334
|
|
|
1328
1335
|
return (
|
|
1329
1336
|
<>
|
|
1330
1337
|
{regionBoxBounds && (dragPagePoint || revealed) && (
|
|
1331
|
-
<RegionBox
|
|
1332
|
-
editor={editor}
|
|
1333
|
-
box={regionBoxBounds}
|
|
1334
|
-
movable={bodyMovable && !dragPagePoint}
|
|
1335
|
-
onPreview={setResizeBounds}
|
|
1336
|
-
onCommit={commitResize}
|
|
1337
|
-
/>
|
|
1338
|
+
<RegionBox editor={editor} box={regionBoxBounds} />
|
|
1338
1339
|
)}
|
|
1339
|
-
{regionBoxBounds && revealed && !dragPagePoint &&
|
|
1340
|
+
{regionBoxBounds && revealed && !dragPagePoint && canComment && (
|
|
1340
1341
|
<RegionResizeHandles
|
|
1341
1342
|
editor={editor}
|
|
1342
1343
|
box={regionBoxBounds}
|
|
@@ -1346,60 +1347,70 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1346
1347
|
/>
|
|
1347
1348
|
)}
|
|
1348
1349
|
<div
|
|
1349
|
-
className={
|
|
1350
|
+
className={[
|
|
1351
|
+
'tlui-cmt-canvas-pin',
|
|
1352
|
+
open && 'tlui-cmt-canvas-pin--open',
|
|
1353
|
+
dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
|
|
1354
|
+
]
|
|
1355
|
+
.filter(Boolean)
|
|
1356
|
+
.join(' ')}
|
|
1350
1357
|
style={{ left: renderPoint.x, top: renderPoint.y }}
|
|
1351
1358
|
>
|
|
1352
|
-
<
|
|
1359
|
+
<button
|
|
1353
1360
|
ref={markerRef}
|
|
1354
|
-
|
|
1361
|
+
type="button"
|
|
1362
|
+
className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
|
|
1363
|
+
aria-label={pinLabel}
|
|
1364
|
+
aria-expanded={open}
|
|
1355
1365
|
onPointerDown={startDrag}
|
|
1356
1366
|
onPointerMove={onDrag}
|
|
1357
1367
|
onPointerUp={endDrag}
|
|
1358
|
-
|
|
1359
|
-
|
|
1368
|
+
onPointerCancel={cancelDrag}
|
|
1369
|
+
// Pointer activation is already handled by endDrag (which distinguishes a click
|
|
1370
|
+
// from a drag), so only take keyboard-synthesised clicks here — those report
|
|
1371
|
+
// `detail === 0` — or the thread would toggle twice per mouse click.
|
|
1372
|
+
onClick={(e) => {
|
|
1373
|
+
if (e.detail !== 0) return
|
|
1374
|
+
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
1375
|
+
}}
|
|
1376
|
+
onPointerEnter={previewHandlers.onPointerEnter}
|
|
1377
|
+
onPointerLeave={previewHandlers.onPointerLeave}
|
|
1378
|
+
// Focus stands in for hover, so tabbing to a marker gets the same preview.
|
|
1379
|
+
onFocus={previewHandlers.onPointerEnter}
|
|
1380
|
+
onBlur={previewHandlers.onPointerLeave}
|
|
1360
1381
|
>
|
|
1361
1382
|
<CommentPin resolved={thread.resolved != null} open={open}>
|
|
1362
1383
|
{pinContent}
|
|
1363
1384
|
</CommentPin>
|
|
1364
|
-
</
|
|
1385
|
+
</button>
|
|
1365
1386
|
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
|
1366
1387
|
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
|
1367
1388
|
{open && (
|
|
1368
1389
|
<ThreadPopover
|
|
1369
1390
|
container={container}
|
|
1370
|
-
style={{
|
|
1391
|
+
style={{
|
|
1392
|
+
left: renderPoint.x + POPOVER_OFFSET.thread.x,
|
|
1393
|
+
top: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
|
1394
|
+
}}
|
|
1371
1395
|
>
|
|
1372
|
-
<
|
|
1373
|
-
header={msg('comments.thread-title')}
|
|
1374
|
-
headerActions={headerActions}
|
|
1375
|
-
renderComment={renderComment}
|
|
1376
|
-
comments={comments.map((c) => toCardProps(c, props, options.components))}
|
|
1377
|
-
resolvedBanner={
|
|
1378
|
-
thread.resolved
|
|
1379
|
-
? msg('comments.resolved-by').replace(
|
|
1380
|
-
'{name}',
|
|
1381
|
-
resolveName(thread.resolved.by) ?? UNKNOWN_AUTHOR
|
|
1382
|
-
)
|
|
1383
|
-
: undefined
|
|
1384
|
-
}
|
|
1385
|
-
composer={
|
|
1386
|
-
currentUserId && !thread.resolved
|
|
1387
|
-
? {
|
|
1388
|
-
author: resolveName(currentUserId) ?? UNKNOWN_AUTHOR,
|
|
1389
|
-
placeholder: msg('comments.reply-placeholder'),
|
|
1390
|
-
sendLabel: msg('comments.send'),
|
|
1391
|
-
value: reply,
|
|
1392
|
-
onChange: setReply,
|
|
1393
|
-
onSubmit: postReply,
|
|
1394
|
-
disabled: isCommentEmpty(reply),
|
|
1395
|
-
getMentionSuggestions,
|
|
1396
|
-
renderMentionSuggestion,
|
|
1397
|
-
}
|
|
1398
|
-
: undefined
|
|
1399
|
-
}
|
|
1400
|
-
/>
|
|
1396
|
+
<ThreadView editor={editor} thread={thread} {...props} />
|
|
1401
1397
|
</ThreadPopover>
|
|
1402
1398
|
)}
|
|
1399
|
+
{/* Not while dragging: the pin is being moved, not read, and a panel trailing the
|
|
1400
|
+
cursor would obscure the drop target. */}
|
|
1401
|
+
{previewShown && !dragPagePoint && (
|
|
1402
|
+
<ThreadPreview
|
|
1403
|
+
editor={editor}
|
|
1404
|
+
threads={previewThreads}
|
|
1405
|
+
container={container}
|
|
1406
|
+
variant="thread"
|
|
1407
|
+
point={renderPoint}
|
|
1408
|
+
onSelectThread={() => openThreadId.set(editor, thread.id)}
|
|
1409
|
+
{...previewHandlers}
|
|
1410
|
+
currentUserId={props.currentUserId}
|
|
1411
|
+
resolveAuthor={resolveAuthor}
|
|
1412
|
+
/>
|
|
1413
|
+
)}
|
|
1403
1414
|
</div>
|
|
1404
1415
|
</>
|
|
1405
1416
|
)
|
|
@@ -1409,12 +1420,25 @@ function PendingComposer({
|
|
|
1409
1420
|
editor,
|
|
1410
1421
|
pending,
|
|
1411
1422
|
currentUserId,
|
|
1412
|
-
|
|
1423
|
+
resolveAuthor,
|
|
1413
1424
|
onPostComment,
|
|
1414
1425
|
getMentionSuggestions,
|
|
1415
1426
|
renderMentionSuggestion,
|
|
1416
|
-
}:
|
|
1417
|
-
const
|
|
1427
|
+
}: CommentingContext & { editor: Editor; pending: PendingComment }) {
|
|
1428
|
+
const ComposerFallback = useCommentingOptions().components.ComposerFallback
|
|
1429
|
+
const canComment = useCanComment(currentUserId)
|
|
1430
|
+
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
|
1431
|
+
// The leading pin previews the pin this draft becomes: a white pin holding the author's avatar.
|
|
1432
|
+
const draftAvatar = (
|
|
1433
|
+
<CommentPin>
|
|
1434
|
+
<Avatar author={me ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
1435
|
+
</CommentPin>
|
|
1436
|
+
)
|
|
1437
|
+
// Click-away keeps the draft (saved on every change) and the next placement composer
|
|
1438
|
+
// restores it — the flip side of dismissing without a discard warning.
|
|
1439
|
+
const [text, setText] = useState<TLRichText>(
|
|
1440
|
+
() => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
|
|
1441
|
+
)
|
|
1418
1442
|
const ref = useRef<HTMLDivElement>(null)
|
|
1419
1443
|
const msg = useTranslation()
|
|
1420
1444
|
const container = useContainer()
|
|
@@ -1457,36 +1481,52 @@ function PendingComposer({
|
|
|
1457
1481
|
authorId: currentUserId,
|
|
1458
1482
|
body: text,
|
|
1459
1483
|
})
|
|
1460
|
-
|
|
1484
|
+
putRecordsInCommit(editor, [thread, comment])
|
|
1461
1485
|
if (onPostComment) onPostComment(comment)
|
|
1462
1486
|
})
|
|
1463
1487
|
setText(EMPTY_COMMENT)
|
|
1488
|
+
clearCommentDraft(NEW_COMMENT_DRAFT)
|
|
1464
1489
|
pendingComment.set(editor, null)
|
|
1465
1490
|
}
|
|
1466
1491
|
|
|
1467
1492
|
return createPortal(
|
|
1468
1493
|
<div
|
|
1469
1494
|
ref={ref}
|
|
1470
|
-
className=
|
|
1495
|
+
className={[
|
|
1496
|
+
'tlui-cmt-canvas-composer',
|
|
1497
|
+
pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
|
|
1498
|
+
!canComment && 'tlui-cmt-canvas-composer--fallback',
|
|
1499
|
+
]
|
|
1500
|
+
.filter(Boolean)
|
|
1501
|
+
.join(' ')}
|
|
1471
1502
|
style={{ left: point.x, top: point.y }}
|
|
1472
1503
|
onPointerDown={stop}
|
|
1504
|
+
onContextMenu={stop}
|
|
1473
1505
|
onKeyDown={(e) => {
|
|
1474
1506
|
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
|
1475
1507
|
}}
|
|
1476
1508
|
>
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1509
|
+
{canComment ? (
|
|
1510
|
+
<CommentComposer
|
|
1511
|
+
author={me ?? UNKNOWN_COMMENT_AUTHOR}
|
|
1512
|
+
placeholder={msg('comments.add-placeholder')}
|
|
1513
|
+
sendLabel={msg('comments.send')}
|
|
1514
|
+
value={text}
|
|
1515
|
+
onChange={(value) => {
|
|
1516
|
+
setText(value)
|
|
1517
|
+
saveCommentDraft(NEW_COMMENT_DRAFT, value)
|
|
1518
|
+
}}
|
|
1519
|
+
onSubmit={submit}
|
|
1520
|
+
// No user, no author for the record — dead send button.
|
|
1521
|
+
disabled={isCommentEmpty(text) || !currentUserId}
|
|
1522
|
+
getMentionSuggestions={getMentionSuggestions}
|
|
1523
|
+
renderMentionSuggestion={renderMentionSuggestion}
|
|
1524
|
+
autoFocus
|
|
1525
|
+
leading={draftAvatar}
|
|
1526
|
+
/>
|
|
1527
|
+
) : (
|
|
1528
|
+
ComposerFallback && <ComposerFallback context="pending" />
|
|
1529
|
+
)}
|
|
1490
1530
|
</div>,
|
|
1491
1531
|
container
|
|
1492
1532
|
)
|