@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.
Files changed (174) hide show
  1. package/commenting.css +280 -151
  2. package/dist-cjs/canvas/comment-body.js +1 -1
  3. package/dist-cjs/canvas/comment-body.js.map +2 -2
  4. package/dist-cjs/canvas/comment-drafts.js +54 -0
  5. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  6. package/dist-cjs/canvas/comment-render.js +2 -0
  7. package/dist-cjs/canvas/comment-render.js.map +2 -2
  8. package/dist-cjs/canvas/comment-tool.js +55 -11
  9. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  10. package/dist-cjs/canvas/comments-filter-menu.js +55 -56
  11. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  12. package/dist-cjs/canvas/comments-overflow-menu.js +21 -19
  13. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  14. package/dist-cjs/canvas/comments-overlay.js +271 -109
  15. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  16. package/dist-cjs/canvas/comments-sidebar.js +12 -18
  17. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  18. package/dist-cjs/canvas/comments-visibility-toggle.js +67 -0
  19. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  20. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  21. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  22. package/dist-cjs/canvas/state.js +11 -0
  23. package/dist-cjs/canvas/state.js.map +2 -2
  24. package/dist-cjs/canvas/thread-state.js +8 -1
  25. package/dist-cjs/canvas/thread-state.js.map +2 -2
  26. package/dist-cjs/clustering/runtime.js +119 -7
  27. package/dist-cjs/clustering/runtime.js.map +2 -2
  28. package/dist-cjs/index.d.ts +100 -39
  29. package/dist-cjs/index.js +7 -1
  30. package/dist-cjs/index.js.map +2 -2
  31. package/dist-cjs/ui/avatar.js +14 -6
  32. package/dist-cjs/ui/avatar.js.map +2 -2
  33. package/dist-cjs/ui/byline.js +4 -4
  34. package/dist-cjs/ui/byline.js.map +2 -2
  35. package/dist-cjs/ui/comment-author.js +17 -0
  36. package/dist-cjs/ui/comment-author.js.map +7 -0
  37. package/dist-cjs/ui/comment-card.js +4 -4
  38. package/dist-cjs/ui/comment-card.js.map +2 -2
  39. package/dist-cjs/ui/comment-composer.js +92 -29
  40. package/dist-cjs/ui/comment-composer.js.map +2 -2
  41. package/dist-cjs/ui/comment-mention.js +1 -1
  42. package/dist-cjs/ui/comment-mention.js.map +2 -2
  43. package/dist-cjs/ui/comment-pin.js +23 -3
  44. package/dist-cjs/ui/comment-pin.js.map +2 -2
  45. package/dist-cjs/ui/comment-text.js +1 -1
  46. package/dist-cjs/ui/comment-text.js.map +2 -2
  47. package/dist-cjs/ui/comment-thread.js +6 -6
  48. package/dist-cjs/ui/comment-thread.js.map +2 -2
  49. package/dist-cjs/ui/comments-list.js +12 -12
  50. package/dist-cjs/ui/comments-list.js.map +2 -2
  51. package/dist-cjs/ui/count-badge.js +1 -1
  52. package/dist-cjs/ui/count-badge.js.map +2 -2
  53. package/dist-cjs/ui/empty-state.js +3 -3
  54. package/dist-cjs/ui/empty-state.js.map +2 -2
  55. package/dist-cjs/ui/format-time.js +3 -0
  56. package/dist-cjs/ui/format-time.js.map +2 -2
  57. package/dist-cjs/ui/mention-list.js +8 -8
  58. package/dist-cjs/ui/mention-list.js.map +2 -2
  59. package/dist-cjs/ui/mention-suggestion.js +3 -3
  60. package/dist-cjs/ui/mention-suggestion.js.map +2 -2
  61. package/dist-cjs/ui/mention.js +1 -1
  62. package/dist-cjs/ui/mention.js.map +2 -2
  63. package/dist-cjs/ui/reaction.js +11 -4
  64. package/dist-cjs/ui/reaction.js.map +2 -2
  65. package/dist-cjs/ui/reactions.js +10 -2
  66. package/dist-cjs/ui/reactions.js.map +2 -2
  67. package/dist-cjs/ui/send-button.js +1 -1
  68. package/dist-cjs/ui/send-button.js.map +2 -2
  69. package/dist-esm/canvas/comment-body.mjs +1 -1
  70. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  71. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  72. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  73. package/dist-esm/canvas/comment-render.mjs +2 -0
  74. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  75. package/dist-esm/canvas/comment-tool.mjs +56 -12
  76. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  77. package/dist-esm/canvas/comments-filter-menu.mjs +55 -56
  78. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  79. package/dist-esm/canvas/comments-overflow-menu.mjs +22 -19
  80. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  81. package/dist-esm/canvas/comments-overlay.mjs +289 -111
  82. package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
  83. package/dist-esm/canvas/comments-sidebar.mjs +15 -21
  84. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  85. package/dist-esm/canvas/comments-visibility-toggle.mjs +47 -0
  86. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  87. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  88. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  89. package/dist-esm/canvas/state.mjs +11 -0
  90. package/dist-esm/canvas/state.mjs.map +2 -2
  91. package/dist-esm/canvas/thread-state.mjs +8 -1
  92. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  93. package/dist-esm/clustering/runtime.mjs +119 -7
  94. package/dist-esm/clustering/runtime.mjs.map +2 -2
  95. package/dist-esm/index.d.mts +100 -39
  96. package/dist-esm/index.mjs +11 -1
  97. package/dist-esm/index.mjs.map +2 -2
  98. package/dist-esm/ui/avatar.mjs +14 -6
  99. package/dist-esm/ui/avatar.mjs.map +2 -2
  100. package/dist-esm/ui/byline.mjs +4 -4
  101. package/dist-esm/ui/byline.mjs.map +2 -2
  102. package/dist-esm/ui/comment-author.mjs +1 -0
  103. package/dist-esm/ui/comment-author.mjs.map +7 -0
  104. package/dist-esm/ui/comment-card.mjs +4 -4
  105. package/dist-esm/ui/comment-card.mjs.map +2 -2
  106. package/dist-esm/ui/comment-composer.mjs +93 -30
  107. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  108. package/dist-esm/ui/comment-mention.mjs +1 -1
  109. package/dist-esm/ui/comment-mention.mjs.map +2 -2
  110. package/dist-esm/ui/comment-pin.mjs +23 -3
  111. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  112. package/dist-esm/ui/comment-text.mjs +1 -1
  113. package/dist-esm/ui/comment-text.mjs.map +2 -2
  114. package/dist-esm/ui/comment-thread.mjs +6 -6
  115. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  116. package/dist-esm/ui/comments-list.mjs +12 -12
  117. package/dist-esm/ui/comments-list.mjs.map +2 -2
  118. package/dist-esm/ui/count-badge.mjs +1 -1
  119. package/dist-esm/ui/count-badge.mjs.map +2 -2
  120. package/dist-esm/ui/empty-state.mjs +3 -3
  121. package/dist-esm/ui/empty-state.mjs.map +2 -2
  122. package/dist-esm/ui/format-time.mjs +3 -0
  123. package/dist-esm/ui/format-time.mjs.map +2 -2
  124. package/dist-esm/ui/mention-list.mjs +8 -8
  125. package/dist-esm/ui/mention-list.mjs.map +2 -2
  126. package/dist-esm/ui/mention-suggestion.mjs +3 -3
  127. package/dist-esm/ui/mention-suggestion.mjs.map +2 -2
  128. package/dist-esm/ui/mention.mjs +1 -1
  129. package/dist-esm/ui/mention.mjs.map +2 -2
  130. package/dist-esm/ui/reaction.mjs +11 -4
  131. package/dist-esm/ui/reaction.mjs.map +2 -2
  132. package/dist-esm/ui/reactions.mjs +10 -2
  133. package/dist-esm/ui/reactions.mjs.map +2 -2
  134. package/dist-esm/ui/send-button.mjs +1 -1
  135. package/dist-esm/ui/send-button.mjs.map +2 -2
  136. package/package.json +3 -3
  137. package/src/canvas/canvas.css +79 -36
  138. package/src/canvas/comment-body.tsx +1 -1
  139. package/src/canvas/comment-drafts.test.ts +55 -0
  140. package/src/canvas/comment-drafts.ts +46 -0
  141. package/src/canvas/comment-render.test.ts +1 -1
  142. package/src/canvas/comment-render.ts +7 -2
  143. package/src/canvas/comment-tool.tsx +87 -15
  144. package/src/canvas/comments-filter-menu.tsx +14 -16
  145. package/src/canvas/comments-overflow-menu.tsx +11 -15
  146. package/src/canvas/comments-overlay.tsx +399 -141
  147. package/src/canvas/comments-sidebar.tsx +27 -28
  148. package/src/canvas/comments-visibility-toggle.tsx +51 -0
  149. package/src/canvas/sidebar-filters.ts +4 -3
  150. package/src/canvas/state.ts +22 -0
  151. package/src/canvas/thread-state.ts +13 -1
  152. package/src/clustering/runtime.test.ts +183 -7
  153. package/src/clustering/runtime.ts +164 -15
  154. package/src/index.ts +6 -0
  155. package/src/ui/avatar.tsx +15 -11
  156. package/src/ui/byline.tsx +6 -5
  157. package/src/ui/comment-author.ts +15 -0
  158. package/src/ui/comment-card.tsx +6 -5
  159. package/src/ui/comment-composer.tsx +122 -33
  160. package/src/ui/comment-mention.ts +2 -2
  161. package/src/ui/comment-pin.tsx +35 -4
  162. package/src/ui/comment-text.tsx +1 -1
  163. package/src/ui/comment-thread.tsx +8 -6
  164. package/src/ui/comments-list.tsx +19 -14
  165. package/src/ui/comments.css +201 -115
  166. package/src/ui/count-badge.tsx +1 -1
  167. package/src/ui/empty-state.tsx +3 -3
  168. package/src/ui/format-time.ts +5 -0
  169. package/src/ui/mention-list.tsx +13 -16
  170. package/src/ui/mention-suggestion.tsx +3 -3
  171. package/src/ui/mention.tsx +1 -1
  172. package/src/ui/reaction.tsx +6 -3
  173. package/src/ui/reactions.tsx +6 -2
  174. 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 { UNKNOWN_AUTHOR } from './comment-render'
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 { anchorPagePoint, regionPinPoint, shapeAnchorAt } from './thread-state'
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 a display name, or `undefined` when the id can't be named. */
88
- resolveName(id: string): string | undefined
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={props.resolveName} />
200
+ <CommentBody richText={comment.body} resolveName={resolveName} />
149
201
  )
