@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.
- package/commenting.css +0 -32
- package/dist-cjs/canvas/anchor-lifecycle.js +4 -5
- package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
- package/dist-cjs/canvas/cluster-fade.js +1 -1
- package/dist-cjs/canvas/cluster-fade.js.map +2 -2
- package/dist-cjs/canvas/cluster-model.js +22 -31
- package/dist-cjs/canvas/cluster-model.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js +19 -19
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js +10 -5
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +12 -16
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +1 -7
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/hooks.js.map +1 -1
- package/dist-cjs/canvas/thread-pin.js +14 -26
- package/dist-cjs/canvas/thread-pin.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +2 -6
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +12 -0
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +67 -78
- package/dist-cjs/canvas/thread-view.js.map +2 -2
- package/dist-cjs/clustering/computeClusterTable.js +2 -9
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/replay.js +10 -23
- package/dist-cjs/clustering/replay.js.map +2 -2
- package/dist-cjs/clustering/runtime.js +9 -15
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/ui/comment-composer.js +2 -4
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/format-time.js +20 -8
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/reaction.js +5 -15
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-esm/canvas/anchor-lifecycle.mjs +5 -6
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
- package/dist-esm/canvas/cluster-fade.mjs +1 -1
- package/dist-esm/canvas/cluster-fade.mjs.map +2 -2
- package/dist-esm/canvas/cluster-model.mjs +23 -32
- package/dist-esm/canvas/cluster-model.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs +19 -19
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs +10 -5
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +13 -17
- package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
- package/dist-esm/canvas/comments-sidebar.mjs +3 -14
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/hooks.mjs.map +1 -1
- package/dist-esm/canvas/thread-pin.mjs +16 -26
- package/dist-esm/canvas/thread-pin.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +3 -7
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +12 -0
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +67 -78
- package/dist-esm/canvas/thread-view.mjs.map +2 -2
- package/dist-esm/clustering/computeClusterTable.mjs +2 -9
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/replay.mjs +10 -23
- package/dist-esm/clustering/replay.mjs.map +2 -2
- package/dist-esm/clustering/runtime.mjs +9 -15
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/ui/comment-composer.mjs +2 -4
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +20 -8
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +7 -15
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/package.json +6 -6
- package/src/canvas/anchor-lifecycle.ts +5 -6
- package/src/canvas/cluster-fade.ts +6 -6
- package/src/canvas/cluster-model.ts +27 -33
- package/src/canvas/comment-render.ts +30 -28
- package/src/canvas/comment-store.ts +16 -6
- package/src/canvas/comments-overlay.tsx +18 -19
- package/src/canvas/comments-sidebar.tsx +3 -18
- package/src/canvas/hooks.ts +1 -1
- package/src/canvas/thread-pin.tsx +21 -31
- package/src/canvas/thread-stack.tsx +3 -7
- package/src/canvas/thread-state.ts +26 -0
- package/src/canvas/thread-view.tsx +93 -95
- package/src/clustering/computeClusterTable.ts +3 -20
- package/src/clustering/replay.ts +10 -23
- package/src/clustering/runtime.ts +9 -15
- package/src/ui/comment-composer.tsx +2 -4
- package/src/ui/comments.css +0 -32
- package/src/ui/format-time.ts +24 -11
- 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,
|
|
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
|
-
|
|
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]
|
|
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
|
-
<
|
|
432
|
-
|
|
433
|
-
<
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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,
|
|
39
|
-
const
|
|
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):
|
|
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
|
package/src/clustering/replay.ts
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
-
|
|
359
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
254
|
-
this.
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/ui/comments.css
CHANGED
|
@@ -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;
|
package/src/ui/format-time.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
}
|
package/src/ui/reaction.tsx
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
}
|