@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,260 @@
1
+ import {
2
+ Box,
3
+ commentSchemaRecords,
4
+ createShapeId,
5
+ createTLSchema,
6
+ createTLStore,
7
+ defaultBindingUtils,
8
+ defaultShapeUtils,
9
+ defaultTools,
10
+ Editor,
11
+ TLShapeId,
12
+ } from 'tldraw'
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
14
+ import { commentsSidebarOpen } from './state'
15
+ import {
16
+ anchorPagePoint,
17
+ commentCenterScreenOffset,
18
+ commentTargetShapeAt,
19
+ impreciseShapePinInset,
20
+ IMPRECISE_PIN_INSET_PX,
21
+ isBoxInInflatedViewport,
22
+ isInInflatedViewport,
23
+ shapeAnchorAt,
24
+ } from './thread-state'
25
+
26
+ /**
27
+ * A real editor: the behavior under test is hit-testing against real shape geometry, which is
28
+ * exactly what a stub can't model.
29
+ */
30
+ let editor: Editor
31
+
32
+ beforeEach(() => {
33
+ editor = new Editor({
34
+ store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
35
+ shapeUtils: defaultShapeUtils,
36
+ bindingUtils: defaultBindingUtils,
37
+ tools: defaultTools,
38
+ getContainer: () => document.body,
39
+ })
40
+ editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
41
+ })
42
+
43
+ describe('commentTargetShapeAt', () => {
44
+ it('hits an arrow pointed at near its line', () => {
45
+ const id = createShapeId()
46
+ editor.createShape({
47
+ id,
48
+ type: 'arrow',
49
+ x: 100,
50
+ y: 100,
51
+ props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
52
+ })
53
+
54
+ // Dead on the line, and a couple of pixels off it — both are a hit. Without a hit-test
55
+ // margin only the pixel-perfect click would land, which made arrows uncommentable.
56
+ expect(commentTargetShapeAt(editor, { x: 200, y: 100 })?.id).toBe(id)
57
+ expect(commentTargetShapeAt(editor, { x: 200, y: 102 })?.id).toBe(id)
58
+ })
59
+
60
+ it('hits a line shape near its segment', () => {
61
+ const id = createShapeId()
62
+ editor.createShape({ id, type: 'line', x: 100, y: 100 })
63
+
64
+ const bounds = editor.getShapePageBounds(id)!
65
+ expect(commentTargetShapeAt(editor, { x: bounds.center.x, y: bounds.center.y })?.id).toBe(id)
66
+ })
67
+
68
+ it('hits inside a hollow shape', () => {
69
+ const id = createShapeId()
70
+ editor.createShape({
71
+ id,
72
+ type: 'geo',
73
+ x: 100,
74
+ y: 100,
75
+ props: { w: 100, h: 100, fill: 'none' },
76
+ })
77
+
78
+ expect(commentTargetShapeAt(editor, { x: 150, y: 150 })?.id).toBe(id)
79
+ })
80
+
81
+ it('returns undefined on empty canvas', () => {
82
+ editor.createShape({
83
+ id: createShapeId(),
84
+ type: 'arrow',
85
+ x: 100,
86
+ y: 100,
87
+ props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
88
+ })
89
+
90
+ expect(commentTargetShapeAt(editor, { x: 600, y: 600 })).toBeUndefined()
91
+ })
92
+ })
93
+
94
+ /** A 100×100 square whose page bounds are (100,100)–(200,200), unrotated. */
95
+ function createSquare(): TLShapeId {
96
+ const id = createShapeId()
97
+ editor.createShape({ id, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } })
98
+ return id
99
+ }
100
+
101
+ describe('shape anchors under a shape transform', () => {
102
+ it('carries a precise pin around when the shape rotates', () => {
103
+ const id = createSquare()
104
+ // Top-right corner of the square.
105
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
106
+ expect(anchor).toMatchObject({ type: 'shape', shapeId: id, x: 1, y: 0, isPrecise: true })
107
+ expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 200, y: 100 })
108
+
109
+ // A quarter turn about the square's centre takes that corner to the bottom-right. The
110
+ // square's page bounds don't change, so a pin normalized against them would sit still.
111
+ editor.rotateShapesBy([id], Math.PI / 2)
112
+ const rotated = anchorPagePoint(editor, anchor)!
113
+ expect(rotated.x).toBeCloseTo(200)
114
+ expect(rotated.y).toBeCloseTo(200)
115
+ })
116
+
117
+ it('records the spot a pin was dropped on a rotated shape', () => {
118
+ const id = createSquare()
119
+ editor.rotateShapesBy([id], Math.PI / 2)
120
+
121
+ // After the quarter turn the square's local top-right corner sits at the page's bottom-right.
122
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 200 }, true)
123
+ expect(anchor.type).toBe('shape')
124
+ if (anchor.type !== 'shape') return
125
+ expect(anchor.x).toBeCloseTo(1)
126
+ expect(anchor.y).toBeCloseTo(0)
127
+
128
+ const point = anchorPagePoint(editor, anchor)!
129
+ expect(point.x).toBeCloseTo(200)
130
+ expect(point.y).toBeCloseTo(200)
131
+ })
132
+
133
+ it('still tracks moves and resizes', () => {
134
+ const id = createSquare()
135
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
136
+
137
+ editor.updateShape({ id, type: 'geo', x: 300, props: { w: 200 } })
138
+ expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 500, y: 100 })
139
+ })
140
+
141
+ it('turns an imprecise pin inset with the shape so it keeps stepping inward', () => {
142
+ const id = createSquare()
143
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, false)
144
+ // The default top-right spot: step left and down, into the square.
145
+ expect(impreciseShapePinInset(editor, anchor)).toMatchObject({
146
+ x: -IMPRECISE_PIN_INSET_PX,
147
+ y: IMPRECISE_PIN_INSET_PX,
148
+ })
149
+
150
+ editor.rotateShapesBy([id], Math.PI / 2)
151
+ // The corner is now bottom-right, so stepping inward means left and up.
152
+ const inset = impreciseShapePinInset(editor, anchor)!
153
+ expect(inset.x).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
154
+ expect(inset.y).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
155
+ })
156
+
157
+ it('hides the pin for a shape that no longer exists', () => {
158
+ const id = createSquare()
159
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
160
+ editor.deleteShape(id)
161
+ expect(anchorPagePoint(editor, anchor)).toBe(null)
162
+ })
163
+ })
164
+
165
+ describe('commentCenterScreenOffset', () => {
166
+ /** A sidebar element whose left edge sits at `left` client px, in the editor's container. */
167
+ function mountSidebar(left: number) {
168
+ const el = document.createElement('div')
169
+ el.className = 'tlui-cmt-canvas-sidebar'
170
+ el.getBoundingClientRect = () => ({ left }) as DOMRect
171
+ document.body.appendChild(el)
172
+ return el
173
+ }
174
+
175
+ afterEach(() => {
176
+ document.querySelector('.tlui-cmt-canvas-sidebar')?.remove()
177
+ })
178
+
179
+ it('is zero while the sidebar is closed', () => {
180
+ mountSidebar(712)
181
+ expect(commentCenterScreenOffset(editor)).toBe(0)
182
+ })
183
+
184
+ it('is half the covered width, so the pin centers in the uncovered area', () => {
185
+ commentsSidebarOpen.set(editor, true)
186
+ // Viewport is 1000px wide; the sidebar covers the rightmost 288px. The centered pin (at
187
+ // 356px) leaves the thread UI clear of the sidebar, so no nudge is needed.
188
+ mountSidebar(712)
189
+ expect(commentCenterScreenOffset(editor)).toBe(144)
190
+ })
191
+
192
+ it('nudges the pin further left when the thread UI would reach the sidebar', () => {
193
+ commentsSidebarOpen.set(editor, true)
194
+ // Centered in the uncovered area the pin sits at 300px, and the thread UI (334px wide,
195
+ // plus the 8px gap) would cross the sidebar's edge — so the pin backs off to 258px.
196
+ mountSidebar(600)
197
+ expect(commentCenterScreenOffset(editor)).toBe(500 - 258)
198
+ })
199
+
200
+ it('never nudges the pin past the left edge', () => {
201
+ commentsSidebarOpen.set(editor, true)
202
+ // Clearing the sidebar would need a negative pin position; the left inset wins.
203
+ mountSidebar(300)
204
+ expect(commentCenterScreenOffset(editor)).toBe(500 - 8)
205
+ })
206
+
207
+ it('is zero while the sidebar element is not mounted', () => {
208
+ commentsSidebarOpen.set(editor, true)
209
+ expect(commentCenterScreenOffset(editor)).toBe(0)
210
+ })
211
+ })
212
+
213
+ describe('isInInflatedViewport', () => {
214
+ // The viewport is 1000x1000 (see beforeEach) and the margin is 120 screen px.
215
+ it('accepts a point on screen', () => {
216
+ expect(isInInflatedViewport(editor, { x: 500, y: 500 })).toBe(true)
217
+ })
218
+
219
+ it('accepts a point just off screen, so a pan finds it already mounted', () => {
220
+ expect(isInInflatedViewport(editor, { x: -119, y: 500 })).toBe(true)
221
+ expect(isInInflatedViewport(editor, { x: 1119, y: 500 })).toBe(true)
222
+ expect(isInInflatedViewport(editor, { x: 500, y: -119 })).toBe(true)
223
+ expect(isInInflatedViewport(editor, { x: 500, y: 1119 })).toBe(true)
224
+ })
225
+
226
+ it('rejects a point past the margin on any side', () => {
227
+ expect(isInInflatedViewport(editor, { x: -121, y: 500 })).toBe(false)
228
+ expect(isInInflatedViewport(editor, { x: 1121, y: 500 })).toBe(false)
229
+ expect(isInInflatedViewport(editor, { x: 500, y: -121 })).toBe(false)
230
+ expect(isInInflatedViewport(editor, { x: 500, y: 1121 })).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe('isBoxInInflatedViewport', () => {
235
+ it('accepts a box wholly on screen', () => {
236
+ expect(isBoxInInflatedViewport(editor, { x: 100, y: 100, w: 200, h: 200 })).toBe(true)
237
+ })
238
+
239
+ it('accepts a box straddling the edge, whose pin corner alone would be culled', () => {
240
+ // This is the case the point test gets wrong: the box's visible edge is on screen while
241
+ // its far corner — where a region thread's pin can sit — is thousands of px away.
242
+ expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 5100, h: 5100 })).toBe(true)
243
+ })
244
+
245
+ it('accepts a box larger than the viewport on every side', () => {
246
+ expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 10000, h: 10000 })).toBe(true)
247
+ })
248
+
249
+ it('rejects a box wholly past the margin', () => {
250
+ expect(isBoxInInflatedViewport(editor, { x: 1200, y: 100, w: 100, h: 100 })).toBe(false)
251
+ expect(isBoxInInflatedViewport(editor, { x: 100, y: -400, w: 100, h: 100 })).toBe(false)
252
+ })
253
+
254
+ it('follows the camera rather than page coordinates', () => {
255
+ const box = { x: 2000, y: 0, w: 100, h: 100 }
256
+ expect(isBoxInInflatedViewport(editor, box)).toBe(false)
257
+ editor.setCamera({ x: -2000, y: 0, z: 1 })
258
+ expect(isBoxInInflatedViewport(editor, box)).toBe(true)
259
+ })
260
+ })
@@ -1,15 +1,98 @@
1
- import { BoxModel, Editor, TLCommentAnchor, TLCommentThread, TLShapeId, VecLike } from 'tldraw'
2
- import { getRegionCommentOptions } from './region-options'
3
- import { openThreadId } from './state'
1
+ import {
2
+ BoxModel,
3
+ Editor,
4
+ Mat,
5
+ TLCommentAnchor,
6
+ TLCommentThread,
7
+ TLShape,
8
+ TLShapeId,
9
+ Vec,
10
+ VecLike,
11
+ } from 'tldraw'
12
+ import { getCommentingOptions } from './options'
13
+ import { commentsSidebarOpen, openThreadId } from './state'
14
+ import { POPOVER_OFFSET } from './thread-view'
4
15
 