150
202
  return {
151
- author: props.resolveName(comment.authorId) ?? UNKNOWN_AUTHOR,
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, wheel and hover pass through to the canvas beneath (these
181
- // events bubble up from the pointer-interactive markers to this layer root).
182
- usePassThroughWheelEvents(layerRef)
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 whose anchor has moved (by any means drag, nudge, align, undo, a collaborator)
190
- // since the rendered clustering was built. They pop out of clustering and render as live pins,
191
- // and only rejoin at the next zoom event, when everything re-clusters anyway.
192
- const [movedThreadIds, setMovedThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
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) => !movedThreadIds.has(thread.id)),
253
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
200
254
  openThreadId.get(editor),
201
255
  impreciseShapeAnchor
202
256
  ),
203
- [editor, threads, impreciseShapeAnchor, movedThreadIds]
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
- // Re-clustering only applies while zooming: a rebuilt model (thread added, moved, or closed)
217
- // is held as `latestModel` and adopted on the next zoom change, so pins never re-flow into
218
- // clusters under a static camera. Until adoption, threads the rendered model doesn't know
219
- // about show as plain unclustered pins (`orphanThreads`). Exception: a rebuild that *removed*
220
- // leaves (thread deleted or opened, page changed) is adopted immediately, so stale pins and
221
- // badge counts never linger.
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
- if (
225
- renderedModel !== latestModel &&
226
- (adoptOnRebuild.current || hasRemovedLeaves(renderedModel.table, latestModel.table))
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
- // Carryover seed: events inside their hysteresis band inherit the outgoing partition's
230
- // merged/unmerged state instead of the geometric-mean tiebreak, so untouched pins never
231
- // snap together (or apart) just because the model was swapped. Idempotent, so safe to
232
- // run during render.
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 ghosts.
239
- // Marking it moved excludes it from the cluster input, which reads as a removal above and
240
- // re-clusters the rest of its pile immediately; the pin itself renders live below.
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
- // eslint-disable-next-line no-console
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
- setMovedThreadIds(next)
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 (movedThreadIds.size === 0) return
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
- setMovedThreadIds(EMPTY_SET)
340
+ setHeldThreadIds(EMPTY_SET)
262
341
  })
