@tldraw/commenting 5.5.0-next.c25ed1f4cf7f → 5.5.0-next.c91b2de0a423

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 (93) 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 +22 -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 +12 -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 +23 -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 +12 -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 +6 -6
  75. package/src/canvas/anchor-lifecycle.ts +5 -6
  76. package/src/canvas/cluster-fade.ts +6 -6
  77. package/src/canvas/cluster-model.ts +27 -33
  78. package/src/canvas/comment-render.ts +30 -28
  79. package/src/canvas/comment-store.ts +16 -6
  80. package/src/canvas/comments-overlay.tsx +18 -19
  81. package/src/canvas/comments-sidebar.tsx +3 -18
  82. package/src/canvas/hooks.ts +1 -1
  83. package/src/canvas/thread-pin.tsx +21 -31
  84. package/src/canvas/thread-stack.tsx +3 -7
  85. package/src/canvas/thread-state.ts +26 -0
  86. package/src/canvas/thread-view.tsx +93 -95
  87. package/src/clustering/computeClusterTable.ts +3 -20
  88. package/src/clustering/replay.ts +10 -23
  89. package/src/clustering/runtime.ts +9 -15
  90. package/src/ui/comment-composer.tsx +2 -4
  91. package/src/ui/comments.css +0 -32
  92. package/src/ui/format-time.ts +24 -11
  93. package/src/ui/reaction.tsx +11 -23
@@ -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
  <>
@@ -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
  *
@@ -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
 