5
- /** Where an imprecise shape comment sits by default: the shape's top-right corner. Overridable. @public */
6
- export const DEFAULT_IMPRECISE_SHAPE_ANCHOR = { x: 1, y: 0 }
16
+ /** How far an imprecise shape pin steps inside the shape from its anchor spot, in screen px —
17
+ * most of the marker sits within the shape, with a small overhang past the corner. */
18
+ export const IMPRECISE_PIN_INSET_PX = 20
7
19
 
8
- /** The default corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right).
9
- * Overridable per editor via region options; pin position, composer placement, region move, and
10
- * which corner has no resize handle all derive from the chosen corner. */
20
+ /** Screen-pixel margin by which the viewport is inflated when culling canvas markers (thread pins
21
+ * and cluster badges), so a marker just off-screen is already mounted when a pan brings it in. */
22
+ const MARKER_CULL_MARGIN_PX = 120
23
+
24
+ /**
25
+ * Whether a viewport-space point sits within the viewport inflated by
26
+ * {@link MARKER_CULL_MARGIN_PX}. The cull test for screen-fixed markers: off-screen markers
27
+ * return null from their position signal and unmount instead of tracking every camera frame.
28
+ * @internal
29
+ */
30
+ export function isInInflatedViewport(editor: Editor, point: VecLike): boolean {
31
+ const viewport = editor.getViewportScreenBounds()
32
+ const margin = MARKER_CULL_MARGIN_PX
33
+ return (
34
+ point.x >= -margin &&
35
+ point.y >= -margin &&
36
+ point.x <= viewport.w + margin &&
37
+ point.y <= viewport.h + margin
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Whether any part of a page-space box overlaps the viewport inflated by
43
+ * {@link MARKER_CULL_MARGIN_PX}. The cull test for region-anchored threads, whose dashed box can
44
+ * be on screen while the pin corner itself is not.
45
+ * @internal
46
+ */
47
+ export function isBoxInInflatedViewport(editor: Editor, box: BoxModel): boolean {
48
+ const viewport = editor.getViewportScreenBounds()
49
+ const margin = MARKER_CULL_MARGIN_PX
50
+ // Zoom is positive, so the page box's corners keep their order through the transform.
51
+ const min = editor.pageToViewport({ x: box.x, y: box.y })
52
+ const max = editor.pageToViewport({ x: box.x + box.w, y: box.y + box.h })
53
+ return (
54
+ max.x >= -margin &&
55
+ max.y >= -margin &&
56
+ min.x <= viewport.w + margin &&
57
+ min.y <= viewport.h + margin
58
+ )
59
+ }
60
+
61
+ /** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
62
+ * extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
63
+ * pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
64
+ export function impreciseShapePinInset(
65
+ editor: Editor,
66
+ anchor: TLCommentThread['anchor']
67
+ ): { x: number; y: number } | null {
68
+ if (anchor.type !== 'shape' || anchor.isPrecise) return null
69
+ const spot = getCommentingOptions(editor).impreciseShapeAnchor
70
+ const inset = {
71
+ x: Math.sign(0.5 - spot.x) * IMPRECISE_PIN_INSET_PX,
72
+ y: Math.sign(0.5 - spot.y) * IMPRECISE_PIN_INSET_PX,
73
+ }
74
+ // The spot is a corner of the shape's own bounds, so which way "toward the centre" points turns
75
+ // with the shape: without this a rotated shape's pin would step out of the shape, not into it.
76
+ const rotation = editor.getShapePageTransform(anchor.shapeId as TLShapeId)?.rotation() ?? 0
77
+ return rotation === 0 ? inset : Vec.Rot(inset, rotation)
78
+ }
79
+
80
+ /** The corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right). Pin
81
+ * position, composer placement, region move, and which corner has no resize handle all derive
82
+ * from it. */
11
83
  export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
12
84
 
85
+ /** A region anchor's pin corner: the corner its creating drag released on, when recorded, else
86
+ * {@link REGION_PIN_CORNER}. @internal */
87
+ export function regionAnchorPinCorner(
88
+ anchor: Extract<TLCommentAnchor, { type: 'region' }>
89
+ ): VecLike {
90
+ if (anchor.pinX !== undefined && anchor.pinY !== undefined) {
91
+ return { x: anchor.pinX, y: anchor.pinY }
92
+ }
93
+ return REGION_PIN_CORNER
94
+ }
95
+
13
96
  /** The page point of a region's pin corner. */
14
97
  export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CORNER): VecLike {
15
98
  return {
@@ -19,42 +102,62 @@ export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CO
19
102
  }
20
103
 
21
104
  /**
22
- * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. For imprecise
23
- * shape anchors the pin uses `impreciseShapeAnchor` (a normalized 0–1 spot, top-right by default)
24
- * rather than the stored `x`/`y`.
105
+ * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. Imprecise shape
106
+ * anchors use {@link CommentingOptions.impreciseShapeAnchor} rather than the stored `x`/`y`.
107
+ *
108
+ * A shape anchor's `x`/`y` are normalized within the shape's bounds and resolved through its page
109
+ * transform, so the pin rides rotation instead of being left behind in the bounding box.
25
110
  * @public
26
111
  */
27
112
  export function anchorPagePoint(
28
113
  editor: Editor,
29
- anchor: TLCommentAnchor,
30
- impreciseShapeAnchor: { x: number; y: number } = DEFAULT_IMPRECISE_SHAPE_ANCHOR
114
+ anchor: TLCommentAnchor
31
115
  ): { x: number; y: number } | null {
32
116
  switch (anchor.type) {
33
117
  case 'shape': {
34
- const bounds = editor.getShapePageBounds(anchor.shapeId as TLShapeId)
35
- if (!bounds) return null
36
- // Precise pins sit at their stored x/y; imprecise ones at the consumer's default spot.
37
- const { x, y } = anchor.isPrecise ? anchor : impreciseShapeAnchor
38
- return { x: bounds.minX + x * bounds.w, y: bounds.minY + y * bounds.h }
39
- }
40
- case 'text-range': {
41
- const bounds = editor.getShapePageBounds(anchor.shapeId as TLShapeId)
42
- if (!bounds) return null
43
- return { x: bounds.maxX, y: bounds.minY }
118
+ const shape = editor.getShape(anchor.shapeId as TLShapeId)
119
+ if (!shape) return null
120
+ const transform = editor.getShapePageTransform(shape)
121
+ if (!transform) return null
122
+ const { point, size } = editor.getShapeGeometry(shape).bounds
123
+ // Precise pins sit at their stored x/y; imprecise ones at the editor's configured spot.
124
+ const spot = anchor.isPrecise ? anchor : getCommentingOptions(editor).impreciseShapeAnchor
125
+ return Mat.applyToPoint(transform, Vec.Add(point, Vec.MulV(spot, size)))
44
126
  }
45
127
  case 'point':
46
128
  return { x: anchor.x, y: anchor.y }
47
129
  case 'region':
48
- return regionPinPoint(anchor, getRegionCommentOptions(editor).pinCorner)
130
+ return regionPinPoint(anchor, regionAnchorPinCorner(anchor))
49
131
  case 'page':
50
132
  return null
51
133
  }
52
134
  }
53
135
 
136
+ /**
137
+ * The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
138
+ *
139
+ * Uses the editor's hit-test margin, the same slack select and hover use — without it, open-path
140
+ * shapes (arrows, lines, draw strokes) are unhittable in practice.
141
+ *
142
+ * `hitFrameInside` lets a click inside a frame's body anchor to the frame, which it otherwise
143
+ * wouldn't (the select-tool convention hits only the edge/label). A child shape under the pointer
144
+ * still wins, so only a frame's empty interior anchors the frame.
145
+ *
146
+ * @internal
147
+ */
148
+ export function commentTargetShapeAt(editor: Editor, page: VecLike): TLShape | undefined {
149
+ return editor.getShapeAtPoint(page, {
150
+ hitInside: true,
151
+ hitFrameInside: true,
152
+ margin: editor.getHitTestMargin(),
153
+ })
154
+ }
155
+
54
156
  /**
55
157
  * A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
56
- * shape's page bounds, remembered either way. When `precise` (Alt held) the pin sits at exactly
57
- * `x`/`y`; otherwise it sits at the consumer's imprecise default (top-right out of the box).
158
+ * shape's own bounds, taken in the shape's own space, so a pin on a rotated shape records the spot
159
+ * it was dropped on. Remembered either way: when `precise` the pin sits at exactly `x`/`y`,
160
+ * otherwise at the consumer's imprecise default.
58
161
  * @public
59
162
  */
60
163
  export function shapeAnchorAt(
@@ -63,29 +166,60 @@ export function shapeAnchorAt(
63
166
  page: { x: number; y: number },
64
167
  precise: boolean
65
168
  ): TLCommentAnchor {
66
- const bounds = editor.getShapePageBounds(shapeId)
67
- if (!bounds || bounds.w === 0 || bounds.h === 0) {
169
+ const shape = editor.getShape(shapeId)
170
+ const bounds = shape && editor.getShapeGeometry(shape).bounds
171
+ if (!shape || !bounds || bounds.w === 0 || bounds.h === 0) {
68
172
  return { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: precise }
69
173
  }
174
+ const local = editor.getPointInShapeSpace(shape, page)
70
175
  return {
71
176
  type: 'shape',
72
177
  shapeId,
73
- x: (page.x - bounds.minX) / bounds.w,
74
- y: (page.y - bounds.minY) / bounds.h,
178
+ x: (local.x - bounds.minX) / bounds.w,
179
+ y: (local.y - bounds.minY) / bounds.h,
75
180
  isPrecise: precise,
76
181
  }
77
182
  }
78
183
 
79
184
  /** Open a thread and bring it into view — switch to its page if needed, then center its pin. @public */
80
- export function focusThread(
81
- editor: Editor,
82
- thread: TLCommentThread,
83
- impreciseShapeAnchor?: { x: number; y: number }
84
- ): void {
185
+ export function focusThread(editor: Editor, thread: TLCommentThread): void {
85
186
  if (thread.pageId !== editor.getCurrentPageId()) {
86
187
  editor.setCurrentPage(thread.pageId as any)
87
188
  }
88
189
  openThreadId.set(editor, thread.id)
89
- const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
90
- if (point) editor.centerOnPoint(point, { animation: { duration: 200 } })
190
+ const point = anchorPagePoint(editor, thread.anchor)
191
+ if (!point) return
192
+ const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
193
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration: 200 } })
194
+ }
195
+
196
+ /** The left inset a nudged pin never crosses, and the gap kept between thread UI and sidebar. */
197
+ const CENTER_MARGIN_PX = 8
198
+
199
+ /** How far the open thread UI reaches right of its pin, in screen px: the popover's offset plus
200
+ * the thread panel's fixed width (300px, `.tlui-cmt-thread`). */
201
+ const THREAD_UI_EXTENT_PX = POPOVER_OFFSET.thread.x + 300
202
+
203
+ /**
204
+ * How far right of a centered-on pin the true viewport center should sit, in screen px. While the
205
+ * comments sidebar covers the viewport's right edge, dead-centering a pin would put its thread
206
+ * popover under the sidebar — so centering aims at the middle of the uncovered area instead, never
207
+ * past the viewport's left edge. Zero when the sidebar is closed or not on screen.
208
+ * @internal
209
+ */
210
+ export function commentCenterScreenOffset(editor: Editor): number {
211
+ if (!commentsSidebarOpen.get(editor)) return 0
212
+ const sidebar = editor.getContainer().querySelector('.tlui-cmt-canvas-sidebar')
213
+ if (!sidebar) return 0
214
+ const viewport = editor.getViewportScreenBounds()
215
+ // Viewport screen bounds are the container's client rect, so this is container-local.
216
+ const sidebarLeft = sidebar.getBoundingClientRect().left - viewport.x
217
+ if (sidebarLeft >= viewport.w) return 0
218
+ // Aim the pin at the middle of the uncovered area, nudged left until the thread UI clears the
219
+ // sidebar — but never past the left edge (the edge wins when there's no room for both).
220
+ const pinX = Math.max(
221
+ Math.min(sidebarLeft / 2, sidebarLeft - CENTER_MARGIN_PX - THREAD_UI_EXTENT_PX),
222
+ CENTER_MARGIN_PX
223
+ )
224
+ return viewport.w / 2 - pinX
91
225
  }
@@ -0,0 +1,72 @@
1
+ import type { CommentAuthor } from '@tldraw/mentions'
2
+ import type { TLComment } from 'tldraw'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { absoluteThreadLink, toCardProps } from './thread-view'
5
+
6
+ const AUTHOR: CommentAuthor = { name: 'Ada' }
7
+
8
+ function comment(overrides: Partial<TLComment> = {}): TLComment {
9
+ return {
10
+ id: 'comment:1',
11
+ typeName: 'comment',
12
+ threadId: 'comment-thread:1',
13
+ pageId: 'page:one',
14
+ authorId: 'user:1',
15
+ body: { type: 'doc', content: [] },
16
+ createdAt: Date.parse('2026-07-30T12:00:00.000Z'),
17
+ editedAt: null,
18
+ meta: {},
19
+ ...overrides,
20
+ } as unknown as TLComment
21
+ }
22
+
23
+ const context = {
24
+ currentUserId: 'user:1',
25
+ resolveAuthor: (id: string) => (id === 'user:1' ? AUTHOR : undefined),
26
+ }
27
+
28
+ const resolveName = (id: string) => context.resolveAuthor(id)?.name
29
+
30
+ describe('toCardProps', () => {
31
+ it('marks your own comments and resolves the author', () => {
32
+ const card = toCardProps(comment(), context, {}, resolveName)
33
+ expect(card.you).toBe(true)
34
+ expect(card.author).toBe(AUTHOR)
35
+ expect(card.date).toBe('2026-07-30T12:00:00.000Z')
36
+ expect(card.edited).toBe(false)
37
+ })
38
+
39
+ it('falls back to the unknown author when the id does not resolve', () => {
40
+ const card = toCardProps(comment({ authorId: 'user:gone' }), context, {}, resolveName)
41
+ expect(card.you).toBe(false)
42
+ expect(card.author).not.toBe(AUTHOR)
43
+ })
44
+ })
45
+
46
+ describe('absoluteThreadLink', () => {
47
+ const BASE = 'https://example.com/f/abc123?page=2'
48
+
49
+ // A host's `getThreadHref` is an href — the sidebar rows use it as one — so it's allowed to be
50
+ // relative. Copying that verbatim would put "/f/abc123?comment=…" on someone's clipboard.
51
+ it('resolves a root-relative href against the current document', () => {
52
+ expect(absoluteThreadLink('/f/abc123?comment=comment-thread%3A1', BASE)).toBe(
53
+ 'https://example.com/f/abc123?comment=comment-thread%3A1'
54
+ )
55
+ })
56
+
57
+ it('leaves an already absolute href alone', () => {
58
+ const href = 'https://other.example/f/xyz?comment=comment-thread%3A1'
59
+ expect(absoluteThreadLink(href, BASE)).toBe(href)
60
+ })
61
+
62
+ it('resolves a path-relative href', () => {
63
+ expect(absoluteThreadLink('?comment=comment-thread%3A1', BASE)).toBe(
64
+ 'https://example.com/f/abc123?comment=comment-thread%3A1'
65
+ )
66
+ })
67
+
68
+ // Better a link that's odd than a copy button that silently does nothing.
69
+ it('copies an unparseable href as-is', () => {
70
+ expect(absoluteThreadLink('::not a url::', '::also not::')).toBe('::not a url::')
71
+ })
72
+ })