263
- }, [movedThreadIds, editor])
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 renderedIds = new Set(clusterModel.table.leaves.map((leaf) => leaf.id))
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) && !renderedIds.has(thread.id))
284
- }, [clusterModel, latestModel, threads])
285
- const movedThreads = useMemo(
286
- () => threads.filter((thread) => movedThreadIds.has(thread.id) && thread.id !== openId),
287
- [threads, movedThreadIds, openId]
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 cursor, not the raw zoom: onCamera runs on every zoom tick (two
290
- // O(1) threshold checks against the event table) but returns the same integer until a merge
291
- // or split event actually fires — so this component only re-renders on cluster changes, not
292
- // on every camera frame.
293
- const clusterCursor = useValue(
294
- 'comment cluster cursor',
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.k
387
+ return clusterModel.runtime.version
298
388
  },
299
389
  [clusterModel, editor]
300
390
  )
301
391
  const visibleNodes = useMemo(() => {
302
- const nodes = Array.from(clusterModel.runtime.getVisible().values())
303
- // eslint-disable-next-line no-console
304
- console.debug(
305
- `[comments] cluster cursor k=${clusterCursor} re-rendering ${nodes.length} visible nodes`
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 (matching Figma). Skipped while typing so
397
- // it never fires from inside a composer. Physical `KeyC` (layout-independent) with shift only.
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
- {movedThreads.map((thread) => (
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
- className="cmt-canvas-cluster"
822
+ ref={badgeRef}
823
+ className="tlui-cmt-canvas-cluster"
737
824
  style={{ left: point.x, top: point.y }}
738
- onPointerDown={stop}
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
- <div ref={ref} className="cmt-canvas-popover" style={style} onPointerDown={stop}>
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
- className={movable ? 'cmt-canvas-region cmt-canvas-region--movable' : 'cmt-canvas-region'}
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
- resolveName,
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
- const [reply, setReply] = useState<TLRichText>(EMPTY_COMMENT)
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
- (c) => c.x !== regionOptions.pinCorner.x || c.y !== regionOptions.pinCorner.y
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<{ startX: number; startY: number; moved: boolean } | null>(null)
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 (target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .cmt-mention-popup'))
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
- return pagePoint ? editor.pageToViewport(pagePoint) : null
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
- <button
1160
- className="cmt-thread__action"
1161
- title={msg('comments.edit')}
1162
- onClick={() => startEdit(comment)}
1163
- >
1164
- <TldrawUiIcon icon="dots-horizontal" label={msg('comments.edit')} small />
1165
- </button>
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
- <button
1189
- className="cmt-thread__action"
1190
- title={msg('comments.delete')}
1191
- onClick={deleteThread}
1192
- >
1193
- <TldrawUiIcon icon="trash" label={msg('comments.delete')} small />
1194
- </button>
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(resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR)
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
- dragRef.current = { startX: e.clientX, startY: e.clientY, moved: false }
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
- setDragPagePoint(editor.screenToPage({ x: e.clientX, y: e.clientY }))
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 pagePoint = editor.screenToPage({ x: e.clientX, y: e.clientY })
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 - regionOptions.pinCorner.x * thread.anchor.w,
1255
- y: pagePoint.y - regionOptions.pinCorner.y * thread.anchor.h,
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
- ? regionPinPoint(resizeBounds, regionOptions.pinCorner)
1270
- : dragPagePoint
1271
- const renderPoint = livePinPage ? editor.pageToViewport(livePinPage) : point
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 - regionOptions.pinCorner.x * regionAnchor.w,
1281
- y: dragPagePoint.y - regionOptions.pinCorner.y * regionAnchor.h,
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
- () => putCommentRecords(editor, [{ ...thread, anchor: { type: 'region', ...bounds } }]),
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={open ? 'cmt-canvas-pin cmt-canvas-pin--open' : 'cmt-canvas-pin'}
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
- style={{ left: renderPoint.x + 36, top: renderPoint.y - 28 }}
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
- resolveName(thread.resolved.by) ?? UNKNOWN_AUTHOR
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: resolveName(currentUserId) ?? UNKNOWN_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: setReply,
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
- resolveName,
1622
+ resolveAuthor,
1380
1623
  onPostComment,
1381
1624
  getMentionSuggestions,
1382
1625
  renderMentionSuggestion,
1383
1626
  }: CanvasCommentsProps & { editor: Editor; pending: PendingComment }) {
1384
- const [text, setText] = useState<TLRichText>(EMPTY_COMMENT)
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="cmt-canvas-composer"
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={currentUserId ? (resolveName(currentUserId) ?? UNKNOWN_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={setText}
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