@tldraw/commenting 5.4.2 → 5.5.0-canary.0241f11f2e99

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 (95) hide show
  1. package/commenting.css +0 -32
  2. package/dist-cjs/canvas/anchor-lifecycle.js +4 -5
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/canvas/cluster-fade.js +1 -1
  5. package/dist-cjs/canvas/cluster-fade.js.map +2 -2
  6. package/dist-cjs/canvas/cluster-model.js +23 -31
  7. package/dist-cjs/canvas/cluster-model.js.map +2 -2
  8. package/dist-cjs/canvas/comment-render.js +19 -19
  9. package/dist-cjs/canvas/comment-render.js.map +2 -2
  10. package/dist-cjs/canvas/comment-store.js +10 -5
  11. package/dist-cjs/canvas/comment-store.js.map +2 -2
  12. package/dist-cjs/canvas/comments-overlay.js +12 -16
  13. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  14. package/dist-cjs/canvas/comments-sidebar.js +1 -7
  15. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  16. package/dist-cjs/canvas/hooks.js.map +1 -1
  17. package/dist-cjs/canvas/thread-pin.js +14 -26
  18. package/dist-cjs/canvas/thread-pin.js.map +2 -2
  19. package/dist-cjs/canvas/thread-stack.js +2 -6
  20. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  21. package/dist-cjs/canvas/thread-state.js +13 -0
  22. package/dist-cjs/canvas/thread-state.js.map +2 -2
  23. package/dist-cjs/canvas/thread-view.js +67 -78
  24. package/dist-cjs/canvas/thread-view.js.map +2 -2
  25. package/dist-cjs/clustering/computeClusterTable.js +2 -9
  26. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  27. package/dist-cjs/clustering/replay.js +10 -23
  28. package/dist-cjs/clustering/replay.js.map +2 -2
  29. package/dist-cjs/clustering/runtime.js +9 -15
  30. package/dist-cjs/clustering/runtime.js.map +2 -2
  31. package/dist-cjs/index.js +1 -1
  32. package/dist-cjs/ui/comment-composer.js +2 -4
  33. package/dist-cjs/ui/comment-composer.js.map +2 -2
  34. package/dist-cjs/ui/format-time.js +20 -8
  35. package/dist-cjs/ui/format-time.js.map +2 -2
  36. package/dist-cjs/ui/reaction.js +5 -15
  37. package/dist-cjs/ui/reaction.js.map +2 -2
  38. package/dist-esm/canvas/anchor-lifecycle.mjs +5 -6
  39. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  40. package/dist-esm/canvas/cluster-fade.mjs +1 -1
  41. package/dist-esm/canvas/cluster-fade.mjs.map +2 -2
  42. package/dist-esm/canvas/cluster-model.mjs +24 -32
  43. package/dist-esm/canvas/cluster-model.mjs.map +2 -2
  44. package/dist-esm/canvas/comment-render.mjs +19 -19
  45. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  46. package/dist-esm/canvas/comment-store.mjs +10 -5
  47. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  48. package/dist-esm/canvas/comments-overlay.mjs +13 -17
  49. package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
  50. package/dist-esm/canvas/comments-sidebar.mjs +3 -14
  51. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  52. package/dist-esm/canvas/hooks.mjs.map +1 -1
  53. package/dist-esm/canvas/thread-pin.mjs +16 -26
  54. package/dist-esm/canvas/thread-pin.mjs.map +2 -2
  55. package/dist-esm/canvas/thread-stack.mjs +3 -7
  56. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  57. package/dist-esm/canvas/thread-state.mjs +13 -0
  58. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  59. package/dist-esm/canvas/thread-view.mjs +67 -78
  60. package/dist-esm/canvas/thread-view.mjs.map +2 -2
  61. package/dist-esm/clustering/computeClusterTable.mjs +2 -9
  62. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  63. package/dist-esm/clustering/replay.mjs +10 -23
  64. package/dist-esm/clustering/replay.mjs.map +2 -2
  65. package/dist-esm/clustering/runtime.mjs +9 -15
  66. package/dist-esm/clustering/runtime.mjs.map +2 -2
  67. package/dist-esm/index.mjs +1 -1
  68. package/dist-esm/ui/comment-composer.mjs +2 -4
  69. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  70. package/dist-esm/ui/format-time.mjs +20 -8
  71. package/dist-esm/ui/format-time.mjs.map +2 -2
  72. package/dist-esm/ui/reaction.mjs +7 -15
  73. package/dist-esm/ui/reaction.mjs.map +2 -2
  74. package/package.json +5 -5
  75. package/src/canvas/anchor-lifecycle.ts +5 -6
  76. package/src/canvas/cluster-fade.ts +6 -6
  77. package/src/canvas/cluster-model.test.ts +64 -1
  78. package/src/canvas/cluster-model.ts +28 -33
  79. package/src/canvas/comment-render.ts +30 -28
  80. package/src/canvas/comment-store.ts +16 -6
  81. package/src/canvas/comments-overlay.tsx +18 -19
  82. package/src/canvas/comments-sidebar.tsx +3 -18
  83. package/src/canvas/hooks.ts +1 -1
  84. package/src/canvas/thread-pin.tsx +21 -31
  85. package/src/canvas/thread-stack.tsx +3 -7
  86. package/src/canvas/thread-state.test.ts +35 -0
  87. package/src/canvas/thread-state.ts +27 -0
  88. package/src/canvas/thread-view.tsx +93 -95
  89. package/src/clustering/computeClusterTable.ts +3 -20
  90. package/src/clustering/replay.ts +10 -23
  91. package/src/clustering/runtime.ts +9 -15
  92. package/src/ui/comment-composer.tsx +2 -4
  93. package/src/ui/comments.css +0 -32
  94. package/src/ui/format-time.ts +24 -11
  95. package/src/ui/reaction.tsx +11 -23
