@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d

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 (338) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1152 -350
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/{ui/avatar.js → canvas/canvas-events.js} +14 -22
  6. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  7. package/dist-cjs/canvas/cluster-badge.js +121 -0
  8. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  9. package/dist-cjs/canvas/cluster-fade.js +85 -0
  10. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  11. package/dist-cjs/canvas/cluster-input.js +53 -3
  12. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  13. package/dist-cjs/canvas/cluster-model.js +252 -0
  14. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  15. package/dist-cjs/canvas/comment-body.js +1 -1
  16. package/dist-cjs/canvas/comment-body.js.map +2 -2
  17. package/dist-cjs/canvas/comment-drafts.js +54 -0
  18. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  19. package/dist-cjs/canvas/comment-mutations.js +152 -0
  20. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  21. package/dist-cjs/canvas/comment-reactions.js +160 -0
  22. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  23. package/dist-cjs/canvas/comment-render.js +9 -6
  24. package/dist-cjs/canvas/comment-render.js.map +2 -2
  25. package/dist-cjs/canvas/comment-store.js +19 -9
  26. package/dist-cjs/canvas/comment-store.js.map +2 -2
  27. package/dist-cjs/canvas/comment-tool.js +63 -14
  28. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  29. package/dist-cjs/canvas/comments-filter-menu.js +17 -37
  30. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  31. package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
  32. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  33. package/dist-cjs/canvas/comments-overlay.js +151 -989
  34. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  35. package/dist-cjs/canvas/comments-sidebar.js +49 -41
  36. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  37. package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
  38. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  39. package/dist-cjs/canvas/context.js +17 -0
  40. package/dist-cjs/canvas/context.js.map +7 -0
  41. package/dist-cjs/canvas/hooks.js +28 -3
  42. package/dist-cjs/canvas/hooks.js.map +2 -2
  43. package/dist-cjs/canvas/license.js.map +1 -1
  44. package/dist-cjs/canvas/mobile-placement.js +91 -0
  45. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  46. package/dist-cjs/canvas/options.js +57 -2
  47. package/dist-cjs/canvas/options.js.map +2 -2
  48. package/dist-cjs/canvas/pending-composer.js +134 -0
  49. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  50. package/dist-cjs/canvas/pin-stacking.js +81 -0
  51. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  52. package/dist-cjs/canvas/region-box.js +113 -0
  53. package/dist-cjs/canvas/region-box.js.map +7 -0
  54. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  55. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  56. package/dist-cjs/canvas/state.js +28 -14
  57. package/dist-cjs/canvas/state.js.map +2 -2
  58. package/dist-cjs/canvas/thread-pin.js +304 -0
  59. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  60. package/dist-cjs/canvas/thread-preview.js +177 -0
  61. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  62. package/dist-cjs/canvas/thread-stack.js +199 -0
  63. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  64. package/dist-cjs/canvas/thread-state.js +82 -21
  65. package/dist-cjs/canvas/thread-state.js.map +2 -2
  66. package/dist-cjs/canvas/thread-view.js +450 -0
  67. package/dist-cjs/canvas/thread-view.js.map +7 -0
  68. package/dist-cjs/clustering/computeClusterTable.js +6 -6
  69. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  70. package/dist-cjs/clustering/replay.js +89 -22
  71. package/dist-cjs/clustering/replay.js.map +3 -3
  72. package/dist-cjs/clustering/runtime.js +126 -7
  73. package/dist-cjs/clustering/runtime.js.map +2 -2
  74. package/dist-cjs/clustering/types.js.map +1 -1
  75. package/dist-cjs/index.d.ts +904 -353
  76. package/dist-cjs/index.js +52 -28
  77. package/dist-cjs/index.js.map +2 -2
  78. package/dist-cjs/ui/byline.js +10 -6
  79. package/dist-cjs/ui/byline.js.map +2 -2
  80. package/dist-cjs/ui/comment-card.js +15 -8
  81. package/dist-cjs/ui/comment-card.js.map +2 -2
  82. package/dist-cjs/ui/comment-composer.js +124 -39
  83. package/dist-cjs/ui/comment-composer.js.map +2 -2
  84. package/dist-cjs/ui/comment-pin.js +11 -2
  85. package/dist-cjs/ui/comment-pin.js.map +2 -2
  86. package/dist-cjs/ui/comment-thread.js +9 -7
  87. package/dist-cjs/ui/comment-thread.js.map +2 -2
  88. package/dist-cjs/ui/comments-list.js +39 -40
  89. package/dist-cjs/ui/comments-list.js.map +2 -2
  90. package/dist-cjs/ui/count-badge.js +3 -2
  91. package/dist-cjs/ui/count-badge.js.map +2 -2
  92. package/dist-cjs/ui/emoji-picker.js +54 -0
  93. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  94. package/dist-cjs/ui/empty-state.js +3 -3
  95. package/dist-cjs/ui/empty-state.js.map +2 -2
  96. package/dist-cjs/ui/format-time.js +16 -1
  97. package/dist-cjs/ui/format-time.js.map +2 -2
  98. package/dist-cjs/ui/icons.js +115 -0
  99. package/dist-cjs/ui/icons.js.map +7 -0
  100. package/dist-cjs/ui/reaction-picker.js +71 -0
  101. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  102. package/dist-cjs/ui/reaction.js +58 -6
  103. package/dist-cjs/ui/reaction.js.map +2 -2
  104. package/dist-cjs/ui/reactions.js +20 -7
  105. package/dist-cjs/ui/reactions.js.map +2 -2
  106. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  107. package/dist-cjs/ui/reply-count.js.map +7 -0
  108. package/dist-cjs/ui/send-button.js +13 -1
  109. package/dist-cjs/ui/send-button.js.map +2 -2
  110. package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
  111. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  112. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  113. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  114. package/dist-esm/canvas/canvas-events.mjs +15 -0
  115. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  116. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  117. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  118. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  119. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  120. package/dist-esm/canvas/cluster-input.mjs +54 -4
  121. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  122. package/dist-esm/canvas/cluster-model.mjs +236 -0
  123. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  124. package/dist-esm/canvas/comment-body.mjs +1 -1
  125. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  126. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  127. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  128. package/dist-esm/canvas/comment-mutations.mjs +132 -0
  129. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  130. package/dist-esm/canvas/comment-reactions.mjs +145 -0
  131. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  132. package/dist-esm/canvas/comment-render.mjs +10 -7
  133. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  134. package/dist-esm/canvas/comment-store.mjs +19 -9
  135. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  136. package/dist-esm/canvas/comment-tool.mjs +66 -17
  137. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  138. package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
  139. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  140. package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
  141. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  142. package/dist-esm/canvas/comments-overlay.mjs +157 -1010
  143. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  144. package/dist-esm/canvas/comments-sidebar.mjs +53 -46
  145. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  146. package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
  147. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  148. package/dist-esm/canvas/context.mjs +1 -0
  149. package/dist-esm/canvas/context.mjs.map +7 -0
  150. package/dist-esm/canvas/hooks.mjs +35 -5
  151. package/dist-esm/canvas/hooks.mjs.map +2 -2
  152. package/dist-esm/canvas/license.mjs.map +1 -1
  153. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  154. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  155. package/dist-esm/canvas/options.mjs +59 -3
  156. package/dist-esm/canvas/options.mjs.map +2 -2
  157. package/dist-esm/canvas/pending-composer.mjs +126 -0
  158. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  159. package/dist-esm/canvas/pin-stacking.mjs +61 -0
  160. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  161. package/dist-esm/canvas/region-box.mjs +93 -0
  162. package/dist-esm/canvas/region-box.mjs.map +7 -0
  163. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  164. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  165. package/dist-esm/canvas/state.mjs +29 -19
  166. package/dist-esm/canvas/state.mjs.map +2 -2
  167. package/dist-esm/canvas/thread-pin.mjs +310 -0
  168. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  169. package/dist-esm/canvas/thread-preview.mjs +163 -0
  170. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  171. package/dist-esm/canvas/thread-stack.mjs +189 -0
  172. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  173. package/dist-esm/canvas/thread-state.mjs +86 -22
  174. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  175. package/dist-esm/canvas/thread-view.mjs +461 -0
  176. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  177. package/dist-esm/clustering/computeClusterTable.mjs +6 -6
  178. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  179. package/dist-esm/clustering/replay.mjs +89 -22
  180. package/dist-esm/clustering/replay.mjs.map +3 -3
  181. package/dist-esm/clustering/runtime.mjs +126 -7
  182. package/dist-esm/clustering/runtime.mjs.map +2 -2
  183. package/dist-esm/index.d.mts +904 -353
  184. package/dist-esm/index.mjs +102 -43
  185. package/dist-esm/index.mjs.map +2 -2
  186. package/dist-esm/ui/byline.mjs +11 -7
  187. package/dist-esm/ui/byline.mjs.map +2 -2
  188. package/dist-esm/ui/comment-card.mjs +15 -8
  189. package/dist-esm/ui/comment-card.mjs.map +2 -2
  190. package/dist-esm/ui/comment-composer.mjs +135 -39
  191. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  192. package/dist-esm/ui/comment-pin.mjs +11 -2
  193. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  194. package/dist-esm/ui/comment-thread.mjs +9 -7
  195. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  196. package/dist-esm/ui/comments-list.mjs +51 -50
  197. package/dist-esm/ui/comments-list.mjs.map +2 -2
  198. package/dist-esm/ui/count-badge.mjs +3 -2
  199. package/dist-esm/ui/count-badge.mjs.map +2 -2
  200. package/dist-esm/ui/emoji-picker.mjs +34 -0
  201. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  202. package/dist-esm/ui/empty-state.mjs +3 -3
  203. package/dist-esm/ui/empty-state.mjs.map +2 -2
  204. package/dist-esm/ui/format-time.mjs +16 -1
  205. package/dist-esm/ui/format-time.mjs.map +2 -2
  206. package/dist-esm/ui/icons.mjs +95 -0
  207. package/dist-esm/ui/icons.mjs.map +7 -0
  208. package/dist-esm/ui/reaction-picker.mjs +59 -0
  209. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  210. package/dist-esm/ui/reaction.mjs +59 -7
  211. package/dist-esm/ui/reaction.mjs.map +2 -2
  212. package/dist-esm/ui/reactions.mjs +21 -8
  213. package/dist-esm/ui/reactions.mjs.map +2 -2
  214. package/dist-esm/ui/reply-count.mjs +9 -0
  215. package/dist-esm/ui/reply-count.mjs.map +7 -0
  216. package/dist-esm/ui/send-button.mjs +13 -1
  217. package/dist-esm/ui/send-button.mjs.map +2 -2
  218. package/dist-esm/ui/visual-viewport.mjs +12 -0
  219. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  220. package/package.json +6 -6
  221. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  222. package/src/canvas/anchor-lifecycle.ts +191 -0
  223. package/src/canvas/canvas-events.ts +18 -0
  224. package/src/canvas/canvas.css +464 -55
  225. package/src/canvas/cluster-badge.tsx +133 -0
  226. package/src/canvas/cluster-fade.ts +102 -0
  227. package/src/canvas/cluster-input.test.ts +178 -36
  228. package/src/canvas/cluster-input.ts +95 -9
  229. package/src/canvas/cluster-model.ts +378 -0
  230. package/src/canvas/comment-body.tsx +1 -1
  231. package/src/canvas/comment-drafts.test.ts +55 -0
  232. package/src/canvas/comment-drafts.ts +46 -0
  233. package/src/canvas/comment-mutations.test.ts +452 -0
  234. package/src/canvas/comment-mutations.ts +299 -0
  235. package/src/canvas/comment-reactions.test.ts +154 -0
  236. package/src/canvas/comment-reactions.tsx +246 -0
  237. package/src/canvas/comment-render.test.ts +1 -1
  238. package/src/canvas/comment-render.ts +17 -13
  239. package/src/canvas/comment-store.ts +68 -33
  240. package/src/canvas/comment-tool.test.ts +162 -0
  241. package/src/canvas/comment-tool.tsx +103 -25
  242. package/src/canvas/comments-filter-menu.tsx +20 -36
  243. package/src/canvas/comments-overflow-menu.tsx +17 -19
  244. package/src/canvas/comments-overlay.tsx +252 -1369
  245. package/src/canvas/comments-sidebar.test.ts +45 -0
  246. package/src/canvas/comments-sidebar.tsx +112 -73
  247. package/src/canvas/comments-visibility-toggle.tsx +25 -0
  248. package/src/canvas/context.ts +48 -0
  249. package/src/canvas/hooks.test.ts +94 -0
  250. package/src/canvas/hooks.ts +66 -11
  251. package/src/canvas/license.ts +1 -1
  252. package/src/canvas/mobile-placement.ts +115 -0
  253. package/src/canvas/options.test.ts +176 -4
  254. package/src/canvas/options.ts +276 -18
  255. package/src/canvas/pending-composer.tsx +157 -0
  256. package/src/canvas/pin-stacking.test.ts +224 -0
  257. package/src/canvas/pin-stacking.ts +103 -0
  258. package/src/canvas/region-box.tsx +124 -0
  259. package/src/canvas/sidebar-filters.ts +4 -3
  260. package/src/canvas/state.ts +88 -39
  261. package/src/canvas/thread-pin.tsx +418 -0
  262. package/src/canvas/thread-preview.test.ts +91 -0
  263. package/src/canvas/thread-preview.tsx +294 -0
  264. package/src/canvas/thread-stack.tsx +242 -0
  265. package/src/canvas/thread-state.test.ts +260 -0
  266. package/src/canvas/thread-state.ts +171 -37
  267. package/src/canvas/thread-view.test.ts +72 -0
  268. package/src/canvas/thread-view.tsx +614 -0
  269. package/src/clustering/computeClusterTable.test.ts +24 -23
  270. package/src/clustering/computeClusterTable.ts +17 -8
  271. package/src/clustering/replay.test.ts +0 -7
  272. package/src/clustering/replay.ts +131 -32
  273. package/src/clustering/runtime.test.ts +267 -13
  274. package/src/clustering/runtime.ts +176 -19
  275. package/src/clustering/schedule.test.ts +0 -6
  276. package/src/clustering/screen-offsets.test.ts +171 -0
  277. package/src/clustering/types.ts +19 -9
  278. package/src/index.ts +91 -42
  279. package/src/ui/byline.tsx +21 -9
  280. package/src/ui/comment-card.tsx +20 -10
  281. package/src/ui/comment-composer.tsx +178 -56
  282. package/src/ui/comment-pin.tsx +13 -2
  283. package/src/ui/comment-thread.tsx +13 -6
  284. package/src/ui/comments-list.test.ts +31 -0
  285. package/src/ui/comments-list.tsx +82 -47
  286. package/src/ui/comments.css +577 -294
  287. package/src/ui/count-badge.tsx +9 -2
  288. package/src/ui/emoji-picker.test.ts +25 -0
  289. package/src/ui/emoji-picker.tsx +76 -0
  290. package/src/ui/empty-state.tsx +3 -3
  291. package/src/ui/format-time.test.ts +69 -0
  292. package/src/ui/format-time.ts +27 -2
  293. package/src/ui/icons.tsx +116 -0
  294. package/src/ui/reaction-picker.tsx +102 -0
  295. package/src/ui/reaction.tsx +125 -6
  296. package/src/ui/reactions.tsx +69 -10
  297. package/src/ui/reply-count.ts +16 -0
  298. package/src/ui/send-button.tsx +13 -3
  299. package/src/ui/visual-viewport.test.ts +36 -0
  300. package/src/ui/visual-viewport.ts +27 -0
  301. package/dist-cjs/canvas/region-options.js +0 -49
  302. package/dist-cjs/canvas/region-options.js.map +0 -7
  303. package/dist-cjs/ui/avatar.js.map +0 -7
  304. package/dist-cjs/ui/comment-mention.js +0 -42
  305. package/dist-cjs/ui/comment-mention.js.map +0 -7
  306. package/dist-cjs/ui/comment-text.js.map +0 -7
  307. package/dist-cjs/ui/mention-list.js +0 -70
  308. package/dist-cjs/ui/mention-list.js.map +0 -7
  309. package/dist-cjs/ui/mention-suggestion.js +0 -206
  310. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  311. package/dist-cjs/ui/mention.js.map +0 -7
  312. package/dist-cjs/ui/render-markdown.js +0 -63
  313. package/dist-cjs/ui/render-markdown.js.map +0 -7
  314. package/dist-esm/canvas/region-options.mjs +0 -29
  315. package/dist-esm/canvas/region-options.mjs.map +0 -7
  316. package/dist-esm/ui/avatar.mjs +0 -23
  317. package/dist-esm/ui/avatar.mjs.map +0 -7
  318. package/dist-esm/ui/comment-mention.mjs +0 -22
  319. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  320. package/dist-esm/ui/comment-text.mjs +0 -9
  321. package/dist-esm/ui/comment-text.mjs.map +0 -7
  322. package/dist-esm/ui/mention-list.mjs +0 -50
  323. package/dist-esm/ui/mention-list.mjs.map +0 -7
  324. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  325. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  326. package/dist-esm/ui/mention.mjs +0 -11
  327. package/dist-esm/ui/mention.mjs.map +0 -7
  328. package/dist-esm/ui/render-markdown.mjs +0 -45
  329. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  330. package/src/canvas/region-options.ts +0 -57
  331. package/src/ui/avatar.tsx +0 -31
  332. package/src/ui/comment-mention.ts +0 -47
  333. package/src/ui/comment-text.tsx +0 -12
  334. package/src/ui/mention-list.tsx +0 -106
  335. package/src/ui/mention-suggestion.test.ts +0 -18
  336. package/src/ui/mention-suggestion.tsx +0 -285
  337. package/src/ui/mention.tsx +0 -9
  338. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,378 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Editor, react, TLCommentThread, useValue } from 'tldraw'
