@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8
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 +1139 -329
- 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-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 +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 +429 -474
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +35 -32
- 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 +119 -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 +763 -333
- package/dist-cjs/index.js +46 -28
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +6 -4
- 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 +137 -36
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +24 -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 +30 -18
- 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 +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 +59 -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 +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-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 +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 +450 -479
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +38 -35
- 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 +119 -7
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +763 -333
- package/dist-esm/index.mjs +87 -41
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +6 -4
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-card.mjs +15 -8
- package/dist-esm/ui/comment-card.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +148 -36
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +24 -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 +43 -29
- 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 +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 +60 -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 +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 +461 -46
- package/src/canvas/cluster-input.test.ts +31 -23
- package/src/canvas/cluster-input.ts +3 -4
- 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 +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.test.ts +1 -1
- 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 +14 -21
- package/src/canvas/comments-overlay.tsx +695 -622
- package/src/canvas/comments-sidebar.test.ts +45 -0
- package/src/canvas/comments-sidebar.tsx +76 -58
- 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 +217 -7
- package/src/clustering/runtime.ts +171 -17
- package/src/clustering/types.ts +9 -9
- package/src/index.ts +76 -40
- package/src/ui/byline.tsx +7 -5
- package/src/ui/comment-card.tsx +20 -10
- package/src/ui/comment-composer.tsx +206 -40
- package/src/ui/comment-pin.tsx +26 -2
- package/src/ui/comment-thread.tsx +13 -6
- package/src/ui/comments-list.test.ts +31 -0
- package/src/ui/comments-list.tsx +45 -21
- package/src/ui/comments.css +566 -281
- 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.ts +7 -2
- package/src/ui/reaction-picker.tsx +109 -0
- package/src/ui/reaction.tsx +121 -6
- package/src/ui/reactions.tsx +69 -10
- 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 -43
- 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,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,42 +147,86 @@ 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
|
-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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])
|
|
216
|
+
// Threads held out of clustering because their anchor moved while folded inside a badge
|
|
217
|
+
// (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
|
|
218
|
+
// as live pins riding their anchor and rejoin clustering on the next zoom-out.
|
|
219
|
+
const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
|
|
193
220
|
const adoptOnRebuild = useRef(false)
|
|
194
221
|
const clusterLeaves = useValue(
|
|
195
222
|
'comment cluster leaves',
|
|
196
223
|
() =>
|
|
197
224
|
collectClusterLeaves(
|
|
198
225
|
editor,
|
|
199
|
-
threads.filter((thread) => !
|
|
200
|
-
openThreadId.get(editor)
|
|
201
|
-
impreciseShapeAnchor
|
|
226
|
+
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
|
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',
|
|
@@ -213,44 +239,69 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
213
239
|
runtime.seed(editor.getZoomLevel())
|
|
214
240
|
return { runtime, table }
|
|
215
241
|
}, [clusterLeaves, clusterZoomBounds, editor])
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// badge
|
|
242
|
+
// The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild
|
|
243
|
+
// (add / move / delete / open / pop-out) is computed immediately as `latestModel` — the MST
|
|
244
|
+
// stays correct — but the on-screen partition is `renderedModel`, and it only ever changes via
|
|
245
|
+
// (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
|
|
246
|
+
// (c) LOCAL detach patches: a leaf that left the input (deleted, opened, popped out) is
|
|
247
|
+
// detached from its own badge in place — count and centroid update for that badge alone,
|
|
248
|
+
// and nothing else on the canvas moves.
|
|
222
249
|
const [renderedModel, setRenderedModel] = useState(latestModel)
|
|
223
250
|
let clusterModel = renderedModel
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
) {
|
|
251
|
+
// A page switch replaces the whole scene: hard-reset rather than detach the world.
|
|
252
|
+
const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
|
|
253
|
+
const pageRef = useRef(pageId)
|
|
254
|
+
if (pageRef.current !== pageId) {
|
|
255
|
+
pageRef.current = pageId
|
|
228
256
|
adoptOnRebuild.current = false
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
257
|
+
latestModel.runtime.seed(editor.getZoomLevel())
|
|
258
|
+
if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
|
|
259
|
+
setRenderedModel(latestModel)
|
|
260
|
+
clusterModel = latestModel
|
|
261
|
+
}
|
|
262
|
+
// adoptOnRebuild is set by the rejoin reaction below, outside React's render cycle, paired
|
|
263
|
+
// with clearing heldThreadIds. Only trust it once that pairing is actually visible here
|
|
264
|
+
// (heldThreadIds confirmed empty) — an unrelated re-render can land in the gap between the
|
|
265
|
+
// ref being set and the state update it was paired with being applied.
|
|
266
|
+
const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
|
|
267
|
+
if (renderedModel !== latestModel && rejoinPending) {
|
|
268
|
+
adoptOnRebuild.current = false
|
|
269
|
+
// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
|
|
270
|
+
// nothing changes state because of the swap alone. Idempotent, so safe during render.
|
|
233
271
|
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
|
234
272
|
setRenderedModel(latestModel)
|
|
235
273
|
clusterModel = latestModel
|
|
274
|
+
} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
|
|
275
|
+
// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
|
|
276
|
+
// survive to force-adopt a later, unrelated rebuild.
|
|
277
|
+
adoptOnRebuild.current = false
|
|
236
278
|
}
|
|
237
279
|
// 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
|
|
239
|
-
//
|
|
240
|
-
// re-clusters the rest of its pile immediately; the pin itself renders live below.
|
|
280
|
+
// is baked into the model), so when its live position drifts from the baked one, hold it out.
|
|
281
|
+
// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
|
|
241
282
|
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
|
242
283
|
if (newlyMovedIds.length > 0) {
|
|
243
|
-
|
|
244
|
-
console.debug(`[comments] pins popped out of clustering: ${newlyMovedIds.join(', ')}`)
|
|
245
|
-
const next = new Set(movedThreadIds)
|
|
284
|
+
const next = new Set(heldThreadIds)
|
|
246
285
|
for (const id of newlyMovedIds) next.add(id)
|
|
247
|
-
|
|
286
|
+
setHeldThreadIds(next)
|
|
287
|
+
}
|
|
288
|
+
// Local partition maintenance — the only non-zoom visual change, and it is local by
|
|
289
|
+
// construction: any displayed leaf that has left the cluster input (deleted, thread opened,
|
|
290
|
+
// popped out above) is detached from its badge in place. The corrected rebuild is already
|
|
291
|
+
// sitting in latestModel awaiting the next zoom-out.
|
|
292
|
+
{
|
|
293
|
+
const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
294
|
+
for (const leaf of clusterModel.table.leaves) {
|
|
295
|
+
if (!latestLeafIds.has(leaf.id)) {
|
|
296
|
+
clusterModel.runtime.detachLeaf(leaf.id)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
248
299
|
}
|
|
249
300
|
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
|
250
301
|
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
|
251
302
|
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
|
252
303
|
useEffect(() => {
|
|
253
|
-
if (
|
|
304
|
+
if (heldThreadIds.size === 0) return
|
|
254
305
|
let lastZoom = editor.getZoomLevel()
|
|
255
306
|
return react('rejoin moved comment pins on zoom out', () => {
|
|
256
307
|
const zoom = editor.getZoomLevel()
|
|
@@ -258,9 +309,9 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
258
309
|
lastZoom = zoom
|
|
259
310
|
if (zoom >= prevZoom) return
|
|
260
311
|
adoptOnRebuild.current = true
|
|
261
|
-
|
|
312
|
+
setHeldThreadIds(EMPTY_SET)
|
|
262
313
|
})
|
|
263
|
-
}, [
|
|
314
|
+
}, [heldThreadIds, editor])
|
|
264
315
|
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
|
265
316
|
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
|
266
317
|
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
|
@@ -276,84 +327,134 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
276
327
|
setRenderedModel(latestModel)
|
|
277
328
|
})
|
|
278
329
|
}, [clusterModel, latestModel, editor])
|
|
330
|
+
// Threads in the current input that the displayed partition doesn't show anywhere (new
|
|
331
|
+
// comments, reopened threads, undone deletions): render as plain pins until the next
|
|
332
|
+
// zoom-out folds them in. Membership is judged against the *displayed* partition (with
|
|
333
|
+
// detaches applied), not the rendered table, so a detached-then-restored leaf reappears.
|
|
334
|
+
const partitionVersion = clusterModel.runtime.version
|
|
279
335
|
const orphanThreads = useMemo(() => {
|
|
280
336
|
if (clusterModel === latestModel) return []
|
|
281
|
-
const
|
|
337
|
+
const displayed = new Set<string>()
|
|
338
|
+
for (const node of clusterModel.runtime.getVisible().values()) {
|
|
339
|
+
for (const member of node.members) displayed.add(member)
|
|
340
|
+
}
|
|
282
341
|
const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
283
|
-
return threads.filter((thread) => latestIds.has(thread.id) && !
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
342
|
+
return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
|
|
343
|
+
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
344
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
345
|
+
}, [clusterModel, latestModel, threads, partitionVersion])
|
|
346
|
+
const heldThreads = useMemo(
|
|
347
|
+
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
|
348
|
+
[threads, heldThreadIds, openId]
|
|
288
349
|
)
|
|
289
|
-
// Subscribe to the runtime
|
|
290
|
-
// O(1) threshold checks
|
|
291
|
-
//
|
|
292
|
-
// on
|
|
293
|
-
|
|
294
|
-
|
|
350
|
+
// Subscribe to the runtime's partition version, not the raw zoom: onCamera runs on every zoom
|
|
351
|
+
// tick (O(1) threshold checks) but the version only moves when the partition actually changes
|
|
352
|
+
// — so this component only re-renders on cluster changes, not on every camera frame. The memo
|
|
353
|
+
// below keys on a fresh inline read of the version rather than the subscribed value, because
|
|
354
|
+
// render-time detaches (above) bump it after the subscription's computed already evaluated.
|
|
355
|
+
useValue(
|
|
356
|
+
'comment cluster version',
|
|
295
357
|
() => {
|
|
296
358
|
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
|
297
|
-
return clusterModel.runtime.
|
|
359
|
+
return clusterModel.runtime.version
|
|
298
360
|
},
|
|
299
361
|
[clusterModel, editor]
|
|
300
362
|
)
|
|
301
363
|
const visibleNodes = useMemo(() => {
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
)
|
|
307
|
-
return nodes
|
|
308
|
-
}, [clusterModel, clusterCursor])
|
|
364
|
+
return Array.from(clusterModel.runtime.getVisible().values())
|
|
365
|
+
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
366
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
+
}, [clusterModel, partitionVersion])
|
|
309
368
|
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
|
310
369
|
const threadsById = useMemo(
|
|
311
370
|
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
|
312
371
|
[threads]
|
|
313
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
|
+
])
|
|
314
380
|
const openThread = openId ? threadsById.get(openId) : null
|
|
315
381
|
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
316
382
|
|
|
317
|
-
// 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.
|
|
318
385
|
useEffect(() => {
|
|
319
386
|
return () => {
|
|
320
387
|
openThreadId.set(editor, null)
|
|
388
|
+
openStackId.set(editor, null)
|
|
321
389
|
pendingComment.set(editor, null)
|
|
390
|
+
revealThreadRequest.set(editor, null)
|
|
322
391
|
}
|
|
323
392
|
}, [editor])
|
|
324
393
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
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.
|
|
327
400
|
useEffect(() => {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
333
408
|
}
|
|
409
|
+
openStackId.set(editor, null)
|
|
410
|
+
}, [editor, pinStacks, threadsById])
|
|
334
411
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
)
|
|
345
427
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
)
|
|
355
|
-
openThreadId.set(editor,
|
|
356
|
-
}, [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
|
+
)
|
|
357
458
|
|
|
358
459
|
// Clicking a badge zooms to just past the zoom at which that cluster first unclusters,
|
|
359
460
|
// centered on its centroid. The event that created a visible cluster is the event that splits
|
|
@@ -366,13 +467,13 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
366
467
|
const event = clusterModel.table.events.find((e) => e.result.id === node.id)
|
|
367
468
|
if (!event || !Number.isFinite(event.zSplit)) return
|
|
368
469
|
const zoom = clamp(
|
|
369
|
-
event.zSplit *
|
|
470
|
+
event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
370
471
|
clusterZoomBounds.minZoom,
|
|
371
472
|
clusterZoomBounds.maxZoom
|
|
372
473
|
)
|
|
373
474
|
centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
|
|
374
475
|
},
|
|
375
|
-
[clusterModel, clusterZoomBounds, editor
|
|
476
|
+
[clusterModel, clusterZoomBounds, editor]
|
|
376
477
|
)
|
|
377
478
|
|
|
378
479
|
// Escape collapses the open thread. Capture-phase + stopPropagation so it runs ahead of the
|
|
@@ -384,7 +485,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
384
485
|
// The mention picker owns Escape while it's open — let it dismiss the roster alone.
|
|
385
486
|
if (isMentionPickerOpen()) return
|
|
386
487
|
const target = e.target as HTMLElement | null
|
|
387
|
-
if (target && target.closest('.cmt-editing')) return
|
|
488
|
+
if (target && target.closest('.tlui-cmt-editing')) return
|
|
388
489
|
openThreadId.set(editor, null)
|
|
389
490
|
e.preventDefault()
|
|
390
491
|
e.stopPropagation()
|
|
@@ -393,8 +494,8 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
393
494
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
394
495
|
}, [editor])
|
|
395
496
|
|
|
396
|
-
// Shift+C toggles comment-pin visibility on the canvas
|
|
397
|
-
//
|
|
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.
|
|
398
499
|
useEffect(() => {
|
|
399
500
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
400
501
|
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
@@ -411,27 +512,88 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
411
512
|
// is read above so this component stays mounted and its shortcut/Escape effects keep running.
|
|
412
513
|
if (hidden) return null
|
|
413
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
|
+
|
|
414
560
|
// Render into the container (above the panels' stacking context) so the pins and popovers
|
|
415
|
-
// 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
|
|
416
564
|
return createPortal(
|
|
417
|
-
<div ref={layerRef} className="cmt-canvas-layer">
|
|
565
|
+
<div ref={layerRef} className="tlui-cmt-canvas-layer">
|
|
418
566
|
{options.enableClustering ? (
|
|
419
567
|
<>
|
|
420
568
|
{fadeNodes.map(({ node, phase }) => {
|
|
421
569
|
let content: ReactNode
|
|
570
|
+
const stackGroup = node.count > 1 ? stackGroupOf(node) : null
|
|
422
571
|
if (node.count === 1) {
|
|
423
572
|
const thread = threadsById.get(node.id)
|
|
424
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 {
|
|
425
586
|
content = (
|
|
426
|
-
<
|
|
587
|
+
<ClusterBadge
|
|
427
588
|
editor={editor}
|
|
428
|
-
|
|
429
|
-
{
|
|
430
|
-
|
|
589
|
+
node={node}
|
|
590
|
+
onExpand={zoomToClusterSplit}
|
|
591
|
+
onSelectThread={revealClusteredThread}
|
|
592
|
+
threadsById={threadsById}
|
|
593
|
+
currentUserId={props.currentUserId}
|
|
594
|
+
resolveAuthor={props.resolveAuthor}
|
|
431
595
|
/>
|
|
432
596
|
)
|
|
433
|
-
} else {
|
|
434
|
-
content = <ClusterBadge editor={editor} node={node} onExpand={zoomToClusterSplit} />
|
|
435
597
|
}
|
|
436
598
|
return (
|
|
437
599
|
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
|
@@ -440,22 +602,10 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
440
602
|
)
|
|
441
603
|
})}
|
|
442
604
|
{orphanThreads.map((thread) => (
|
|
443
|
-
<
|
|
444
|
-
key={thread.id}
|
|
445
|
-
editor={editor}
|
|
446
|
-
thread={thread}
|
|
447
|
-
{...props}
|
|
448
|
-
regionOptions={regionOptions}
|
|
449
|
-
/>
|
|
605
|
+
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
450
606
|
))}
|
|
451
|
-
{
|
|
452
|
-
<
|
|
453
|
-
key={thread.id}
|
|
454
|
-
editor={editor}
|
|
455
|
-
thread={thread}
|
|
456
|
-
{...props}
|
|
457
|
-
regionOptions={regionOptions}
|
|
458
|
-
/>
|
|
607
|
+
{heldThreads.map((thread) => (
|
|
608
|
+
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
|
459
609
|
))}
|
|
460
610
|
</>
|
|
461
611
|
) : (
|
|
@@ -465,34 +615,22 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
465
615
|
// otherwise it would mount a second, stacked pin.
|
|
466
616
|
threads
|
|
467
617
|
.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
|
-
))
|
|
618
|
+
.map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
|
|
477
619
|
)}
|
|
478
620
|
{openThread && (
|
|
479
|
-
<
|
|
480
|
-
key={`open:${openThread.id}`}
|
|
481
|
-
editor={editor}
|
|
482
|
-
thread={openThread}
|
|
483
|
-
{...props}
|
|
484
|
-
regionOptions={regionOptions}
|
|
485
|
-
/>
|
|
621
|
+
<Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
|
|
486
622
|
)}
|
|
487
623
|
<RegionDraftBox editor={editor} />
|
|
488
624
|
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
|
489
625
|
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
|
490
|
-
{pending?.anchor.type === 'region' &&
|
|
491
|
-
|
|
626
|
+
{pending?.anchor.type === 'region' && showPendingComposer && (
|
|
627
|
+
<RegionBox editor={editor} box={pending.anchor} />
|
|
628
|
+
)}
|
|
629
|
+
{pending && showPendingComposer && (
|
|
492
630
|
<PendingComposer editor={editor} pending={pending} {...props} />
|
|
493
631
|
)}
|
|
494
632
|
</div>,
|
|
495
|
-
|
|
633
|
+
portalHost
|
|
496
634
|
)
|
|
497
635
|
}
|
|
498
636
|
|
|
@@ -597,7 +735,7 @@ function cancelClusterFadeFrame(frame: number) {
|
|
|
597
735
|
}
|
|
598
736
|
|
|
599
737
|
function clusterFadeClassName(phase: ClusterFadePhase): string {
|
|
600
|
-
return `cmt-cluster-fade cmt-cluster-fade--${phase}`
|
|
738
|
+
return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
|
|
601
739
|
}
|
|
602
740
|
|
|
603
741
|
/**
|
|
@@ -627,12 +765,6 @@ function findMovedClusteredLeafIds(
|
|
|
627
765
|
return moved
|
|
628
766
|
}
|
|
629
767
|
|
|
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
768
|
function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
|
|
637
769
|
const cameraOptions = editor.getCameraOptions()
|
|
638
770
|
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
|
@@ -643,20 +775,19 @@ function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: numbe
|
|
|
643
775
|
}
|
|
644
776
|
}
|
|
645
777
|
|
|
646
|
-
function
|
|
778
|
+
function revealThreadPin(
|
|
647
779
|
editor: Editor,
|
|
648
780
|
thread: TLCommentThread,
|
|
649
781
|
table: ClusterTable,
|
|
650
782
|
zoomBounds: { minZoom: number; maxZoom: number },
|
|
651
783
|
options: CommentingOptions,
|
|
652
|
-
|
|
784
|
+
duration = 200
|
|
653
785
|
) {
|
|
654
786
|
if (thread.pageId !== editor.getCurrentPageId()) {
|
|
655
787
|
editor.setCurrentPage(thread.pageId as any)
|
|
656
788
|
}
|
|
657
789
|
|
|
658
|
-
|
|
659
|
-
const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
790
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
660
791
|
if (!point) return
|
|
661
792
|
|
|
662
793
|
// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
|
|
@@ -669,16 +800,17 @@ function revealDeepLinkedThread(
|
|
|
669
800
|
parentEvent.zSplit <= zoomBounds.maxZoom
|
|
670
801
|
) {
|
|
671
802
|
const zoom = clamp(
|
|
672
|
-
parentEvent.zSplit *
|
|
803
|
+
parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
673
804
|
zoomBounds.minZoom,
|
|
674
805
|
zoomBounds.maxZoom
|
|
675
806
|
)
|
|
676
|
-
centerOnPointAtZoom(editor, point, zoom)
|
|
807
|
+
centerOnPointAtZoom(editor, point, zoom, duration)
|
|
677
808
|
return
|
|
678
809
|
}
|
|
679
810
|
}
|
|
680
811
|
|
|
681
|
-
|
|
812
|
+
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
|
813
|
+
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
|
|
682
814
|
}
|
|
683
815
|
|
|
684
816
|
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
|
@@ -692,9 +824,11 @@ function centerOnPointAtZoom(
|
|
|
692
824
|
duration = 200
|
|
693
825
|
) {
|
|
694
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)
|
|
695
829
|
editor.setCamera(
|
|
696
830
|
{
|
|
697
|
-
x: viewport.w /
|
|
831
|
+
x: (viewport.w / 2 - offset) / zoom - point.x,
|
|
698
832
|
y: viewport.h / (2 * zoom) - point.y,
|
|
699
833
|
z: zoom,
|
|
700
834
|
},
|
|
@@ -714,11 +848,23 @@ const ClusterBadge = memo(function ClusterBadge({
|
|
|
714
848
|
editor,
|
|
715
849
|
node,
|
|
716
850
|
onExpand,
|
|
717
|
-
|
|
851
|
+
onSelectThread,
|
|
852
|
+
threadsById,
|
|
853
|
+
...props
|
|
854
|
+
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
|
718
855
|
editor: Editor
|
|
719
856
|
node: ClusterNode
|
|
720
857
|
onExpand(node: ClusterNode): void
|
|
858
|
+
onSelectThread(thread: TLCommentThread): void
|
|
859
|
+
threadsById: ReadonlyMap<string, TLCommentThread>
|
|
721
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)
|
|
722
868
|
const point = useValue(
|
|
723
869
|
'cluster badge point',
|
|
724
870
|
() => {
|
|
@@ -729,26 +875,64 @@ const ClusterBadge = memo(function ClusterBadge({
|
|
|
729
875
|
[editor, node]
|
|
730
876
|
)
|
|
731
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
|
+
|
|
732
890
|
if (!point) return null
|
|
733
891
|
|
|
734
892
|
return (
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
+
</>
|
|
746
930
|
)
|
|
747
931
|
})
|
|
748
932
|
|
|
749
933
|
function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
|
|
750
934
|
const viewport = editor.getViewportScreenBounds()
|
|
751
|
-
const margin =
|
|
935
|
+
const margin = CLUSTER_CULL_MARGIN_PX
|
|
752
936
|
return (
|
|
753
937
|
point.x >= -margin &&
|
|
754
938
|
point.y >= -margin &&
|
|
@@ -757,44 +941,12 @@ function isInInflatedViewport(editor: Editor, point: { x: number; y: number }):
|
|
|
757
941
|
)
|
|
758
942
|
}
|
|
759
943
|
|
|
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
944
|
/** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
|
|
783
945
|
* a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
|
|
784
946
|
* `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
}) {
|
|
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 }) {
|
|
798
950
|
const rect = useValue(
|
|
799
951
|
'region rect',
|
|
800
952
|
() => {
|
|
@@ -805,39 +957,7 @@ function RegionBox({
|
|
|
805
957
|
},
|
|
806
958
|
[editor, box.x, box.y, box.w, box.h]
|
|
807
959
|
)
|
|
808
|
-
|
|
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
|
-
)
|
|
960
|
+
return <div className="tlui-cmt-canvas-region" style={rect} />
|
|
841
961
|
}
|
|
842
962
|
|
|
843
963
|
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
|
@@ -847,27 +967,21 @@ function RegionDraftBox({ editor }: { editor: Editor }) {
|
|
|
847
967
|
return <RegionBox editor={editor} box={box} />
|
|
848
968
|
}
|
|
849
969
|
|
|
850
|
-
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5
|
|
851
|
-
//
|
|
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.
|
|
852
972
|
interface RegionHandle {
|
|
853
973
|
x: number
|
|
854
974
|
y: number
|
|
855
975
|
cursor: string
|
|
856
976
|
}
|
|
857
977
|
|
|
858
|
-
// The four corners
|
|
978
|
+
// The four corners, each resizing both axes.
|
|
859
979
|
const REGION_CORNERS: readonly RegionHandle[] = [
|
|
860
980
|
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
|
861
981
|
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
|
862
982
|
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
|
863
983
|
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
|
864
984
|
]
|
|
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
985
|
|
|
872
986
|
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
|
873
987
|
// the handles (which sit on the edge) are comfortably reachable.
|
|
@@ -939,7 +1053,7 @@ function RegionResizeHandles({
|
|
|
939
1053
|
{points.map((h) => (
|
|
940
1054
|
<div
|
|
941
1055
|
key={h.key}
|
|
942
|
-
className="cmt-canvas-region-handle"
|
|
1056
|
+
className="tlui-cmt-canvas-region-handle"
|
|
943
1057
|
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
|
944
1058
|
onPointerDown={startResize}
|
|
945
1059
|
onPointerMove={onResize(h)}
|
|
@@ -953,41 +1067,29 @@ function RegionResizeHandles({
|
|
|
953
1067
|
const ThreadPin = memo(function ThreadPin({
|
|
954
1068
|
editor,
|
|
955
1069
|
thread,
|
|
956
|
-
regionOptions,
|
|
957
1070
|
...props
|
|
958
|
-
}:
|
|
1071
|
+
}: CommentingContext & {
|
|
959
1072
|
editor: Editor
|
|
960
1073
|
thread: TLCommentThread
|
|
961
|
-
regionOptions: RegionCommentOptions
|
|
962
1074
|
}) {
|
|
963
|
-
const {
|
|
964
|
-
currentUserId,
|
|
965
|
-
resolveName,
|
|
966
|
-
onPostComment,
|
|
967
|
-
isCommentUnread,
|
|
968
|
-
onCommentRead,
|
|
969
|
-
getMentionSuggestions,
|
|
970
|
-
renderMentionSuggestion,
|
|
971
|
-
} = props
|
|
1075
|
+
const { resolveAuthor } = props
|
|
972
1076
|
const options = useCommentingOptions()
|
|
973
|
-
const
|
|
1077
|
+
const canComment = useCanComment(props.currentUserId)
|
|
974
1078
|
const container = useContainer()
|
|
975
|
-
const comments = useThreadComments(editor, thread.id)
|
|
976
1079
|
const msg = useTranslation()
|
|
1080
|
+
const comments = useThreadComments(editor, thread.id)
|
|
977
1081
|
// Only one thread's popover is open at a time — shared across pins via the atom.
|
|
978
1082
|
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
|
979
1083
|
editor,
|
|
980
1084
|
thread.id,
|
|
981
1085
|
])
|
|
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
1086
|
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
986
1087
|
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
|
987
1088
|
// The live bounds while a corner handle is resizing the region, else null.
|
|
988
1089
|
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
|
989
|
-
//
|
|
990
|
-
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])
|
|
991
1093
|
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
|
992
1094
|
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
|
993
1095
|
// handle never loses the affordance — the box stays `pointer-events: none`.
|
|
@@ -1003,25 +1105,40 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1003
1105
|
},
|
|
1004
1106
|
[editor, thread.anchor, thread.pageId]
|
|
1005
1107
|
)
|
|
1006
|
-
// A region's box and handles are revealed while open
|
|
1007
|
-
//
|
|
1008
|
-
const revealed =
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
//
|
|
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.
|
|
1014
1116
|
const resizeHandles = useMemo(
|
|
1015
|
-
() =>
|
|
1016
|
-
|
|
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]
|
|
1117
|
+
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
|
1118
|
+
[pinCorner]
|
|
1022
1119
|
)
|
|
1023
|
-
const dragRef = useRef<{
|
|
1024
|
-
|
|
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])
|
|
1025
1142
|
|
|
1026
1143
|
// Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
|
|
1027
1144
|
// pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
|
|
@@ -1032,15 +1149,17 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1032
1149
|
const onPointerDown = (e: PointerEvent) => {
|
|
1033
1150
|
const target = e.target as HTMLElement | null
|
|
1034
1151
|
if (!target) return
|
|
1035
|
-
if (target.closest('.cmt-canvas-popover')) return
|
|
1152
|
+
if (target.closest('.tlui-cmt-canvas-popover')) return
|
|
1036
1153
|
const marker = markerRef.current
|
|
1037
1154
|
if (marker && marker.contains(target)) return
|
|
1038
1155
|
// 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
|
|
1156
|
+
if (target.closest('.tlui-cmt-canvas-region-handle, .tlui-cmt-canvas-region--movable')) return
|
|
1040
1157
|
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
|
1041
1158
|
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
|
1042
1159
|
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
|
1043
|
-
if (
|
|
1160
|
+
if (
|
|
1161
|
+
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
|
1162
|
+
)
|
|
1044
1163
|
return
|
|
1045
1164
|
openThreadId.set(editor, null)
|
|
1046
1165
|
}
|
|
@@ -1052,223 +1171,141 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1052
1171
|
'pin point',
|
|
1053
1172
|
() => {
|
|
1054
1173
|
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
1055
|
-
const pagePoint = anchorPagePoint(editor, thread.anchor
|
|
1056
|
-
|
|
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
|
|
1057
1179
|
},
|
|
1058
|
-
[editor, thread.anchor, thread.pageId
|
|
1180
|
+
[editor, thread.anchor, thread.pageId]
|
|
1059
1181
|
)
|
|
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
1182
|
if (!point) return null
|
|
1075
1183
|
|
|
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
|
-
)
|
|
1153
|
-
}
|
|
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
1184
|
const PinContent = options.components.PinContent
|
|
1207
|
-
// 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)
|
|
1208
1187
|
const pinContent = PinContent ? (
|
|
1209
1188
|
<PinContent thread={thread} comments={comments} />
|
|
1210
1189
|
) : (
|
|
1211
|
-
|
|
1190
|
+
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
1212
1191
|
)
|
|
1192
|
+
const pinLabel = msg(
|
|
1193
|
+
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
|
1194
|
+
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
|
1213
1195
|
|
|
1214
1196
|
// Drag the marker to move the thread: its position is overridden locally while dragging, then
|
|
1215
1197
|
// re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
|
|
1216
1198
|
// a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
|
|
1217
1199
|
// toggle the popover.
|
|
1218
|
-
// Which affordances move a region, per the option: 'pin' → pin only, 'body' → body only, 'both'.
|
|
1219
1200
|
const isRegion = thread.anchor.type === 'region'
|
|
1220
|
-
|
|
1221
|
-
const
|
|
1222
|
-
|
|
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
|
+
}
|
|
1223
1209
|
e.stopPropagation()
|
|
1224
|
-
|
|
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
|
+
}
|
|
1225
1227
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
1226
1228
|
}
|
|
1227
|
-
const onDrag = (e: ReactPointerEvent<
|
|
1229
|
+
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
1228
1230
|
const drag = dragRef.current
|
|
1229
1231
|
if (!drag) return
|
|
1230
|
-
//
|
|
1231
|
-
|
|
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
|
|
1232
1235
|
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
1233
1236
|
drag.moved = true
|
|
1234
|
-
|
|
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
|
+
}
|
|
1235
1246
|
}
|
|
1236
|
-
|
|
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>) => {
|
|
1237
1250
|
const drag = dragRef.current
|
|
1238
1251
|
dragRef.current = null
|
|
1239
1252
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1240
1253
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1241
1254
|
}
|
|
1242
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([])
|
|
1243
1267
|
if (!drag.moved) {
|
|
1244
1268
|
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
1245
1269
|
return
|
|
1246
1270
|
}
|
|
1247
|
-
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 }
|
|
1248
1273
|
setDragPagePoint(null)
|
|
1249
1274
|
let anchor: TLCommentThread['anchor']
|
|
1250
1275
|
if (thread.anchor.type === 'region') {
|
|
1251
1276
|
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
1252
1277
|
anchor = {
|
|
1253
1278
|
...thread.anchor,
|
|
1254
|
-
x: pagePoint.x -
|
|
1255
|
-
y: pagePoint.y -
|
|
1279
|
+
x: pagePoint.x - pinCorner.x * thread.anchor.w,
|
|
1280
|
+
y: pagePoint.y - pinCorner.y * thread.anchor.h,
|
|
1256
1281
|
}
|
|
1257
1282
|
} else {
|
|
1258
|
-
const hit = editor
|
|
1283
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
1259
1284
|
anchor = hit
|
|
1260
|
-
? 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
|
+
)
|
|
1261
1295
|
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
1262
1296
|
}
|
|
1263
|
-
commitCommentMutation(editor, () =>
|
|
1297
|
+
commitCommentMutation(editor, () => putRecordsInCommit(editor, [{ ...thread, anchor }]), 'drag')
|
|
1264
1298
|
}
|
|
1265
1299
|
|
|
1266
1300
|
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
1267
1301
|
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
1268
|
-
const livePinPage = resizeBounds
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
|
1272
1309
|
|
|
1273
1310
|
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
1274
1311
|
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
@@ -1277,33 +1314,30 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1277
1314
|
regionAnchor && dragPagePoint
|
|
1278
1315
|
? {
|
|
1279
1316
|
...regionAnchor,
|
|
1280
|
-
x: dragPagePoint.x -
|
|
1281
|
-
y: dragPagePoint.y -
|
|
1317
|
+
x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
|
|
1318
|
+
y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
|
|
1282
1319
|
}
|
|
1283
1320
|
: regionAnchor
|
|
1284
1321
|
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
1285
1322
|
const commitResize = (bounds: BoxModel) => {
|
|
1286
1323
|
setResizeBounds(null)
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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'
|
|
1292
1332
|
)
|
|
1293
1333
|
}
|
|
1294
1334
|
|
|
1295
1335
|
return (
|
|
1296
1336
|
<>
|
|
1297
1337
|
{regionBoxBounds && (dragPagePoint || revealed) && (
|
|
1298
|
-
<RegionBox
|
|
1299
|
-
editor={editor}
|
|
1300
|
-
box={regionBoxBounds}
|
|
1301
|
-
movable={bodyMovable && !dragPagePoint}
|
|
1302
|
-
onPreview={setResizeBounds}
|
|
1303
|
-
onCommit={commitResize}
|
|
1304
|
-
/>
|
|
1338
|
+
<RegionBox editor={editor} box={regionBoxBounds} />
|
|
1305
1339
|
)}
|
|
1306
|
-
{regionBoxBounds && revealed && !dragPagePoint &&
|
|
1340
|
+
{regionBoxBounds && revealed && !dragPagePoint && canComment && (
|
|
1307
1341
|
<RegionResizeHandles
|
|
1308
1342
|
editor={editor}
|
|
1309
1343
|
box={regionBoxBounds}
|
|
@@ -1313,60 +1347,70 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1313
1347
|
/>
|
|
1314
1348
|
)}
|
|
1315
1349
|
<div
|
|
1316
|
-
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(' ')}
|
|
1317
1357
|
style={{ left: renderPoint.x, top: renderPoint.y }}
|
|
1318
1358
|
>
|
|
1319
|
-
<
|
|
1359
|
+
<button
|
|
1320
1360
|
ref={markerRef}
|
|
1321
|
-
|
|
1361
|
+
type="button"
|
|
1362
|
+
className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
|
|
1363
|
+
aria-label={pinLabel}
|
|
1364
|
+
aria-expanded={open}
|
|
1322
1365
|
onPointerDown={startDrag}
|
|
1323
1366
|
onPointerMove={onDrag}
|
|
1324
1367
|
onPointerUp={endDrag}
|
|
1325
|
-
|
|
1326
|
-
|
|
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}
|
|
1327
1381
|
>
|
|
1328
1382
|
<CommentPin resolved={thread.resolved != null} open={open}>
|
|
1329
1383
|
{pinContent}
|
|
1330
1384
|
</CommentPin>
|
|
1331
|
-
</
|
|
1385
|
+
</button>
|
|
1332
1386
|
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
|
1333
1387
|
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
|
1334
1388
|
{open && (
|
|
1335
1389
|
<ThreadPopover
|
|
1336
1390
|
container={container}
|
|
1337
|
-
style={{
|
|
1391
|
+
style={{
|
|
1392
|
+
left: renderPoint.x + POPOVER_OFFSET.thread.x,
|
|
1393
|
+
top: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
|
1394
|
+
}}
|
|
1338
1395
|
>
|
|
1339
|
-
<
|
|
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
|
|
1366
|
-
}
|
|
1367
|
-
/>
|
|
1396
|
+
<ThreadView editor={editor} thread={thread} {...props} />
|
|
1368
1397
|
</ThreadPopover>
|
|
1369
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
|
+
)}
|
|
1370
1414
|
</div>
|
|
1371
1415
|
</>
|
|
1372
1416
|
)
|
|
@@ -1376,12 +1420,25 @@ function PendingComposer({
|
|
|
1376
1420
|
editor,
|
|
1377
1421
|
pending,
|
|
1378
1422
|
currentUserId,
|
|
1379
|
-
|
|
1423
|
+
resolveAuthor,
|
|
1380
1424
|
onPostComment,
|
|
1381
1425
|
getMentionSuggestions,
|
|
1382
1426
|
renderMentionSuggestion,
|
|
1383
|
-
}:
|
|
1384
|
-
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
|
+
)
|
|
1385
1442
|
const ref = useRef<HTMLDivElement>(null)
|
|
1386
1443
|
const msg = useTranslation()
|
|
1387
1444
|
const container = useContainer()
|
|
@@ -1402,7 +1459,7 @@ function PendingComposer({
|
|
|
1402
1459
|
if (!el || !target) return
|
|
1403
1460
|
// A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
|
|
1404
1461
|
// not "outside" — keep the draft open so the pick can insert.
|
|
1405
|
-
if (el.contains(target) || target.closest('.cmt-mention-popup')) return
|
|
1462
|
+
if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
|
|
1406
1463
|
pendingComment.set(editor, null)
|
|
1407
1464
|
}
|
|
1408
1465
|
document.addEventListener('pointerdown', onPointerDown, true)
|
|
@@ -1424,36 +1481,52 @@ function PendingComposer({
|
|
|
1424
1481
|
authorId: currentUserId,
|
|
1425
1482
|
body: text,
|
|
1426
1483
|
})
|
|
1427
|
-
|
|
1484
|
+
putRecordsInCommit(editor, [thread, comment])
|
|
1428
1485
|
if (onPostComment) onPostComment(comment)
|
|
1429
1486
|
})
|
|
1430
1487
|
setText(EMPTY_COMMENT)
|
|
1488
|
+
clearCommentDraft(NEW_COMMENT_DRAFT)
|
|
1431
1489
|
pendingComment.set(editor, null)
|
|
1432
1490
|
}
|
|
1433
1491
|
|
|
1434
1492
|
return createPortal(
|
|
1435
1493
|
<div
|
|
1436
1494
|
ref={ref}
|
|
1437
|
-
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(' ')}
|
|
1438
1502
|
style={{ left: point.x, top: point.y }}
|
|
1439
1503
|
onPointerDown={stop}
|
|
1504
|
+
onContextMenu={stop}
|
|
1440
1505
|
onKeyDown={(e) => {
|
|
1441
1506
|
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
|
1442
1507
|
}}
|
|
1443
1508
|
>
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
+
)}
|
|
1457
1530
|
</div>,
|
|
1458
1531
|
container
|
|
1459
1532
|
)
|