@tldraw/commenting 0.0.0-bootstrap
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/README.md +11 -0
- package/commenting.css +832 -0
- package/dist-cjs/canvas/cluster-input.js +40 -0
- package/dist-cjs/canvas/cluster-input.js.map +7 -0
- package/dist-cjs/canvas/comment-body.js +31 -0
- package/dist-cjs/canvas/comment-body.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +72 -0
- package/dist-cjs/canvas/comment-render.js.map +7 -0
- package/dist-cjs/canvas/comment-store.js +48 -0
- package/dist-cjs/canvas/comment-store.js.map +7 -0
- package/dist-cjs/canvas/comment-tool.js +160 -0
- package/dist-cjs/canvas/comment-tool.js.map +7 -0
- package/dist-cjs/canvas/comments-filter-menu.js +109 -0
- package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-menu-item.js +42 -0
- package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
- package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
- package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-overlay.js +1081 -0
- package/dist-cjs/canvas/comments-overlay.js.map +7 -0
- package/dist-cjs/canvas/comments-sidebar.js +134 -0
- package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +45 -0
- package/dist-cjs/canvas/hooks.js.map +7 -0
- package/dist-cjs/canvas/license.js +28 -0
- package/dist-cjs/canvas/license.js.map +7 -0
- package/dist-cjs/canvas/options.js +45 -0
- package/dist-cjs/canvas/options.js.map +7 -0
- package/dist-cjs/canvas/region-options.js +49 -0
- package/dist-cjs/canvas/region-options.js.map +7 -0
- package/dist-cjs/canvas/rich-text.js +28 -0
- package/dist-cjs/canvas/rich-text.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +30 -0
- package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
- package/dist-cjs/canvas/state.js +76 -0
- package/dist-cjs/canvas/state.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +81 -0
- package/dist-cjs/canvas/thread-state.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +83 -0
- package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
- package/dist-cjs/clustering/mst.js +117 -0
- package/dist-cjs/clustering/mst.js.map +7 -0
- package/dist-cjs/clustering/replay.js +275 -0
- package/dist-cjs/clustering/replay.js.map +7 -0
- package/dist-cjs/clustering/runtime.js +134 -0
- package/dist-cjs/clustering/runtime.js.map +7 -0
- package/dist-cjs/clustering/schedule.js +170 -0
- package/dist-cjs/clustering/schedule.js.map +7 -0
- package/dist-cjs/clustering/types.js +17 -0
- package/dist-cjs/clustering/types.js.map +7 -0
- package/dist-cjs/index.d.ts +868 -0
- package/dist-cjs/index.js +126 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/ui/avatar.js +43 -0
- package/dist-cjs/ui/avatar.js.map +7 -0
- package/dist-cjs/ui/byline.js +35 -0
- package/dist-cjs/ui/byline.js.map +7 -0
- package/dist-cjs/ui/comment-card.js +37 -0
- package/dist-cjs/ui/comment-card.js.map +7 -0
- package/dist-cjs/ui/comment-composer.js +136 -0
- package/dist-cjs/ui/comment-composer.js.map +7 -0
- package/dist-cjs/ui/comment-extensions.js +37 -0
- package/dist-cjs/ui/comment-extensions.js.map +7 -0
- package/dist-cjs/ui/comment-mention.js +42 -0
- package/dist-cjs/ui/comment-mention.js.map +7 -0
- package/dist-cjs/ui/comment-pin.js +29 -0
- package/dist-cjs/ui/comment-pin.js.map +7 -0
- package/dist-cjs/ui/comment-text.js +29 -0
- package/dist-cjs/ui/comment-text.js.map +7 -0
- package/dist-cjs/ui/comment-thread.js +45 -0
- package/dist-cjs/ui/comment-thread.js.map +7 -0
- package/dist-cjs/ui/comments-list.js +106 -0
- package/dist-cjs/ui/comments-list.js.map +7 -0
- package/dist-cjs/ui/count-badge.js +28 -0
- package/dist-cjs/ui/count-badge.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +31 -0
- package/dist-cjs/ui/empty-state.js.map +7 -0
- package/dist-cjs/ui/format-time.js +46 -0
- package/dist-cjs/ui/format-time.js.map +7 -0
- package/dist-cjs/ui/mention-list.js +70 -0
- package/dist-cjs/ui/mention-list.js.map +7 -0
- package/dist-cjs/ui/mention-suggestion.js +206 -0
- package/dist-cjs/ui/mention-suggestion.js.map +7 -0
- package/dist-cjs/ui/mention.js +31 -0
- package/dist-cjs/ui/mention.js.map +7 -0
- package/dist-cjs/ui/reaction.js +31 -0
- package/dist-cjs/ui/reaction.js.map +7 -0
- package/dist-cjs/ui/reactions.js +34 -0
- package/dist-cjs/ui/reactions.js.map +7 -0
- package/dist-cjs/ui/render-markdown.js +63 -0
- package/dist-cjs/ui/render-markdown.js.map +7 -0
- package/dist-cjs/ui/send-button.js +28 -0
- package/dist-cjs/ui/send-button.js.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +20 -0
- package/dist-esm/canvas/cluster-input.mjs.map +7 -0
- package/dist-esm/canvas/comment-body.mjs +11 -0
- package/dist-esm/canvas/comment-body.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +52 -0
- package/dist-esm/canvas/comment-render.mjs.map +7 -0
- package/dist-esm/canvas/comment-store.mjs +28 -0
- package/dist-esm/canvas/comment-store.mjs.map +7 -0
- package/dist-esm/canvas/comment-tool.mjs +142 -0
- package/dist-esm/canvas/comment-tool.mjs.map +7 -0
- package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
- package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-menu-item.mjs +22 -0
- package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-overlay.mjs +1094 -0
- package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
- package/dist-esm/canvas/comments-sidebar.mjs +120 -0
- package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +25 -0
- package/dist-esm/canvas/hooks.mjs.map +7 -0
- package/dist-esm/canvas/license.mjs +8 -0
- package/dist-esm/canvas/license.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +27 -0
- package/dist-esm/canvas/options.mjs.map +7 -0
- package/dist-esm/canvas/region-options.mjs +29 -0
- package/dist-esm/canvas/region-options.mjs.map +7 -0
- package/dist-esm/canvas/rich-text.mjs +8 -0
- package/dist-esm/canvas/rich-text.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +10 -0
- package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs +60 -0
- package/dist-esm/canvas/state.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +61 -0
- package/dist-esm/canvas/thread-state.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +63 -0
- package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
- package/dist-esm/clustering/mst.mjs +97 -0
- package/dist-esm/clustering/mst.mjs.map +7 -0
- package/dist-esm/clustering/replay.mjs +255 -0
- package/dist-esm/clustering/replay.mjs.map +7 -0
- package/dist-esm/clustering/runtime.mjs +114 -0
- package/dist-esm/clustering/runtime.mjs.map +7 -0
- package/dist-esm/clustering/schedule.mjs +150 -0
- package/dist-esm/clustering/schedule.mjs.map +7 -0
- package/dist-esm/clustering/types.mjs +1 -0
- package/dist-esm/clustering/types.mjs.map +7 -0
- package/dist-esm/index.d.mts +868 -0
- package/dist-esm/index.mjs +139 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/ui/avatar.mjs +23 -0
- package/dist-esm/ui/avatar.mjs.map +7 -0
- package/dist-esm/ui/byline.mjs +15 -0
- package/dist-esm/ui/byline.mjs.map +7 -0
- package/dist-esm/ui/comment-card.mjs +17 -0
- package/dist-esm/ui/comment-card.mjs.map +7 -0
- package/dist-esm/ui/comment-composer.mjs +116 -0
- package/dist-esm/ui/comment-composer.mjs.map +7 -0
- package/dist-esm/ui/comment-extensions.mjs +17 -0
- package/dist-esm/ui/comment-extensions.mjs.map +7 -0
- package/dist-esm/ui/comment-mention.mjs +22 -0
- package/dist-esm/ui/comment-mention.mjs.map +7 -0
- package/dist-esm/ui/comment-pin.mjs +9 -0
- package/dist-esm/ui/comment-pin.mjs.map +7 -0
- package/dist-esm/ui/comment-text.mjs +9 -0
- package/dist-esm/ui/comment-text.mjs.map +7 -0
- package/dist-esm/ui/comment-thread.mjs +25 -0
- package/dist-esm/ui/comment-thread.mjs.map +7 -0
- package/dist-esm/ui/comments-list.mjs +86 -0
- package/dist-esm/ui/comments-list.mjs.map +7 -0
- package/dist-esm/ui/count-badge.mjs +8 -0
- package/dist-esm/ui/count-badge.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +11 -0
- package/dist-esm/ui/empty-state.mjs.map +7 -0
- package/dist-esm/ui/format-time.mjs +26 -0
- package/dist-esm/ui/format-time.mjs.map +7 -0
- package/dist-esm/ui/mention-list.mjs +50 -0
- package/dist-esm/ui/mention-list.mjs.map +7 -0
- package/dist-esm/ui/mention-suggestion.mjs +186 -0
- package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
- package/dist-esm/ui/mention.mjs +11 -0
- package/dist-esm/ui/mention.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +11 -0
- package/dist-esm/ui/reaction.mjs.map +7 -0
- package/dist-esm/ui/reactions.mjs +14 -0
- package/dist-esm/ui/reactions.mjs.map +7 -0
- package/dist-esm/ui/render-markdown.mjs +45 -0
- package/dist-esm/ui/render-markdown.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +8 -0
- package/dist-esm/ui/send-button.mjs.map +7 -0
- package/package.json +71 -0
- package/src/canvas/canvas.css +148 -0
- package/src/canvas/cluster-input.test.ts +223 -0
- package/src/canvas/cluster-input.ts +29 -0
- package/src/canvas/comment-body.tsx +21 -0
- package/src/canvas/comment-render.test.ts +90 -0
- package/src/canvas/comment-render.ts +92 -0
- package/src/canvas/comment-store.ts +55 -0
- package/src/canvas/comment-tool.tsx +200 -0
- package/src/canvas/comments-filter-menu.tsx +104 -0
- package/src/canvas/comments-menu-item.tsx +24 -0
- package/src/canvas/comments-overflow-menu.tsx +62 -0
- package/src/canvas/comments-overlay.tsx +1460 -0
- package/src/canvas/comments-sidebar.tsx +184 -0
- package/src/canvas/hooks.ts +28 -0
- package/src/canvas/license.ts +15 -0
- package/src/canvas/options.test.ts +127 -0
- package/src/canvas/options.ts +111 -0
- package/src/canvas/region-options.ts +57 -0
- package/src/canvas/rich-text.ts +15 -0
- package/src/canvas/sidebar-filters.ts +25 -0
- package/src/canvas/state.ts +102 -0
- package/src/canvas/thread-state.ts +91 -0
- package/src/clustering/computeClusterTable.test.ts +299 -0
- package/src/clustering/computeClusterTable.ts +82 -0
- package/src/clustering/locality.test.ts +189 -0
- package/src/clustering/mst.test.ts +383 -0
- package/src/clustering/mst.ts +134 -0
- package/src/clustering/replay.test.ts +547 -0
- package/src/clustering/replay.ts +326 -0
- package/src/clustering/runtime.test.ts +596 -0
- package/src/clustering/runtime.ts +153 -0
- package/src/clustering/schedule.test.ts +514 -0
- package/src/clustering/schedule.ts +184 -0
- package/src/clustering/types.ts +102 -0
- package/src/index.ts +96 -0
- package/src/ui/avatar.tsx +31 -0
- package/src/ui/byline.tsx +23 -0
- package/src/ui/comment-card.tsx +32 -0
- package/src/ui/comment-composer.tsx +179 -0
- package/src/ui/comment-extensions.ts +25 -0
- package/src/ui/comment-mention.ts +47 -0
- package/src/ui/comment-pin.tsx +21 -0
- package/src/ui/comment-text.tsx +12 -0
- package/src/ui/comment-thread.tsx +56 -0
- package/src/ui/comments-list.tsx +139 -0
- package/src/ui/comments.css +678 -0
- package/src/ui/count-badge.tsx +9 -0
- package/src/ui/empty-state.tsx +16 -0
- package/src/ui/format-time.ts +29 -0
- package/src/ui/mention-list.tsx +106 -0
- package/src/ui/mention-suggestion.test.ts +18 -0
- package/src/ui/mention-suggestion.tsx +285 -0
- package/src/ui/mention.tsx +9 -0
- package/src/ui/reaction.tsx +16 -0
- package/src/ui/reactions.tsx +15 -0
- package/src/ui/render-markdown.tsx +72 -0
- package/src/ui/send-button.tsx +15 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { ClusterNode, ContractedEvent, MergeEvent, RawMergeEvent } from './types'
|
|
2
|
+
|
|
3
|
+
export function contract(raw: readonly RawMergeEvent[], eps: number): ContractedEvent[] {
|
|
4
|
+
if (!Number.isFinite(eps) || eps < 0) {
|
|
5
|
+
throw new Error('eps must be finite and greater than or equal to 0')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const out: ContractedEvent[] = []
|
|
9
|
+
let i = 0
|
|
10
|
+
while (i < raw.length) {
|
|
11
|
+
const anchor = raw[i].z
|
|
12
|
+
let j = i
|
|
13
|
+
while (j + 1 < raw.length && raw[j + 1].z >= anchor / (1 + eps)) {
|
|
14
|
+
j++
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const chains = contractWindow(raw.slice(i, j + 1), anchor)
|
|
18
|
+
out.push(...chains)
|
|
19
|
+
i = j + 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function finalize(
|
|
26
|
+
events: readonly ContractedEvent[],
|
|
27
|
+
opts: { Tc: number; Tu: number; minZoom: number; maxZoom: number; maxSplitZoom: number }
|
|
28
|
+
): MergeEvent[] {
|
|
29
|
+
validateFinalizeOptions(opts)
|
|
30
|
+
|
|
31
|
+
const r = opts.Tu / opts.Tc
|
|
32
|
+
// Every cluster must have split by maxSplitZoom, however close (or coincident) its members.
|
|
33
|
+
// Capping zMerge at maxSplitZoom / r keeps the whole band below the cap, preserving
|
|
34
|
+
// zSplit > zMerge and the table's sort order (min with a constant is order-preserving).
|
|
35
|
+
const zMergeCap = opts.maxSplitZoom / r
|
|
36
|
+
const out: MergeEvent[] = []
|
|
37
|
+
for (const event of events) {
|
|
38
|
+
const zMerge = Math.min(event.zMerge, zMergeCap)
|
|
39
|
+
if (zMerge < opts.minZoom) break
|
|
40
|
+
let zSplit = zMerge * r
|
|
41
|
+
if (zMerge < opts.maxZoom) {
|
|
42
|
+
zSplit = Math.min(zSplit, opts.maxZoom)
|
|
43
|
+
}
|
|
44
|
+
out.push({
|
|
45
|
+
zMerge,
|
|
46
|
+
zSplit,
|
|
47
|
+
children: event.children,
|
|
48
|
+
result: event.result,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function contractWindow(events: readonly RawMergeEvent[], zMerge: number): ContractedEvent[] {
|
|
55
|
+
const uf = new ComponentUnionFind(events.length)
|
|
56
|
+
const resultToIndex = new Map<string, number>()
|
|
57
|
+
for (let i = 0; i < events.length; i++) {
|
|
58
|
+
resultToIndex.set(events[i].result.id, i)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < events.length; i++) {
|
|
62
|
+
for (const child of events[i].children) {
|
|
63
|
+
const producer = resultToIndex.get(child.id)
|
|
64
|
+
if (producer !== undefined) {
|
|
65
|
+
uf.union(i, producer)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const byRoot = new Map<number, number[]>()
|
|
71
|
+
for (let i = 0; i < events.length; i++) {
|
|
72
|
+
const root = uf.find(i)
|
|
73
|
+
const list = byRoot.get(root)
|
|
74
|
+
if (list) {
|
|
75
|
+
list.push(i)
|
|
76
|
+
} else {
|
|
77
|
+
byRoot.set(root, [i])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Array.from(byRoot.values())
|
|
82
|
+
.map((indices) => contractChain(events, indices, zMerge))
|
|
83
|
+
.sort((a, b) => compareNodesByMinMember(a.result, b.result))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function contractChain(
|
|
87
|
+
events: readonly RawMergeEvent[],
|
|
88
|
+
indices: readonly number[],
|
|
89
|
+
zMerge: number
|
|
90
|
+
): ContractedEvent {
|
|
91
|
+
const produced = new Set<string>()
|
|
92
|
+
const consumedProduced = new Set<string>()
|
|
93
|
+
for (const index of indices) {
|
|
94
|
+
produced.add(events[index].result.id)
|
|
95
|
+
}
|
|
96
|
+
for (const index of indices) {
|
|
97
|
+
for (const child of events[index].children) {
|
|
98
|
+
if (produced.has(child.id)) consumedProduced.add(child.id)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const childrenById = new Map<string, ClusterNode>()
|
|
103
|
+
for (const index of indices) {
|
|
104
|
+
for (const child of events[index].children) {
|
|
105
|
+
if (!produced.has(child.id)) childrenById.set(child.id, child)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let result: ClusterNode | undefined
|
|
110
|
+
for (const index of indices) {
|
|
111
|
+
const candidate = events[index].result
|
|
112
|
+
if (!consumedProduced.has(candidate.id)) {
|
|
113
|
+
if (!result || compareNodesByMinMember(candidate, result) < 0) result = candidate
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
zMerge,
|
|
119
|
+
children: Array.from(childrenById.values()).sort(compareNodesByMinMember),
|
|
120
|
+
result: result!,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateFinalizeOptions(opts: {
|
|
125
|
+
Tc: number
|
|
126
|
+
Tu: number
|
|
127
|
+
minZoom: number
|
|
128
|
+
maxZoom: number
|
|
129
|
+
maxSplitZoom: number
|
|
130
|
+
}) {
|
|
131
|
+
if (!Number.isFinite(opts.maxSplitZoom) || opts.maxSplitZoom <= 0) {
|
|
132
|
+
throw new Error('maxSplitZoom must be finite and greater than 0')
|
|
133
|
+
}
|
|
134
|
+
if (!Number.isFinite(opts.Tc) || opts.Tc <= 0) {
|
|
135
|
+
throw new Error('Tc must be finite and greater than 0')
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isFinite(opts.Tu) || opts.Tu <= opts.Tc) {
|
|
138
|
+
throw new Error('Tu must be finite and greater than Tc')
|
|
139
|
+
}
|
|
140
|
+
if (!Number.isFinite(opts.minZoom) || opts.minZoom <= 0) {
|
|
141
|
+
throw new Error('minZoom must be finite and greater than 0')
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isFinite(opts.maxZoom) || opts.maxZoom <= opts.minZoom) {
|
|
144
|
+
throw new Error('maxZoom must be finite and greater than minZoom')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compareNodesByMinMember(a: ClusterNode, b: ClusterNode): number {
|
|
149
|
+
const aMin = a.members[0]
|
|
150
|
+
const bMin = b.members[0]
|
|
151
|
+
if (aMin < bMin) return -1
|
|
152
|
+
if (aMin > bMin) return 1
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
class ComponentUnionFind {
|
|
157
|
+
private readonly parent: Int32Array
|
|
158
|
+
|
|
159
|
+
constructor(n: number) {
|
|
160
|
+
this.parent = new Int32Array(n)
|
|
161
|
+
for (let i = 0; i < n; i++) {
|
|
162
|
+
this.parent[i] = i
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
find(index: number): number {
|
|
167
|
+
let root = index
|
|
168
|
+
while (this.parent[root] !== root) {
|
|
169
|
+
root = this.parent[root]
|
|
170
|
+
}
|
|
171
|
+
while (this.parent[index] !== index) {
|
|
172
|
+
const next = this.parent[index]
|
|
173
|
+
this.parent[index] = root
|
|
174
|
+
index = next
|
|
175
|
+
}
|
|
176
|
+
return root
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
union(a: number, b: number) {
|
|
180
|
+
const rootA = this.find(a)
|
|
181
|
+
const rootB = this.find(b)
|
|
182
|
+
if (rootA !== rootB) this.parent[rootB] = rootA
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { VecLike } from 'tldraw'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One comment thread's pin, already resolved to a page-space anchor point.
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export interface LeafInput {
|
|
8
|
+
/** Unique. Thread id. Uniqueness is a precondition — throw on duplicates. */
|
|
9
|
+
id: string
|
|
10
|
+
/** Page-space coordinates. Must be finite — throw on NaN/Infinity. */
|
|
11
|
+
point: VecLike
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** An edge of the Euclidean MST over the leaf anchor points. */
|
|
15
|
+
export interface MstEdge {
|
|
16
|
+
/** Index into the input leaves array. Normalized: leaves[a].id < leaves[b].id (lexicographic). */
|
|
17
|
+
a: number
|
|
18
|
+
/** Index into the input leaves array. */
|
|
19
|
+
b: number
|
|
20
|
+
/** Exact Euclidean page-space distance between the two anchors. May be 0 (coincident). */
|
|
21
|
+
d: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A cluster in the merge tree: a leaf (one thread) or a merged group.
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
export interface ClusterNode {
|
|
29
|
+
/** Leaves: the thread id verbatim. Merged nodes: `cluster:${count}:${minMemberId}`. */
|
|
30
|
+
id: string
|
|
31
|
+
/** Page space; count-weighted mean of all member leaf anchors. */
|
|
32
|
+
centroid: VecLike
|
|
33
|
+
/** Number of member leaves. Leaves = 1. */
|
|
34
|
+
count: number
|
|
35
|
+
/** All member thread ids, sorted lexicographically ascending. */
|
|
36
|
+
members: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One merge produced by the capped replay, before contraction. */
|
|
40
|
+
export interface RawMergeEvent {
|
|
41
|
+
/** Effective merge threshold zEff = min(Tc/d, Dmax/unionBboxDiag). +Infinity for coincident anchors. */
|
|
42
|
+
z: number
|
|
43
|
+
/** The two clusters consumed, ordered by ascending min-member id. */
|
|
44
|
+
children: [ClusterNode, ClusterNode]
|
|
45
|
+
/** The cluster produced. */
|
|
46
|
+
result: ClusterNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A merge event after contraction: possibly multi-way, before hysteresis. */
|
|
50
|
+
export interface ContractedEvent {
|
|
51
|
+
/** Fires (merges) when zoom <= zMerge. May be +Infinity. */
|
|
52
|
+
zMerge: number
|
|
53
|
+
/** Clusters consumed — 2 or more, ordered by ascending min-member id. */
|
|
54
|
+
children: ClusterNode[]
|
|
55
|
+
/** Cluster produced. */
|
|
56
|
+
result: ClusterNode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A finalized merge event, ready for the runtime.
|
|
61
|
+
* @public
|
|
62
|
+
*/
|
|
63
|
+
export interface MergeEvent {
|
|
64
|
+
zMerge: number
|
|
65
|
+
/** Reverses (splits) when zoom \>= zSplit. Always \> zMerge. May be +Infinity. */
|
|
66
|
+
zSplit: number
|
|
67
|
+
children: ClusterNode[]
|
|
68
|
+
result: ClusterNode
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The precomputed clustering schedule for one page's comments.
|
|
73
|
+
* @public
|
|
74
|
+
*/
|
|
75
|
+
export interface ClusterTable {
|
|
76
|
+
/** Sorted non-increasing by zMerge; satisfies the invariants of CLUSTERING.md §7.6. */
|
|
77
|
+
events: readonly MergeEvent[]
|
|
78
|
+
/** One node per input leaf, in input order. */
|
|
79
|
+
leaves: readonly ClusterNode[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @public */
|
|
83
|
+
export interface ClusterOptions {
|
|
84
|
+
/** Cluster (merge) distance, screen px. Default 40. */
|
|
85
|
+
Tc?: number
|
|
86
|
+
/** Uncluster (split) distance, screen px. Must be \> Tc. Default 1.5 · Tc. */
|
|
87
|
+
Tu?: number
|
|
88
|
+
/** Contraction window ratio. Default 0.12. */
|
|
89
|
+
eps?: number
|
|
90
|
+
/** Max cluster screen extent at birth, screen px. Must be \>= Tc. Default 3 · Tc. */
|
|
91
|
+
Dmax?: number
|
|
92
|
+
/** Camera zoom bounds — pass the editor's camera constraints. Required. */
|
|
93
|
+
minZoom: number
|
|
94
|
+
maxZoom: number
|
|
95
|
+
/**
|
|
96
|
+
* Zoom by which every cluster has split, no matter how close its members are — including
|
|
97
|
+
* coincident anchors, which otherwise never split. Merge thresholds are capped at
|
|
98
|
+
* `maxSplitZoom / (Tu/Tc)` so the hysteresis band keeps its shape below the cap.
|
|
99
|
+
* Default 6 (600%).
|
|
100
|
+
*/
|
|
101
|
+
maxSplitZoom?: number
|
|
102
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
|
2
|
+
|
|
3
|
+
// Presentational commenting components. These are tldraw-independent and can be used to build
|
|
4
|
+
// custom commenting UI.
|
|
5
|
+
export { Avatar, type AvatarProps } from './ui/avatar'
|
|
6
|
+
export { Byline, type BylineProps } from './ui/byline'
|
|
7
|
+
export { CommentCard, type CommentCardProps } from './ui/comment-card'
|
|
8
|
+
export { CommentComposer, type CommentComposerProps } from './ui/comment-composer'
|
|
9
|
+
export { CountBadge, type CountBadgeProps } from './ui/count-badge'
|
|
10
|
+
export { CommentPin, type CommentPinProps } from './ui/comment-pin'
|
|
11
|
+
export { CommentText, type CommentTextProps } from './ui/comment-text'
|
|
12
|
+
export { CommentThread, type CommentThreadProps } from './ui/comment-thread'
|
|
13
|
+
export { CommentsList, type CommentListItemProps, type CommentsListProps } from './ui/comments-list'
|
|
14
|
+
export { EmptyState, type EmptyStateProps } from './ui/empty-state'
|
|
15
|
+
export { formatRelativeTime } from './ui/format-time'
|
|
16
|
+
export { Mention, type MentionProps } from './ui/mention'
|
|
17
|
+
export { MentionList, type MentionListProps, type MentionMember } from './ui/mention-list'
|
|
18
|
+
export {
|
|
19
|
+
createMentionSuggestion,
|
|
20
|
+
filterMentionMembers,
|
|
21
|
+
type MentionSuggestionOptions,
|
|
22
|
+
} from './ui/mention-suggestion'
|
|
23
|
+
export { Reaction, type ReactionProps } from './ui/reaction'
|
|
24
|
+
export { Reactions } from './ui/reactions'
|
|
25
|
+
export { renderMarkdown } from './ui/render-markdown'
|
|
26
|
+
export { SendButton, type SendButtonProps } from './ui/send-button'
|
|
27
|
+
|
|
28
|
+
// The tldraw-coupled commenting layer: the comment tool, reactive hooks over the comment
|
|
29
|
+
// records, a rich-text body renderer, and a batteries-included <CanvasComments> overlay. Pairs
|
|
30
|
+
// with the presentational components above.
|
|
31
|
+
export { CommentBody, type CommentBodyProps } from './canvas/comment-body'
|
|
32
|
+
export {
|
|
33
|
+
CommentTool,
|
|
34
|
+
commentToolOverrides,
|
|
35
|
+
commentTools,
|
|
36
|
+
type PendingComment,
|
|
37
|
+
} from './canvas/comment-tool'
|
|
38
|
+
export { collectClusterLeaves } from './canvas/cluster-input'
|
|
39
|
+
export { computeClusterTable } from './clustering/computeClusterTable'
|
|
40
|
+
export {
|
|
41
|
+
getCommentRecord,
|
|
42
|
+
getComments,
|
|
43
|
+
getCommentThreads,
|
|
44
|
+
putCommentRecords,
|
|
45
|
+
removeCommentRecords,
|
|
46
|
+
type TLCommentRecord,
|
|
47
|
+
} from './canvas/comment-store'
|
|
48
|
+
export { createClusterRuntime, type ClusterRuntime } from './clustering/runtime'
|
|
49
|
+
export type {
|
|
50
|
+
ClusterNode,
|
|
51
|
+
ClusterOptions,
|
|
52
|
+
ClusterTable,
|
|
53
|
+
LeafInput,
|
|
54
|
+
MergeEvent,
|
|
55
|
+
} from './clustering/types'
|
|
56
|
+
export { CommentsFilterMenu, type CommentsFilterMenuProps } from './canvas/comments-filter-menu'
|
|
57
|
+
export { CommentsMenuItem } from './canvas/comments-menu-item'
|
|
58
|
+
export { CanvasComments, type CanvasCommentsProps } from './canvas/comments-overlay'
|
|
59
|
+
export {
|
|
60
|
+
type CommentingComponents,
|
|
61
|
+
type CommentingOptions,
|
|
62
|
+
defaultCommentingOptions,
|
|
63
|
+
getCommentingOptions,
|
|
64
|
+
useCommentingOptions,
|
|
65
|
+
} from './canvas/options'
|
|
66
|
+
export { CommentsOverflowMenu } from './canvas/comments-overflow-menu'
|
|
67
|
+
export { CanvasCommentsSidebar, type CanvasCommentsSidebarProps } from './canvas/comments-sidebar'
|
|
68
|
+
export { useComments, useCommentThreads, useThreadComments } from './canvas/hooks'
|
|
69
|
+
export { useCommentingEnabled } from './canvas/license'
|
|
70
|
+
export { DEFAULT_REGION_COMMENT_OPTIONS, type RegionCommentOptions } from './canvas/region-options'
|
|
71
|
+
export { richTextToPlaintext } from './canvas/rich-text'
|
|
72
|
+
export { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './canvas/sidebar-filters'
|
|
73
|
+
export {
|
|
74
|
+
commentsHidden,
|
|
75
|
+
commitCommentMutation,
|
|
76
|
+
openThreadId,
|
|
77
|
+
pendingComment,
|
|
78
|
+
sidebarFilters,
|
|
79
|
+
toggleCommentsHidden,
|
|
80
|
+
useCommentsHidden,
|
|
81
|
+
useOpenThreadId,
|
|
82
|
+
usePendingComment,
|
|
83
|
+
useSidebarFilters,
|
|
84
|
+
} from './canvas/state'
|
|
85
|
+
export {
|
|
86
|
+
anchorPagePoint,
|
|
87
|
+
DEFAULT_IMPRECISE_SHAPE_ANCHOR,
|
|
88
|
+
focusThread,
|
|
89
|
+
shapeAnchorAt,
|
|
90
|
+
} from './canvas/thread-state'
|
|
91
|
+
|
|
92
|
+
registerTldrawLibraryVersion(
|
|
93
|
+
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
|
94
|
+
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
|
95
|
+
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
|
96
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getFirstCharacter } from 'tldraw'
|
|
2
|
+
|
|
3
|
+
/** @public */
|
|
4
|
+
export interface AvatarProps {
|
|
5
|
+
name: string
|
|
6
|
+
/** Background colour, used when there's no `image`. Falls back to the default avatar tint. */
|
|
7
|
+
color?: string
|
|
8
|
+
/** Avatar image URL. When set, shows the image instead of the coloured initial. */
|
|
9
|
+
image?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function initial(name: string) {
|
|
13
|
+
return (getFirstCharacter(name.trim()) || '?').toUpperCase()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A commenter's avatar — their image if provided, otherwise a single-initial coloured circle.
|
|
17
|
+
* @public @react */
|
|
18
|
+
export function Avatar({ name, color, image }: AvatarProps) {
|
|
19
|
+
if (image) {
|
|
20
|
+
return <img className="cmt-avatar cmt-avatar--image" src={image} alt="" aria-hidden="true" />
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="cmt-avatar"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
style={color ? { backgroundColor: color } : undefined}
|
|
27
|
+
>
|
|
28
|
+
{initial(name)}
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { formatRelativeTime } from './format-time'
|
|
2
|
+
|
|
3
|
+
/** @public */
|
|
4
|
+
export interface BylineProps {
|
|
5
|
+
author: string
|
|
6
|
+
/** ISO datetime; formatted to relative time by the component. */
|
|
7
|
+
date: string
|
|
8
|
+
/** Shows an "edited" marker when the comment has been edited. */
|
|
9
|
+
edited?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A comment's metadata line: author name, relative time, and an edited marker. @public @react */
|
|
13
|
+
export function Byline({ author, date, edited }: BylineProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="cmt-head">
|
|
16
|
+
<span className="cmt-author">{author}</span>
|
|
17
|
+
<span className="cmt-time">
|
|
18
|
+
{formatRelativeTime(date)}
|
|
19
|
+
{edited && <span className="cmt-edited"> · edited</span>}
|
|
20
|
+
</span>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ReactNode } from 'react'
|
|
2
|
+
import { Avatar } from './avatar'
|
|
3
|
+
import { Byline } from './byline'
|
|
4
|
+
|
|
5
|
+
/** @public */
|
|
6
|
+
export interface CommentCardProps {
|
|
7
|
+
author: string
|
|
8
|
+
/** The rendered comment body. The card doesn't dictate a format — pass a `<CommentText>`
|
|
9
|
+
* for markdown, a rich-text render, or any node. */
|
|
10
|
+
body: ReactNode
|
|
11
|
+
/** ISO datetime; formatted to relative time by the component. */
|
|
12
|
+
date: string
|
|
13
|
+
you: boolean
|
|
14
|
+
/** Whether the comment has been edited (shows an "edited" marker). */
|
|
15
|
+
edited?: boolean
|
|
16
|
+
/** Hover-revealed controls at the card's top-right (e.g. an edit affordance). */
|
|
17
|
+
actions?: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A single comment: Avatar, Byline, and a body slot the consumer renders. @public @react */
|
|
21
|
+
export function CommentCard({ author, body, date, you, edited, actions }: CommentCardProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={you ? 'cmt-card cmt-card--you' : 'cmt-card'}>
|
|
24
|
+
<Avatar name={author} />
|
|
25
|
+
<div className="cmt-body">
|
|
26
|
+
<Byline author={author} date={date} edited={edited} />
|
|
27
|
+
{body}
|
|
28
|
+
</div>
|
|
29
|
+
{actions !== undefined && <div className="cmt-card__actions">{actions}</div>}
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Extension, JSONContent } from '@tiptap/core'
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
3
|
+
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { isEqual, TLRichText, useMaybeEditor } from 'tldraw'
|
|
5
|
+
import { Avatar } from './avatar'
|
|
6
|
+
import { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'
|
|
7
|
+
import { commentMention } from './comment-mention'
|
|
8
|
+
import { MentionMember } from './mention-list'
|
|
9
|
+
import { createMentionSuggestion, isMentionPickerOpen } from './mention-suggestion'
|
|
10
|
+
import { SendButton } from './send-button'
|
|
11
|
+
|
|
12
|
+
/** @public */
|
|
13
|
+
export interface CommentComposerProps {
|
|
14
|
+
author: string
|
|
15
|
+
placeholder: string
|
|
16
|
+
/** Controlled rich-text value. Omit for the presentational (display-only) composer. */
|
|
17
|
+
value?: TLRichText
|
|
18
|
+
onChange?(value: TLRichText): void
|
|
19
|
+
/** Called on Send click or Enter. When set, the composer is interactive. */
|
|
20
|
+
onSubmit?(): void
|
|
21
|
+
sendLabel?: string
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
autoFocus?: boolean
|
|
24
|
+
/** The leading element before the field. Defaults to the author's avatar. */
|
|
25
|
+
leading?: ReactNode
|
|
26
|
+
/** Resolve the members matching an `@`-query (sync or async). Provide to enable mentions. */
|
|
27
|
+
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
|
28
|
+
/** Override a picker row's content. Defaults to avatar + name (+ secondary). */
|
|
29
|
+
renderMentionSuggestion?(member: MentionMember): ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The input for writing a comment: a TipTap rich-text editor restricted to the comment extension
|
|
34
|
+
* set (bold, italic, lists, links, code, highlight — no headings), with a Send button. Formatting
|
|
35
|
+
* is applied through markdown and keyboard shortcuts (e.g. `**bold**`, `- `, Cmd+B); there's no
|
|
36
|
+
* floating toolbar. Presentational by default; pass value/onChange/onSubmit to drive it as a form.
|
|
37
|
+
* @public @react
|
|
38
|
+
*/
|
|
39
|
+
export function CommentComposer({
|
|
40
|
+
author,
|
|
41
|
+
placeholder,
|
|
42
|
+
value,
|
|
43
|
+
onChange,
|
|
44
|
+
onSubmit,
|
|
45
|
+
sendLabel = 'Send',
|
|
46
|
+
disabled,
|
|
47
|
+
autoFocus,
|
|
48
|
+
leading,
|
|
49
|
+
getMentionSuggestions,
|
|
50
|
+
renderMentionSuggestion,
|
|
51
|
+
}: CommentComposerProps) {
|
|
52
|
+
const interactive = !!onChange || !!onSubmit
|
|
53
|
+
// The canvas editor the composer lives in, if any — lets the mention popup track the camera. Null
|
|
54
|
+
// when the composer is used outside a tldraw editor (e.g. an isolated demo).
|
|
55
|
+
const tlEditor = useMaybeEditor()
|
|
56
|
+
|
|
57
|
+
// Callbacks are read through refs so the editor instance doesn't need to be recreated when they
|
|
58
|
+
// change identity between renders.
|
|
59
|
+
const onChangeRef = useRef(onChange)
|
|
60
|
+
onChangeRef.current = onChange
|
|
61
|
+
const onSubmitRef = useRef(onSubmit)
|
|
62
|
+
onSubmitRef.current = onSubmit
|
|
63
|
+
const disabledRef = useRef(disabled)
|
|
64
|
+
disabledRef.current = disabled
|
|
65
|
+
const getMentionSuggestionsRef = useRef(getMentionSuggestions)
|
|
66
|
+
getMentionSuggestionsRef.current = getMentionSuggestions
|
|
67
|
+
const renderMentionSuggestionRef = useRef(renderMentionSuggestion)
|
|
68
|
+
renderMentionSuggestionRef.current = renderMentionSuggestion
|
|
69
|
+
|
|
70
|
+
const [isEmpty, setIsEmpty] = useState(() => !value || isCommentEmpty(value))
|
|
71
|
+
|
|
72
|
+
// Enter submits the comment. Shift+Enter (and Cmd/Ctrl+Enter) keep their default behavior — a
|
|
73
|
+
// new line — for the occasional multi-line comment.
|
|
74
|
+
const submitExtension = useMemo(
|
|
75
|
+
() =>
|
|
76
|
+
Extension.create({
|
|
77
|
+
name: 'commentComposerSubmit',
|
|
78
|
+
priority: 1000,
|
|
79
|
+
addKeyboardShortcuts() {
|
|
80
|
+
return {
|
|
81
|
+
Enter: () => {
|
|
82
|
+
// While the @-mention picker is open, Enter selects the highlighted member.
|
|
83
|
+
// This extension outranks the mention plugin (priority 1000), so defer to the
|
|
84
|
+
// picker here or Enter would submit before the member could be inserted.
|
|
85
|
+
if (isMentionPickerOpen()) return false
|
|
86
|
+
if (!disabledRef.current) onSubmitRef.current?.()
|
|
87
|
+
return true
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
[]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// The suggestion plugin is built once (the editor is recreated only on `interactive`) and runs
|
|
96
|
+
// outside React, so it must read the mention callbacks through refs — like onChange/onSubmit —
|
|
97
|
+
// or it queries the roster present at mount forever, never seeing a member who loads or joins
|
|
98
|
+
// later. Whether mentions (and a custom picker row) are wired at all is fixed at mount; only the
|
|
99
|
+
// callbacks themselves are live.
|
|
100
|
+
const mentionsEnabled = !!getMentionSuggestions
|
|
101
|
+
const hasCustomRow = !!renderMentionSuggestion
|
|
102
|
+
const extensions = useMemo(() => {
|
|
103
|
+
const list = [...commentTipTapExtensions, submitExtension]
|
|
104
|
+
// Always register the mention node so an existing body that contains a mention keeps it on
|
|
105
|
+
// edit (an unregistered node would be stripped by ProseMirror when the content loads). The `@`
|
|
106
|
+
// picker itself only turns on when the host provides a resolver; otherwise the node is present
|
|
107
|
+
// but its trigger is disabled.
|
|
108
|
+
if (mentionsEnabled) {
|
|
109
|
+
const resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []
|
|
110
|
+
const renderRow = hasCustomRow
|
|
111
|
+
? (member: MentionMember) => renderMentionSuggestionRef.current?.(member)
|
|
112
|
+
: undefined
|
|
113
|
+
list.push(
|
|
114
|
+
commentMention({
|
|
115
|
+
suggestion: createMentionSuggestion(resolveSuggestions, {
|
|
116
|
+
renderMember: renderRow,
|
|
117
|
+
editor: tlEditor,
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
} else {
|
|
122
|
+
list.push(commentMention({ suggestion: { char: '@', allow: () => false } }))
|
|
123
|
+
}
|
|
124
|
+
return list
|
|
125
|
+
}, [submitExtension, mentionsEnabled, hasCustomRow, tlEditor])
|
|
126
|
+
|
|
127
|
+
const editor = useEditor(
|
|
128
|
+
{
|
|
129
|
+
extensions,
|
|
130
|
+
content: (value ?? EMPTY_COMMENT) as JSONContent,
|
|
131
|
+
editable: interactive,
|
|
132
|
+
// tldraw's default extensions add their own TextDirection extension (so it can be
|
|
133
|
+
// overridden), so disable TipTap's core one to avoid a duplicate-extension warning —
|
|
134
|
+
// mirrors RichTextArea's setup.
|
|
135
|
+
enableCoreExtensions: { textDirection: false },
|
|
136
|
+
textDirection: 'auto',
|
|
137
|
+
editorProps: { attributes: { class: 'cmt-input' } },
|
|
138
|
+
onUpdate: ({ editor }) => {
|
|
139
|
+
setIsEmpty(editor.isEmpty)
|
|
140
|
+
onChangeRef.current?.(editor.getJSON() as TLRichText)
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
[interactive]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// Sync controlled resets (e.g. the parent clearing to EMPTY_COMMENT after posting) into the
|
|
147
|
+
// editor without echoing back the value the editor itself just emitted.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!editor || value === undefined) return
|
|
150
|
+
if (isEqual(editor.getJSON(), value)) return
|
|
151
|
+
editor.commands.setContent(value as JSONContent)
|
|
152
|
+
setIsEmpty(editor.isEmpty)
|
|
153
|
+
}, [editor, value])
|
|
154
|
+
|
|
155
|
+
// Focus on the next frame rather than via TipTap's autofocus: the composer often mounts from a
|
|
156
|
+
// canvas pointer event whose default focus handling would otherwise steal it back.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!autoFocus || !editor) return
|
|
159
|
+
const raf = requestAnimationFrame(() => editor.commands.focus('end'))
|
|
160
|
+
return () => cancelAnimationFrame(raf)
|
|
161
|
+
}, [autoFocus, editor])
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="cmt-composer">
|
|
165
|
+
{leading ?? <Avatar name={author} />}
|
|
166
|
+
<div className="cmt-composer__field">
|
|
167
|
+
<div className="cmt-composer__input-wrap">
|
|
168
|
+
<EditorContent editor={editor} />
|
|
169
|
+
{isEmpty && (
|
|
170
|
+
<div className="cmt-input__placeholder" aria-hidden="true">
|
|
171
|
+
{placeholder}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
{interactive && <SendButton label={sendLabel} onClick={onSubmit} disabled={disabled} />}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getTipTapDefaultExtensions, TLRichText, toRichText } from 'tldraw'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tldraw's default rich-text extension set, minus headings — the deliberately limited set used for
|
|
5
|
+
* both the comment composer and comment display. Comments support paragraphs, bold, italic, lists,
|
|
6
|
+
* links, code, and highlight, but not headings. Built from tldraw's shared factory so the config
|
|
7
|
+
* stays in lockstep with the text shape's defaults rather than drifting from a copy.
|
|
8
|
+
*/
|
|
9
|
+
export const commentTipTapExtensions = getTipTapDefaultExtensions({ heading: false })
|
|
10
|
+
|
|
11
|
+
/** An empty comment document — the seed value for a fresh composer and its post-submit reset. */
|
|
12
|
+
export const EMPTY_COMMENT: TLRichText = toRichText('')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a rich-text comment body has no text. Mirrors tldraw's private `isEmptyRichText`: an
|
|
16
|
+
* empty doc can be encoded as an empty `content` array or a single empty paragraph.
|
|
17
|
+
*/
|
|
18
|
+
export function isCommentEmpty(richText: TLRichText): boolean {
|
|
19
|
+
if (richText.content.length === 0) return true
|
|
20
|
+
if (richText.content.length === 1) {
|
|
21
|
+
const node = richText.content[0] as any
|
|
22
|
+
if (!node.content || node.content.length === 0) return true
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|