@tldraw/commenting 0.0.0-bootstrap → 5.3.0-internal.1640468db8fd

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