@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,115 @@
1
+ import { RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
2
+ import { PORTRAIT_BREAKPOINT, useBreakpoint, useContainer } from 'tldraw'
3
+ import { getVisibleViewport } from '../ui/visual-viewport'
4
+
5
+ /**
6
+ * Mobile mode for the commenting surfaces — the same breakpoint gate as tldraw's mobile toolbar and
7
+ * style panel (which also honors `forceMobile` on `<Tldraw>`). The commenting layer can mount
8
+ * without tldraw's default UI (`hideUi`, custom UI), where the breakpoint provider is absent and
9
+ * `useBreakpoint` throws; fall back to desktop there, matching the pre-mobile rendering those hosts
10
+ * always had.
11
+ */
12
+ export function useIsMobileCommenting(): boolean {
13
+ let breakpoint: number
14
+ try {
15
+ // The call is unconditional — the try only guards the provider's absence, not hook order.
16
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
17
+ breakpoint = useBreakpoint()
18
+ } catch {
19
+ return false
20
+ }
21
+ return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
22
+ }
23
+
24
+ const VIEWPORT_MARGIN = 8
25
+
26
+ /**
27
+ * Keep a canvas-anchored panel — the pending composer or an open thread popover — within the
28
+ * visible viewport on mobile. The software keyboard shrinks the *visual* viewport while leaving the
29
+ * layout viewport (and CSS `dvh`) untouched, so a panel placed at a fixed offset from its pin can
30
+ * end up behind the keyboard. On mobile this clamps the panel's top-left into the visible box so it
31
+ * stays on-screen above the keyboard. Desktop is untouched: when `enabled` is false the base point
32
+ * is returned unchanged, tracking the camera exactly as before.
33
+ *
34
+ * `base` is the panel's desired top-left in container-relative viewport coordinates (what the call
35
+ * site would otherwise write straight into `left`/`top`).
36
+ */
37
+ export function useMobilePlacement(
38
+ ref: RefObject<HTMLElement | null>,
39
+ base: { x: number; y: number },
40
+ enabled: boolean
41
+ ): { left: number; top: number } {
42
+ const container = useContainer()
43
+ // Destructured so the effects below depend on the two numbers rather than the object, which the
44
+ // call sites build fresh on every render.
45
+ const { x: baseX, y: baseY } = base
46
+ const [placed, setPlaced] = useState<{ left: number; top: number }>(() => ({
47
+ left: baseX,
48
+ top: baseY,
49
+ }))
50
+ // The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
51
+ // across those frames, so the observers below are set up once per panel rather than being torn
52
+ // down and rebuilt each frame.
53
+ const baseRef = useRef({ x: baseX, y: baseY })
54
+
55
+ const update = useCallback(() => {
56
+ if (!enabled) return
57
+ const el = ref.current
58
+ if (!el) return
59
+ const win = container.ownerDocument.defaultView ?? window
60
+ const { x, y } = baseRef.current
61
+
62
+ // Panel coordinates are container-relative; the visual viewport is window-relative.
63
+ const cRect = container.getBoundingClientRect()
64
+ const vp = getVisibleViewport(win)
65
+ const top = vp.top - cRect.top + VIEWPORT_MARGIN
66
+ const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
67
+ const left = vp.left - cRect.left + VIEWPORT_MARGIN
68
+ const right = vp.right - cRect.left - VIEWPORT_MARGIN
69
+
70
+ const w = el.offsetWidth
71
+ const h = el.offsetHeight
72
+
73
+ const nextLeft = Math.max(left, Math.min(x, right - w))
74
+ // The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
75
+ // bottom would be hidden) but must never push it down below its natural spot. `y` is the
76
+ // floor: any spurious rise in `top` (e.g. iOS scrolling the page to reveal a focused input)
77
+ // is capped here, so a panel that already clears the keyboard doesn't move at all.
78
+ const nextTop = Math.min(y, Math.max(top, bottom - h))
79
+ setPlaced((prev) =>
80
+ prev.left === nextLeft && prev.top === nextTop ? prev : { left: nextLeft, top: nextTop }
81
+ )
82
+ }, [container, ref, enabled])
83
+
84
+ // Re-place as the camera moves the panel's base point.
85
+ useLayoutEffect(() => {
86
+ baseRef.current = { x: baseX, y: baseY }
87
+ update()
88
+ }, [baseX, baseY, update])
89
+
90
+ // Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
91
+ // pinch-zoom), or when the window resizes.
92
+ useLayoutEffect(() => {
93
+ if (!enabled) return
94
+ const el = ref.current
95
+ if (!el) return
96
+ const win = container.ownerDocument.defaultView ?? window
97
+ const ro = new ResizeObserver(update)
98
+ ro.observe(el)
99
+ const vv = win.visualViewport
100
+ vv?.addEventListener('resize', update)
101
+ vv?.addEventListener('scroll', update)
102
+ win.addEventListener('resize', update)
103
+ return () => {
104
+ ro.disconnect()
105
+ vv?.removeEventListener('resize', update)
106
+ vv?.removeEventListener('scroll', update)
107
+ win.removeEventListener('resize', update)
108
+ }
109
+ }, [container, ref, enabled, update])
110
+
111
+ // Desktop keeps its fixed placement, recomputed each render so it tracks the pin as the camera
112
+ // moves.
113
+ if (!enabled) return { left: baseX, top: baseY }
114
+ return placed
115
+ }
@@ -1,8 +1,17 @@
1
- import type { Editor } from 'tldraw'
2
- import { describe, expect, it } from 'vitest'
1
+ import { createComment, createCommentThread, toRichText, type Editor, type TLPageId } from 'tldraw'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { commitCommentMutation } from './comment-mutations'
3
4
  import { CommentTool } from './comment-tool'
4
- import { defaultCommentingOptions, getCommentingOptions, type CommentingOptions } from './options'
5
- import { commitCommentMutation, openThreadId, pendingComment } from './state'
5
+ import {
6
+ defaultCanModifyComment,
7
+ defaultCommentingOptions,
8
+ getCanComment,
9
+ getCanModifyComment,
10
+ getCommentingOptions,
11
+ type CommentingOptions,
12
+ type CommentModificationContext,
13
+ } from './options'
14
+ import { openThreadId, pendingComment } from './state'
6
15
 
7
16
  // The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
8
17
  // instantiate a configured tool and read its merged options.
@@ -78,6 +87,169 @@ describe('getCommentingOptions', () => {
78
87
  })
79
88
  })