3
+ import { computeClusterTable } from '../clustering/computeClusterTable'
4
+ import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
5
+ import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
6
+ import { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'
7
+ import {
8
+ type ClusterInput,
9
+ clusterInputEqual,
10
+ clusterInputIdsEqual,
11
+ collectClusterLeaves,
12
+ } from './cluster-input'
13
+ import { type CommentingOptions } from './options'
14
+ import { openThreadId } from './state'
15
+ import { anchorPagePoint, commentCenterScreenOffset } from './thread-state'
16
+
17
+ /** Duration of the click-a-badge zoom-to-split animation. */
18
+ export const CLUSTER_EXPAND_ZOOM_MS = 450
19
+ /** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
20
+ * lands clear of the threshold it just crossed rather than flickering on it. */
21
+ const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
22
+
23
+ const EMPTY_SET: ReadonlySet<string> = new Set()
24
+ const MOVED_LEAF_EPSILON = 1e-6
25
+
26
+ /** Default select-tool states that move shapes continuously, one store write per pointermove. A
27
+ * custom tool in their place just never matches, falling back to a rebuild per frame. */
28
+ const SHAPE_DRAG_STATE_PATHS = [
29
+ 'select.translating',
30
+ 'select.resizing',
31
+ 'select.rotating',
32
+ 'select.dragging_handle',
33
+ 'select.crop.cropping',
34
+ ] as const
35
+
36
+ /** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the
37
+ * gesture starts or settles. */
38
+ function isShapeDragInProgress(editor: Editor): boolean {
39
+ return editor.isInAny(...SHAPE_DRAG_STATE_PATHS)
40
+ }
41
+
42
+ /** The clustering table for the current scene, plus the runtime walking its merge events. */
43
+ export interface ClusterModel {
44
+ runtime: ClusterRuntime
45
+ table: ClusterTable
46
+ }
47
+
48
+ export interface ClusterZoomBounds {
49
+ minZoom: number
50
+ maxZoom: number
51
+ }
52
+
53
+ export interface ClusterModelState {
54
+ /** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */
55
+ model: ClusterModel
56
+ zoomBounds: ClusterZoomBounds
57
+ /** The displayed nodes, each tagged with its cross-fade phase. */
58
+ fadeNodes: ClusterFadeNode[]
59
+ /** Threads in the input that the displayed partition doesn't show — render them as plain pins. */
60
+ orphanThreads: TLCommentThread[]
61
+ /** Threads held out of clustering because their anchor moved while folded into a badge. */
62
+ heldThreads: TLCommentThread[]
63
+ }
64
+
65
+ /**
66
+ * The clustering state machine behind the comments layer.
67
+ *
68
+ * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is
69
+ * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only
70
+ * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
71
+ * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in
72
+ * place and nothing else on the canvas moves.
73
+ *
74
+ * Threads the displayed partition can't represent are returned separately (`orphanThreads`,
75
+ * `heldThreads`) for the layer to draw as ordinary pins.
76
+ */
77
+ export function useClusterModel(
78
+ editor: Editor,
79
+ threads: readonly TLCommentThread[],
80
+ openId: string | null
81
+ ): ClusterModelState {
82
+ // Threads held out of clustering because their anchor moved while folded inside a badge
83
+ // (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
84
+ // as live pins riding their anchor and rejoin clustering on the next zoom-out.
85
+ const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
86
+ const adoptOnRebuild = useRef(false)
87
+ // This input's identity keys the O(N²) table rebuild below, so it's gated on value equality: a
88
+ // reply, a reaction, a resolve — anything that touches comment records without moving a pin —
89
+ // returns the previous input and rebuilds nothing.
90
+ //
91
+ // Mid-drag the gate widens to ignore positions too, and that costs something real: it freezes
92
+ // `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin
93
+ // folded into a badge stops popping out to ride its anchor and corrects on release instead. The
94
+ // per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.
95
+ // An added or deleted thread changes the id set, so it still rebuilds promptly.
96
+ const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
97
+ const clusterInput = useValue(
98
+ 'comment cluster leaves',
99
+ () => {
100
+ const next = collectClusterLeaves(
101
+ editor,
102
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
103
+ openThreadId.get(editor)
104
+ )
105
+ const prev = clusterInputRef.current
106
+ if (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev
107
+ if (clusterInputEqual(prev, next)) return prev
108
+ clusterInputRef.current = next
109
+ return next
110
+ },
111
+ [editor, threads, heldThreadIds]
112
+ )
113
+ const clusterZoomBounds = useValue(
114
+ 'comment cluster zoom bounds',
115
+ () => getClusterZoomBounds(editor),
116
+ [editor]
117
+ )
118
+ const latestModel = useMemo(() => {
119
+ const table = computeClusterTable(
120
+ clusterInput.leaves,
121
+ clusterZoomBounds,
122
+ clusterInput.screenOffsets
123
+ )
124
+ const runtime = createClusterRuntime(table)
125
+ runtime.seed(editor.getZoomLevel())
126
+ return { runtime, table }
127
+ }, [clusterInput, clusterZoomBounds, editor])
128
+ const [renderedModel, setRenderedModel] = useState(latestModel)
129
+ let clusterModel = renderedModel
130
+ // A page switch replaces the whole scene: hard-reset rather than detach the world.
131
+ const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
132
+ const pageRef = useRef(pageId)
133
+ if (pageRef.current !== pageId) {
134
+ pageRef.current = pageId
135
+ adoptOnRebuild.current = false
136
+ latestModel.runtime.seed(editor.getZoomLevel())
137
+ if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
138
+ setRenderedModel(latestModel)
139
+ clusterModel = latestModel
140
+ }
141
+ // adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust
142
+ // it once that pairing is visible here, or an unrelated re-render can land in the gap.
143
+ const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
144
+ if (renderedModel !== latestModel && rejoinPending) {
145
+ adoptOnRebuild.current = false
146
+ // Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
147
+ // nothing changes state because of the swap alone. Idempotent, so safe during render.
148
+ latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
149
+ setRenderedModel(latestModel)
150
+ clusterModel = latestModel
151
+ } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
152
+ // Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
153
+ // survive to force-adopt a later, unrelated rebuild.
154
+ adoptOnRebuild.current = false
155
+ }
156
+ // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
157
+ // is baked into the model), so when its live position drifts from the baked one, hold it out.
158
+ // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
159
+ const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
160
+ if (newlyMovedIds.length > 0) {
161
+ const next = new Set(heldThreadIds)
162
+ for (const id of newlyMovedIds) next.add(id)
163
+ setHeldThreadIds(next)
164
+ }
165
+ // Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
166
+ // cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
167
+ // awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
168
+ if (clusterModel !== latestModel) {
169
+ const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
170
+ const removedLeafIds: string[] = []
171
+ for (const leaf of clusterModel.table.leaves) {
172
+ if (!latestLeafIds.has(leaf.id)) {
173
+ removedLeafIds.push(leaf.id)
174
+ }
175
+ }
176
+ // Batched: one patch rebuild and one version bump for the whole set.
177
+ if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)
178
+ }
179
+ // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
180
+ // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
181
+ // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
182
+ useEffect(() => {
183
+ if (heldThreadIds.size === 0) return
184
+ let lastZoom = editor.getZoomLevel()
185
+ return react('rejoin moved comment pins on zoom out', () => {
186
+ const zoom = editor.getZoomLevel()
187
+ const prevZoom = lastZoom
188
+ lastZoom = zoom
189
+ if (zoom >= prevZoom) return
190
+ adoptOnRebuild.current = true
191
+ setHeldThreadIds(EMPTY_SET)
192
+ })
193
+ }, [heldThreadIds, editor])
194
+ // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
195
+ // a merge, and merging only happens while zooming out. While zooming in, the stale table still
196
+ // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
197
+ useEffect(() => {
198
+ if (clusterModel === latestModel) return
199
+ let lastZoom = editor.getZoomLevel()
200
+ return react('adopt pending cluster model on zoom out', () => {
201
+ const zoom = editor.getZoomLevel()
202
+ const prevZoom = lastZoom
203
+ lastZoom = zoom
204
+ if (zoom >= prevZoom) return
205
+ latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
206
+ setRenderedModel(latestModel)
207
+ })
208
+ }, [clusterModel, latestModel, editor])
209
+ // Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone
210
+ // deletions) render as plain pins until the next zoom-out folds them in. Judged against the
211
+ // displayed partition, so a detached-then-restored leaf reappears.
212
+ const partitionVersion = clusterModel.runtime.version
213
+ const orphanThreads = useMemo(() => {
214
+ if (clusterModel === latestModel) return []
215
+ const displayed = new Set<string>()
216
+ for (const node of clusterModel.runtime.getVisible().values()) {
217
+ for (const member of node.members) displayed.add(member)
218
+ }
219
+ const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
220
+ return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
221
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [clusterModel, latestModel, threads, partitionVersion])
224
+ const heldThreads = useMemo(
225
+ () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
226
+ [threads, heldThreadIds, openId]
227
+ )
228
+ // Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster
229
+ // changes rather than every camera frame. The memo below re-reads the version inline because
230
+ // render-time detaches bump it after the subscription's computed already evaluated.
231
+ useValue(
232
+ 'comment cluster version',
233
+ () => {
234
+ clusterModel.runtime.onCamera(editor.getZoomLevel())
235
+ return clusterModel.runtime.version
236
+ },
237
+ [clusterModel, editor]
238
+ )
239
+ const visibleNodes = useMemo(() => {
240
+ return Array.from(clusterModel.runtime.getVisible().values())
241
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
242
+ // eslint-disable-next-line react-hooks/exhaustive-deps
243
+ }, [clusterModel, partitionVersion])
244
+ const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
245
+
246
+ return {
247
+ model: clusterModel,
248
+ zoomBounds: clusterZoomBounds,
249
+ fadeNodes,
250
+ orphanThreads,
251
+ heldThreads,
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Leaves folded inside a badge whose live anchor no longer matches the position the rendered
257
+ * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
258
+ * stay deferred; a badge can't follow a member, so these must pop out of clustering.
259
+ */
260
+ function findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {
261
+ if (rendered.table === latest.table) return []
262
+ const visible = rendered.runtime.getVisible()
263
+ const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
264
+ const moved: string[] = []
265
+ for (const leaf of rendered.table.leaves) {
266
+ if (visible.has(leaf.id)) continue
267
+ const current = latestById.get(leaf.id)
268
+ if (!current) continue
269
+ if (
270
+ Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
271
+ Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
272
+ ) {
273
+ moved.push(leaf.id)
274
+ }
275
+ }
276
+ return moved
277
+ }
278
+
279
+ function getClusterZoomBounds(editor: Editor): ClusterZoomBounds {
280
+ const cameraOptions = editor.getCameraOptions()
281
+ const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
282
+ const zoomSteps = cameraOptions.zoomSteps
283
+ return {
284
+ minZoom: zoomSteps[0] * baseZoom,
285
+ maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split
291
+ * that unfolds it from its badge (or just centre on it when it isn't clustered).
292
+ */
293
+ export function revealThreadPin(
294
+ editor: Editor,
295
+ thread: TLCommentThread,
296
+ table: ClusterTable,
297
+ zoomBounds: ClusterZoomBounds,
298
+ options: CommentingOptions,
299
+ duration = 200
300
+ ) {
301
+ if (thread.pageId !== editor.getCurrentPageId()) {
302
+ editor.setCurrentPage(thread.pageId as any)
303
+ }
304
+
305
+ const point = anchorPagePoint(editor, thread.anchor)
306
+ if (!point) return
307
+
308
+ // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
309
+ // badge never exists) and just center on the pin.
310
+ if (options.enableClustering) {
311
+ const parentEvent = findDirectParentEvent(table, thread.id)
312
+ if (
313
+ parentEvent &&
314
+ Number.isFinite(parentEvent.zSplit) &&
315
+ parentEvent.zSplit <= zoomBounds.maxZoom
316
+ ) {
317
+ const zoom = clamp(
318
+ parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
319
+ zoomBounds.minZoom,
320
+ zoomBounds.maxZoom
321
+ )
322
+ centerOnPointAtZoom(editor, point, zoom, duration)
323
+ return
324
+ }
325
+ }
326
+
327
+ const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
328
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
329
+ }
330
+
331
+ /**
332
+ * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The
333
+ * event that created a visible cluster is the event that splits it, and has the smallest zSplit of
334
+ * everything applied inside it — so its zSplit is exactly the first split within those comments.
335
+ * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.
336
+ */
337
+ export function zoomToClusterSplit(
338
+ editor: Editor,
339
+ table: ClusterTable,
340
+ zoomBounds: ClusterZoomBounds,
341
+ node: ClusterNode
342
+ ) {
343
+ const event = table.events.find((e) => e.result.id === node.id)
344
+ if (!event || !Number.isFinite(event.zSplit)) return
345
+ const zoom = clamp(
346
+ event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
347
+ zoomBounds.minZoom,
348
+ zoomBounds.maxZoom
349
+ )
350
+ centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
351
+ }
352
+
353
+ function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
354
+ return table.events.find((event) => event.children.some((child) => child.id === threadId))
355
+ }
356
+
357
+ function centerOnPointAtZoom(
358
+ editor: Editor,
359
+ point: { x: number; y: number },
360
+ zoom: number,
361
+ duration = 200
362
+ ) {
363
+ const viewport = editor.getViewportScreenBounds()
364
+ // The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
365
+ const offset = commentCenterScreenOffset(editor)
366
+ editor.setCamera(
367
+ {
368
+ x: (viewport.w / 2 - offset) / zoom - point.x,
369
+ y: viewport.h / (2 * zoom) - point.y,
370
+ z: zoom,
371
+ },
372
+ { animation: { duration } }
373
+ )
374
+ }
375
+
376
+ function clamp(value: number, min: number, max: number): number {
377
+ return Math.max(min, Math.min(max, value))
378
+ }
@@ -17,5 +17,5 @@ export interface CommentBodyProps {
17
17
  */
18
18
  export function CommentBody({ richText, resolveName }: CommentBodyProps) {
19
19
  const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
20
- return <div className="cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
20
+ return <div className="tlui-cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
21
21
  }
@@ -0,0 +1,55 @@
1
+ /* eslint-disable tldraw/no-direct-storage */
2
+ import type { TLRichText } from 'tldraw'
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import {
5
+ clearCommentDraft,
6
+ getCommentDraft,
7
+ NEW_COMMENT_DRAFT,
8
+ replyDraftSlot,
9
+ saveCommentDraft,
10
+ } from './comment-drafts'
11
+
12
+ function richText(text: string): TLRichText {
13
+ return {
14
+ type: 'doc',
15
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
16
+ } as unknown as TLRichText
17
+ }
18
+
19
+ const EMPTY = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as TLRichText
20
+
21
+ beforeEach(() => {
22
+ localStorage.clear()
23
+ })
24
+
25
+ describe('comment drafts', () => {
26
+ it('round-trips a draft through its slot', () => {
27
+ saveCommentDraft(NEW_COMMENT_DRAFT, richText('hello'))
28
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toEqual(richText('hello'))
29
+ })
30
+
31
+ it('keeps reply drafts apart by thread id', () => {
32
+ saveCommentDraft(replyDraftSlot('comment-thread:a'), richText('for a'))
33
+ saveCommentDraft(replyDraftSlot('comment-thread:b'), richText('for b'))
34
+ expect(getCommentDraft(replyDraftSlot('comment-thread:a'))).toEqual(richText('for a'))
35
+ expect(getCommentDraft(replyDraftSlot('comment-thread:b'))).toEqual(richText('for b'))
36
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
37
+ })
38
+
39
+ it('saving empty content clears the slot instead', () => {
40
+ saveCommentDraft(NEW_COMMENT_DRAFT, richText('typed then deleted'))
41
+ saveCommentDraft(NEW_COMMENT_DRAFT, EMPTY)
42
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
43
+ })
44
+
45
+ it('clears a slot explicitly', () => {
46
+ saveCommentDraft(NEW_COMMENT_DRAFT, richText('posted'))
47
+ clearCommentDraft(NEW_COMMENT_DRAFT)
48
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
49
+ })
50
+
51
+ it('returns undefined for a corrupt stored value', () => {
52
+ localStorage.setItem('tldraw-comment-draft:new', '{not json')
53
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
54
+ })
55
+ })
@@ -0,0 +1,46 @@
1
+ import { deleteFromLocalStorage, getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
2
+ import type { TLRichText } from 'tldraw'
3
+ import { isCommentEmpty } from '../ui/comment-extensions'
4
+
5
+ const DRAFT_KEY_PREFIX = 'tldraw-comment-draft'
6
+
7
+ /** The single slot for an unsent new-comment (placement composer) draft. The anchor is re-picked
8
+ * by clicking when the composer reopens, so only the text is worth keeping — and without a
9
+ * universal document id in the SDK, one slot is the honest scope. */
10
+ export const NEW_COMMENT_DRAFT = 'new'
11
+
12
+ /** The draft slot for an unsent reply on a thread. Thread record ids are globally unique and
13
+ * stable across reloads, so replies never need a document key. @public */
14
+ export function replyDraftSlot(threadId: string): string {
15
+ return `reply:${threadId}`
16
+ }
17
+
18
+ /** Read a saved unsent draft, or undefined when there is none (or it fails to parse). @public */
19
+ export function getCommentDraft(slot: string): TLRichText | undefined {
20
+ const raw = getFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
21
+ if (!raw) return undefined
22
+ try {
23
+ return JSON.parse(raw) as TLRichText
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Save an unsent draft so click-away and reload don't lose it — the flip side of not warning
31
+ * before discard. Saving empty content clears the slot instead, so deleting your text and
32
+ * closing doesn't resurrect an empty draft later.
33
+ * @public
34
+ */
35
+ export function saveCommentDraft(slot: string, value: TLRichText) {
36
+ if (isCommentEmpty(value)) {
37
+ clearCommentDraft(slot)
38
+ return
39
+ }
40
+ setInLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`, JSON.stringify(value))
41
+ }
42
+
43
+ /** Remove a saved draft (after posting, or when its content was emptied). @public */
44
+ export function clearCommentDraft(slot: string) {
45
+ deleteFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
46
+ }