@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.
- 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 +23 -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 +13 -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 +24 -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 +13 -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 +5 -5
- package/src/canvas/anchor-lifecycle.ts +5 -6
- package/src/canvas/cluster-fade.ts +6 -6
- package/src/canvas/cluster-model.test.ts +64 -1
- package/src/canvas/cluster-model.ts +28 -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.test.ts +35 -0
- package/src/canvas/thread-state.ts +27 -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
|
@@ -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
|
|
169
|
-
if (!
|
|
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
|
-
//
|
|
251
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
<>
|
|
@@ -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
|
-
<
|
|
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
|
|