@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.
Files changed (238) hide show
  1. package/commenting.css +63 -71
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +252 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +0 -1
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +77 -870
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +134 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +323 -202
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +12 -0
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +115 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +236 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +0 -1
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +54 -878
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +126 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +323 -202
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +12 -0
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +95 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +5 -5
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.tsx +8 -12
  189. package/src/canvas/comments-filter-menu.tsx +9 -16
  190. package/src/canvas/comments-overflow-menu.tsx +8 -3
  191. package/src/canvas/comments-overlay.tsx +154 -1344
  192. package/src/canvas/comments-sidebar.tsx +40 -19
  193. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  194. package/src/canvas/context.ts +4 -3
  195. package/src/canvas/hooks.test.ts +94 -0
  196. package/src/canvas/hooks.ts +17 -3
  197. package/src/canvas/license.ts +1 -1
  198. package/src/canvas/mobile-placement.ts +115 -0
  199. package/src/canvas/options.test.ts +124 -2
  200. package/src/canvas/options.ts +192 -58
  201. package/src/canvas/pending-composer.tsx +157 -0
  202. package/src/canvas/pin-stacking.test.ts +111 -1
  203. package/src/canvas/pin-stacking.ts +46 -0
  204. package/src/canvas/region-box.tsx +124 -0
  205. package/src/canvas/state.ts +20 -28
  206. package/src/canvas/thread-pin.tsx +418 -0
  207. package/src/canvas/thread-preview.tsx +77 -87
  208. package/src/canvas/thread-stack.tsx +19 -29
  209. package/src/canvas/thread-state.test.ts +51 -0
  210. package/src/canvas/thread-state.ts +56 -23
  211. package/src/canvas/thread-view.test.ts +72 -0
  212. package/src/canvas/thread-view.tsx +231 -133
  213. package/src/clustering/computeClusterTable.ts +12 -3
  214. package/src/clustering/replay.test.ts +0 -7
  215. package/src/clustering/replay.ts +131 -32
  216. package/src/clustering/runtime.test.ts +50 -6
  217. package/src/clustering/runtime.ts +42 -39
  218. package/src/clustering/schedule.test.ts +0 -6
  219. package/src/clustering/screen-offsets.test.ts +171 -0
  220. package/src/clustering/types.ts +10 -0
  221. package/src/index.ts +15 -2
  222. package/src/ui/byline.tsx +16 -6
  223. package/src/ui/comment-composer.tsx +25 -69
  224. package/src/ui/comment-pin.tsx +3 -16
  225. package/src/ui/comments-list.tsx +40 -29
  226. package/src/ui/comments.css +58 -60
  227. package/src/ui/format-time.test.ts +69 -0
  228. package/src/ui/format-time.ts +20 -0
  229. package/src/ui/icons.tsx +116 -0
  230. package/src/ui/reaction-picker.tsx +9 -16
  231. package/src/ui/reaction.tsx +10 -6
  232. package/src/ui/send-button.tsx +3 -8
  233. package/src/ui/visual-viewport.test.ts +36 -0
  234. package/src/ui/visual-viewport.ts +27 -0
  235. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  236. package/dist-esm/ui/tooltip-button.mjs +0 -12
  237. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  238. 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
+ })
@@ -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 { CanvasCommentsSidebar, type CanvasCommentsSidebarProps } from './canvas/comments-sidebar'
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 { formatRelativeTime } from './format-time'
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
- /** A comment's metadata line: author name, relative time, and an edited marker. @public @react */
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
- <span className="tlui-cmt-time">
20
- {formatRelativeTime(date)}
21
- {edited && <span className="tlui-cmt-edited"> · edited</span>}
22
- </span>
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
- // The canvas editor the composer lives in, if any — lets the mention popup track the camera. Null
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
- // Callbacks are read through refs so the editor instance doesn't need to be recreated when they
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. The mirror is a
98
- // hidden, nowrap clone of the editor's rendered content, so marks and mention chips measure at
99
- // their true width. A small dead zone between the expand and collapse thresholds keeps the
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's DOM has laid out reads zero widths,
107
- // which would flash the expanded layout for a frame on mount.
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
- // The single-line space the input has beside the send button: the wrap's *content* box
127
- // (clientWidth minus its own horizontal padding the text wraps there, not at clientWidth),
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
- // The editor instance, reachable from `handleKeyDown` (which is created before `useEditor`
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
- // Set while we replay Enter through the keymaps for a Shift+Enter newline: `commands.enter()`
149
- // re-dispatches Enter through `handleKeyDown`, and without this guard our own handler would catch
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
- // Enter and Cmd/Ctrl+Enter submit the comment; Shift+Enter inserts a new line for the occasional
154
- // multi-line comment. This is handled through `editorProps.handleKeyDown` (below) rather than a
155
- // keyboard-shortcut extension: ProseMirror runs `handleKeyDown` before every keymap plugin, so it
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
- // Always register the mention node so an existing body that contains a mention keeps it on
169
- // edit (an unregistered node would be stripped by ProseMirror when the content loads). The `@`
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's default extensions add their own TextDirection extension (so it can be
197
- // overridden), so disable TipTap's core one to avoid a duplicate-extension warning —
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 (edit the comment above). With
214
- // content, Up stays cursor movement; with the mention picker open, it navigates it.
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. Replay a plain Enter through the keymaps (guarded so
230
- // we don't re-enter and submit) to reuse the editor's list-aware Enter handling a new
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 has nothing to break onto swallow the keypress so it doesn't
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, so measure here the editor's DOM is
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.
@@ -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
- /* An inline check, not a text glyph — the '✓' character sits off-baseline and varies by font. */
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.
@@ -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
- /** Override how each item renders. Defaults to `<CommentListItem>`. */
43
- renderItem?(item: CommentListItemProps): ReactNode
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
- renderItem ? (
75
- renderItem(item)
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
- function CommentListItem({
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
- }: CommentListItemProps & { resolvedLabel?: string; onSelect?(id: string): void }) {
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
- }