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