@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02
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 +63 -71
- package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
- package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
- package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
- package/dist-cjs/canvas/canvas-events.js.map +7 -0
- package/dist-cjs/canvas/cluster-badge.js +121 -0
- package/dist-cjs/canvas/cluster-badge.js.map +7 -0
- package/dist-cjs/canvas/cluster-fade.js +85 -0
- package/dist-cjs/canvas/cluster-fade.js.map +7 -0
- package/dist-cjs/canvas/cluster-input.js +51 -1
- package/dist-cjs/canvas/cluster-input.js.map +2 -2
- package/dist-cjs/canvas/cluster-model.js +252 -0
- package/dist-cjs/canvas/cluster-model.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +57 -28
- package/dist-cjs/canvas/comment-mutations.js.map +2 -2
- package/dist-cjs/canvas/comment-reactions.js +4 -7
- package/dist-cjs/canvas/comment-reactions.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js +4 -3
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +0 -1
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +11 -13
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +77 -870
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +16 -11
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
- package/dist-cjs/canvas/context.js.map +1 -1
- package/dist-cjs/canvas/hooks.js +8 -2
- package/dist-cjs/canvas/hooks.js.map +2 -2
- package/dist-cjs/canvas/license.js.map +1 -1
- package/dist-cjs/canvas/mobile-placement.js +91 -0
- package/dist-cjs/canvas/mobile-placement.js.map +7 -0
- package/dist-cjs/canvas/options.js +39 -1
- package/dist-cjs/canvas/options.js.map +2 -2
- package/dist-cjs/canvas/pending-composer.js +134 -0
- package/dist-cjs/canvas/pending-composer.js.map +7 -0
- package/dist-cjs/canvas/pin-stacking.js +26 -1
- package/dist-cjs/canvas/pin-stacking.js.map +2 -2
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.js.map +7 -0
- package/dist-cjs/canvas/state.js.map +2 -2
- package/dist-cjs/canvas/thread-pin.js +304 -0
- package/dist-cjs/canvas/thread-pin.js.map +7 -0
- package/dist-cjs/canvas/thread-preview.js +41 -49
- package/dist-cjs/canvas/thread-preview.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +7 -7
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +15 -0
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +120 -67
- package/dist-cjs/canvas/thread-view.js.map +3 -3
- package/dist-cjs/clustering/computeClusterTable.js +2 -2
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/replay.js +89 -22
- package/dist-cjs/clustering/replay.js.map +3 -3
- package/dist-cjs/clustering/runtime.js +19 -12
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +323 -202
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +5 -3
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +2 -18
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +2 -15
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +11 -24
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/format-time.js +12 -0
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/icons.js +115 -0
- package/dist-cjs/ui/icons.js.map +7 -0
- package/dist-cjs/ui/reaction-picker.js +11 -13
- package/dist-cjs/ui/reaction-picker.js.map +2 -2
- package/dist-cjs/ui/reaction.js +3 -4
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/send-button.js +2 -9
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/visual-viewport.js +32 -0
- package/dist-cjs/ui/visual-viewport.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
- package/dist-esm/canvas/canvas-events.mjs +15 -0
- package/dist-esm/canvas/canvas-events.mjs.map +7 -0
- package/dist-esm/canvas/cluster-badge.mjs +106 -0
- package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
- package/dist-esm/canvas/cluster-fade.mjs +65 -0
- package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +52 -2
- package/dist-esm/canvas/cluster-input.mjs.map +2 -2
- package/dist-esm/canvas/cluster-model.mjs +236 -0
- package/dist-esm/canvas/cluster-model.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +57 -28
- package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
- package/dist-esm/canvas/comment-reactions.mjs +5 -12
- package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs +5 -4
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +0 -1
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +54 -878
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +18 -14
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
- package/dist-esm/canvas/hooks.mjs +9 -2
- package/dist-esm/canvas/hooks.mjs.map +2 -2
- package/dist-esm/canvas/license.mjs.map +1 -1
- package/dist-esm/canvas/mobile-placement.mjs +71 -0
- package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +39 -1
- package/dist-esm/canvas/options.mjs.map +2 -2
- package/dist-esm/canvas/pending-composer.mjs +126 -0
- package/dist-esm/canvas/pending-composer.mjs.map +7 -0
- package/dist-esm/canvas/pin-stacking.mjs +26 -1
- package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs.map +2 -2
- package/dist-esm/canvas/thread-pin.mjs +310 -0
- package/dist-esm/canvas/thread-pin.mjs.map +7 -0
- package/dist-esm/canvas/thread-preview.mjs +42 -49
- package/dist-esm/canvas/thread-preview.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +8 -9
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +15 -0
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +131 -72
- package/dist-esm/canvas/thread-view.mjs.map +3 -3
- package/dist-esm/clustering/computeClusterTable.mjs +2 -2
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/replay.mjs +89 -22
- package/dist-esm/clustering/replay.mjs.map +3 -3
- package/dist-esm/clustering/runtime.mjs +19 -12
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +323 -202
- package/dist-esm/index.mjs +16 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +6 -4
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +2 -18
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +2 -15
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +10 -23
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +12 -0
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/icons.mjs +95 -0
- package/dist-esm/ui/icons.mjs.map +7 -0
- package/dist-esm/ui/reaction-picker.mjs +12 -13
- package/dist-esm/ui/reaction-picker.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +3 -4
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/send-button.mjs +2 -9
- package/dist-esm/ui/send-button.mjs.map +2 -2
- package/dist-esm/ui/visual-viewport.mjs +12 -0
- package/dist-esm/ui/visual-viewport.mjs.map +7 -0
- package/package.json +5 -5
- package/src/canvas/anchor-lifecycle.test.ts +2 -2
- package/src/canvas/anchor-lifecycle.ts +25 -39
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +5 -11
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +148 -14
- package/src/canvas/cluster-input.ts +92 -5
- package/src/canvas/cluster-model.ts +378 -0
- package/src/canvas/comment-mutations.test.ts +46 -4
- package/src/canvas/comment-mutations.ts +115 -104
- package/src/canvas/comment-reactions.test.ts +22 -0
- package/src/canvas/comment-reactions.tsx +33 -32
- package/src/canvas/comment-render.ts +8 -8
- package/src/canvas/comment-store.ts +12 -17
- package/src/canvas/comment-tool.tsx +8 -12
- package/src/canvas/comments-filter-menu.tsx +9 -16
- package/src/canvas/comments-overflow-menu.tsx +8 -3
- package/src/canvas/comments-overlay.tsx +154 -1344
- package/src/canvas/comments-sidebar.tsx +40 -19
- package/src/canvas/comments-visibility-toggle.tsx +6 -30
- package/src/canvas/context.ts +4 -3
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +17 -3
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +124 -2
- package/src/canvas/options.ts +192 -58
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +111 -1
- package/src/canvas/pin-stacking.ts +46 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/state.ts +20 -28
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.tsx +77 -87
- package/src/canvas/thread-stack.tsx +19 -29
- package/src/canvas/thread-state.test.ts +51 -0
- package/src/canvas/thread-state.ts +56 -23
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +231 -133
- package/src/clustering/computeClusterTable.ts +12 -3
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +50 -6
- package/src/clustering/runtime.ts +42 -39
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +10 -0
- package/src/index.ts +15 -2
- package/src/ui/byline.tsx +16 -6
- package/src/ui/comment-composer.tsx +25 -69
- package/src/ui/comment-pin.tsx +3 -16
- package/src/ui/comments-list.tsx +40 -29
- package/src/ui/comments.css +58 -60
- package/src/ui/format-time.test.ts +69 -0
- package/src/ui/format-time.ts +20 -0
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +9 -16
- package/src/ui/reaction.tsx +10 -6
- package/src/ui/send-button.tsx +3 -8
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -0
- package/dist-cjs/ui/tooltip-button.js.map +0 -7
- package/dist-esm/ui/tooltip-button.mjs +0 -12
- package/dist-esm/ui/tooltip-button.mjs.map +0 -7
- package/src/ui/tooltip-button.tsx +0 -20
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { VecLike } from 'tldraw'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { computeClusterTable } from './computeClusterTable'
|
|
4
|
+
import type { LeafInput } from './types'
|
|
5
|
+
|
|
6
|
+
function leaf(id: string, x: number, y: number): LeafInput {
|
|
7
|
+
return { id, point: { x, y } }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mulberry32(seed: number): () => number {
|
|
11
|
+
let a = seed >>> 0
|
|
12
|
+
return () => {
|
|
13
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
14
|
+
let t = a
|
|
15
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
16
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
17
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
|
22
|
+
const rand = mulberry32(seed)
|
|
23
|
+
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ZOOM_BOUNDS = { minZoom: 0.05, maxZoom: 8 }
|
|
27
|
+
// eps 0 keeps every merge as its own solo event, so the exact thresholds under test aren't
|
|
28
|
+
// snapped to a contraction window's anchor.
|
|
29
|
+
const EXACT_OPTS = { Tc: 22, Tu: 26.4, eps: 0, Dmax: 82.5, ...ZOOM_BOUNDS }
|
|
30
|
+
|
|
31
|
+
function offsets(entries: Record<string, VecLike>): Map<string, VecLike> {
|
|
32
|
+
return new Map(Object.entries(entries))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('computeClusterTable without screen offsets (the guarantee)', () => {
|
|
36
|
+
it('is deep-equal across omitted, undefined, and empty offset arguments', () => {
|
|
37
|
+
for (const seed of [3, 21]) {
|
|
38
|
+
const leaves = randomLeaves(40, seed)
|
|
39
|
+
const plain = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
|
40
|
+
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, undefined)).toEqual(plain)
|
|
41
|
+
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, new Map())).toEqual(plain)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('is deep-equal when every leaf carries the same offset (Δō is always zero)', () => {
|
|
46
|
+
const leaves = randomLeaves(30, 11)
|
|
47
|
+
const uniform = new Map(leaves.map((l) => [l.id, { x: -20, y: 20 }]))
|
|
48
|
+
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, uniform)).toEqual(
|
|
49
|
+
computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('never emits a zSplit override on the offset-free path', () => {
|
|
54
|
+
const table = computeClusterTable(randomLeaves(20, 5), { ...ZOOM_BOUNDS }, new Map())
|
|
55
|
+
for (const event of table.events) {
|
|
56
|
+
// finalize always derives ratio splits when no override was carried
|
|
57
|
+
expect(event.zSplit).toBeCloseTo(event.zMerge * 1.2, 10)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('offset-aware pricing', () => {
|
|
63
|
+
const A = leaf('a', 0, 0)
|
|
64
|
+
const B = leaf('b', 100, 0)
|
|
65
|
+
|
|
66
|
+
it('merges earlier when the insets point toward each other, at the exact visual crossing', () => {
|
|
67
|
+
// Visual positions: a at 100·0·z + 20, b at 100z − 20 → distance |100z − 40|. Tc = 22
|
|
68
|
+
// crosses at z = 0.62 (raw anchors would say 0.22); the split derives from the Tu/Tc
|
|
69
|
+
// ratio, as for every event.
|
|
70
|
+
const table = computeClusterTable(
|
|
71
|
+
[A, B],
|
|
72
|
+
EXACT_OPTS,
|
|
73
|
+
offsets({ a: { x: 20, y: 0 }, b: { x: -20, y: 0 } })
|
|
74
|
+
)
|
|
75
|
+
expect(table.events).toHaveLength(1)
|
|
76
|
+
expect(table.events[0].zMerge).toBeCloseTo(0.62, 10)
|
|
77
|
+
expect(table.events[0].zSplit).toBeCloseTo(0.62 * 1.2, 10)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('merges later when the insets point apart, at the exact visual crossing', () => {
|
|
81
|
+
// Distance 100z + 10: Tc at z = 0.12 (raw: 0.22); ratio split at 0.144.
|
|
82
|
+
const table = computeClusterTable(
|
|
83
|
+
[A, B],
|
|
84
|
+
EXACT_OPTS,
|
|
85
|
+
offsets({ a: { x: -5, y: 0 }, b: { x: 5, y: 0 } })
|
|
86
|
+
)
|
|
87
|
+
expect(table.events).toHaveLength(1)
|
|
88
|
+
expect(table.events[0].zMerge).toBeCloseTo(0.12, 10)
|
|
89
|
+
expect(table.events[0].zSplit).toBeCloseTo(0.144, 10)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('never merges a pair whose visual distance never reaches Tc', () => {
|
|
93
|
+
// Distance 100z + 40 ≥ 40 > Tc at every zoom: priced 0 and pruned by the minZoom cut.
|
|
94
|
+
const table = computeClusterTable(
|
|
95
|
+
[A, B],
|
|
96
|
+
EXACT_OPTS,
|
|
97
|
+
offsets({ a: { x: -20, y: 0 }, b: { x: 20, y: 0 } })
|
|
98
|
+
)
|
|
99
|
+
expect(table.events).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('still applies the Dmax fit cap to a corrected merge', () => {
|
|
103
|
+
// Exaggerated inward offsets put the Tc crossing at z = 1.42, above the spread cap
|
|
104
|
+
// Dmax/diag = 82.5/100 = 0.825, so the cap wins the min.
|
|
105
|
+
const table = computeClusterTable(
|
|
106
|
+
[A, B],
|
|
107
|
+
EXACT_OPTS,
|
|
108
|
+
offsets({ a: { x: 60, y: 0 }, b: { x: -60, y: 0 } })
|
|
109
|
+
)
|
|
110
|
+
expect(table.events).toHaveLength(1)
|
|
111
|
+
expect(table.events[0].zMerge).toBeCloseTo(0.825, 10)
|
|
112
|
+
expect(table.events[0].zSplit).toBeCloseTo(0.825 * 1.2, 10)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('treats coincident anchors with differing offsets as a constant visual distance', () => {
|
|
116
|
+
// Same anchor point, one pin tucked: constant 20px apart < Tc → merged at every zoom,
|
|
117
|
+
// entering the same maxSplitZoom band as a coincident pair (zMerge 6/1.2 = 5, zSplit 6).
|
|
118
|
+
const table = computeClusterTable(
|
|
119
|
+
[leaf('a', 50, 50), leaf('b', 50, 50)],
|
|
120
|
+
{ ...EXACT_OPTS, maxSplitZoom: 6 },
|
|
121
|
+
offsets({ a: { x: 20, y: 0 } })
|
|
122
|
+
)
|
|
123
|
+
expect(table.events).toHaveLength(1)
|
|
124
|
+
expect(table.events[0].zMerge).toBe(5)
|
|
125
|
+
expect(table.events[0].zSplit).toBe(6)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("dilutes a cluster's mean offset as precise members fold in", () => {
|
|
129
|
+
// a (tucked 20px) and b (precise) share an anchor: constant 20px apart, always merged.
|
|
130
|
+
// Their cluster's mean offset is (10, 0); against precise c 100 units right the visual
|
|
131
|
+
// distance is |100z − 10| → Tc at z = 0.32 — half the lone pin's correction.
|
|
132
|
+
const table = computeClusterTable(
|
|
133
|
+
[leaf('a', 0, 0), leaf('b', 0, 0), leaf('c', 100, 0)],
|
|
134
|
+
EXACT_OPTS,
|
|
135
|
+
offsets({ a: { x: 20, y: 0 } })
|
|
136
|
+
)
|
|
137
|
+
expect(table.events).toHaveLength(2)
|
|
138
|
+
const outer = table.events.find((e) => e.result.count === 3)!
|
|
139
|
+
expect(outer.zMerge).toBeCloseTo(0.32, 10)
|
|
140
|
+
expect(outer.zSplit).toBeCloseTo(0.32 * 1.2, 10)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('table ordering with offsets', () => {
|
|
145
|
+
it('keeps zMerge and zSplit non-increasing (splits are always ratio-derived)', () => {
|
|
146
|
+
// Mixed offset and plain leaves at varied spacings: every split is zMerge · (Tu/Tc), so
|
|
147
|
+
// both threshold sequences inherit the table's non-increasing order — the invariant the
|
|
148
|
+
// runtime's prefix cursor, split walk, and seed bisection rely on.
|
|
149
|
+
const leaves = [
|
|
150
|
+
leaf('a', 0, 0),
|
|
151
|
+
leaf('b', 100, 0),
|
|
152
|
+
leaf('c', 130, 0),
|
|
153
|
+
leaf('d', 400, 0),
|
|
154
|
+
leaf('e', 470, 0),
|
|
155
|
+
leaf('f', 1000, 300),
|
|
156
|
+
]
|
|
157
|
+
const table = computeClusterTable(
|
|
158
|
+
leaves,
|
|
159
|
+
EXACT_OPTS,
|
|
160
|
+
offsets({ a: { x: -20, y: 0 }, b: { x: 14, y: 0 }, d: { x: 20, y: 14 } })
|
|
161
|
+
)
|
|
162
|
+
expect(table.events.length).toBeGreaterThan(2)
|
|
163
|
+
for (let i = 1; i < table.events.length; i++) {
|
|
164
|
+
expect(table.events[i].zMerge).toBeLessThanOrEqual(table.events[i - 1].zMerge)
|
|
165
|
+
expect(table.events[i].zSplit).toBeLessThanOrEqual(table.events[i - 1].zSplit)
|
|
166
|
+
}
|
|
167
|
+
for (const event of table.events) {
|
|
168
|
+
expect(event.zSplit).toBeCloseTo(event.zMerge * 1.2, 10)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
package/src/clustering/types.ts
CHANGED
|
@@ -11,6 +11,16 @@ export interface LeafInput {
|
|
|
11
11
|
point: VecLike
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Constant screen-px render offsets for markers that don't draw exactly on their anchor —
|
|
16
|
+
* imprecise shape pins, which tuck ~20px into their shape. Keyed by leaf id; leaves absent from
|
|
17
|
+
* the map render on their anchor. A marker's visual position at zoom z is `z · point + offset`.
|
|
18
|
+
* Passed as the optional third argument to `computeClusterTable`; when omitted or empty the
|
|
19
|
+
* algorithm's behavior (and output) is identical to an offset-unaware run.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export type LeafScreenOffsets = ReadonlyMap<string, VecLike>
|
|
23
|
+
|
|
14
24
|
/** An edge of the Euclidean MST over the leaf anchor points. */
|
|
15
25
|
export interface MstEdge {
|
|
16
26
|
/** Index into the input leaves array. Normalized: leaves[a].id < leaves[b].id (lexicographic). */
|
package/src/index.ts
CHANGED
|
@@ -23,13 +23,15 @@ export { CountBadge, type CountBadgeProps } from './ui/count-badge'
|
|
|
23
23
|
export { CommentPin, type CommentPinProps } from './ui/comment-pin'
|
|
24
24
|
export { CommentThread, type CommentThreadProps } from './ui/comment-thread'
|
|
25
25
|
export {
|
|
26
|
+
CommentListItem,
|
|
26
27
|
CommentsList,
|
|
27
28
|
isOpenInNewTabClick,
|
|
28
29
|
type CommentListItemProps,
|
|
30
|
+
type CommentListItemRenderProps,
|
|
29
31
|
type CommentsListProps,
|
|
30
32
|
} from './ui/comments-list'
|
|
31
33
|
export { EmptyState, type EmptyStateProps } from './ui/empty-state'
|
|
32
|
-
export { formatRelativeTime } from './ui/format-time'
|
|
34
|
+
export { formatFullDateTime, formatRelativeTime } from './ui/format-time'
|
|
33
35
|
export {
|
|
34
36
|
DEFAULT_REACTION_EMOJI,
|
|
35
37
|
EmojiPicker,
|
|
@@ -73,6 +75,7 @@ export {
|
|
|
73
75
|
type CommentReactionPickerProps,
|
|
74
76
|
CommentReactions,
|
|
75
77
|
type CommentReactionsProps,
|
|
78
|
+
type ReactionSummaryInput,
|
|
76
79
|
summarizeReactions,
|
|
77
80
|
toggleCommentReaction,
|
|
78
81
|
useCommentReactions,
|
|
@@ -94,16 +97,26 @@ export { CanvasComments, type CanvasCommentsProps } from './canvas/comments-over
|
|
|
94
97
|
export {
|
|
95
98
|
type CommentingComponents,
|
|
96
99
|
type CommentingOptions,
|
|
100
|
+
type CommentModification,
|
|
101
|
+
type CommentModificationContext,
|
|
102
|
+
defaultCanModifyComment,
|
|
97
103
|
defaultCommentingOptions,
|
|
98
104
|
getCanComment,
|
|
105
|
+
getCanModifyComment,
|
|
99
106
|
getCommentingOptions,
|
|
100
107
|
type ShapeCommentPrecisionContext,
|
|
101
108
|
useCanComment,
|
|
109
|
+
useCanModifyComment,
|
|
102
110
|
useCommentingOptions,
|
|
103
111
|
} from './canvas/options'
|
|
104
112
|
export { CommentsOverflowMenu } from './canvas/comments-overflow-menu'
|
|
105
113
|
export { CommentsVisibilityToggle } from './canvas/comments-visibility-toggle'
|
|
106
|
-
export {
|
|
114
|
+
export {
|
|
115
|
+
CanvasCommentsSidebar,
|
|
116
|
+
sortSidebarRows,
|
|
117
|
+
type CanvasCommentsSidebarProps,
|
|
118
|
+
type SidebarRow,
|
|
119
|
+
} from './canvas/comments-sidebar'
|
|
107
120
|
export { useComments, useCommentThreads, useThreadComments } from './canvas/hooks'
|
|
108
121
|
export { useCommentingEnabled } from './canvas/license'
|
|
109
122
|
export { richTextToPlaintext } from './canvas/rich-text'
|
package/src/ui/byline.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Avatar, type CommentAuthor } from '@tldraw/mentions'
|
|
2
|
-
import {
|
|
2
|
+
import { TldrawUiTooltip, useCurrentTranslation } from 'tldraw'
|
|
3
|
+
import { formatFullDateTime, formatRelativeTime } from './format-time'
|
|
3
4
|
|
|
4
5
|
/** @public */
|
|
5
6
|
export interface BylineProps {
|
|
@@ -10,16 +11,25 @@ export interface BylineProps {
|
|
|
10
11
|
edited?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* A comment's metadata line: author name, relative time, and an edited marker. Hovering the time
|
|
16
|
+
* shows the full date and time in a tooltip.
|
|
17
|
+
* @public @react
|
|
18
|
+
*/
|
|
14
19
|
export function Byline({ author, date, edited }: BylineProps) {
|
|
20
|
+
// The relative time is the one piece of a byline that isn't the host's data, so it follows the
|
|
21
|
+
// same translation context as every other string in the UI rather than a prop of its own.
|
|
22
|
+
const { locale } = useCurrentTranslation()
|
|
15
23
|
return (
|
|
16
24
|
<div className="tlui-cmt-head">
|
|
17
25
|
<Avatar author={author} />
|
|
18
26
|
<span className="tlui-cmt-author">{author.name}</span>
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
<TldrawUiTooltip content={formatFullDateTime(date, locale)}>
|
|
28
|
+
<span className="tlui-cmt-time">
|
|
29
|
+
{formatRelativeTime(date, locale)}
|
|
30
|
+
{edited && <span className="tlui-cmt-edited"> · edited</span>}
|
|
31
|
+
</span>
|
|
32
|
+
</TldrawUiTooltip>
|
|
23
33
|
</div>
|
|
24
34
|
)
|
|
25
35
|
}
|
|
@@ -66,12 +66,10 @@ export function CommentComposer({
|
|
|
66
66
|
renderMentionSuggestion,
|
|
67
67
|
}: CommentComposerProps) {
|
|
68
68
|
const interactive = !!onChange || !!onSubmit
|
|
69
|
-
//
|
|
70
|
-
// when the composer is used outside a tldraw editor (e.g. an isolated demo).
|
|
69
|
+
// Lets the mention popup track the camera. Null outside a tldraw editor.
|
|
71
70
|
const tlEditor = useMaybeEditor()
|
|
72
71
|
|
|
73
|
-
//
|
|
74
|
-
// change identity between renders.
|
|
72
|
+
// Read through refs so the editor isn't recreated when callback identity changes.
|
|
75
73
|
const onChangeRef = useRef(onChange)
|
|
76
74
|
onChangeRef.current = onChange
|
|
77
75
|
const onSubmitRef = useRef(onSubmit)
|
|
@@ -94,17 +92,16 @@ export function CommentComposer({
|
|
|
94
92
|
const inputWrapRef = useRef<HTMLDivElement>(null)
|
|
95
93
|
const mirrorRef = useRef<HTMLDivElement>(null)
|
|
96
94
|
|
|
97
|
-
// Measure whether the content still fits on one line beside the send button
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// layout from flapping while typing at the boundary.
|
|
95
|
+
// Measure whether the content still fits on one line beside the send button, against a hidden
|
|
96
|
+
// nowrap clone so marks and mention chips measure at their true width. The gap between the
|
|
97
|
+
// expand and collapse thresholds is a dead zone, so the layout can't flap at the boundary.
|
|
101
98
|
const remeasure = () => {
|
|
102
99
|
const wrap = inputWrapRef.current
|
|
103
100
|
const mirror = mirrorRef.current
|
|
104
101
|
const editor = editorRef.current
|
|
105
102
|
if (!wrap || !mirror || !editor) return
|
|
106
|
-
// Empty always fits — and measuring before TipTap
|
|
107
|
-
//
|
|
103
|
+
// Empty always fits — and measuring before TipTap has laid out reads zero widths, flashing
|
|
104
|
+
// the expanded layout for a frame on mount.
|
|
108
105
|
if (editor.isEmpty) {
|
|
109
106
|
if (expandedRef.current) setExpanded(false)
|
|
110
107
|
return
|
|
@@ -123,11 +120,8 @@ export function CommentComposer({
|
|
|
123
120
|
if (!send || !input) return
|
|
124
121
|
mirror.innerHTML = input.innerHTML
|
|
125
122
|
const textWidth = mirror.offsetWidth
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// less the button plus the field's 6px gap when already expanded (the wrap then spans the
|
|
129
|
-
// full field). Without subtracting the padding, a padded wrap wraps to a second line ~padding
|
|
130
|
-
// px before expansion fires, so the input grows a line and then snaps to the expanded layout.
|
|
123
|
+
// Measured against the wrap's *content* box: text wraps at clientWidth minus padding, so
|
|
124
|
+
// without subtracting it the input grows a line before expansion fires and then snaps.
|
|
131
125
|
const wrapStyle = getComputedStyle(wrap)
|
|
132
126
|
const wrapPadX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight)
|
|
133
127
|
const collapsedAvailable =
|
|
@@ -141,34 +135,22 @@ export function CommentComposer({
|
|
|
141
135
|
const remeasureRef = useRef(remeasure)
|
|
142
136
|
remeasureRef.current = remeasure
|
|
143
137
|
|
|
144
|
-
//
|
|
145
|
-
// returns). Updated on every render so the ref never points at a stale editor.
|
|
138
|
+
// Reachable from `handleKeyDown`, which is created before `useEditor` returns.
|
|
146
139
|
const editorRef = useRef<ReturnType<typeof useEditor>>(null)
|
|
147
140
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// that synthetic Enter and submit instead.
|
|
141
|
+
// `commands.enter()` re-dispatches through `handleKeyDown`; without this guard our own handler
|
|
142
|
+
// would catch the synthetic Enter and submit.
|
|
151
143
|
const replayingEnter = useRef(false)
|
|
152
144
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// can tell Shift+Enter apart from Enter — an `Enter` keymap binding also fires on Shift/Cmd+Enter
|
|
157
|
-
// and would otherwise swallow the newline.
|
|
158
|
-
|
|
159
|
-
// The suggestion plugin is built once (the editor is recreated only on `interactive`) and runs
|
|
160
|
-
// outside React, so it must read the mention callbacks through refs — like onChange/onSubmit —
|
|
161
|
-
// or it queries the roster present at mount forever, never seeing a member who loads or joins
|
|
162
|
-
// later. Whether mentions (and a custom picker row) are wired at all is fixed at mount; only the
|
|
163
|
-
// callbacks themselves are live.
|
|
145
|
+
// The suggestion plugin is built once and runs outside React, so it reads the mention callbacks
|
|
146
|
+
// through refs — otherwise it queries the roster present at mount forever, never seeing a member
|
|
147
|
+
// who joins later. Whether mentions are wired at all is fixed at mount; the callbacks are live.
|
|
164
148
|
const mentionsEnabled = !!getMentionSuggestions
|
|
165
149
|
const hasCustomRow = !!renderMentionSuggestion
|
|
166
150
|
const extensions = useMemo(() => {
|
|
167
151
|
const list = [...commentTipTapExtensions]
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// picker itself only turns on when the host provides a resolver; otherwise the node is present
|
|
171
|
-
// but its trigger is disabled.
|
|
152
|
+
// The mention node is always registered, or ProseMirror strips existing mentions when an
|
|
153
|
+
// edited body loads. Only the `@` trigger is gated on the host providing a resolver.
|
|
172
154
|
if (mentionsEnabled) {
|
|
173
155
|
const resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []
|
|
174
156
|
const renderRow = hasCustomRow
|
|
@@ -193,9 +175,8 @@ export function CommentComposer({
|
|
|
193
175
|
extensions,
|
|
194
176
|
content: (value ?? EMPTY_COMMENT) as JSONContent,
|
|
195
177
|
editable: interactive,
|
|
196
|
-
// tldraw
|
|
197
|
-
//
|
|
198
|
-
// mirrors RichTextArea's setup.
|
|
178
|
+
// tldraw ships its own TextDirection extension, so TipTap's core one would warn about a
|
|
179
|
+
// duplicate. Mirrors RichTextArea's setup.
|
|
199
180
|
enableCoreExtensions: { textDirection: false },
|
|
200
181
|
textDirection: 'auto',
|
|
201
182
|
editorProps: {
|
|
@@ -210,8 +191,8 @@ export function CommentComposer({
|
|
|
210
191
|
handleKeyDown: (_view, event) => {
|
|
211
192
|
// Let the keymaps handle the synthetic Enter we replay for a Shift+Enter newline.
|
|
212
193
|
if (replayingEnter.current) return false
|
|
213
|
-
// Up in an empty composer hands off to the host
|
|
214
|
-
//
|
|
194
|
+
// Up in an empty composer hands off to the host. With content it stays cursor
|
|
195
|
+
// movement, and with the picker open it navigates the roster.
|
|
215
196
|
if (event.key === 'ArrowUp' && !event.isComposing) {
|
|
216
197
|
if (
|
|
217
198
|
onArrowUpWhenEmptyRef.current &&
|
|
@@ -226,12 +207,10 @@ export function CommentComposer({
|
|
|
226
207
|
if (event.key !== 'Enter' || event.isComposing) return false
|
|
227
208
|
// While the @-mention picker is open, Enter selects the highlighted member — defer.
|
|
228
209
|
if (isMentionPickerOpen()) return false
|
|
229
|
-
// Shift+Enter inserts a new line
|
|
230
|
-
//
|
|
231
|
-
// list item in a list, a new paragraph otherwise. tldraw doesn't do soft breaks.
|
|
210
|
+
// Shift+Enter inserts a new line by replaying a plain Enter through the keymaps, reusing
|
|
211
|
+
// the editor's list-aware handling. tldraw doesn't do soft breaks.
|
|
232
212
|
if (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
233
|
-
// An empty field
|
|
234
|
-
// open the comment with a stray leading blank line.
|
|
213
|
+
// An empty field would open the comment with a stray leading blank line.
|
|
235
214
|
if (editorRef.current?.isEmpty) return true
|
|
236
215
|
replayingEnter.current = true
|
|
237
216
|
try {
|
|
@@ -248,8 +227,7 @@ export function CommentComposer({
|
|
|
248
227
|
},
|
|
249
228
|
onUpdate: ({ editor }) => {
|
|
250
229
|
setIsEmpty(editor.isEmpty)
|
|
251
|
-
// Same-value state sets don't re-render,
|
|
252
|
-
// already updated when onUpdate fires.
|
|
230
|
+
// Same-value state sets don't re-render, and the DOM is already updated here.
|
|
253
231
|
remeasureRef.current()
|
|
254
232
|
onChangeRef.current?.(editor.getJSON() as TLRichText)
|
|
255
233
|
},
|
|
@@ -281,28 +259,6 @@ export function CommentComposer({
|
|
|
281
259
|
return () => cancelAnimationFrame(raf)
|
|
282
260
|
}, [autoFocus, editor])
|
|
283
261
|
|
|
284
|
-
// tldraw's pass-through-wheel hook (on the floating composer's wrapper) forwards a wheel to the
|
|
285
|
-
// canvas unless ITS element is scrollable — but the scrollable element is this input, not that
|
|
286
|
-
// wrapper, so an overflowing input never scrolled: the wheel panned the canvas instead. Catch the
|
|
287
|
-
// wheel on the wrap first (a native listener here fires before the wrapper's, deeper in the tree)
|
|
288
|
-
// and stop it while the input can still scroll that way; at the scroll boundary let it through so
|
|
289
|
-
// the canvas still zooms.
|
|
290
|
-
useEffect(() => {
|
|
291
|
-
const wrap = inputWrapRef.current
|
|
292
|
-
if (!wrap) return
|
|
293
|
-
const onWheel = (e: WheelEvent) => {
|
|
294
|
-
const input = wrap.querySelector<HTMLElement>('.tlui-cmt-input')
|
|
295
|
-
if (!input || input.scrollHeight <= input.clientHeight) return
|
|
296
|
-
const atTop = input.scrollTop <= 0
|
|
297
|
-
const atBottom = input.scrollTop >= input.scrollHeight - input.clientHeight - 1
|
|
298
|
-
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
|
|
299
|
-
e.stopPropagation()
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
wrap.addEventListener('wheel', onWheel)
|
|
303
|
-
return () => wrap.removeEventListener('wheel', onWheel)
|
|
304
|
-
}, [])
|
|
305
|
-
|
|
306
262
|
// The whole field behaves like the text input: clicking its empty area (the padding, or the
|
|
307
263
|
// space beside/below a short line) focuses the editor rather than only the text glyphs being
|
|
308
264
|
// clickable. The input (caret placement) and the send button keep their own click handling.
|
package/src/ui/comment-pin.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ReactNode } from 'react'
|
|
2
|
+
import { PinCheckIcon } from './icons'
|
|
2
3
|
|
|
3
4
|
/** @public */
|
|
4
5
|
export interface CommentPinProps {
|
|
@@ -10,22 +11,8 @@ export interface CommentPinProps {
|
|
|
10
11
|
open?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
/*
|
|
14
|
-
const resolvedCheck =
|
|
15
|
-
<svg
|
|
16
|
-
viewBox="0 0 24 24"
|
|
17
|
-
width="15"
|
|
18
|
-
height="15"
|
|
19
|
-
fill="none"
|
|
20
|
-
stroke="currentColor"
|
|
21
|
-
strokeWidth="2.5"
|
|
22
|
-
strokeLinecap="round"
|
|
23
|
-
strokeLinejoin="round"
|
|
24
|
-
aria-hidden="true"
|
|
25
|
-
>
|
|
26
|
-
<path d="M4 12.5l5 5L20 6.5" />
|
|
27
|
-
</svg>
|
|
28
|
-
)
|
|
14
|
+
/* Decorative — the pin's accessible name lives on whatever wraps it. */
|
|
15
|
+
const resolvedCheck = <PinCheckIcon />
|
|
29
16
|
|
|
30
17
|
/** A canvas comment marker: shows its `children` (or a check when resolved). Purely
|
|
31
18
|
* presentational — it reflects open/resolved state via CSS; wrap it to make it clickable.
|
package/src/ui/comments-list.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Avatar, type CommentAuthor } from '@tldraw/mentions'
|
|
2
|
-
import { MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { Fragment, MouseEvent, ReactNode } from 'react'
|
|
3
3
|
import { useTranslation } from 'tldraw'
|
|
4
4
|
import { Byline } from './byline'
|
|
5
|
+
import { CheckIcon } from './icons'
|
|
6
|
+
import { Reactions, type ReactionSummary } from './reactions'
|
|
5
7
|
import { replyCountLabel } from './reply-count'
|
|
6
8
|
|
|
7
9
|
/** @public */
|
|
@@ -19,6 +21,9 @@ export interface CommentListItemProps {
|
|
|
19
21
|
count?: number
|
|
20
22
|
/** Whether this thread is the open one. */
|
|
21
23
|
selected?: boolean
|
|
24
|
+
/** Tallied reactions for the row (a thread's, or a single comment's when the row is one
|
|
25
|
+
* comment), shown as inert pills under the preview. Omit to hide. */
|
|
26
|
+
reactions?: ReactionSummary[]
|
|
22
27
|
/**
|
|
23
28
|
* Link target for the item. When set, the row renders as an anchor so browser affordances
|
|
24
29
|
* (ctrl/cmd-click, middle-click) open it in a new tab; a plain click still calls `onSelect`.
|
|
@@ -26,6 +31,20 @@ export interface CommentListItemProps {
|
|
|
26
31
|
href?: string
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What a row is rendered with: the item, plus the list-level wiring it needs to be interactive.
|
|
36
|
+
* A custom row gets the same props the default `<CommentListItem>` does, so it can wrap the
|
|
37
|
+
* default rather than reimplement it.
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export interface CommentListItemRenderProps extends CommentListItemProps {
|
|
42
|
+
/** Label for a resolved thread's marker on its row. */
|
|
43
|
+
resolvedLabel?: string
|
|
44
|
+
/** Called with the thread id when the row is chosen. */
|
|
45
|
+
onSelect?(id: string): void
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
/** @public */
|
|
30
49
|
export interface CommentsListProps {
|
|
31
50
|
items: CommentListItemProps[]
|
|
@@ -39,8 +58,12 @@ export interface CommentsListProps {
|
|
|
39
58
|
empty?: ReactNode
|
|
40
59
|
/** Label for a resolved thread's marker on its row. Defaults to "Resolved". */
|
|
41
60
|
resolvedLabel?: string
|
|
42
|
-
/**
|
|
43
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Override how each item renders. Defaults to `<CommentListItem>`, which is exported — so a
|
|
63
|
+
* row that only adds something can spread these props into it rather than start over. The list
|
|
64
|
+
* supplies the key, so a custom row doesn't need one.
|
|
65
|
+
*/
|
|
66
|
+
renderItem?(props: CommentListItemRenderProps): ReactNode
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
/**
|
|
@@ -70,25 +93,25 @@ export function CommentsList({
|
|
|
70
93
|
<div className="tlui-cmt-list__empty">{empty}</div>
|
|
71
94
|
) : (
|
|
72
95
|
<div className="tlui-cmt-list__items">
|
|
73
|
-
{items.map((item) =>
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
{items.map((item) => {
|
|
97
|
+
const props: CommentListItemRenderProps = { ...item, resolvedLabel, onSelect }
|
|
98
|
+
// A custom row is wrapped rather than keyed directly: it's the consumer's element,
|
|
99
|
+
// and requiring them to remember a key is the kind of thing that only shows up as a
|
|
100
|
+
// console warning in someone else's app.
|
|
101
|
+
return renderItem ? (
|
|
102
|
+
<Fragment key={item.id}>{renderItem(props)}</Fragment>
|
|
76
103
|
) : (
|
|
77
|
-
<CommentListItem
|
|
78
|
-
key={item.id}
|
|
79
|
-
{...item}
|
|
80
|
-
resolvedLabel={resolvedLabel}
|
|
81
|
-
onSelect={onSelect}
|
|
82
|
-
/>
|
|
104
|
+
<CommentListItem key={item.id} {...props} />
|
|
83
105
|
)
|
|
84
|
-
)}
|
|
106
|
+
})}
|
|
85
107
|
</div>
|
|
86
108
|
)}
|
|
87
109
|
</div>
|
|
88
110
|
)
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
|
|
113
|
+
/** One thread's row in a {@link CommentsList}. @public @react */
|
|
114
|
+
export function CommentListItem({
|
|
92
115
|
id,
|
|
93
116
|
author,
|
|
94
117
|
preview,
|
|
@@ -97,10 +120,11 @@ function CommentListItem({
|
|
|
97
120
|
page,
|
|
98
121
|
count,
|
|
99
122
|
selected,
|
|
123
|
+
reactions,
|
|
100
124
|
href,
|
|
101
125
|
resolvedLabel = 'Resolved',
|
|
102
126
|
onSelect,
|
|
103
|
-
}:
|
|
127
|
+
}: CommentListItemRenderProps) {
|
|
104
128
|
const msg = useTranslation()
|
|
105
129
|
const handleClick = (e: MouseEvent) => {
|
|
106
130
|
if (href && isOpenInNewTabClick(e)) return
|
|
@@ -123,6 +147,7 @@ function CommentListItem({
|
|
|
123
147
|
<div className="tlui-cmt-list__item-body">
|
|
124
148
|
<Byline author={author} date={date} />
|
|
125
149
|
<div className="tlui-cmt-list__item-preview">{preview}</div>
|
|
150
|
+
{reactions && <Reactions reactions={reactions} canReact={false} enableHoverList={false} />}
|
|
126
151
|
{(resolved || page !== undefined || replies) && (
|
|
127
152
|
<div className="tlui-cmt-list__item-meta">
|
|
128
153
|
{resolved && (
|
|
@@ -147,17 +172,3 @@ function CommentListItem({
|
|
|
147
172
|
export function isOpenInNewTabClick(e: MouseEvent) {
|
|
148
173
|
return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0
|
|
149
174
|
}
|
|
150
|
-
|
|
151
|
-
function CheckIcon() {
|
|
152
|
-
return (
|
|
153
|
-
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
154
|
-
<path
|
|
155
|
-
d="M2.5 6.2 4.7 8.4 9.5 3.6"
|
|
156
|
-
stroke="currentColor"
|
|
157
|
-
strokeWidth="1.5"
|
|
158
|
-
strokeLinecap="round"
|
|
159
|
-
strokeLinejoin="round"
|
|
160
|
-
/>
|
|
161
|
-
</svg>
|
|
162
|
-
)
|
|
163
|
-
}
|