@tldraw/commenting 0.0.0-bootstrap → 5.3.0-internal.1640468db8fd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commenting.css +284 -153
- 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-render.js +2 -0
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +65 -12
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +54 -63
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +21 -27
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +304 -112
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +12 -18
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +66 -0
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
- package/dist-cjs/canvas/options.js +1 -0
- package/dist-cjs/canvas/options.js.map +2 -2
- 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 +11 -0
- package/dist-cjs/canvas/state.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +8 -1
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/clustering/runtime.js +119 -7
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/index.d.ts +126 -41
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/avatar.js +14 -6
- package/dist-cjs/ui/avatar.js.map +2 -2
- package/dist-cjs/ui/byline.js +4 -4
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-author.js +17 -0
- package/dist-cjs/ui/comment-author.js.map +7 -0
- package/dist-cjs/ui/comment-card.js +4 -4
- package/dist-cjs/ui/comment-card.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +92 -29
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-mention.js +1 -1
- package/dist-cjs/ui/comment-mention.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +23 -3
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comment-text.js +1 -1
- package/dist-cjs/ui/comment-text.js.map +2 -2
- package/dist-cjs/ui/comment-thread.js +6 -6
- package/dist-cjs/ui/comment-thread.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +12 -12
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/count-badge.js +1 -1
- package/dist-cjs/ui/count-badge.js.map +2 -2
- 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 +3 -0
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/mention-list.js +8 -8
- package/dist-cjs/ui/mention-list.js.map +2 -2
- package/dist-cjs/ui/mention-suggestion.js +3 -3
- package/dist-cjs/ui/mention-suggestion.js.map +2 -2
- package/dist-cjs/ui/mention.js +1 -1
- package/dist-cjs/ui/mention.js.map +2 -2
- package/dist-cjs/ui/reaction.js +11 -4
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/reactions.js +10 -2
- package/dist-cjs/ui/reactions.js.map +2 -2
- package/dist-cjs/ui/send-button.js +1 -1
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/tooltip-button.js +32 -0
- package/dist-cjs/ui/tooltip-button.js.map +7 -0
- package/dist-esm/canvas/comment-body.mjs +1 -1
- package/dist-esm/canvas/comment-body.mjs.map +2 -2
- package/dist-esm/canvas/comment-drafts.mjs +34 -0
- package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +2 -0
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +67 -14
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +54 -63
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +22 -27
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +322 -114
- package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
- package/dist-esm/canvas/comments-sidebar.mjs +15 -21
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +46 -0
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +1 -0
- package/dist-esm/canvas/options.mjs.map +2 -2
- 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 +11 -0
- package/dist-esm/canvas/state.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +8 -1
- package/dist-esm/canvas/thread-state.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 +126 -41
- package/dist-esm/index.mjs +11 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/avatar.mjs +14 -6
- package/dist-esm/ui/avatar.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +4 -4
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-author.mjs +1 -0
- package/dist-esm/ui/comment-author.mjs.map +7 -0
- package/dist-esm/ui/comment-card.mjs +4 -4
- package/dist-esm/ui/comment-card.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +93 -30
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-mention.mjs +1 -1
- package/dist-esm/ui/comment-mention.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +23 -3
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comment-text.mjs +1 -1
- package/dist-esm/ui/comment-text.mjs.map +2 -2
- package/dist-esm/ui/comment-thread.mjs +6 -6
- package/dist-esm/ui/comment-thread.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +12 -12
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/count-badge.mjs +1 -1
- package/dist-esm/ui/count-badge.mjs.map +2 -2
- 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 +3 -0
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/mention-list.mjs +8 -8
- package/dist-esm/ui/mention-list.mjs.map +2 -2
- package/dist-esm/ui/mention-suggestion.mjs +3 -3
- package/dist-esm/ui/mention-suggestion.mjs.map +2 -2
- package/dist-esm/ui/mention.mjs +1 -1
- package/dist-esm/ui/mention.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +11 -4
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/reactions.mjs +10 -2
- package/dist-esm/ui/reactions.mjs.map +2 -2
- package/dist-esm/ui/send-button.mjs +1 -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 +3 -3
- package/src/canvas/canvas.css +79 -36
- 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-render.test.ts +1 -1
- package/src/canvas/comment-render.ts +7 -2
- package/src/canvas/comment-tool.tsx +98 -17
- package/src/canvas/comments-filter-menu.tsx +27 -33
- package/src/canvas/comments-overflow-menu.tsx +13 -21
- package/src/canvas/comments-overlay.tsx +441 -145
- package/src/canvas/comments-sidebar.tsx +27 -28
- package/src/canvas/comments-visibility-toggle.tsx +50 -0
- package/src/canvas/options.ts +25 -0
- package/src/canvas/sidebar-filters.ts +4 -3
- package/src/canvas/state.ts +22 -0
- package/src/canvas/thread-state.ts +16 -3
- package/src/clustering/runtime.test.ts +183 -7
- package/src/clustering/runtime.ts +164 -15
- package/src/index.ts +7 -0
- package/src/ui/avatar.tsx +15 -11
- package/src/ui/byline.tsx +6 -5
- package/src/ui/comment-author.ts +15 -0
- package/src/ui/comment-card.tsx +6 -5
- package/src/ui/comment-composer.tsx +122 -33
- package/src/ui/comment-mention.ts +2 -2
- package/src/ui/comment-pin.tsx +35 -4
- package/src/ui/comment-text.tsx +1 -1
- package/src/ui/comment-thread.tsx +8 -6
- package/src/ui/comments-list.tsx +19 -14
- package/src/ui/comments.css +205 -117
- package/src/ui/count-badge.tsx +1 -1
- package/src/ui/empty-state.tsx +3 -3
- package/src/ui/format-time.ts +5 -0
- package/src/ui/mention-list.tsx +13 -16
- package/src/ui/mention-suggestion.tsx +3 -3
- package/src/ui/mention.tsx +1 -1
- package/src/ui/reaction.tsx +6 -3
- package/src/ui/reactions.tsx +6 -2
- package/src/ui/send-button.tsx +1 -1
- package/src/ui/tooltip-button.tsx +20 -0
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
TLCommentId,
|
|
22
22
|
TLCommentThread,
|
|
23
23
|
TLRichText,
|
|
24
|
+
TldrawUiDropdownMenuContent,
|
|
25
|
+
TldrawUiDropdownMenuGroup,
|
|
26
|
+
TldrawUiDropdownMenuItem,
|
|
27
|
+
TldrawUiDropdownMenuRoot,
|
|
28
|
+
TldrawUiDropdownMenuTrigger,
|
|
24
29
|
TldrawUiIcon,
|
|
25
30
|
useContainer,
|
|
26
31
|
useEditor,
|
|
@@ -33,6 +38,7 @@ import {
|
|
|
33
38
|
import { computeClusterTable } from '../clustering/computeClusterTable'
|
|
34
39
|
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
|
35
40
|
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
|
41
|
+
import { CommentAuthor } from '../ui/comment-author'
|
|
36
42
|
import { CommentCard, CommentCardProps } from '../ui/comment-card'
|
|
37
43
|
import { CommentComposer } from '../ui/comment-composer'
|
|
38
44
|
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
|
@@ -41,9 +47,17 @@ import { CommentThread } from '../ui/comment-thread'
|
|
|
41
47
|
import { CountBadge } from '../ui/count-badge'
|
|
42
48
|
import { MentionMember } from '../ui/mention-list'
|
|
43
49
|
import { isMentionPickerOpen } from '../ui/mention-suggestion'
|
|
50
|
+
import { TooltipButton } from '../ui/tooltip-button'
|
|
44
51
|
import { collectClusterLeaves } from './cluster-input'
|
|
45
52
|
import { CommentBody } from './comment-body'
|
|
46
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
clearCommentDraft,
|
|
55
|
+
getCommentDraft,
|
|
56
|
+
NEW_COMMENT_DRAFT,
|
|
57
|
+
replyDraftSlot,
|
|
58
|
+
saveCommentDraft,
|
|
59
|
+
} from './comment-drafts'
|
|
60
|
+
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
47
61
|
import { getCommentRecord, putCommentRecords, removeCommentRecords } from './comment-store'
|
|
48
62
|
import { PendingComment } from './comment-tool'
|
|
49
63
|
import { useCommentThreads, useThreadComments } from './hooks'
|
|
@@ -68,7 +82,12 @@ import {
|
|
|
68
82
|
toggleCommentsHidden,
|
|
69
83
|
usePendingComment,
|
|
70
84
|
} from './state'
|
|
71
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
anchorPagePoint,
|
|
87
|
+
regionAnchorPinCorner,
|
|
88
|
+
regionPinPoint,
|
|
89
|
+
shapeAnchorAt,
|
|
90
|
+
} from './thread-state'
|
|
72
91
|
|
|
73
92
|
/**
|
|
74
93
|
* A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
|
|
@@ -84,8 +103,8 @@ import { anchorPagePoint, regionPinPoint, shapeAnchorAt } from './thread-state'
|
|
|
84
103
|
export interface CanvasCommentsProps {
|
|
85
104
|
/** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
|
|
86
105
|
currentUserId: string | null
|
|
87
|
-
/** Map an author id to
|
|
88
|
-
|
|
106
|
+
/** Map an author id to their display info, or `undefined` when the id can't be resolved. */
|
|
107
|
+
resolveAuthor(id: string): CommentAuthor | undefined
|
|
89
108
|
/** Called after any comment (a new thread's first comment, or a reply) is posted. */
|
|
90
109
|
onPostComment?(comment: TLComment): void
|
|
91
110
|
/** Whether a comment is unread for the current user (return true for unread). */
|
|
@@ -108,6 +127,39 @@ export interface CanvasCommentsProps {
|
|
|
108
127
|
|
|
109
128
|
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
|
110
129
|
|
|
130
|
+
/** How far an imprecise shape pin steps inside the shape from its anchor spot, in screen px —
|
|
131
|
+
* most of the marker sits within the shape, with a small overhang past the corner. */
|
|
132
|
+
const IMPRECISE_PIN_INSET_PX = 20
|
|
133
|
+
|
|
134
|
+
/** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
|
|
135
|
+
* extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
|
|
136
|
+
* pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
|
|
137
|
+
function impreciseShapePinInset(
|
|
138
|
+
anchor: TLCommentThread['anchor'],
|
|
139
|
+
spot: { x: number; y: number }
|
|
140
|
+
): { x: number; y: number } | null {
|
|
141
|
+
if (anchor.type !== 'shape' || anchor.isPrecise) return null
|
|
142
|
+
return {
|
|
143
|
+
x: Math.sign(0.5 - spot.x) * IMPRECISE_PIN_INSET_PX,
|
|
144
|
+
y: Math.sign(0.5 - spot.y) * IMPRECISE_PIN_INSET_PX,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A pointer-down that belongs to the camera, not the comment UI: any non-primary button
|
|
149
|
+
* (middle/right-button pans), or a primary press with the spacebar pan key held. */
|
|
150
|
+
const isCanvasPanGesture = (editor: Editor, e: ReactPointerEvent) =>
|
|
151
|
+
e.button !== 0 || editor.inputs.keys.has('Space')
|
|
152
|
+
|
|
153
|
+
/** Hand a pointer event to the canvas beneath the comments layer, marked the same way the
|
|
154
|
+
* pass-through wheel/hover hooks mark their re-dispatched events. */
|
|
155
|
+
function forwardPointerEventToCanvas(container: HTMLElement, e: ReactPointerEvent) {
|
|
156
|
+
const cvs = container.querySelector('.tl-canvas')
|
|
157
|
+
if (!cvs) return
|
|
158
|
+
const newEvent = new PointerEvent(e.type, e.nativeEvent as any)
|
|
159
|
+
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
160
|
+
cvs.dispatchEvent(newEvent)
|
|
161
|
+
}
|
|
162
|
+
|
|
111
163
|
const initialOf = (name: string): string => (getFirstCharacter(name.trim()) || '?').toUpperCase()
|
|
112
164
|
const CLUSTER_FADE_MS = 150
|
|
113
165
|
/** Duration of the click-a-badge zoom-to-split animation. */
|
|
@@ -115,8 +167,8 @@ const CLUSTER_EXPAND_ZOOM_MS = 450
|
|
|
115
167
|
|
|
116
168
|
/** The leading element for the placement composer — the comment pin's shape, but a pencil
|
|
117
169
|
* instead of an initial, marking an unsent draft. */
|
|
118
|
-
const draftAvatar = (
|
|
119
|
-
<CommentPin>
|
|
170
|
+
const draftAvatar = (color?: string) => (
|
|
171
|
+
<CommentPin color={color}>
|
|
120
172
|
<svg
|
|
121
173
|
viewBox="0 0 24 24"
|
|
122
174
|
width="15"
|
|
@@ -137,7 +189,8 @@ const draftAvatar = (
|
|
|
137
189
|
function toCardProps(
|
|
138
190
|
comment: TLComment,
|
|
139
191
|
props: CanvasCommentsProps,
|
|
140
|
-
components: CommentingComponents
|
|
192
|
+
components: CommentingComponents,
|
|
193
|
+
resolveName: (id: string) => string | undefined
|
|
141
194
|
): CommentCardProps {
|
|
142
195
|
const Body = components.CommentBody
|
|
143
196
|
// The `CommentBody` component slot overrides the built-in rich-text default (which resolves
|
|
@@ -145,10 +198,10 @@ function toCardProps(
|
|
|
145
198
|
const body = Body ? (
|
|
146
199
|
<Body comment={comment} />
|
|
147
200
|
) : (
|
|
148
|
-
<CommentBody richText={comment.body} resolveName={
|
|
201
|
+
<CommentBody richText={comment.body} resolveName={resolveName} />
|
|
149
202
|
)
|
|
150
203
|
return {
|
|
151
|
-
author: props.
|
|
204
|
+
author: props.resolveAuthor(comment.authorId) ?? UNKNOWN_COMMENT_AUTHOR,
|
|
152
205
|
body,
|
|
153
206
|
date: new Date(comment.createdAt).toISOString(),
|
|
154
207
|
you: comment.authorId === props.currentUserId,
|
|
@@ -177,30 +230,32 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
177
230
|
)
|
|
178
231
|
useEffect(() => setRegionCommentOptions(editor, regionOptions), [editor, regionOptions])
|
|
179
232
|
const layerRef = useRef<HTMLDivElement>(null)
|
|
180
|
-
// Over the pins and cluster badges,
|
|
181
|
-
//
|
|
182
|
-
|
|
233
|
+
// Over the pins and cluster badges, hover passes through to the canvas beneath (these events
|
|
234
|
+
// bubble up from the pointer-interactive markers to this layer root). Wheel pass-through is
|
|
235
|
+
// NOT on this root: it lives on each interactive element instead. The root spans the whole
|
|
236
|
+
// canvas, so any pin past its bottom/right edge inflates the root's scrollHeight — which the
|
|
237
|
+
// wheel hook's is-this-scrollable guard reads as scrollable, silently disabling pass-through.
|
|
183
238
|
usePassThroughMouseOverEvents(layerRef)
|
|
184
239
|
const deepLinkHandled = useRef(false)
|
|
185
240
|
const threads = useCommentThreads(editor)
|
|
186
241
|
const pending = usePendingComment()
|
|
187
242
|
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
|
188
243
|
const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
|
|
189
|
-
// Threads
|
|
190
|
-
//
|
|
191
|
-
// and
|
|
192
|
-
const [
|
|
244
|
+
// Threads held out of clustering because their anchor moved while folded inside a badge
|
|
245
|
+
// (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
|
|
246
|
+
// as live pins riding their anchor and rejoin clustering on the next zoom-out.
|
|
247
|
+
const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
|
|
193
248
|
const adoptOnRebuild = useRef(false)
|
|
194
249
|
const clusterLeaves = useValue(
|
|
195
250
|
'comment cluster leaves',
|
|
196
251
|
() =>
|
|
197
252
|
collectClusterLeaves(
|
|
198
253
|
editor,
|
|
199
|
-
threads.filter((thread) => !
|
|
254
|
+
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
|
200
255
|
openThreadId.get(editor),
|
|
201
256
|
impreciseShapeAnchor
|
|
202
257
|
),
|
|
203
|
-
[editor, threads, impreciseShapeAnchor,
|
|
258
|
+
[editor, threads, impreciseShapeAnchor, heldThreadIds]
|
|
204
259
|
)
|
|
205
260
|
const clusterZoomBounds = useValue(
|
|
206
261
|
'comment cluster zoom bounds',
|
|
@@ -213,44 +268,69 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
213
268
|
runtime.seed(editor.getZoomLevel())
|
|
214
269
|
return { runtime, table }
|
|
215
270
|
}, [clusterLeaves, clusterZoomBounds, editor])
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// badge
|
|
271
|
+
// The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild
|
|
272
|
+
// (add / move / delete / open / pop-out) is computed immediately as `latestModel` — the MST
|
|
273
|
+
// stays correct — but the on-screen partition is `renderedModel`, and it only ever changes via
|
|
274
|
+
// (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
|
|
275
|
+
// (c) LOCAL detach patches: a leaf that left the input (deleted, opened, popped out) is
|
|
276
|
+
// detached from its own badge in place — count and centroid update for that badge alone,
|
|
277
|
+
// and nothing else on the canvas moves.
|
|
222
278
|
const [renderedModel, setRenderedModel] = useState(latestModel)
|
|
223
279
|
let clusterModel = renderedModel
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
) {
|
|
280
|
+
// A page switch replaces the whole scene: hard-reset rather than detach the world.
|
|
281
|
+
const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
|
|
282
|
+
const pageRef = useRef(pageId)
|
|
283
|
+
if (pageRef.current !== pageId) {
|
|
284
|
+
pageRef.current = pageId
|
|
228
285
|
adoptOnRebuild.current = false
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
286
|
+
latestModel.runtime.seed(editor.getZoomLevel())
|
|
287
|
+
if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
|
|
288
|
+
setRenderedModel(latestModel)
|
|
289
|
+
clusterModel = latestModel
|
|
290
|
+
}
|
|
291
|
+
// adoptOnRebuild is set by the rejoin reaction below, outside React's render cycle, paired
|
|
292
|
+
// with clearing heldThreadIds. Only trust it once that pairing is actually visible here
|
|
293
|
+
// (heldThreadIds confirmed empty) — an unrelated re-render can land in the gap between the
|
|
294
|
+
// ref being set and the state update it was paired with being applied.
|
|
295
|
+
const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
|
|
296
|
+
if (renderedModel !== latestModel && rejoinPending) {
|
|
297
|
+
adoptOnRebuild.current = false
|
|
298
|
+
// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
|
|
299
|
+
// nothing changes state because of the swap alone. Idempotent, so safe during render.
|
|
233
300
|
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
|
234
301
|
setRenderedModel(latestModel)
|
|
235
302
|
clusterModel = latestModel
|
|
303
|
+
} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
|
|
304
|
+
// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
|
|
305
|
+
// survive to force-adopt a later, unrelated rebuild.
|
|
306
|
+
adoptOnRebuild.current = false
|
|
236
307
|
}
|
|
237
308
|
// 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.
|
|
309
|
+
// is baked into the model), so when its live position drifts from the baked one, hold it out.
|
|
310
|
+
// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
|
|
241
311
|
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
|
242
312
|
if (newlyMovedIds.length > 0) {
|
|
243
|
-
|
|
244
|
-
console.debug(`[comments] pins popped out of clustering: ${newlyMovedIds.join(', ')}`)
|
|
245
|
-
const next = new Set(movedThreadIds)
|
|
313
|
+
const next = new Set(heldThreadIds)
|
|
246
314
|
for (const id of newlyMovedIds) next.add(id)
|
|
247
|
-
|
|
315
|
+
setHeldThreadIds(next)
|
|
316
|
+
}
|
|
317
|
+
// Local partition maintenance — the only non-zoom visual change, and it is local by
|
|
318
|
+
// construction: any displayed leaf that has left the cluster input (deleted, thread opened,
|
|
319
|
+
// popped out above) is detached from its badge in place. The corrected rebuild is already
|
|
320
|
+
// sitting in latestModel awaiting the next zoom-out.
|
|
321
|
+
{
|
|
322
|
+
const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
323
|
+
for (const leaf of clusterModel.table.leaves) {
|
|
324
|
+
if (!latestLeafIds.has(leaf.id)) {
|
|
325
|
+
clusterModel.runtime.detachLeaf(leaf.id)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
248
328
|
}
|
|
249
329
|
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
|
250
330
|
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
|
251
331
|
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
|
252
332
|
useEffect(() => {
|
|
253
|
-
if (
|
|
333
|
+
if (heldThreadIds.size === 0) return
|
|
254
334
|
let lastZoom = editor.getZoomLevel()
|
|
255
335
|
return react('rejoin moved comment pins on zoom out', () => {
|
|
256
336
|
const zoom = editor.getZoomLevel()
|
|
@@ -258,9 +338,9 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
258
338
|
lastZoom = zoom
|
|
259
339
|
if (zoom >= prevZoom) return
|
|
260
340
|
adoptOnRebuild.current = true
|
|
261
|
-
|
|
341
|
+
setHeldThreadIds(EMPTY_SET)
|
|
262
342
|
})
|
|
263
|
-
}, [
|
|
343
|
+
}, [heldThreadIds, editor])
|
|
264
344
|
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
|
265
345
|
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
|
266
346
|
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
|
@@ -276,36 +356,44 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
276
356
|
setRenderedModel(latestModel)
|
|
277
357
|
})
|
|
278
358
|
}, [clusterModel, latestModel, editor])
|
|
359
|
+
// Threads in the current input that the displayed partition doesn't show anywhere (new
|
|
360
|
+
// comments, reopened threads, undone deletions): render as plain pins until the next
|
|
361
|
+
// zoom-out folds them in. Membership is judged against the *displayed* partition (with
|
|
362
|
+
// detaches applied), not the rendered table, so a detached-then-restored leaf reappears.
|
|
363
|
+
const partitionVersion = clusterModel.runtime.version
|
|
279
364
|
const orphanThreads = useMemo(() => {
|
|
280
365
|
if (clusterModel === latestModel) return []
|
|
281
|
-
const
|
|
366
|
+
const displayed = new Set<string>()
|
|
367
|
+
for (const node of clusterModel.runtime.getVisible().values()) {
|
|
368
|
+
for (const member of node.members) displayed.add(member)
|
|
369
|
+
}
|
|
282
370
|
const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
283
|
-
return threads.filter((thread) => latestIds.has(thread.id) && !
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
371
|
+
return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
|
|
372
|
+
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
373
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
374
|
+
}, [clusterModel, latestModel, threads, partitionVersion])
|
|
375
|
+
const heldThreads = useMemo(
|
|
376
|
+
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
|
377
|
+
[threads, heldThreadIds, openId]
|
|
288
378
|
)
|
|
289
|
-
// Subscribe to the runtime
|
|
290
|
-
// O(1) threshold checks
|
|
291
|
-
//
|
|
292
|
-
// on
|
|
293
|
-
|
|
294
|
-
|
|
379
|
+
// Subscribe to the runtime's partition version, not the raw zoom: onCamera runs on every zoom
|
|
380
|
+
// tick (O(1) threshold checks) but the version only moves when the partition actually changes
|
|
381
|
+
// — so this component only re-renders on cluster changes, not on every camera frame. The memo
|
|
382
|
+
// below keys on a fresh inline read of the version rather than the subscribed value, because
|
|
383
|
+
// render-time detaches (above) bump it after the subscription's computed already evaluated.
|
|
384
|
+
useValue(
|
|
385
|
+
'comment cluster version',
|
|
295
386
|
() => {
|
|
296
387
|
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
|
297
|
-
return clusterModel.runtime.
|
|
388
|
+
return clusterModel.runtime.version
|
|
298
389
|
},
|
|
299
390
|
[clusterModel, editor]
|
|
300
391
|
)
|
|
301
392
|
const visibleNodes = useMemo(() => {
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
)
|
|
307
|
-
return nodes
|
|
308
|
-
}, [clusterModel, clusterCursor])
|
|
393
|
+
return Array.from(clusterModel.runtime.getVisible().values())
|
|
394
|
+
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
395
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
396
|
+
}, [clusterModel, partitionVersion])
|
|
309
397
|
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
|
310
398
|
const threadsById = useMemo(
|
|
311
399
|
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
|
@@ -384,7 +472,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
384
472
|
// The mention picker owns Escape while it's open — let it dismiss the roster alone.
|
|
385
473
|
if (isMentionPickerOpen()) return
|
|
386
474
|
const target = e.target as HTMLElement | null
|
|
387
|
-
if (target && target.closest('.cmt-editing')) return
|
|
475
|
+
if (target && target.closest('.tlui-cmt-editing')) return
|
|
388
476
|
openThreadId.set(editor, null)
|
|
389
477
|
e.preventDefault()
|
|
390
478
|
e.stopPropagation()
|
|
@@ -393,8 +481,8 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
393
481
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
394
482
|
}, [editor])
|
|
395
483
|
|
|
396
|
-
// Shift+C toggles comment-pin visibility on the canvas
|
|
397
|
-
//
|
|
484
|
+
// Shift+C toggles comment-pin visibility on the canvas. Skipped while typing so it never fires
|
|
485
|
+
// from inside a composer. Physical `KeyC` (layout-independent) with shift only.
|
|
398
486
|
useEffect(() => {
|
|
399
487
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
400
488
|
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
@@ -414,7 +502,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
414
502
|
// Render into the container (above the panels' stacking context) so the pins and popovers
|
|
415
503
|
// live in the UI layer rather than being clipped by the canvas layer.
|
|
416
504
|
return createPortal(
|
|
417
|
-
<div ref={layerRef} className="cmt-canvas-layer">
|
|
505
|
+
<div ref={layerRef} className="tlui-cmt-canvas-layer">
|
|
418
506
|
{options.enableClustering ? (
|
|
419
507
|
<>
|
|
420
508
|
{fadeNodes.map(({ node, phase }) => {
|
|
@@ -448,7 +536,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
|
|
|
448
536
|
regionOptions={regionOptions}
|
|
449
537
|
/>
|
|
450
538
|
))}
|
|
451
|
-
{
|
|
539
|
+
{heldThreads.map((thread) => (
|
|
452
540
|
<ThreadPin
|
|
453
541
|
key={thread.id}
|
|
454
542
|
editor={editor}
|
|
@@ -597,7 +685,7 @@ function cancelClusterFadeFrame(frame: number) {
|
|
|
597
685
|
}
|
|
598
686
|
|
|
599
687
|
function clusterFadeClassName(phase: ClusterFadePhase): string {
|
|
600
|
-
return `cmt-cluster-fade cmt-cluster-fade--${phase}`
|
|
688
|
+
return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
|
|
601
689
|
}
|
|
602
690
|
|
|
603
691
|
/**
|
|
@@ -627,12 +715,6 @@ function findMovedClusteredLeafIds(
|
|
|
627
715
|
return moved
|
|
628
716
|
}
|
|
629
717
|
|
|
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
718
|
function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
|
|
637
719
|
const cameraOptions = editor.getCameraOptions()
|
|
638
720
|
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
|
@@ -719,6 +801,11 @@ const ClusterBadge = memo(function ClusterBadge({
|
|
|
719
801
|
node: ClusterNode
|
|
720
802
|
onExpand(node: ClusterNode): void
|
|
721
803
|
}) {
|
|
804
|
+
const container = useContainer()
|
|
805
|
+
const badgeRef = useRef<HTMLDivElement>(null)
|
|
806
|
+
// Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
|
|
807
|
+
// note on the layer.
|
|
808
|
+
usePassThroughWheelEvents(badgeRef)
|
|
722
809
|
const point = useValue(
|
|
723
810
|
'cluster badge point',
|
|
724
811
|
() => {
|
|
@@ -733,9 +820,16 @@ const ClusterBadge = memo(function ClusterBadge({
|
|
|
733
820
|
|
|
734
821
|
return (
|
|
735
822
|
<div
|
|
736
|
-
|
|
823
|
+
ref={badgeRef}
|
|
824
|
+
className="tlui-cmt-canvas-cluster"
|
|
737
825
|
style={{ left: point.x, top: point.y }}
|
|
738
|
-
onPointerDown={
|
|
826
|
+
onPointerDown={(e) => {
|
|
827
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
828
|
+
forwardPointerEventToCanvas(container, e)
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
e.stopPropagation()
|
|
832
|
+
}}
|
|
739
833
|
onClick={(e) => {
|
|
740
834
|
e.stopPropagation()
|
|
741
835
|
onExpand(node)
|
|
@@ -772,7 +866,15 @@ function ThreadPopover({
|
|
|
772
866
|
usePassThroughWheelEvents(ref)
|
|
773
867
|
usePassThroughMouseOverEvents(ref)
|
|
774
868
|
return createPortal(
|
|
775
|
-
|
|
869
|
+
// contextmenu also stops here: portals bubble React events to the canvas's context-menu
|
|
870
|
+
// trigger (the layer mounts inside it), which would open the canvas menu over this panel.
|
|
871
|
+
<div
|
|
872
|
+
ref={ref}
|
|
873
|
+
className="tlui-cmt-canvas-popover"
|
|
874
|
+
style={style}
|
|
875
|
+
onPointerDown={stop}
|
|
876
|
+
onContextMenu={stop}
|
|
877
|
+
>
|
|
776
878
|
{children}
|
|
777
879
|
</div>,
|
|
778
880
|
container
|
|
@@ -808,12 +910,21 @@ function RegionBox({
|
|
|
808
910
|
// The grab point and the box at grab time, captured so the drag translates by a stable delta even
|
|
809
911
|
// as the box prop reflows under the live preview.
|
|
810
912
|
const grabRef = useRef<{ page: VecLike; box: BoxModel } | null>(null)
|
|
913
|
+
const container = useContainer()
|
|
914
|
+
const boxRef = useRef<HTMLDivElement>(null)
|
|
915
|
+
// A movable region body takes pointer events over its whole area — wheel pass-through sits
|
|
916
|
+
// here for the same reason as on the pin markers (see the note on the layer).
|
|
917
|
+
usePassThroughWheelEvents(boxRef)
|
|
811
918
|
const translated = (e: ReactPointerEvent<HTMLDivElement>): BoxModel => {
|
|
812
919
|
const g = grabRef.current!
|
|
813
920
|
const p = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
814
921
|
return { ...g.box, x: g.box.x + (p.x - g.page.x), y: g.box.y + (p.y - g.page.y) }
|
|
815
922
|
}
|
|
816
923
|
const startMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
924
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
925
|
+
forwardPointerEventToCanvas(container, e)
|
|
926
|
+
return
|
|
927
|
+
}
|
|
817
928
|
e.stopPropagation()
|
|
818
929
|
grabRef.current = { page: editor.screenToPage({ x: e.clientX, y: e.clientY }), box }
|
|
819
930
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
@@ -831,7 +942,12 @@ function RegionBox({
|
|
|
831
942
|
}
|
|
832
943
|
return (
|
|
833
944
|
<div
|
|
834
|
-
|
|
945
|
+
ref={boxRef}
|
|
946
|
+
className={
|
|
947
|
+
movable
|
|
948
|
+
? 'tlui-cmt-canvas-region tlui-cmt-canvas-region--movable'
|
|
949
|
+
: 'tlui-cmt-canvas-region'
|
|
950
|
+
}
|
|
835
951
|
style={rect}
|
|
836
952
|
onPointerDown={movable ? startMove : undefined}
|
|
837
953
|
onPointerMove={movable ? onMove : undefined}
|
|
@@ -939,7 +1055,7 @@ function RegionResizeHandles({
|
|
|
939
1055
|
{points.map((h) => (
|
|
940
1056
|
<div
|
|
941
1057
|
key={h.key}
|
|
942
|
-
className="cmt-canvas-region-handle"
|
|
1058
|
+
className="tlui-cmt-canvas-region-handle"
|
|
943
1059
|
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
|
944
1060
|
onPointerDown={startResize}
|
|
945
1061
|
onPointerMove={onResize(h)}
|
|
@@ -962,13 +1078,17 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
962
1078
|
}) {
|
|
963
1079
|
const {
|
|
964
1080
|
currentUserId,
|
|
965
|
-
|
|
1081
|
+
resolveAuthor,
|
|
966
1082
|
onPostComment,
|
|
967
1083
|
isCommentUnread,
|
|
968
1084
|
onCommentRead,
|
|
969
1085
|
getMentionSuggestions,
|
|
970
1086
|
renderMentionSuggestion,
|
|
971
1087
|
} = props
|
|
1088
|
+
// Name-only view of the resolver, for the mention/rich-text paths (stable identity so
|
|
1089
|
+
// CommentBody's memoized render doesn't recompute every render).
|
|
1090
|
+
const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
|
1091
|
+
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
|
972
1092
|
const options = useCommentingOptions()
|
|
973
1093
|
const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
|
|
974
1094
|
const container = useContainer()
|
|
@@ -979,7 +1099,11 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
979
1099
|
editor,
|
|
980
1100
|
thread.id,
|
|
981
1101
|
])
|
|
982
|
-
|
|
1102
|
+
// An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
|
|
1103
|
+
// the flip side of dismissing without a discard warning.
|
|
1104
|
+
const [reply, setReply] = useState<TLRichText>(
|
|
1105
|
+
() => getCommentDraft(replyDraftSlot(thread.id)) ?? EMPTY_COMMENT
|
|
1106
|
+
)
|
|
983
1107
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
984
1108
|
const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
|
|
985
1109
|
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
@@ -1010,18 +1134,42 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1010
1134
|
resizeBounds != null ||
|
|
1011
1135
|
(regionOptions.reveal === 'pointer' && pointerInRegion) ||
|
|
1012
1136
|
(regionOptions.reveal === 'pin-hover' && pinHovered)
|
|
1137
|
+
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
|
1138
|
+
// the configured default as the fallback for older records.
|
|
1139
|
+
const pinCorner =
|
|
1140
|
+
thread.anchor.type === 'region'
|
|
1141
|
+
? regionAnchorPinCorner(editor, thread.anchor)
|
|
1142
|
+
: regionOptions.pinCorner
|
|
1013
1143
|
// The resize handles: side midpoints ('edges'), or the corners other than the pin's ('corners').
|
|
1014
1144
|
const resizeHandles = useMemo(
|
|
1015
1145
|
() =>
|
|
1016
1146
|
regionOptions.resize === 'edges'
|
|
1017
1147
|
? REGION_EDGES
|
|
1018
|
-
: REGION_CORNERS.filter(
|
|
1019
|
-
|
|
1020
|
-
),
|
|
1021
|
-
[regionOptions.resize, regionOptions.pinCorner]
|
|
1148
|
+
: REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
|
1149
|
+
[regionOptions.resize, pinCorner]
|
|
1022
1150
|
)
|
|
1023
|
-
const dragRef = useRef<{
|
|
1151
|
+
const dragRef = useRef<{
|
|
1152
|
+
startX: number
|
|
1153
|
+
startY: number
|
|
1154
|
+
moved: boolean
|
|
1155
|
+
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
|
1156
|
+
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
|
1157
|
+
offsetX: number
|
|
1158
|
+
offsetY: number
|
|
1159
|
+
} | null>(null)
|
|
1024
1160
|
const markerRef = useRef<HTMLDivElement>(null)
|
|
1161
|
+
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
|
1162
|
+
// see the note on the layer.
|
|
1163
|
+
usePassThroughWheelEvents(markerRef)
|
|
1164
|
+
|
|
1165
|
+
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
|
1166
|
+
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
|
1167
|
+
// clear the hint here or it stays on the shape indefinitely.
|
|
1168
|
+
useEffect(() => {
|
|
1169
|
+
return () => {
|
|
1170
|
+
if (dragRef.current) editor.setHintingShapes([])
|
|
1171
|
+
}
|
|
1172
|
+
}, [editor])
|
|
1025
1173
|
|
|
1026
1174
|
// Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
|
|
1027
1175
|
// pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
|
|
@@ -1032,15 +1180,17 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1032
1180
|
const onPointerDown = (e: PointerEvent) => {
|
|
1033
1181
|
const target = e.target as HTMLElement | null
|
|
1034
1182
|
if (!target) return
|
|
1035
|
-
if (target.closest('.cmt-canvas-popover')) return
|
|
1183
|
+
if (target.closest('.tlui-cmt-canvas-popover')) return
|
|
1036
1184
|
const marker = markerRef.current
|
|
1037
1185
|
if (marker && marker.contains(target)) return
|
|
1038
1186
|
// 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
|
|
1187
|
+
if (target.closest('.tlui-cmt-canvas-region-handle, .tlui-cmt-canvas-region--movable')) return
|
|
1040
1188
|
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
|
1041
1189
|
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
|
1042
1190
|
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
|
1043
|
-
if (
|
|
1191
|
+
if (
|
|
1192
|
+
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
|
1193
|
+
)
|
|
1044
1194
|
return
|
|
1045
1195
|
openThreadId.set(editor, null)
|
|
1046
1196
|
}
|
|
@@ -1053,7 +1203,10 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1053
1203
|
() => {
|
|
1054
1204
|
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
1055
1205
|
const pagePoint = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
1056
|
-
|
|
1206
|
+
if (!pagePoint) return null
|
|
1207
|
+
const viewportPoint = editor.pageToViewport(pagePoint)
|
|
1208
|
+
const inset = impreciseShapePinInset(thread.anchor, impreciseShapeAnchor)
|
|
1209
|
+
return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
|
|
1057
1210
|
},
|
|
1058
1211
|
[editor, thread.anchor, thread.pageId, impreciseShapeAnchor]
|
|
1059
1212
|
)
|
|
@@ -1086,6 +1239,7 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1086
1239
|
if (onPostComment) onPostComment(comment)
|
|
1087
1240
|
})
|
|
1088
1241
|
setReply(EMPTY_COMMENT)
|
|
1242
|
+
clearCommentDraft(replyDraftSlot(thread.id))
|
|
1089
1243
|
}
|
|
1090
1244
|
|
|
1091
1245
|
const toggleResolve = () => {
|
|
@@ -1112,6 +1266,18 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1112
1266
|
setEditText(comment.body)
|
|
1113
1267
|
}
|
|
1114
1268
|
|
|
1269
|
+
const deleteComment = (comment: TLComment) => {
|
|
1270
|
+
commitCommentMutation(editor, () => {
|
|
1271
|
+
// Deleting a thread's only comment deletes the thread — an empty thread has no surface.
|
|
1272
|
+
if (comments.length === 1) {
|
|
1273
|
+
openThreadId.set(editor, null)
|
|
1274
|
+
removeCommentRecords(editor, [thread.id, comment.id])
|
|
1275
|
+
} else {
|
|
1276
|
+
removeCommentRecords(editor, [comment.id])
|
|
1277
|
+
}
|
|
1278
|
+
})
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1115
1281
|
const saveEdit = () => {
|
|
1116
1282
|
const comment = comments.find((c) => c.id === editingId)
|
|
1117
1283
|
if (!comment || isCommentEmpty(editText)) return
|
|
@@ -1128,7 +1294,7 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1128
1294
|
if (editingId === comment.id) {
|
|
1129
1295
|
return (
|
|
1130
1296
|
<div
|
|
1131
|
-
className="cmt-editing"
|
|
1297
|
+
className="tlui-cmt-editing"
|
|
1132
1298
|
onKeyDown={(e) => {
|
|
1133
1299
|
if (e.key === 'Escape') {
|
|
1134
1300
|
setEditingId(null)
|
|
@@ -1156,13 +1322,43 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1156
1322
|
{...card}
|
|
1157
1323
|
actions={
|
|
1158
1324
|
comment.authorId === currentUserId ? (
|
|
1159
|
-
<
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1325
|
+
<TldrawUiDropdownMenuRoot id={`comment-actions-${comment.id}`}>
|
|
1326
|
+
<TldrawUiDropdownMenuTrigger>
|
|
1327
|
+
<TooltipButton
|
|
1328
|
+
tooltip={msg('comments.more-options')}
|
|
1329
|
+
className="tlui-cmt-thread__action"
|
|
1330
|
+
>
|
|
1331
|
+
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
|
1332
|
+
</TooltipButton>
|
|
1333
|
+
</TldrawUiDropdownMenuTrigger>
|
|
1334
|
+
<TldrawUiDropdownMenuContent
|
|
1335
|
+
className="tlui-cmt-menu"
|
|
1336
|
+
side="bottom"
|
|
1337
|
+
align="end"
|
|
1338
|
+
alignOffset={0}
|
|
1339
|
+
>
|
|
1340
|
+
<TldrawUiDropdownMenuGroup>
|
|
1341
|
+
<TldrawUiDropdownMenuItem>
|
|
1342
|
+
<button
|
|
1343
|
+
type="button"
|
|
1344
|
+
className="tlui-cmt-menu-item"
|
|
1345
|
+
onClick={() => startEdit(comment)}
|
|
1346
|
+
>
|
|
1347
|
+
<span>{msg('comments.edit-comment')}</span>
|
|
1348
|
+
</button>
|
|
1349
|
+
</TldrawUiDropdownMenuItem>
|
|
1350
|
+
<TldrawUiDropdownMenuItem>
|
|
1351
|
+
<button
|
|
1352
|
+
type="button"
|
|
1353
|
+
className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
|
|
1354
|
+
onClick={() => deleteComment(comment)}
|
|
1355
|
+
>
|
|
1356
|
+
<span>{msg('comments.delete-comment')}</span>
|
|
1357
|
+
</button>
|
|
1358
|
+
</TldrawUiDropdownMenuItem>
|
|
1359
|
+
</TldrawUiDropdownMenuGroup>
|
|
1360
|
+
</TldrawUiDropdownMenuContent>
|
|
1361
|
+
</TldrawUiDropdownMenuRoot>
|
|
1166
1362
|
) : undefined
|
|
1167
1363
|
}
|
|
1168
1364
|
/>
|
|
@@ -1172,9 +1368,9 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1172
1368
|
const headerActions = (
|
|
1173
1369
|
<>
|
|
1174
1370
|
{currentUserId && (
|
|
1175
|
-
<
|
|
1176
|
-
|
|
1177
|
-
|
|
1371
|
+
<TooltipButton
|
|
1372
|
+
tooltip={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
1373
|
+
className="tlui-cmt-thread__action"
|
|
1178
1374
|
onClick={toggleResolve}
|
|
1179
1375
|
>
|
|
1180
1376
|
<TldrawUiIcon
|
|
@@ -1182,33 +1378,48 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1182
1378
|
label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
|
|
1183
1379
|
small
|
|
1184
1380
|
/>
|
|
1185
|
-
</
|
|
1381
|
+
</TooltipButton>
|
|
1186
1382
|
)}
|
|
1187
1383
|
{currentUserId && (
|
|
1188
|
-
<
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1384
|
+
<TldrawUiDropdownMenuRoot id={`comment-thread-actions-${thread.id}`}>
|
|
1385
|
+
<TldrawUiDropdownMenuTrigger>
|
|
1386
|
+
<TooltipButton
|
|
1387
|
+
tooltip={msg('comments.more-options')}
|
|
1388
|
+
className="tlui-cmt-thread__action"
|
|
1389
|
+
>
|
|
1390
|
+
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
|
1391
|
+
</TooltipButton>
|
|
1392
|
+
</TldrawUiDropdownMenuTrigger>
|
|
1393
|
+
<TldrawUiDropdownMenuContent
|
|
1394
|
+
className="tlui-cmt-menu"
|
|
1395
|
+
side="bottom"
|
|
1396
|
+
align="end"
|
|
1397
|
+
alignOffset={0}
|
|
1398
|
+
>
|
|
1399
|
+
<TldrawUiDropdownMenuGroup>
|
|
1400
|
+
<TldrawUiDropdownMenuItem>
|
|
1401
|
+
<button
|
|
1402
|
+
type="button"
|
|
1403
|
+
className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
|
|
1404
|
+
onClick={deleteThread}
|
|
1405
|
+
>
|
|
1406
|
+
<span>{msg('comments.delete')}</span>
|
|
1407
|
+
</button>
|
|
1408
|
+
</TldrawUiDropdownMenuItem>
|
|
1409
|
+
</TldrawUiDropdownMenuGroup>
|
|
1410
|
+
</TldrawUiDropdownMenuContent>
|
|
1411
|
+
</TldrawUiDropdownMenuRoot>
|
|
1195
1412
|
)}
|
|
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
1413
|
</>
|
|
1204
1414
|
)
|
|
1205
1415
|
|
|
1206
1416
|
const PinContent = options.components.PinContent
|
|
1207
1417
|
// The `PinContent` component slot overrides the built-in author-initial default.
|
|
1418
|
+
const threadAuthor = resolveAuthor(thread.createdBy)
|
|
1208
1419
|
const pinContent = PinContent ? (
|
|
1209
1420
|
<PinContent thread={thread} comments={comments} />
|
|
1210
1421
|
) : (
|
|
1211
|
-
initialOf(
|
|
1422
|
+
initialOf(threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
|
1212
1423
|
)
|
|
1213
1424
|
|
|
1214
1425
|
// Drag the marker to move the thread: its position is overridden locally while dragging, then
|
|
@@ -1220,8 +1431,30 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1220
1431
|
const pinMovable = regionOptions.move !== 'body'
|
|
1221
1432
|
const bodyMovable = regionOptions.move !== 'pin'
|
|
1222
1433
|
const startDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1434
|
+
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
|
1435
|
+
// hand it to the canvas untouched.
|
|
1436
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
1437
|
+
forwardPointerEventToCanvas(container, e)
|
|
1438
|
+
return
|
|
1439
|
+
}
|
|
1223
1440
|
e.stopPropagation()
|
|
1224
|
-
|
|
1441
|
+
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1442
|
+
const anchorPage = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
|
|
1443
|
+
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
|
1444
|
+
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
|
1445
|
+
const inset = impreciseShapePinInset(thread.anchor, impreciseShapeAnchor)
|
|
1446
|
+
if (anchorPage && inset) {
|
|
1447
|
+
const zoom = editor.getZoomLevel()
|
|
1448
|
+
anchorPage.x += inset.x / zoom
|
|
1449
|
+
anchorPage.y += inset.y / zoom
|
|
1450
|
+
}
|
|
1451
|
+
dragRef.current = {
|
|
1452
|
+
startX: e.clientX,
|
|
1453
|
+
startY: e.clientY,
|
|
1454
|
+
moved: false,
|
|
1455
|
+
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
|
1456
|
+
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
|
1457
|
+
}
|
|
1225
1458
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
1226
1459
|
}
|
|
1227
1460
|
const onDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
@@ -1231,7 +1464,27 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1231
1464
|
if (isRegion && !pinMovable) return
|
|
1232
1465
|
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
1233
1466
|
drag.moved = true
|
|
1234
|
-
|
|
1467
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1468
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
1469
|
+
setDragPagePoint(pagePoint)
|
|
1470
|
+
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
|
1471
|
+
// with. Regions translate rather than re-anchor, so they never hint.
|
|
1472
|
+
if (!isRegion) {
|
|
1473
|
+
const hit = editor.getShapeAtPoint(pagePoint, { hitInside: true })
|
|
1474
|
+
editor.setHintingShapes(hit ? [hit.id] : [])
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
|
1478
|
+
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
|
1479
|
+
const cancelDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1480
|
+
const drag = dragRef.current
|
|
1481
|
+
dragRef.current = null
|
|
1482
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
1483
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1484
|
+
}
|
|
1485
|
+
if (!drag) return
|
|
1486
|
+
setDragPagePoint(null)
|
|
1487
|
+
editor.setHintingShapes([])
|
|
1235
1488
|
}
|
|
1236
1489
|
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1237
1490
|
const drag = dragRef.current
|
|
@@ -1240,24 +1493,35 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1240
1493
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
1241
1494
|
}
|
|
1242
1495
|
if (!drag) return
|
|
1496
|
+
editor.setHintingShapes([])
|
|
1243
1497
|
if (!drag.moved) {
|
|
1244
1498
|
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
1245
1499
|
return
|
|
1246
1500
|
}
|
|
1247
|
-
const
|
|
1501
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
1502
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
1248
1503
|
setDragPagePoint(null)
|
|
1249
1504
|
let anchor: TLCommentThread['anchor']
|
|
1250
1505
|
if (thread.anchor.type === 'region') {
|
|
1251
1506
|
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
1252
1507
|
anchor = {
|
|
1253
1508
|
...thread.anchor,
|
|
1254
|
-
x: pagePoint.x -
|
|
1255
|
-
y: pagePoint.y -
|
|
1509
|
+
x: pagePoint.x - pinCorner.x * thread.anchor.w,
|
|
1510
|
+
y: pagePoint.y - pinCorner.y * thread.anchor.h,
|
|
1256
1511
|
}
|
|
1257
1512
|
} else {
|
|
1258
1513
|
const hit = editor.getShapeAtPoint(pagePoint, { hitInside: true })
|
|
1259
1514
|
anchor = hit
|
|
1260
|
-
? shapeAnchorAt(
|
|
1515
|
+
? shapeAnchorAt(
|
|
1516
|
+
editor,
|
|
1517
|
+
hit.id,
|
|
1518
|
+
pagePoint,
|
|
1519
|
+
getCommentingOptions(editor).shouldBePrecise(editor, {
|
|
1520
|
+
shapeId: hit.id,
|
|
1521
|
+
point: pagePoint,
|
|
1522
|
+
altKey: e.altKey,
|
|
1523
|
+
})
|
|
1524
|
+
)
|
|
1261
1525
|
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
1262
1526
|
}
|
|
1263
1527
|
commitCommentMutation(editor, () => putCommentRecords(editor, [{ ...thread, anchor }]), 'drag')
|
|
@@ -1265,10 +1529,13 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1265
1529
|
|
|
1266
1530
|
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
1267
1531
|
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
1268
|
-
const livePinPage = resizeBounds
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1532
|
+
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
|
1533
|
+
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
1534
|
+
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
|
1535
|
+
// The marker anchors bottom-left, so step half its 34px size left and down (screen px).
|
|
1536
|
+
const renderPoint = isRegion
|
|
1537
|
+
? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
|
|
1538
|
+
: renderPointBase
|
|
1272
1539
|
|
|
1273
1540
|
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
1274
1541
|
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
@@ -1277,15 +1544,16 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1277
1544
|
regionAnchor && dragPagePoint
|
|
1278
1545
|
? {
|
|
1279
1546
|
...regionAnchor,
|
|
1280
|
-
x: dragPagePoint.x -
|
|
1281
|
-
y: dragPagePoint.y -
|
|
1547
|
+
x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
|
|
1548
|
+
y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
|
|
1282
1549
|
}
|
|
1283
1550
|
: regionAnchor
|
|
1284
1551
|
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
1285
1552
|
const commitResize = (bounds: BoxModel) => {
|
|
1286
1553
|
setResizeBounds(null)
|
|
1287
1554
|
editor.run(
|
|
1288
|
-
|
|
1555
|
+
// Spread the existing anchor first so the region's pin corner survives a resize.
|
|
1556
|
+
() => putCommentRecords(editor, [{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
|
1289
1557
|
{
|
|
1290
1558
|
history: 'ignore',
|
|
1291
1559
|
}
|
|
@@ -1313,19 +1581,26 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1313
1581
|
/>
|
|
1314
1582
|
)}
|
|
1315
1583
|
<div
|
|
1316
|
-
className={
|
|
1584
|
+
className={[
|
|
1585
|
+
'tlui-cmt-canvas-pin',
|
|
1586
|
+
open && 'tlui-cmt-canvas-pin--open',
|
|
1587
|
+
dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
|
|
1588
|
+
]
|
|
1589
|
+
.filter(Boolean)
|
|
1590
|
+
.join(' ')}
|
|
1317
1591
|
style={{ left: renderPoint.x, top: renderPoint.y }}
|
|
1318
1592
|
>
|
|
1319
1593
|
<div
|
|
1320
1594
|
ref={markerRef}
|
|
1321
|
-
className="cmt-canvas-pin__marker"
|
|
1595
|
+
className="tlui-cmt-canvas-pin__marker"
|
|
1322
1596
|
onPointerDown={startDrag}
|
|
1323
1597
|
onPointerMove={onDrag}
|
|
1324
1598
|
onPointerUp={endDrag}
|
|
1599
|
+
onPointerCancel={cancelDrag}
|
|
1325
1600
|
onPointerEnter={() => setPinHovered(true)}
|
|
1326
1601
|
onPointerLeave={() => setPinHovered(false)}
|
|
1327
1602
|
>
|
|
1328
|
-
<CommentPin resolved={thread.resolved != null} open={open}>
|
|
1603
|
+
<CommentPin resolved={thread.resolved != null} open={open} color={threadAuthor?.color}>
|
|
1329
1604
|
{pinContent}
|
|
1330
1605
|
</CommentPin>
|
|
1331
1606
|
</div>
|
|
@@ -1334,29 +1609,35 @@ const ThreadPin = memo(function ThreadPin({
|
|
|
1334
1609
|
{open && (
|
|
1335
1610
|
<ThreadPopover
|
|
1336
1611
|
container={container}
|
|
1337
|
-
|
|
1612
|
+
// Clear the bottom-left-anchored pin: it spans 34px right of and above the
|
|
1613
|
+
// anchor, plus the open ring's 5px — the popover starts past that, opening
|
|
1614
|
+
// above the pin's top.
|
|
1615
|
+
style={{ left: renderPoint.x + 48, top: renderPoint.y - 54 }}
|
|
1338
1616
|
>
|
|
1339
1617
|
<CommentThread
|
|
1340
1618
|
header={msg('comments.thread-title')}
|
|
1341
1619
|
headerActions={headerActions}
|
|
1342
1620
|
renderComment={renderComment}
|
|
1343
|
-
comments={comments.map((c) => toCardProps(c, props, options.components))}
|
|
1621
|
+
comments={comments.map((c) => toCardProps(c, props, options.components, resolveName))}
|
|
1344
1622
|
resolvedBanner={
|
|
1345
1623
|
thread.resolved
|
|
1346
1624
|
? msg('comments.resolved-by').replace(
|
|
1347
1625
|
'{name}',
|
|
1348
|
-
|
|
1626
|
+
resolveAuthor(thread.resolved.by)?.name ?? UNKNOWN_AUTHOR
|
|
1349
1627
|
)
|
|
1350
1628
|
: undefined
|
|
1351
1629
|
}
|
|
1352
1630
|
composer={
|
|
1353
1631
|
currentUserId && !thread.resolved
|
|
1354
1632
|
? {
|
|
1355
|
-
author:
|
|
1633
|
+
author: me ?? UNKNOWN_COMMENT_AUTHOR,
|
|
1356
1634
|
placeholder: msg('comments.reply-placeholder'),
|
|
1357
1635
|
sendLabel: msg('comments.send'),
|
|
1358
1636
|
value: reply,
|
|
1359
|
-
onChange:
|
|
1637
|
+
onChange: (value: TLRichText) => {
|
|
1638
|
+
setReply(value)
|
|
1639
|
+
saveCommentDraft(replyDraftSlot(thread.id), value)
|
|
1640
|
+
},
|
|
1360
1641
|
onSubmit: postReply,
|
|
1361
1642
|
disabled: isCommentEmpty(reply),
|
|
1362
1643
|
getMentionSuggestions,
|
|
@@ -1376,12 +1657,17 @@ function PendingComposer({
|
|
|
1376
1657
|
editor,
|
|
1377
1658
|
pending,
|
|
1378
1659
|
currentUserId,
|
|
1379
|
-
|
|
1660
|
+
resolveAuthor,
|
|
1380
1661
|
onPostComment,
|
|
1381
1662
|
getMentionSuggestions,
|
|
1382
1663
|
renderMentionSuggestion,
|
|
1383
1664
|
}: CanvasCommentsProps & { editor: Editor; pending: PendingComment }) {
|
|
1384
|
-
const
|
|
1665
|
+
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
|
1666
|
+
// Click-away keeps the draft (saved on every change) and the next placement composer
|
|
1667
|
+
// restores it — the flip side of dismissing without a discard warning.
|
|
1668
|
+
const [text, setText] = useState<TLRichText>(
|
|
1669
|
+
() => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
|
|
1670
|
+
)
|
|
1385
1671
|
const ref = useRef<HTMLDivElement>(null)
|
|
1386
1672
|
const msg = useTranslation()
|
|
1387
1673
|
const container = useContainer()
|
|
@@ -1402,7 +1688,7 @@ function PendingComposer({
|
|
|
1402
1688
|
if (!el || !target) return
|
|
1403
1689
|
// A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
|
|
1404
1690
|
// not "outside" — keep the draft open so the pick can insert.
|
|
1405
|
-
if (el.contains(target) || target.closest('.cmt-mention-popup')) return
|
|
1691
|
+
if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
|
|
1406
1692
|
pendingComment.set(editor, null)
|
|
1407
1693
|
}
|
|
1408
1694
|
document.addEventListener('pointerdown', onPointerDown, true)
|
|
@@ -1428,31 +1714,41 @@ function PendingComposer({
|
|
|
1428
1714
|
if (onPostComment) onPostComment(comment)
|
|
1429
1715
|
})
|
|
1430
1716
|
setText(EMPTY_COMMENT)
|
|
1717
|
+
clearCommentDraft(NEW_COMMENT_DRAFT)
|
|
1431
1718
|
pendingComment.set(editor, null)
|
|
1432
1719
|
}
|
|
1433
1720
|
|
|
1434
1721
|
return createPortal(
|
|
1435
1722
|
<div
|
|
1436
1723
|
ref={ref}
|
|
1437
|
-
className=
|
|
1724
|
+
className={[
|
|
1725
|
+
'tlui-cmt-canvas-composer',
|
|
1726
|
+
pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
|
|
1727
|
+
]
|
|
1728
|
+
.filter(Boolean)
|
|
1729
|
+
.join(' ')}
|
|
1438
1730
|
style={{ left: point.x, top: point.y }}
|
|
1439
1731
|
onPointerDown={stop}
|
|
1732
|
+
onContextMenu={stop}
|
|
1440
1733
|
onKeyDown={(e) => {
|
|
1441
1734
|
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
|
1442
1735
|
}}
|
|
1443
1736
|
>
|
|
1444
1737
|
<CommentComposer
|
|
1445
|
-
author={
|
|
1738
|
+
author={me ?? UNKNOWN_COMMENT_AUTHOR}
|
|
1446
1739
|
placeholder={msg('comments.add-placeholder')}
|
|
1447
1740
|
sendLabel={msg('comments.send')}
|
|
1448
1741
|
value={text}
|
|
1449
|
-
onChange={
|
|
1742
|
+
onChange={(value) => {
|
|
1743
|
+
setText(value)
|
|
1744
|
+
saveCommentDraft(NEW_COMMENT_DRAFT, value)
|
|
1745
|
+
}}
|
|
1450
1746
|
onSubmit={submit}
|
|
1451
1747
|
disabled={isCommentEmpty(text)}
|
|
1452
1748
|
getMentionSuggestions={getMentionSuggestions}
|
|
1453
1749
|
renderMentionSuggestion={renderMentionSuggestion}
|
|
1454
1750
|
autoFocus
|
|
1455
|
-
leading={draftAvatar}
|
|
1751
|
+
leading={draftAvatar(me?.color)}
|
|
1456
1752
|
/>
|
|
1457
1753
|
</div>,
|
|
1458
1754
|
container
|