80
89
 
90
+ describe('getCanComment', () => {
91
+ it('defaults to requiring a current user', () => {
92
+ const { editor } = stubEditor(defaultCommentingOptions)
93
+ expect(getCanComment(editor, 'alice')).toBe(true)
94
+ expect(getCanComment(editor, null)).toBe(false)
95
+ expect(getCanComment(editor, undefined)).toBe(false)
96
+ })
97
+
98
+ it('normalizes an undefined viewer to null for the callback', () => {
99
+ const calls: Array<string | null> = []
100
+ const { editor } = stubEditor({
101
+ ...defaultCommentingOptions,
102
+ canComment: (ctx) => {
103
+ calls.push(ctx.currentUserId)
104
+ return false
105
+ },
106
+ })
107
+ getCanComment(editor, undefined)
108
+ expect(calls).toEqual([null])
109
+ })
110
+
111
+ it('delegates to the canComment callback, passing the editor and viewer', () => {
112
+ const calls: Array<{ editor: Editor; currentUserId: string | null }> = []
113
+ const { editor } = stubEditor({
114
+ ...defaultCommentingOptions,
115
+ canComment: (ctx) => {
116
+ calls.push(ctx)
117
+ return ctx.currentUserId === 'alice'
118
+ },
119
+ })
120
+ expect(getCanComment(editor, 'alice')).toBe(true)
121
+ expect(getCanComment(editor, 'bob')).toBe(false)
122
+ expect(calls).toEqual([
123
+ { editor, currentUserId: 'alice' },
124
+ { editor, currentUserId: 'bob' },
125
+ ])
126
+ })
127
+
128
+ it('lets the callback fully replace the signed-in default', () => {
129
+ const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
130
+ expect(getCanComment(editor, null)).toBe(true)
131
+ })
132
+
133
+ // This is read during render, so a throwing host rule would take the comments layer with it.
134
+ it('denies rather than throws when the callback throws', () => {
135
+ const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
136
+ const { editor } = stubEditor({
137
+ ...defaultCommentingOptions,
138
+ canComment: () => {
139
+ throw new Error('lookup failed')
140
+ },
141
+ })
142
+ expect(getCanComment(editor, 'alice')).toBe(false)
143
+ expect(onError).toHaveBeenCalled()
144
+ onError.mockRestore()
145
+ })
146
+ })
147
+
148
+ // Plain records for the permission checks — the factories are pure, so no store is involved.
149
+ const PAGE_ID = 'page:test' as TLPageId
150
+
151
+ function makeThread(createdBy: string) {
152
+ return createCommentThread({
153
+ pageId: PAGE_ID,
154
+ anchor: { type: 'point', x: 0, y: 0 },
155
+ createdBy,
156
+ })
157
+ }
158
+
159
+ function makeComment(authorId: string) {
160
+ return createComment({
161
+ threadId: makeThread(authorId).id,
162
+ pageId: PAGE_ID,
163
+ authorId,
164
+ body: toRichText('hello'),
165
+ })
166
+ }
167
+
168
+ describe('getCanModifyComment', () => {
169
+ it('defaults to the record owner: the comment author edits and deletes', () => {
170
+ const { editor } = stubEditor(defaultCommentingOptions)
171
+ const comment = makeComment('alice')
172
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
173
+ expect(getCanModifyComment(editor, 'alice', { action: 'delete-comment', comment })).toBe(true)
174
+ expect(getCanModifyComment(editor, 'bob', { action: 'edit-comment', comment })).toBe(false)
175
+ expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
176
+ })
177
+
178
+ it('defaults to the thread creator for a thread delete', () => {
179
+ const { editor } = stubEditor(defaultCommentingOptions)
180
+ const thread = makeThread('alice')
181
+ expect(getCanModifyComment(editor, 'alice', { action: 'delete-thread', thread })).toBe(true)
182
+ expect(getCanModifyComment(editor, 'bob', { action: 'delete-thread', thread })).toBe(false)
183
+ })
184
+
185
+ it('withholds everything from a viewer with no identity', () => {
186
+ const { editor } = stubEditor(defaultCommentingOptions)
187
+ const comment = makeComment('alice')
188
+ const thread = makeThread('alice')
189
+ expect(getCanModifyComment(editor, null, { action: 'edit-comment', comment })).toBe(false)
190
+ expect(getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })).toBe(
191
+ false
192
+ )
193
+ expect(getCanModifyComment(editor, null, { action: 'delete-thread', thread })).toBe(false)
194
+ })
195
+
196
+ it('passes the editor, the viewer, and the targeted write to the callback', () => {
197
+ const calls: CommentModificationContext[] = []
198
+ const { editor } = stubEditor({
199
+ ...defaultCommentingOptions,
200
+ canModifyComment: (ctx) => {
201
+ calls.push(ctx)
202
+ return true
203
+ },
204
+ })
205
+ const comment = makeComment('alice')
206
+ getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })
207
+ // An undefined viewer normalizes to null, as it does for `canComment`.
208
+ expect(calls).toEqual([{ editor, currentUserId: null, action: 'delete-comment', comment }])
209
+ })
210
+
211
+ it("lets a callback widen the default: a moderator deleting anyone's comment", () => {
212
+ const { editor } = stubEditor({
213
+ ...defaultCommentingOptions,
214
+ canModifyComment: (ctx) =>
215
+ (ctx.action !== 'edit-comment' && ctx.currentUserId === 'mod') ||
216
+ defaultCanModifyComment(ctx),
217
+ })
218
+ const comment = makeComment('alice')
219
+ const thread = makeThread('alice')
220
+ expect(getCanModifyComment(editor, 'mod', { action: 'delete-comment', comment })).toBe(true)
221
+ expect(getCanModifyComment(editor, 'mod', { action: 'delete-thread', thread })).toBe(true)
222
+ // Widening deletion doesn't hand the moderator anyone else's edit affordance.
223
+ expect(getCanModifyComment(editor, 'mod', { action: 'edit-comment', comment })).toBe(false)
224
+ // The author keeps their own affordances.
225
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
226
+ // Everyone else still gets nothing.
227
+ expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
228
+ })
229
+
230
+ it('lets a callback narrow the default', () => {
231
+ const { editor } = stubEditor({ ...defaultCommentingOptions, canModifyComment: () => false })
232
+ const comment = makeComment('alice')
233
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
234
+ })
235
+
236
+ // A throw withholds the affordance rather than the whole layer, and never offers a write a
237
+ // server enforcing the same rule would reject anyway.
238
+ it('denies rather than throws when the callback throws', () => {
239
+ const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
240
+ const { editor } = stubEditor({
241
+ ...defaultCommentingOptions,
242
+ canModifyComment: () => {
243
+ throw new Error('lookup failed')
244
+ },
245
+ })
246
+ const comment = makeComment('alice')
247
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
248
+ expect(onError).toHaveBeenCalled()
249
+ onError.mockRestore()
250
+ })
251
+ })
252
+
81
253
  describe('commitCommentMutation', () => {
82
254
  it('uses options.history for a mutation and returns the callback result', () => {
83
255
  const { editor, runCalls } = stubEditor({
@@ -4,8 +4,52 @@ import {
4
4
  type TLComment,
5
5
  type TLCommentThread,
6
6
  type TLHistoryBatchOptions,
7
+ type TLShapeId,
8
+ type VecLike,
7
9
  useEditor,
10
+ useValue,
8
11
  } from 'tldraw'
12
+ import { type CommentListItemRenderProps } from '../ui/comments-list'
13
+ import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
14
+ import { type ReactionTooltipProps } from '../ui/reaction'
15
+
16
+ /**
17
+ * The gesture that's creating a shape anchor, passed to
18
+ * {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
19
+ * whether Alt was held.
20
+ *
21
+ * @public
22
+ */
23
+ export interface ShapeCommentPrecisionContext {
24
+ readonly shapeId: TLShapeId
25
+ readonly point: VecLike
26
+ readonly altKey: boolean
27
+ }
28
+
29
+ /**
30
+ * A commenting write that belongs to someone in particular, and the record it targets — the
31
+ * argument to {@link CommentingOptions.canModifyComment}.
32
+ *
33
+ * Resolving, reopening, reacting, and moving a pin aren't here: none of them is anyone's in
34
+ * particular, so {@link CommentingOptions.canComment} is the only gate on them.
35
+ *
36
+ * @public
37
+ */
38
+ export type CommentModification =
39
+ | { readonly action: 'edit-comment'; readonly comment: TLComment }
40
+ | { readonly action: 'delete-comment'; readonly comment: TLComment }
41
+ | { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
42
+
43
+ /**
44
+ * The argument to {@link CommentingOptions.canModifyComment}: which write, against which record,
45
+ * and by whom.
46
+ *
47
+ * @public
48
+ */
49
+ export type CommentModificationContext = {
50
+ readonly editor: Editor
51
+ readonly currentUserId: string | null
52
+ } & CommentModification
9
53
 
10
54
  /**
11
55
  * Component overrides for the batteries-included comments layer. Each slot replaces a built-in
@@ -20,12 +64,50 @@ export interface CommentingComponents {
20
64
  PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
21
65
  /** A sidebar row's preview. Replaces the plaintext default. */
22
66
  ThreadPreview?: ComponentType<{ comment: TLComment }>
67
+ /**
68
+ * A whole sidebar row. Replaces the default `<CommentListItem>`, which is exported — so a row
69
+ * that only adds an unread dot or a status chip can spread these props into it. Use
70
+ * `ThreadPreview` instead when only the preview text is changing.
71
+ */
72
+ ThreadRow?: ComponentType<CommentListItemRenderProps & { thread: TLCommentThread }>
73
+ /**
74
+ * Extra controls in an open thread's header, added ahead of the built-in resolve and dismiss
75
+ * buttons rather than replacing them. "Copy link" is already built in whenever the host supplies
76
+ * `getThreadHref`.
77
+ */
78
+ ThreadActions?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
79
+ /**
80
+ * A reaction's visual, given its token. The default renders the token string for the OS emoji
81
+ * font. Override to draw a custom palette — an `<img>`, an SVG, anything. The token is what gets
82
+ * stored and synced; this only controls how it's drawn.
83
+ */
84
+ ReactionContent?: ComponentType<{ token: string }>
85
+ /**
86
+ * What the add-reaction button opens. Replaces the default `<EmojiPicker>` grid. Pairs with
87
+ * `ReactionContent` (which draws the tokens this emits) and `isAllowedReaction` (which must
88
+ * accept them).
89
+ */
90
+ ReactionPalette?: ComponentType<EmojiPickerProps>
91
+ /**
92
+ * The hover affordance naming who reacted with an emoji. Receives the reactors and the pill (as
93
+ * `children`) and owns the whole thing — box, size, shape, position. For a wording change,
94
+ * translate the `comments.reacted-*` strings instead.
95
+ */
96
+ ReactionTooltip?: ComponentType<ReactionTooltipProps>
97
+ /**
98
+ * Shown where a composer would sit when the viewer can't compose (see
99
+ * {@link CommentingOptions.canComment}). `context` is the surface rendering it: an open thread
100
+ * popover (`'thread'`) or the comment tool's placement popover (`'pending'`). Unset, those
101
+ * surfaces render nothing.
102
+ */
103
+ ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
23
104
  }
24
105
 
25
106
  /**
26
107
  * Configuration for the commenting layer. Static config only — pass it once via
27
108
  * `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
28
- * (`currentUserId`, `resolveName`, read-status callbacks) stay as props on `<CanvasComments>`.
109
+ * (`currentUserId`, author resolution, read-status callbacks) are the `CommentingContext`, passed as
110
+ * props to each commenting surface.
29
111
  *
30
112
  * For defaults, see {@link defaultCommentingOptions}.
31
113
  *
@@ -37,13 +119,11 @@ export interface CommentingComponents {
37
119
  * @public
38
120
  */
39
121
  export interface CommentingOptions {
40
- // ── History / undo ───────────────────────────────────────────────────────────────────────
122
+ // History / undo
41
123
  /**
42
- * How comment mutations (post, reply, edit, resolve, delete) interact with the editor undo
43
- * stack. Defaults to `'ignore'` — comments are deliberately not undoable (see `TLComment`).
44
- * `'record'` is a multiplayer footgun: undoing a delete resurrects a thread a collaborator
45
- * already removed, and undoing a resolve/edit reverts their newer state. Safe only single-player
46
- * or on a non-synced local comment store.
124
+ * How comment mutations interact with the editor undo stack. Defaults to `'ignore'` — comments
125
+ * are deliberately not undoable (see `TLComment`). `'record'` is a multiplayer footgun: undoing
126
+ * a delete resurrects a thread a collaborator already removed. Safe only single-player.
47
127
  */
48
128
  readonly history: TLHistoryBatchOptions['history']
49
129
  /**
@@ -52,21 +132,83 @@ export interface CommentingOptions {
52
132
  */
53
133
  readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
54
134
 
55
- // ── Feature toggles ──────────────────────────────────────────────────────────────────────
135
+ // Feature toggles
56
136
  /** Fold nearby pins into count badges as the camera zooms out. */
57
137
  readonly enableClustering: boolean
138
+ /**
139
+ * Whether a user may hold several emoji reactions on one comment. `true` (the default) is the
140
+ * Slack model: each emoji toggles independently. `false` is single-select: picking a new emoji
141
+ * replaces the user's existing reaction. Note this is enforced client-side; the server accepts
142
+ * per-emoji records either way.
143
+ */
144
+ readonly allowMultipleReactions: boolean
145
+ /**
146
+ * Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji}, which
147
+ * keeps a scripted client from writing junk values the picker would never offer. Override
148
+ * alongside a custom `ReactionPalette`. Removals aren't checked — an off-palette reaction must
149
+ * still be clearable.
150
+ */
151
+ isAllowedReaction(token: string): boolean
152
+ /**
153
+ * Whether dragging the comment tool out creates a region anchor — a comment attached to a
154
+ * rectangular area, drawn as a dashed box with the pin on the corner the drag released on. Off
155
+ * by default, where comments attach to points and shapes only and a drag trails the composer.
156
+ */
157
+ readonly enableRegions: boolean
158
+
159
+ // Permissions
160
+ /**
161
+ * Whether the viewer may participate in commenting: composing, editing, deleting, resolving, and
162
+ * moving pins. When false, {@link CommentingComponents.ComposerFallback} renders in the
163
+ * composer's place and action affordances are hidden. Unset, participation is allowed exactly
164
+ * when `currentUserId` is set.
165
+ *
166
+ * Called during render via {@link useCanComment}, so signal reads are tracked. Posting still
167
+ * needs a `currentUserId`, so returning true for a signed-out viewer yields a composer whose
168
+ * send button stays disabled. A callback that throws is logged and read as false, rather than
169
+ * taking the comments layer down with it.
170
+ */
171
+ readonly canComment:
172
+ | ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
173
+ | undefined
174
+ /**
175
+ * Whether the viewer may make a particular write against a particular record: editing or
176
+ * deleting a comment, or deleting a thread. Unset, each is its record's owner's to make
177
+ * ({@link defaultCanModifyComment}) — you edit and delete your own comments, and delete threads
178
+ * you started. Override it to widen that (a workspace admin or moderator who may remove
179
+ * anyone's comment) or to narrow it (no edits after an hour). Where it returns false the
180
+ * affordance isn't rendered.
181
+ *
182
+ * Checked after {@link CommentingOptions.canComment}, which gates commenting as a whole: a
183
+ * viewer who may not participate gets no action affordances at all, whatever this returns.
184
+ *
185
+ * Called during render via {@link useCanModifyComment}, so reactive reads (signals) are tracked.
186
+ * A callback that throws is logged and read as false: an affordance is withheld rather than the
187
+ * comments layer lost, and a denial is what a server enforcing the same rule would have said.
188
+ *
189
+ * @example
190
+ * ```tsx
191
+ * CommentTool.configure({
192
+ * canModifyComment: (ctx) =>
193
+ * // Moderators may delete anything; everything else stays the owner's to do.
194
+ * (ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
195
+ * defaultCanModifyComment(ctx),
196
+ * })
197
+ * ```
198
+ */
199
+ readonly canModifyComment: ((ctx: CommentModificationContext) => boolean) | undefined
58
200
 
59
- // ── Anchoring ────────────────────────────────────────────────────────────────────────────
201
+ // Anchoring
60
202
  /** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
61
203
  readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
204
+ /**
205
+ * Whether a comment landing on a shape pins to the exact clicked spot, or to the shape as a
206
+ * whole (rendered at `impreciseShapeAnchor`). Always precise by default; return `false`, or
207
+ * decide from the context. Governs new placements only — existing anchors render as stored.
208
+ */
209
+ shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
62
210
 
63
- // ── Clustering tuning ─────────────────────────────────────────────────────────────────────
64
- /** Screen-pixel margin by which the viewport is inflated when culling cluster badges. */
65
- readonly clusterCullMargin: number
66
- /** How far past a cluster's split zoom to land when expanding it (1.05 = 5% overshoot). */
67
- readonly clusterSplitZoomFactor: number
68
-
69
- // ── Components ────────────────────────────────────────────────────────────────────────────
211
+ // Components
70
212
  /** Component overrides. See {@link CommentingComponents}. */
71
213
  readonly components: CommentingComponents
72
214
  }
@@ -80,9 +222,13 @@ export const defaultCommentingOptions = {
80
222
  history: 'ignore',
81
223
  dragHistory: undefined,
82
224
  enableClustering: true,
225
+ allowMultipleReactions: true,
226
+ isAllowedReaction: isAllowedReactionEmoji,
227
+ enableRegions: false,
228
+ canComment: undefined,
229
+ canModifyComment: undefined,
83
230
  impreciseShapeAnchor: { x: 1, y: 0 },
84
- clusterCullMargin: 120,
85
- clusterSplitZoomFactor: 1.05,
231
+ shouldBePrecise: () => true,
86
232
  components: {},
87
233
  } as const satisfies CommentingOptions
88
234
 
@@ -109,3 +255,115 @@ export function useCommentingOptions(): CommentingOptions {
109
255
  const editor = useEditor()
110
256
  return useMemo(() => getCommentingOptions(editor), [editor])
111
257
  }
258
+
259
+ /**
260
+ * Ask a host's permission callback, denying the write if it throws.
261
+ *
262
+ * These are called during render, so an exception in one would take the comments layer down with
263
+ * the answer. Denying costs an affordance, which is what a `false` would have cost anyway, and it
264
+ * can't offer a write a server enforcing the same rule would then reject.
265
+ */
266
+ function permits(option: string, check: () => boolean): boolean {
267
+ try {
268
+ return check()
269
+ } catch (error) {
270
+ console.error(`[tldraw] \`${option}\` threw, so the write is denied:`, error)
271
+ return false
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
277
+ * (defaulting to `currentUserId != null` when unset). A callback that throws denies.
278
+ *
279
+ * This is a plain, untracked read — in React, use {@link useCanComment} instead.
280
+ *
281
+ * @public
282
+ */
283
+ export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
284
+ const { canComment } = getCommentingOptions(editor)
285
+ if (!canComment) return currentUserId != null
286
+ return permits('canComment', () => canComment({ editor, currentUserId: currentUserId ?? null }))
287
+ }
288
+
289
+ /**
290
+ * Reactive React hook for {@link getCanComment}: a `canComment` callback that reads signals
291
+ * re-evaluates when they change.
292
+ *
293
+ * @public
294
+ */
295
+ export function useCanComment(currentUserId: string | null | undefined): boolean {
296
+ const editor = useEditor()
297
+ return useValue('can comment', () => getCanComment(editor, currentUserId), [
298
+ editor,
299
+ currentUserId,
300
+ ])
301
+ }
302
+
303
+ /**
304
+ * The default {@link CommentingOptions.canModifyComment}: a write is its record's owner's to make —
305
+ * a comment's author edits and deletes it, a thread's creator deletes the thread — and a viewer
306
+ * with no identity may make none of them.
307
+ *
308
+ * Exported so a callback can widen the default rather than restate it:
309
+ * `(ctx) => isModerator(ctx.currentUserId) || defaultCanModifyComment(ctx)`.
310
+ *
311
+ * @public
312
+ */
313
+ export function defaultCanModifyComment(ctx: CommentModificationContext): boolean {
314
+ const { currentUserId } = ctx
315
+ if (!currentUserId) return false
316
+ const owner = ctx.action === 'delete-thread' ? ctx.thread.createdBy : ctx.comment.authorId
317
+ return owner === currentUserId
318
+ }
319
+
320
+ /**
321
+ * Whether the viewer may make a given write against a given record, per
322
+ * {@link CommentingOptions.canModifyComment} (defaulting to {@link defaultCanModifyComment} when
323
+ * unset). Where this is false the affordance isn't rendered.
324
+ *
325
+ * This is the per-record rule alone: the built-in UI additionally requires
326
+ * {@link CommentingOptions.canComment}, since a viewer who may not participate gets no action
327
+ * affordances at all.
328
+ *
329
+ * A plain, untracked read — a `canModifyComment` callback that reads signals is not observed. In
330
+ * React, use {@link useCanModifyComment} instead.
331
+ *
332
+ * @public
333
+ */
334
+ export function getCanModifyComment(
335
+ editor: Editor,
336
+ currentUserId: string | null | undefined,
337
+ modification: CommentModification
338
+ ): boolean {
339
+ const { canModifyComment } = getCommentingOptions(editor)
340
+ const ctx: CommentModificationContext = {
341
+ editor,
342
+ currentUserId: currentUserId ?? null,
343
+ ...modification,
344
+ }
345
+ if (!canModifyComment) return defaultCanModifyComment(ctx)
346
+ return permits('canModifyComment', () => canModifyComment(ctx))
347
+ }
348
+
349
+ /**
350
+ * Reactive React hook for {@link getCanModifyComment}: a `canModifyComment` callback that reads
351
+ * signals re-evaluates when they change.
352
+ *
353
+ * @public
354
+ */
355
+ export function useCanModifyComment(
356
+ currentUserId: string | null | undefined,
357
+ modification: CommentModification
358
+ ): boolean {
359
+ const editor = useEditor()
360
+ // Comment records are immutable, so the record itself is what changes when the thing being
361
+ // checked changes — `modification` is a fresh object on every render and can't be a dep.
362
+ const record =
363
+ modification.action === 'delete-thread' ? modification.thread : modification.comment
364
+ return useValue(
365
+ 'can modify comment',
366
+ () => getCanModifyComment(editor, currentUserId, modification),
367
+ [editor, currentUserId, modification.action, record]
368
+ )
369
+ }