@@ -114,8 +114,7 @@ export function CommentComposer({
114
114
  if (!expandedRef.current) setExpanded(true)
115
115
  return
116
116
  }
117
- const field = wrap.parentElement
118
- const send = field ? field.querySelector<HTMLElement>('.tlui-cmt-send') : null
117
+ const send = wrap.parentElement?.querySelector<HTMLElement>('.tlui-cmt-send')
119
118
  const input = wrap.querySelector('.tlui-cmt-input')
120
119
  if (!send || !input) return
121
120
  mirror.innerHTML = input.innerHTML
@@ -263,8 +262,7 @@ export function CommentComposer({
263
262
  // space beside/below a short line) focuses the editor rather than only the text glyphs being
264
263
  // clickable. The input (caret placement) and the send button keep their own click handling.
265
264
  const focusEditorFromField = (e: ReactMouseEvent<HTMLDivElement>) => {
266
- const target = e.target as HTMLElement
267
- if (target.closest('.tlui-cmt-input') || target.closest('.tlui-cmt-send')) return
265
+ if ((e.target as HTMLElement).closest('.tlui-cmt-input, .tlui-cmt-send')) return
268
266
  e.preventDefault()
269
267
  editor?.commands.focus('end')
270
268
  }
@@ -383,35 +383,6 @@
383
383
  user-select: none;
384
384
  }
385
385
 
386
- /* thread */
387
- .tlui-cmt-thread {
388
- display: flex;
389
- flex-direction: column;
390
- width: 300px;
391
- }
392
-
393
- .tlui-cmt-thread__head {
394
- display: flex;
395
- align-items: center;
396
- gap: 8px;
397
- font-size: 12px;
398
- font-weight: 600;
399
- color: var(--tl-color-text-3);
400
- text-transform: uppercase;
401
- letter-spacing: 0.04em;
402
- }
403
-
404
- .tlui-cmt-badge {
405
- font-size: 10px;
406
- font-weight: 600;
407
- text-transform: none;
408
- letter-spacing: 0;
409
- color: #fff;
410
- background: var(--tl-color-muted-1);
411
- padding: 1px 6px;
412
- border-radius: 999px;
413
- }
414
-
415
386
  /* reactions */
416
387
  .tlui-cmt-reactions {
417
388
  display: flex;
@@ -648,9 +619,6 @@ button.tlui-cmt-reaction--active:hover {
648
619
  .tlui-button.tlui-cmt-thread__action:hover {
649
620
  color: var(--tl-color-text-1);
650
621
  }
651
- .tlui-button.tlui-cmt-thread__action--danger:hover {
652
- color: var(--tl-color-danger);
653
- }
654
622
  .tlui-cmt-thread__resolved {
655
623
  margin: 4px 12px;
656
624
  padding: 6px 10px;
@@ -8,6 +8,20 @@ const DIVISIONS = [
8
8
  { amount: Number.POSITIVE_INFINITY, unit: 'year' },
9
9
  ] as const
10
10
 
11
+ // Both formatters are cached per locale — comment cards re-render on every reply keystroke, and
12
+ // constructing an Intl formatter is far more expensive than formatting with one.
13
+ const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>()
14
+ const fullDateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
15
+
16
+ function getCached<T>(cache: Map<string, T>, locale: string, create: () => T): T {
17
+ let formatter = cache.get(locale)
18
+ if (!formatter) {
19
+ formatter = create()
20
+ cache.set(locale, formatter)
21
+ }
22
+ return formatter
23
+ }
24
+
11
25
  /**
12
26
  * Format an ISO datetime as compact relative time ("2h ago", "yesterday", "last wk.").
13
27
  * Locale-aware via Intl.RelativeTimeFormat.
@@ -17,7 +31,11 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
17
31
  const then = new Date(iso).getTime()
18
32
  if (Number.isNaN(then)) return ''
19
33
 
20
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' })
34
+ const rtf = getCached(
35
+ relativeTimeFormatters,
36
+ locale,
37
+ () => new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' })
38
+ )
21
39
  let duration = (then - Date.now()) / 1000
22
40
  // Under a minute is just "now" — with second granularity the label visibly ticks while
23
41
  // typing a reply (every keystroke re-renders the card).
@@ -33,10 +51,6 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
33
51
  return ''
34
52
  }
35
53
 
36
- // Cached per locale — comment cards re-render on every reply keystroke, and constructing a
37
- // DateTimeFormat is far more expensive than formatting with one.
38
- const fullDateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
39
-
40
54
  /**
41
55
  * Format an ISO datetime as a full date and time ("Tuesday, July 22, 2025 at 4:44 PM").
42
56
  * Locale-aware via Intl.DateTimeFormat.
@@ -45,10 +59,9 @@ const fullDateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
45
59
  export function formatFullDateTime(iso: string, locale = 'en'): string {
46
60
  const date = new Date(iso)
47
61
  if (Number.isNaN(date.getTime())) return ''
48
- let formatter = fullDateTimeFormatters.get(locale)
49
- if (!formatter) {
50
- formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' })
51
- fullDateTimeFormatters.set(locale, formatter)
52
- }
53
- return formatter.format(date)
62
+ return getCached(
63
+ fullDateTimeFormatters,
64
+ locale,
65
+ () => new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' })
66
+ ).format(date)
54
67
  }
@@ -108,28 +108,16 @@ export function DefaultReactionTooltipContent({ reactors }: { reactors: Reaction
108
108
  const names = reactors.map((reactor) =>
109
109
  reactor.you ? msg('comments.mention-you') : reactor.name
110
110
  )
111
+ if (names.length === 0) return null
111
112
  const [a, b, c] = names
112
- switch (names.length) {
113
- case 0:
114
- return null
115
- case 1:
116
- return <>{msg('comments.reacted-1').replace('{a}', a)}</>
117
- case 2:
118
- return <>{msg('comments.reacted-2').replace('{a}', a).replace('{b}', b)}</>
119
- case 3:
120
- return <>{msg('comments.reacted-3').replace('{a}', a).replace('{b}', b).replace('{c}', c)}</>
121
- default: {
122
- const others = names.length - 3
123
- const template = others === 1 ? 'comments.reacted-more-one' : 'comments.reacted-more'
124
- return (
125
- <>
126
- {msg(template)
127
- .replace('{a}', a)
128
- .replace('{b}', b)
129
- .replace('{c}', c)
130
- .replace('{count}', String(others))}
131
- </>
132
- )
133
- }
134
- }
113
+ const others = names.length - 3
114
+ const key =
115
+ names.length <= 3
116
+ ? `comments.reacted-${names.length}`
117
+ : others === 1
118
+ ? 'comments.reacted-more-one'
119
+ : 'comments.reacted-more'
120
+ // Single pass, so a substituted name containing a placeholder can't be re-substituted.
121
+ const vars: Record<string, string | undefined> = { a, b, c, count: String(others) }
122
+ return <>{msg(key).replace(/\{(a|b|c|count)\}/g, (_, k) => vars[k] ?? '')}</>
135
123
  }