@@ -33,6 +33,7 @@ import { openThreadId } from './state'
33
33
  import { ThreadPreview, useMarkerPreview } from './thread-preview'
34
34
  import {
35
35
  anchorPagePoint,
36
+ anchorViewportPoint,
36
37
  commentTargetShapeAt,
37
38
  impreciseShapePinInset,
38
39
  isBoxInInflatedViewport,
@@ -40,6 +41,7 @@ import {
40
41
  REGION_PIN_CORNER,
41
42
  regionAnchorPinCorner,
42
43
  regionPinPoint,
44
+ regionWithPinAt,
43
45
  shapeAnchorAt,
44
46
  } from './thread-state'
45
47
  import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
@@ -107,7 +109,7 @@ export const ThreadPin = memo(function ThreadPin({
107
109
  // A region resizes from its corners — every corner but the pin's own, which the pin owns.
108
110
  const resizeHandles = useMemo(
109
111
  () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
110
- [pinCorner]
112
+ [pinCorner.x, pinCorner.y]
111
113
  )
112
114
  const dragRef = useRef<{
113
115
  startX: number
@@ -165,13 +167,8 @@ export const ThreadPin = memo(function ThreadPin({
165
167
  'pin point',
166
168
  () => {
167
169
  if (thread.pageId !== editor.getCurrentPageId()) return null
168
- const pagePoint = anchorPagePoint(editor, thread.anchor)
169
- if (!pagePoint) return null
170
- const viewportPoint = editor.pageToViewport(pagePoint)
171
- const inset = impreciseShapePinInset(editor, thread.anchor)
172
- const point = inset
173
- ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y }
174
- : viewportPoint
170
+ const point = anchorViewportPoint(editor, thread.anchor)
171
+ if (!point) return null
175
172
  // Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
176
173
  // region thread stays mounted while any part of its box is on screen, since the box renders here too.
177
174
  if (!exemptFromCull) {
@@ -187,6 +184,9 @@ export const ThreadPin = memo(function ThreadPin({
187
184
  )
188
185
  if (!point) return null
189
186
 
187
+ const toggleOpen = () =>
188
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
189
+
190
190
  const PinContent = options.components.PinContent
191
191
  const threadAuthor = resolveAuthor(thread.createdBy)
192
192
  const pinContent = PinContent ? (
@@ -247,28 +247,28 @@ export const ThreadPin = memo(function ThreadPin({
247
247
  editor.setHintingShapes(hit ? [hit.id] : [])
248
248
  }
249
249
  }
250
- // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
251
- // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
252
- const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
250
+ // Release capture and take the drag record; null when no drag was in progress.
251
+ const takeDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
253
252
  const drag = dragRef.current
254
253
  dragRef.current = null
255
254
  if (e.currentTarget.hasPointerCapture(e.pointerId)) {
256
255
  e.currentTarget.releasePointerCapture(e.pointerId)
257
256
  }
258
- if (!drag) return
257
+ return drag
258
+ }
259
+ // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
260
+ // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
261
+ const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
262
+ if (!takeDrag(e)) return
259
263
  setDragPagePoint(null)
260
264
  editor.setHintingShapes([])
261
265
  }
262
266
  const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
263
- const drag = dragRef.current
264
- dragRef.current = null
265
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
266
- e.currentTarget.releasePointerCapture(e.pointerId)
267
- }
267
+ const drag = takeDrag(e)
268
268
  if (!drag) return
269
269
  editor.setHintingShapes([])
270
270
  if (!drag.moved) {
271
- openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
271
+ toggleOpen()
272
272
  return
273
273
  }
274
274
  const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
@@ -276,12 +276,7 @@ export const ThreadPin = memo(function ThreadPin({
276
276
  setDragPagePoint(null)
277
277
  let anchor: TLCommentThread['anchor']
278
278
  if (thread.anchor.type === 'region') {
279
- // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
280
- anchor = {
281
- ...thread.anchor,
282
- x: pagePoint.x - pinCorner.x * thread.anchor.w,
283
- y: pagePoint.y - pinCorner.y * thread.anchor.h,
284
- }
279
+ anchor = regionWithPinAt(thread.anchor, pinCorner, pagePoint)
285
280
  } else {
286
281
  const hit = commentTargetShapeAt(editor, pagePoint)
287
282
  anchor = hit
@@ -316,11 +311,7 @@ export const ThreadPin = memo(function ThreadPin({
316
311
  const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
317
312
  const movedRegion =
318
313
  regionAnchor && dragPagePoint
319
- ? {
320
- ...regionAnchor,
321
- x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
322
- y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
323
- }
314
+ ? regionWithPinAt(regionAnchor, pinCorner, dragPagePoint)
324
315
  : regionAnchor
325
316
  const regionBoxBounds = resizeBounds ?? movedRegion
326
317
  const commitResize = (bounds: BoxModel) => {
@@ -374,8 +365,7 @@ export const ThreadPin = memo(function ThreadPin({
374
365
  // from a drag), so only take keyboard-synthesised clicks here — those report
375
366
  // `detail === 0` — or the thread would toggle twice per mouse click.
376
367
  onClick={(e) => {
377
- if (e.detail !== 0) return
378
- openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
368
+ if (e.detail === 0) toggleOpen()
379
369
  }}
380
370
  onPointerEnter={previewHandlers.onPointerEnter}
381
371
  onPointerLeave={previewHandlers.onPointerLeave}
@@ -15,7 +15,7 @@ import { useCommentingOptions } from './options'
15
15
  import { pinStackKey } from './pin-stacking'
16
16
  import { openStackId, openThreadId } from './state'
17
17
  import { ThreadPreview, useMarkerPreview } from './thread-preview'
18
- import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
18
+ import { anchorPagePoint, anchorViewportPoint } from './thread-state'
19
19
  import {
20
20
  POPOVER_OFFSET,
21
21
  ThreadPopover,
@@ -73,11 +73,7 @@ export const ThreadStackPin = memo(function ThreadStackPin({
73
73
  // The badge hangs off its anchor point bottom-left like a pin, and applies the same imprecise-shape
74
74
  // inset the pins it stands in for would (see ThreadPin) — otherwise the marker would snap from tucked
75
75
  // inside the shape to the raw corner the moment a second comment turns a pin into a stack.
76
- const pagePoint = anchorPagePoint(editor, first.anchor)
77
- if (!pagePoint) return null
78
- const viewportPoint = editor.pageToViewport(pagePoint)
79
- const inset = impreciseShapePinInset(editor, first.anchor)
80
- return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
76
+ return anchorViewportPoint(editor, first.anchor)
81
77
  },
82
78
  [editor, threads]
83
79
  )
@@ -132,7 +128,7 @@ export const ThreadStackPin = memo(function ThreadStackPin({
132
128
  // The expanded thread gains a header that pushes its first comment down from where the hover preview
133
129
  // showed it. When that thread is the list's first, lift the whole list so its "You" holds position
134
130
  // across hover -> open. Only the first entry — lifting can't also hold a lower thread's neighbours.
135
- const liftForHeader = threads[0]?.id === openId ? 42 : 0
131
+ const liftForHeader = threads[0].id === openId ? 42 : 0
136
132
 
137
133
  return (
138
134
  <>
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Box,
3
3
  commentSchemaRecords,
4
+ createCommentThread,
4
5
  createShapeId,
5
6
  createTLSchema,
6
7
  createTLStore,
@@ -8,6 +9,7 @@ import {
8
9
  defaultShapeUtils,
9
10
  defaultTools,
10
11
  Editor,
12
+ PageRecordType,
11
13
  TLShapeId,
12
14
  } from 'tldraw'
13
15
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -16,6 +18,7 @@ import {
16
18
  anchorPagePoint,
17
19
  commentCenterScreenOffset,
18
20
  commentTargetShapeAt,
21
+ focusThread,
19
22
  impreciseShapePinInset,
20
23
  IMPRECISE_PIN_INSET_PX,
21
24
  isBoxInInflatedViewport,
@@ -258,3 +261,35 @@ describe('isBoxInInflatedViewport', () => {
258
261
  expect(isBoxInInflatedViewport(editor, box)).toBe(true)
259
262
  })
260
263
  })
264
+
265
+ describe('focusThread', () => {
266
+ it('records a cross-page jump as its own undo step', () => {
267
+ const page1Id = editor.getCurrentPageId()
268
+ const page2Id = PageRecordType.createId()
269
+ const boxId = createShapeId()
270
+
271
+ // Set up the second page outside of history so the only undoable steps are the ones the
272
+ // test creates: one edit on page 1, then the jump to the thread on page 2.
273
+ editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
274
+ editor.clearHistory()
275
+
276
+ editor.markHistoryStoppingPoint('create box')
277
+ editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
278
+
279
+ const thread = createCommentThread({
280
+ pageId: page2Id,
281
+ anchor: { type: 'point', x: 50, y: 50 },
282
+ createdBy: 'me',
283
+ })
284
+ focusThread(editor, thread)
285
+ expect(editor.getCurrentPageId()).toBe(page2Id)
286
+
287
+ // The first undo only takes us back to page 1; the edit made there is still intact.
288
+ editor.undo()
289
+ expect(editor.getCurrentPageId()).toBe(page1Id)
290
+ expect(editor.getShape(boxId)).toBeDefined()
291
+
292
+ editor.undo()
293
+ expect(editor.getShape(boxId)).toBeUndefined()
294
+ })
295
+ })
@@ -101,6 +101,16 @@ export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CO
101
101
  }
102
102
  }
103
103
 
104
+ /** The inverse of {@link regionPinPoint}: a region translated so its pin corner lands on `pinPage`;
105
+ * size unchanged. */
106
+ export function regionWithPinAt(
107
+ region: Extract<TLCommentAnchor, { type: 'region' }>,
108
+ pinCorner: VecLike,
109
+ pinPage: VecLike
110
+ ): Extract<TLCommentAnchor, { type: 'region' }> {
111
+ return { ...region, x: pinPage.x - pinCorner.x * region.w, y: pinPage.y - pinCorner.y * region.h }
112
+ }
113
+
104
114
  /**
105
115
  * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. Imprecise shape
106
116
  * anchors use {@link CommentingOptions.impreciseShapeAnchor} rather than the stored `x`/`y`.
@@ -133,6 +143,22 @@ export function anchorPagePoint(
133
143
  }
134
144
  }
135
145
 
146
+ /**
147
+ * Where a thread's marker is drawn, in viewport space: {@link anchorPagePoint} projected through
148
+ * the camera, plus the imprecise-shape inset. Null when the anchor resolves to no point.
149
+ * @internal
150
+ */
151
+ export function anchorViewportPoint(
152
+ editor: Editor,
153
+ anchor: TLCommentAnchor
154
+ ): { x: number; y: number } | null {
155
+ const pagePoint = anchorPagePoint(editor, anchor)
156
+ if (!pagePoint) return null
157
+ const viewportPoint = editor.pageToViewport(pagePoint)
158
+ const inset = impreciseShapePinInset(editor, anchor)
159
+ return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
160
+ }
161
+
136
162
  /**
137
163
  * The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
138
164
  *
@@ -184,6 +210,7 @@ export function shapeAnchorAt(
184
210
  /** Open a thread and bring it into view — switch to its page if needed, then center its pin. @public */
185
211
  export function focusThread(editor: Editor, thread: TLCommentThread): void {
186
212
  if (thread.pageId !== editor.getCurrentPageId()) {
213
+ editor.markHistoryStoppingPoint('change-page')
187
214
  editor.setCurrentPage(thread.pageId as any)
188
215
  }
189
216
  openThreadId.set(editor, thread.id)
@@ -53,6 +53,68 @@ import { openThreadId } from './state'
53
53
 
54
54
  const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
55
55
 
56
+ /** The "⋯" dropdown used for both a comment's and the thread's actions. */
57
+ function MoreOptionsMenu({
58
+ id,
59
+ moreFor,
60
+ children,
61
+ }: {
62
+ id: string
63
+ /** The comment whose ⋯ button this is, so `startEdit` can find it again to return focus. */
64
+ moreFor?: string
65
+ children: ReactNode
66
+ }) {
67
+ const msg = useTranslation()
68
+ return (
69
+ <TldrawUiDropdownMenuRoot id={id}>
70
+ <TldrawUiDropdownMenuTrigger>
71
+ <TldrawUiButton
72
+ type="icon"
73
+ tooltip={msg('comments.more-options')}
74
+ title={msg('comments.more-options')}
75
+ className="tlui-cmt-thread__action"
76
+ data-cmt-more-for={moreFor}
77
+ >
78
+ <TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
79
+ </TldrawUiButton>
80
+ </TldrawUiDropdownMenuTrigger>
81
+ <TldrawUiDropdownMenuContent
82
+ className="tlui-cmt-menu"
83
+ side="bottom"
84
+ align="start"
85
+ alignOffset={0}
86
+ sideOffset={4}
87
+ >
88
+ <TldrawUiDropdownMenuGroup>{children}</TldrawUiDropdownMenuGroup>
89
+ </TldrawUiDropdownMenuContent>
90
+ </TldrawUiDropdownMenuRoot>
91
+ )
92
+ }
93
+
94
+ function MenuItem({
95
+ label,
96
+ danger,
97
+ noClose,
98
+ onClick,
99
+ }: {
100
+ label: string
101
+ danger?: boolean
102
+ noClose?: boolean
103
+ onClick(): void
104
+ }) {
105
+ return (
106
+ <TldrawUiDropdownMenuItem noClose={noClose}>
107
+ <button
108
+ type="button"
109
+ className={danger ? 'tlui-cmt-menu-item tlui-cmt-menu-item--danger' : 'tlui-cmt-menu-item'}
110
+ onClick={onClick}
111
+ >
112
+ <span>{label}</span>
113
+ </button>
114
+ </TldrawUiDropdownMenuItem>
115
+ )
116
+ }
117
+
56
118
  /**
57
119
  * A name-only view of an author resolver, for the mention/rich-text paths. Stable identity, so
58
120
  * `CommentBody`'s memoized render doesn't recompute on every render of its host.
@@ -352,9 +414,7 @@ export const ThreadView = memo(function ThreadView({
352
414
  // No `currentUserId` check: unlike a resolve, a delete stamps nothing on the record, so who may
353
415
  // make it is `canModifyComment`'s call alone (which withholds it from an unidentified viewer by
354
416
  // default).
355
- const removeThread = () => {
356
- deleteThread(editor, thread)
357
- }
417
+ const removeThread = () => deleteThread(editor, thread)
358
418
 
359
419
  const ThreadActions = options.components.ThreadActions
360
420
  // The host's URL for this thread. Its presence is what puts "copy link" in the header menu.
@@ -428,55 +488,21 @@ export const ThreadView = memo(function ThreadView({
428
488
  canComment && (
429
489
  <>
430
490
  {(permissions?.edit || permissions?.delete) && (
431
- <TldrawUiDropdownMenuRoot id={`comment-actions-${comment.id}`}>
432
- <TldrawUiDropdownMenuTrigger>
433
- <TldrawUiButton
434
- type="icon"
435
- tooltip={msg('comments.more-options')}
436
- title={msg('comments.more-options')}
437
- className="tlui-cmt-thread__action"
438
- data-cmt-more-for={comment.id}
439
- >
440
- <TldrawUiIcon
441
- icon="dots-vertical"
442
- label={msg('comments.more-options')}
443
- small
444
- />
445
- </TldrawUiButton>
446
- </TldrawUiDropdownMenuTrigger>
447
- <TldrawUiDropdownMenuContent
448
- className="tlui-cmt-menu"
449
- side="bottom"
450
- align="start"
451
- alignOffset={0}
452
- sideOffset={4}
453
- >
454
- <TldrawUiDropdownMenuGroup>
455
- {permissions?.edit && (
456
- <TldrawUiDropdownMenuItem>
457
- <button
458
- type="button"
459
- className="tlui-cmt-menu-item"
460
- onClick={() => startEdit(comment, { fromMoreMenu: true })}
461
- >
462
- <span>{msg('comments.edit')}</span>
463
- </button>
464
- </TldrawUiDropdownMenuItem>
465
- )}
466
- {permissions?.delete && (
467
- <TldrawUiDropdownMenuItem>
468
- <button
469
- type="button"
470
- className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
471
- onClick={() => deleteComment(editor, comment)}
472
- >
473
- <span>{msg('action.delete')}</span>
474
- </button>
475
- </TldrawUiDropdownMenuItem>
476
- )}
477
- </TldrawUiDropdownMenuGroup>
478
- </TldrawUiDropdownMenuContent>
479
- </TldrawUiDropdownMenuRoot>
491
+ <MoreOptionsMenu id={`comment-actions-${comment.id}`} moreFor={comment.id}>
492
+ {permissions?.edit && (
493
+ <MenuItem
494
+ label={msg('comments.edit')}
495
+ onClick={() => startEdit(comment, { fromMoreMenu: true })}
496
+ />
497
+ )}
498
+ {permissions?.delete && (
499
+ <MenuItem
500
+ label={msg('action.delete')}
501
+ danger
502
+ onClick={() => deleteComment(editor, comment)}
503
+ />
504
+ )}
505
+ </MoreOptionsMenu>
480
506
  )}
481
507
  {/* Last so react is always the rightmost pill. */}
482
508
  <CommentReactionPicker comment={comment} currentUserId={currentUserId} />
@@ -495,49 +521,21 @@ export const ThreadView = memo(function ThreadView({
495
521
  {/* Host verbs — assign, link a ticket — sit ahead of the built-in actions. */}
496
522
  {ThreadActions && <ThreadActions thread={thread} comments={comments} />}
497
523
  {(threadHref !== undefined || canDeleteThread) && (
498
- <TldrawUiDropdownMenuRoot id={`comment-thread-actions-${thread.id}`}>
499
- <TldrawUiDropdownMenuTrigger>
500
- <TldrawUiButton
501
- type="icon"
502
- tooltip={msg('comments.more-options')}
503
- title={msg('comments.more-options')}
504
- className="tlui-cmt-thread__action"
505
- >
506
- <TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
507
- </TldrawUiButton>
508
- </TldrawUiDropdownMenuTrigger>
509
- <TldrawUiDropdownMenuContent
510
- className="tlui-cmt-menu"
511
- side="bottom"
512
- align="start"
513
- alignOffset={0}
514
- sideOffset={4}
515
- >
516
- <TldrawUiDropdownMenuGroup>
517
- {/* A link is a read affordance: offered to anyone who can see the thread,
518
- including a viewer who can't comment. `noClose` keeps the menu up so the
519
- item can confirm the copy in place. */}
520
- {threadHref !== undefined && (
521
- <TldrawUiDropdownMenuItem noClose>
522
- <button type="button" className="tlui-cmt-menu-item" onClick={copyThreadLink}>
523
- <span>{msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}</span>
524
- </button>
525
- </TldrawUiDropdownMenuItem>
526
- )}
527
- {canDeleteThread && (
528
- <TldrawUiDropdownMenuItem>
529
- <button
530
- type="button"
531
- className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
532
- onClick={removeThread}
533
- >
534
- <span>{msg('comments.delete')}</span>
535
- </button>
536
- </TldrawUiDropdownMenuItem>
537
- )}
538
- </TldrawUiDropdownMenuGroup>
539
- </TldrawUiDropdownMenuContent>
540
- </TldrawUiDropdownMenuRoot>
524
+ <MoreOptionsMenu id={`comment-thread-actions-${thread.id}`}>
525
+ {/* A link is a read affordance: offered to anyone who can see the thread,
526
+ including a viewer who can't comment. `noClose` keeps the menu up so the
527
+ item can confirm the copy in place. */}
528
+ {threadHref !== undefined && (
529
+ <MenuItem
530
+ label={msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}
531
+ noClose
532
+ onClick={copyThreadLink}
533
+ />
534
+ )}
535
+ {canDeleteThread && (
536
+ <MenuItem label={msg('comments.delete')} danger onClick={removeThread} />
537
+ )}
538
+ </MoreOptionsMenu>
541
539
  )}
542
540
  {canComment && currentUserId && (
543
541
  <TldrawUiButton
@@ -9,16 +9,6 @@ import type {
9
9
  LeafScreenOffsets,
10
10
  } from './types'
11
11
 
12
- interface ResolvedClusterOptions {
13
- Tc: number
14
- Tu: number
15
- eps: number
16
- Dmax: number
17
- minZoom: number
18
- maxZoom: number
19
- maxSplitZoom: number
20
- }
21
-
22
12
  /** @internal */
23
13
  export function computeClusterTable(
24
14
  leaves: readonly LeafInput[],
@@ -35,20 +25,13 @@ export function computeClusterTable(
35
25
  return { events: [], leaves: leafNodes }
36
26
  }
37
27
 
38
- const raw = cappedReplay(leaves, edges, { Tc: opts.Tc, Dmax: opts.Dmax }, screenOffsets)
39
- const contracted = contract(raw, opts.eps)
40
- const events = finalize(contracted, {
41
- Tc: opts.Tc,
42
- Tu: opts.Tu,
43
- minZoom: opts.minZoom,
44
- maxZoom: opts.maxZoom,
45
- maxSplitZoom: opts.maxSplitZoom,
46
- })
28
+ const raw = cappedReplay(leaves, edges, opts, screenOffsets)
29
+ const events = finalize(contract(raw, opts.eps), opts)
47
30
 
48
31
  return { events, leaves: leafNodes }
49
32
  }
50
33
 
51
- function resolveOptions(options: ClusterOptions): ResolvedClusterOptions {
34
+ function resolveOptions(options: ClusterOptions): Required<ClusterOptions> {
52
35
  const Tc = options.Tc ?? 22
53
36
  const Tu = options.Tu ?? 1.2 * Tc
54
37
  const eps = options.eps ?? 0.7
@@ -166,8 +166,6 @@ class ClusterState {
166
166
  private readonly centroidY: Float64Array
167
167
  private readonly counts: Int32Array
168
168
  private readonly nodes: ClusterNode[]
169
- private readonly memberLists: string[][]
170
- private readonly minMemberIds: string[]
171
169
  // Per-cluster sums of member render offsets (screen px), maintained like the centroid sums.
172
170
  // Null when no offsets were passed — offsetDelta() then answers null unconditionally, which
173
171
  // routes every pricing call down the offset-unaware path.
@@ -185,8 +183,6 @@ class ClusterState {
185
183
  this.centroidY = new Float64Array(n)
186
184
  this.counts = new Int32Array(n)
187
185
  this.nodes = new Array(n)
188
- this.memberLists = new Array(n)
189
- this.minMemberIds = new Array(n)
190
186
 
191
187
  if (screenOffsets !== undefined && screenOffsets.size > 0) {
192
188
  this.offsetX = new Float64Array(n)
@@ -213,8 +209,6 @@ class ClusterState {
213
209
  this.centroidX[i] = leaf.point.x
214
210
  this.centroidY[i] = leaf.point.y
215
211
  this.counts[i] = 1
216
- this.memberLists[i] = [leaf.id]
217
- this.minMemberIds[i] = leaf.id
218
212
  this.nodes[i] = {
219
213
  id: leaf.id,
220
214
  centroid: { x: leaf.point.x, y: leaf.point.y },
@@ -237,6 +231,7 @@ class ClusterState {
237
231
  return root
238
232
  }
239
233
 
234
+ /** Allocation-free: this is the hot path when no offsets are passed. */
240
235
  centroidDistance(aRoot: number, bRoot: number): number {
241
236
  return Math.hypot(
242
237
  this.centroidX[aRoot] - this.centroidX[bRoot],
@@ -271,12 +266,12 @@ class ClusterState {
271
266
  }
272
267
 
273
268
  merge(aRoot: number, bRoot: number, z: number): RawMergeEvent {
274
- const leftRoot = this.minMemberIds[aRoot] < this.minMemberIds[bRoot] ? aRoot : bRoot
269
+ const leftRoot = this.nodes[aRoot].members[0] < this.nodes[bRoot].members[0] ? aRoot : bRoot
275
270
  const rightRoot = leftRoot === aRoot ? bRoot : aRoot
276
271
  const left = this.nodes[leftRoot]
277
272
  const right = this.nodes[rightRoot]
278
273
  const count = this.counts[leftRoot] + this.counts[rightRoot]
279
- const members = mergeSortedMembers(this.memberLists[leftRoot], this.memberLists[rightRoot])
274
+ const members = mergeSortedMembers(left.members, right.members)
280
275
 
281
276
  const minX = Math.min(this.minX[leftRoot], this.minX[rightRoot])
282
277
  const minY = Math.min(this.minY[leftRoot], this.minY[rightRoot])
@@ -308,8 +303,6 @@ class ClusterState {
308
303
  this.centroidY[leftRoot] = centroidY
309
304
  this.counts[leftRoot] = count
310
305
  this.nodes[leftRoot] = result
311
- this.memberLists[leftRoot] = members
312
- this.minMemberIds[leftRoot] = members[0]
313
306
  if (this.offsetX !== null && this.offsetY !== null) {
314
307
  this.offsetX[leftRoot] += this.offsetX[rightRoot]
315
308
  this.offsetY[leftRoot] += this.offsetY[rightRoot]
@@ -343,10 +336,11 @@ interface HeapEntry {
343
336
 
344
337
  class EdgeMaxHeap {
345
338
  private readonly items: HeapEntry[] = []
346
- // Per-edge normalized (lo, hi) id pair for the z tie-break, precomputed once so comparisons
347
- // allocate nothing. With coincident anchors every edge prices to the same z (+Infinity), so
348
- // the tie-break runs on nearly every comparison of a rebuild allocating the pair there
349
- // churned millions of short-lived tuples.
339
+ // Per-edge (lo, hi) id pair for the z tie-break, precomputed once so comparisons allocate
340
+ // nothing. Relies on MstEdge's invariant that leaves[a].id < leaves[b].id; the heap does not
341
+ // re-normalize. With coincident anchors every edge prices to the same z (+Infinity), so the
342
+ // tie-break runs on nearly every comparison of a rebuild: allocating the pair there churned
343
+ // millions of short-lived tuples.
350
344
  private readonly loIds: string[]
351
345
  private readonly hiIds: string[]
352
346
 
@@ -355,15 +349,8 @@ class EdgeMaxHeap {
355
349
  this.loIds = new Array(n)
356
350
  this.hiIds = new Array(n)
357
351
  for (let i = 0; i < n; i++) {
358
- const aId = leaves[edges[i].a].id
359
- const bId = leaves[edges[i].b].id
360
- if (aId < bId) {
361
- this.loIds[i] = aId
362
- this.hiIds[i] = bId
363
- } else {
364
- this.loIds[i] = bId
365
- this.hiIds[i] = aId
366
- }
352
+ this.loIds[i] = leaves[edges[i].a].id
353
+ this.hiIds[i] = leaves[edges[i].b].id
367
354
  }
368
355
  }
369
356
 
@@ -70,7 +70,7 @@ class ClusterRuntimeImpl implements ClusterRuntime {
70
70
  private resolvedCache: { version: number; map: Map<string, ClusterNode> } | null = null
71
71
 
72
72
  constructor(private readonly table: ClusterTable) {
73
- this.resetVisible()
73
+ this.reset()
74
74
  }
75
75
 
76
76
  getSuppressedCount(): number {
@@ -84,10 +84,7 @@ class ClusterRuntimeImpl implements ClusterRuntime {
84
84
  seed(zoom: number): void {
85
85
  validateZoom(zoom)
86
86
  this.k = seedCount(this.table.events, zoom)
87
- this.suppressed.clear()
88
- this.detached.clear()
89
- this.patched.clear()
90
- this.resetVisible()
87
+ this.reset()
91
88
  for (let i = 0; i < this.k; i++) {
92
89
  applyEvent(this.visible, this.table.events[i])
93
90
  }
@@ -114,10 +111,7 @@ class ClusterRuntimeImpl implements ClusterRuntime {
114
111
  }
115
112
 
116
113
  this.k = k
117
- this.suppressed.clear()
118
- this.detached.clear()
119
- this.patched.clear()
120
- this.resetVisible()
114
+ this.reset()
121
115
  for (let i = 0; i < k; i++) {
122
116
  const event = events[i]
123
117
  if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) {
@@ -222,8 +216,8 @@ class ClusterRuntimeImpl implements ClusterRuntime {
222
216
  this.patched.set(id, null)
223
217
  }
224
218
  const patch = (node: ClusterNode) => {
225
- if (node.members.length === 1 || !node.members.some((m) => this.detached.has(m))) return
226
219
  const members = node.members.filter((m) => !this.detached.has(m))
220
+ if (members.length === node.members.length) return
227
221
  if (members.length === 0) {
228
222
  this.patched.set(node.id, null)
229
223
  } else if (members.length === 1) {
@@ -250,11 +244,11 @@ class ClusterRuntimeImpl implements ClusterRuntime {
250
244
  }
251
245
  }
252
246
 
253
- private resetVisible() {
254
- this.visible = new Map()
255
- for (const leaf of this.table.leaves) {
256
- this.visible.set(leaf.id, leaf)
257
- }
247
+ private reset() {
248
+ this.suppressed.clear()
249
+ this.detached.clear()
250
+ this.patched.clear()
251
+ this.visible = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
258
252
  }
259
253
  }
260
254