@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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 (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  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/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -1,10 +1,12 @@
1
+ import { Avatar, isMentionPickerOpen } from '@tldraw/mentions'
1
2
  import {
3
+ Fragment,
2
4
  memo,
3
- type CSSProperties,
4
5
  type PointerEvent as ReactPointerEvent,
5
6
  ReactNode,
6
7
  useCallback,
7
8
  useEffect,
9
+ useLayoutEffect,
8
10
  useMemo,
9
11
  useRef,
10
12
  useState,
@@ -15,13 +17,9 @@ import {
15
17
  createComment,
16
18
  createCommentThread,
17
19
  Editor,
18
- getFirstCharacter,
19
20
  react,
20
- TLComment,
21
- TLCommentId,
22
21
  TLCommentThread,
23
22
  TLRichText,
24
- TldrawUiIcon,
25
23
  useContainer,
26
24
  useEditor,
27
25
  usePassThroughMouseOverEvents,
@@ -33,130 +31,114 @@ import {
33
31
  import { computeClusterTable } from '../clustering/computeClusterTable'
34
32
  import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
35
33
  import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
36
- import { CommentCard, CommentCardProps } from '../ui/comment-card'
37
34
  import { CommentComposer } from '../ui/comment-composer'
38
35
  import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
39
36
  import { CommentPin } from '../ui/comment-pin'
40
- import { CommentThread } from '../ui/comment-thread'
41
37
  import { CountBadge } from '../ui/count-badge'
42
- import { MentionMember } from '../ui/mention-list'
43
- import { isMentionPickerOpen } from '../ui/mention-suggestion'
38
+ import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
44
39
  import { collectClusterLeaves } from './cluster-input'
45
- import { CommentBody } from './comment-body'
46
- import { UNKNOWN_AUTHOR } from './comment-render'
47
- import { getCommentRecord, putCommentRecords, removeCommentRecords } from './comment-store'
40
+ import {
41
+ clearCommentDraft,
42
+ getCommentDraft,
43
+ NEW_COMMENT_DRAFT,
44
+ saveCommentDraft,
45
+ } from './comment-drafts'
46
+ import { commitCommentMutation, putRecordsInCommit } from './comment-mutations'
47
+ import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
48
+ import { getCommentRecord } from './comment-store'
48
49
  import { PendingComment } from './comment-tool'
50
+ import { type CommentingContext } from './context'
49
51
  import { useCommentThreads, useThreadComments } from './hooks'
50
52
  import { useCommentingEnabled } from './license'
51
53
  import {
52
- type CommentingComponents,
53
54
  type CommentingOptions,
54
55
  getCommentingOptions,
56
+ useCanComment,
55
57
  useCommentingOptions,
56
58
  } from './options'
57
- import {
58
- DEFAULT_REGION_COMMENT_OPTIONS,
59
- RegionCommentOptions,
60
- setRegionCommentOptions,
61
- } from './region-options'
59
+ import { computePinStacks, pinStackKey } from './pin-stacking'
62
60
  import {
63
61
  commentsHidden,
64
- commitCommentMutation,
62
+ openStackId,
65
63
  openThreadId,
66
64
  pendingComment,
67
65
  regionDraft,
66
+ revealThreadRequest,
67
+ sidebarFilters,
68
68
  toggleCommentsHidden,
69
69
  usePendingComment,
70
70
  } from './state'
71
- import { anchorPagePoint, regionPinPoint, shapeAnchorAt } from './thread-state'
71
+ import { ThreadPreview, sortThreadsForPreview, useMarkerPreview } from './thread-preview'
72
+ import { ThreadStackPin } from './thread-stack'
73
+ import {
74
+ anchorPagePoint,
75
+ commentCenterScreenOffset,
76
+ commentTargetShapeAt,
77
+ impreciseShapePinInset,
78
+ REGION_PIN_CORNER,
79
+ regionAnchorPinCorner,
80
+ regionPinPoint,
81
+ shapeAnchorAt,
82
+ } from './thread-state'
83
+ import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
72
84
 
73
85
  /**
74
- * A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
75
- * thread popover (with a reply composer) on click, and shows a composer where the comment tool
76
- * placed a new thread. Reads/writes comment records straight from `editor.store`.
86
+ * The host wiring for {@link CanvasComments} see {@link CommentingContext}, which the sidebar
87
+ * takes the same fields from.
77
88
  *
78
- * It's meant as the batteries-included default — every visible piece is a lever (the `CommentBody`
79
- * and `PinContent` slots on `CommentTool.configure({ components })`), and the pieces it composes
80
- * (`CommentPin`, `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a
81
- * consumer can rebuild this from parts instead.
82
89
  * @public
83
90
  */
84
- export interface CanvasCommentsProps {
85
- /** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
86
- currentUserId: string | null
87
- /** Map an author id to a display name, or `undefined` when the id can't be named. */
88
- resolveName(id: string): string | undefined
89
- /** Called after any comment (a new thread's first comment, or a reply) is posted. */
90
- onPostComment?(comment: TLComment): void
91
- /** Whether a comment is unread for the current user (return true for unread). */
92
- isCommentUnread?(commentId: TLCommentId): boolean
93
- /**
94
- * Called for each unread comment shown to the user in an open thread popover, so hosts can
95
- * record a read receipt. Needs `isCommentUnread` to know what's unread.
96
- */
97
- onCommentRead?(commentId: TLCommentId): void
98
- /** Resolve the members matching an `@`-query in the composers (sync or async). */
99
- getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
100
- /** Override a mention-picker row's content. */
101
- renderMentionSuggestion?(member: MentionMember): ReactNode
102
- /** Where imprecise shape pins sit — a normalized (0–1) spot within the shape. Default top-right. */
103
- impreciseShapeAnchor?: { x: number; y: number }
104
- /** Region comment behaviour. Region is off by default — omit this for click-only point/shape
105
- * comments. Anything unset falls back to {@link DEFAULT_REGION_COMMENT_OPTIONS}. */
106
- regionOptions?: Partial<RegionCommentOptions>
107
- }
91
+ export type CanvasCommentsProps = CommentingContext
108
92
 
109
93
  const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
110
94
 
111
- const initialOf = (name: string): string => (getFirstCharacter(name.trim()) || '?').toUpperCase()
95
+ /** A pointer-down that belongs to the camera, not the comment UI: any non-primary button
96
+ * (middle/right-button pans), or a primary press with the spacebar pan key held. */
97
+ const isCanvasPanGesture = (editor: Editor, e: ReactPointerEvent) =>
98
+ e.button !== 0 || editor.inputs.keys.has('Space')
99
+
100
+ /** Hand a pointer event to the canvas beneath the comments layer, marked the same way the
101
+ * pass-through wheel/hover hooks mark their re-dispatched events. */
102
+ function forwardPointerEventToCanvas(container: HTMLElement, e: ReactPointerEvent) {
103
+ const cvs = container.querySelector('.tl-canvas')
104
+ if (!cvs) return
105
+ const newEvent = new PointerEvent(e.type, e.nativeEvent as any)
106
+ ;(newEvent as any).isSpecialRedispatchedEvent = true
107
+ cvs.dispatchEvent(newEvent)
108
+ }
109
+
112
110
  const CLUSTER_FADE_MS = 150
113
111
  /** Duration of the click-a-badge zoom-to-split animation. */
114
112
  const CLUSTER_EXPAND_ZOOM_MS = 450
113
+ /** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
114
+ * lands clear of the threshold it just crossed rather than flickering on it. */
115
+ const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
116
+ /** Screen-pixel margin by which the viewport is inflated when culling cluster badges, so a badge
117
+ * just off-screen is already mounted when a pan brings it in. */
118
+ const CLUSTER_CULL_MARGIN_PX = 120
119
+
120
+ /** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
121
+ * first comment then lands where the preview's sat. Measured: the expanded first comment sits ~42px
122
+ * below the panel top (the 40px header, no gap) vs the preview's 4px (its panel padding).
123
+ * Re-measure if the header height or the preview panel padding changes. */
124
+ const THREAD_HEADER_BLOCK = 36
115
125
 
116
- /** The leading element for the placement composer — the comment pin's shape, but a pencil
117
- * instead of an initial, marking an unsent draft. */
118
- const draftAvatar = (
119
- <CommentPin>
120
- <svg
121
- viewBox="0 0 24 24"
122
- width="15"
123
- height="15"
124
- fill="none"
125
- stroke="currentColor"
126
- strokeWidth="2"
127
- strokeLinecap="round"
128
- strokeLinejoin="round"
129
- aria-hidden="true"
130
- >
131
- <path d="M12 20h9" />
132
- <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
133
- </svg>
134
- </CommentPin>
135
- )
136
-
137
- function toCardProps(
138
- comment: TLComment,
139
- props: CanvasCommentsProps,
140
- components: CommentingComponents
141
- ): CommentCardProps {
142
- const Body = components.CommentBody
143
- // The `CommentBody` component slot overrides the built-in rich-text default (which resolves
144
- // mention ids to names).
145
- const body = Body ? (
146
- <Body comment={comment} />
147
- ) : (
148
- <CommentBody richText={comment.body} resolveName={props.resolveName} />
149
- )
150
- return {
151
- author: props.resolveName(comment.authorId) ?? UNKNOWN_AUTHOR,
152
- body,
153
- date: new Date(comment.createdAt).toISOString(),
154
- you: comment.authorId === props.currentUserId,
155
- edited: comment.editedAt != null,
156
- }
157
- }
158
-
159
- /** @public @react */
126
+ /**
127
+ * A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
128
+ * thread popover (with a reply composer) on click, and shows a composer where the comment tool
129
+ * placed a new thread. Reads/writes comment records straight from `editor.store`.
130
+ *
131
+ * It's meant as the batteries-included default — every visible piece is a lever (the `CommentBody`
132
+ * and `PinContent` slots on `CommentTool.configure({ components })`), and the pieces it composes
133
+ * (`CommentPin`, `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a
134
+ * consumer can rebuild this from parts instead.
135
+ *
136
+ * The host wiring — who the viewer is, how ids become names, read status, mentions — is the
137
+ * {@link CommentingContext}, which `CanvasCommentsSidebar` takes too, so a host mounting both can
138
+ * build it once and spread it into each.
139
+ *
140
+ * @public @react
141
+ */
160
142
  export function CanvasComments(props: CanvasCommentsProps) {
161
143
  // Gate the whole layer on the license before doing any work. The inner component holds all the
162
144
  // other hooks, so mounting/unmounting it as the license resolves keeps hook order stable here.
@@ -165,42 +147,86 @@ export function CanvasComments(props: CanvasCommentsProps) {
165
147
  return <CanvasCommentsLayer {...props} />
166
148
  }
167
149
 
168
- function CanvasCommentsLayer(props: CanvasCommentsProps) {
150
+ /**
151
+ * A mount point appended to the end of the editor container, for a portal that has to come last
152
+ * among the container's children.
153
+ *
154
+ * `createPortal(…, container)` doesn't get to say where its node lands: React places a portal
155
+ * during the same commit that mounts it, and a portal nested this deep in the tree is placed
156
+ * before the container's own, shallower children — so the layer ends up ahead of the UI and its
157
+ * "move focus to canvas" skip link. That link only works if nothing precedes it, and the pins are
158
+ * real buttons, so a single comment would take the first tab stop and leave no keyboard route to
159
+ * the canvas. A layout effect runs after the whole commit instead, by which point the container's
160
+ * children are all in place and appending is guaranteed to land at the end.
161
+ *
162
+ * Null until the effect has run, so the first render has nothing to portal into.
163
+ */
164
+ function useTrailingPortalHost(container: HTMLElement) {
165
+ const [host, setHost] = useState<HTMLDivElement | null>(null)
166
+ useLayoutEffect(() => {
167
+ const elm = container.ownerDocument.createElement('div')
168
+ // The host is a position in the DOM, not a box — what it holds is positioned against the
169
+ // container, the same as it was when it hung off the container directly.
170
+ elm.style.display = 'contents'
171
+ container.appendChild(elm)
172
+ setHost(elm)
173
+ return () => {
174
+ elm.remove()
175
+ setHost(null)
176
+ }
177
+ }, [container])
178
+ return host
179
+ }
180
+
181
+ function CanvasCommentsLayer(props: CommentingContext) {
169
182
  const editor = useEditor()
170
183
  const options = useCommentingOptions()
171
184
  const container = useContainer()
172
- // Merge the consumer's region options over the disabled defaults and publish them for this editor,
173
- // so the comment tool (which has no props) reads the same per-instance config.
174
- const regionOptions = useMemo(
175
- () => ({ ...DEFAULT_REGION_COMMENT_OPTIONS, ...props.regionOptions }),
176
- [props.regionOptions]
177
- )
178
- useEffect(() => setRegionCommentOptions(editor, regionOptions), [editor, regionOptions])
185
+ const portalHost = useTrailingPortalHost(container)
179
186
  const layerRef = useRef<HTMLDivElement>(null)
180
- // Over the pins and cluster badges, wheel and hover pass through to the canvas beneath (these
181
- // events bubble up from the pointer-interactive markers to this layer root).
182
- usePassThroughWheelEvents(layerRef)
187
+ // Over the pins and cluster badges, hover passes through to the canvas beneath (these events
188
+ // bubble up from the pointer-interactive markers to this layer root). Wheel pass-through is
189
+ // NOT on this root: it lives on each interactive element instead. The root spans the whole
190
+ // canvas, so any pin past its bottom/right edge inflates the root's scrollHeight — which the
191
+ // wheel hook's is-this-scrollable guard reads as scrollable, silently disabling pass-through.
183
192
  usePassThroughMouseOverEvents(layerRef)
184
- const deepLinkHandled = useRef(false)
185
- const threads = useCommentThreads(editor)
193
+ const allThreads = useCommentThreads(editor)
186
194
  const pending = usePendingComment()
195
+ const canComment = useCanComment(props.currentUserId)
196
+ // With composing blocked and no fallback slot there's nothing to render for a pending comment —
197
+ // and the dismiss handlers (Escape, click-away) live inside PendingComposer, which would never
198
+ // mount. Clear the atom instead of stranding it (a stale pending would pop a composer at the
199
+ // old click point if `canComment` later flips true).
200
+ const canRenderComposer = canComment || options.components.ComposerFallback != null
201
+ const showPendingComposer = pending != null && canRenderComposer
202
+ useEffect(() => {
203
+ if (pending && !showPendingComposer) pendingComment.set(editor, null)
204
+ }, [editor, pending, showPendingComposer])
187
205
  const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
188
- const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
189
- // Threads whose anchor has moved (by any means drag, nudge, align, undo, a collaborator)
190
- // since the rendered clustering was built. They pop out of clustering and render as live pins,
191
- // and only rejoin at the next zoom event, when everything re-clusters anyway.
192
- const [movedThreadIds, setMovedThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
206
+ // Hide resolved threads' pins by default, matching the sidebar's `showResolved` filter. The open
207
+ // thread stays in resolving from its own popover shouldn't make the pin vanish under it.
208
+ const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
209
+ editor,
210
+ ])
211
+ const threads = useMemo(
212
+ () => allThreads.filter((t) => showResolved || t.resolved == null || t.id === openId),
213
+ [allThreads, showResolved, openId]
214
+ )
215
+ useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
216
+ // Threads held out of clustering because their anchor moved while folded inside a badge
217
+ // (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
218
+ // as live pins riding their anchor and rejoin clustering on the next zoom-out.
219
+ const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
193
220
  const adoptOnRebuild = useRef(false)
194
221
  const clusterLeaves = useValue(
195
222
  'comment cluster leaves',
196
223
  () =>
197
224
  collectClusterLeaves(
198
225
  editor,
199
- threads.filter((thread) => !movedThreadIds.has(thread.id)),
200
- openThreadId.get(editor),
201
- impreciseShapeAnchor
226
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
227
+ openThreadId.get(editor)
202
228
  ),
203
- [editor, threads, impreciseShapeAnchor, movedThreadIds]
229
+ [editor, threads, heldThreadIds]
204
230
  )
205
231
  const clusterZoomBounds = useValue(
206
232
  'comment cluster zoom bounds',
@@ -213,44 +239,69 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
213
239
  runtime.seed(editor.getZoomLevel())
214
240
  return { runtime, table }
215
241
  }, [clusterLeaves, clusterZoomBounds, editor])
216
- // Re-clustering only applies while zooming: a rebuilt model (thread added, moved, or closed)
217
- // is held as `latestModel` and adopted on the next zoom change, so pins never re-flow into
218
- // clusters under a static camera. Until adoption, threads the rendered model doesn't know
219
- // about show as plain unclustered pins (`orphanThreads`). Exception: a rebuild that *removed*
220
- // leaves (thread deleted or opened, page changed) is adopted immediately, so stale pins and
221
- // badge counts never linger.
242
+ // The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild
243
+ // (add / move / delete / open / pop-out) is computed immediately as `latestModel` the MST
244
+ // stays correct but the on-screen partition is `renderedModel`, and it only ever changes via
245
+ // (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
246
+ // (c) LOCAL detach patches: a leaf that left the input (deleted, opened, popped out) is
247
+ // detached from its own badge in place — count and centroid update for that badge alone,
248
+ // and nothing else on the canvas moves.
222
249
  const [renderedModel, setRenderedModel] = useState(latestModel)
223
250
  let clusterModel = renderedModel
224
- if (
225
- renderedModel !== latestModel &&
226
- (adoptOnRebuild.current || hasRemovedLeaves(renderedModel.table, latestModel.table))
227
- ) {
251
+ // A page switch replaces the whole scene: hard-reset rather than detach the world.
252
+ const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
253
+ const pageRef = useRef(pageId)
254
+ if (pageRef.current !== pageId) {
255
+ pageRef.current = pageId
228
256
  adoptOnRebuild.current = false
229
- // Carryover seed: events inside their hysteresis band inherit the outgoing partition's
230
- // merged/unmerged state instead of the geometric-mean tiebreak, so untouched pins never
231
- // snap together (or apart) just because the model was swapped. Idempotent, so safe to
232
- // run during render.
257
+ latestModel.runtime.seed(editor.getZoomLevel())
258
+ if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
259
+ setRenderedModel(latestModel)
260
+ clusterModel = latestModel
261
+ }
262
+ // adoptOnRebuild is set by the rejoin reaction below, outside React's render cycle, paired
263
+ // with clearing heldThreadIds. Only trust it once that pairing is actually visible here
264
+ // (heldThreadIds confirmed empty) — an unrelated re-render can land in the gap between the
265
+ // ref being set and the state update it was paired with being applied.
266
+ const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
267
+ if (renderedModel !== latestModel && rejoinPending) {
268
+ adoptOnRebuild.current = false
269
+ // Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
270
+ // nothing changes state because of the swap alone. Idempotent, so safe during render.
233
271
  latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
234
272
  setRenderedModel(latestModel)
235
273
  clusterModel = latestModel
274
+ } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
275
+ // Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
276
+ // survive to force-adopt a later, unrelated rebuild.
277
+ adoptOnRebuild.current = false
236
278
  }
237
279
  // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
238
- // is baked into the model), so when its live position drifts from the baked one it ghosts.
239
- // Marking it moved excludes it from the cluster input, which reads as a removal above and
240
- // re-clusters the rest of its pile immediately; the pin itself renders live below.
280
+ // is baked into the model), so when its live position drifts from the baked one, hold it out.
281
+ // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
241
282
  const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
242
283
  if (newlyMovedIds.length > 0) {
243
- // eslint-disable-next-line no-console
244
- console.debug(`[comments] pins popped out of clustering: ${newlyMovedIds.join(', ')}`)
245
- const next = new Set(movedThreadIds)
284
+ const next = new Set(heldThreadIds)
246
285
  for (const id of newlyMovedIds) next.add(id)
247
- setMovedThreadIds(next)
286
+ setHeldThreadIds(next)
287
+ }
288
+ // Local partition maintenance — the only non-zoom visual change, and it is local by
289
+ // construction: any displayed leaf that has left the cluster input (deleted, thread opened,
290
+ // popped out above) is detached from its badge in place. The corrected rebuild is already
291
+ // sitting in latestModel awaiting the next zoom-out.
292
+ {
293
+ const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
294
+ for (const leaf of clusterModel.table.leaves) {
295
+ if (!latestLeafIds.has(leaf.id)) {
296
+ clusterModel.runtime.detachLeaf(leaf.id)
297
+ }
298
+ }
248
299
  }
249
300
  // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
250
301
  // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
251
302
  // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
252
303
  useEffect(() => {
253
- if (movedThreadIds.size === 0) return
304
+ if (heldThreadIds.size === 0) return
254
305
  let lastZoom = editor.getZoomLevel()
255
306
  return react('rejoin moved comment pins on zoom out', () => {
256
307
  const zoom = editor.getZoomLevel()
@@ -258,9 +309,9 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
258
309
  lastZoom = zoom
259
310
  if (zoom >= prevZoom) return
260
311
  adoptOnRebuild.current = true
261
- setMovedThreadIds(EMPTY_SET)
312
+ setHeldThreadIds(EMPTY_SET)
262
313
  })
263
- }, [movedThreadIds, editor])
314
+ }, [heldThreadIds, editor])
264
315
  // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
265
316
  // a merge, and merging only happens while zooming out. While zooming in, the stale table still
266
317
  // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
@@ -276,84 +327,134 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
276
327
  setRenderedModel(latestModel)
277
328
  })
278
329
  }, [clusterModel, latestModel, editor])
330
+ // Threads in the current input that the displayed partition doesn't show anywhere (new
331
+ // comments, reopened threads, undone deletions): render as plain pins until the next
332
+ // zoom-out folds them in. Membership is judged against the *displayed* partition (with
333
+ // detaches applied), not the rendered table, so a detached-then-restored leaf reappears.
334
+ const partitionVersion = clusterModel.runtime.version
279
335
  const orphanThreads = useMemo(() => {
280
336
  if (clusterModel === latestModel) return []
281
- const renderedIds = new Set(clusterModel.table.leaves.map((leaf) => leaf.id))
337
+ const displayed = new Set<string>()
338
+ for (const node of clusterModel.runtime.getVisible().values()) {
339
+ for (const member of node.members) displayed.add(member)
340
+ }
282
341
  const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
283
- return threads.filter((thread) => latestIds.has(thread.id) && !renderedIds.has(thread.id))
284
- }, [clusterModel, latestModel, threads])
285
- const movedThreads = useMemo(
286
- () => threads.filter((thread) => movedThreadIds.has(thread.id) && thread.id !== openId),
287
- [threads, movedThreadIds, openId]
342
+ return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
343
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
344
+ // eslint-disable-next-line react-hooks/exhaustive-deps
345
+ }, [clusterModel, latestModel, threads, partitionVersion])
346
+ const heldThreads = useMemo(
347
+ () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
348
+ [threads, heldThreadIds, openId]
288
349
  )
289
- // Subscribe to the runtime cursor, not the raw zoom: onCamera runs on every zoom tick (two
290
- // O(1) threshold checks against the event table) but returns the same integer until a merge
291
- // or split event actually fires — so this component only re-renders on cluster changes, not
292
- // on every camera frame.
293
- const clusterCursor = useValue(
294
- 'comment cluster cursor',
350
+ // Subscribe to the runtime's partition version, not the raw zoom: onCamera runs on every zoom
351
+ // tick (O(1) threshold checks) but the version only moves when the partition actually changes
352
+ // — so this component only re-renders on cluster changes, not on every camera frame. The memo
353
+ // below keys on a fresh inline read of the version rather than the subscribed value, because
354
+ // render-time detaches (above) bump it after the subscription's computed already evaluated.
355
+ useValue(
356
+ 'comment cluster version',
295
357
  () => {
296
358
  clusterModel.runtime.onCamera(editor.getZoomLevel())
297
- return clusterModel.runtime.k
359
+ return clusterModel.runtime.version
298
360
  },
299
361
  [clusterModel, editor]
300
362
  )
301
363
  const visibleNodes = useMemo(() => {
302
- const nodes = Array.from(clusterModel.runtime.getVisible().values())
303
- // eslint-disable-next-line no-console
304
- console.debug(
305
- `[comments] cluster cursor k=${clusterCursor} re-rendering ${nodes.length} visible nodes`
306
- )
307
- return nodes
308
- }, [clusterModel, clusterCursor])
364
+ return Array.from(clusterModel.runtime.getVisible().values())
365
+ // The runtime mutates its partition in place; partitionVersion is its change stamp.
366
+ // eslint-disable-next-line react-hooks/exhaustive-deps
367
+ }, [clusterModel, partitionVersion])
309
368
  const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
310
369
  const threadsById = useMemo(
311
370
  () => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
312
371
  [threads]
313
372
  )
373
+ // Zooming separates near pins, but pins with the *same* anchor point (several imprecise
374
+ // comments on one shape) coincide at every zoom — those render as one count-badge stack that
375
+ // opens the threads as a list. Keyed on page-space anchors, so camera moves never recompute this.
376
+ const pinStacks = useValue('comment pin stacks', () => computePinStacks(editor, threads), [
377
+ editor,
378
+ threads,
379
+ ])
314
380
  const openThread = openId ? threadsById.get(openId) : null
315
381
  const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
316
382
 
317
- // Reset the transient UI state (open thread, half-placed comment) when this unmounts.
383
+ // Reset the transient UI state (open thread, open stack, half-placed comment, unserved reveal)
384
+ // when this unmounts.
318
385
  useEffect(() => {
319
386
  return () => {
320
387
  openThreadId.set(editor, null)
388
+ openStackId.set(editor, null)
321
389
  pendingComment.set(editor, null)
390
+ revealThreadRequest.set(editor, null)
322
391
  }
323
392
  }, [editor])
324
393
 
325
- // Open the thread named by a deep link (?comment=<thread or comment id>). If the thread is
326
- // currently inside a cluster, zoom to the first split that reveals it before opening.
394
+ // Clear a stale open-stack key. `openStackId` is a stack's coincident point key, and only the
395
+ // stack's own (mounted) handlers clear it — so collapsing the stack to a single pin unmounts the
396
+ // `ThreadStackPin` and strands the key. A dangling `openStackId` is not harmless: `useMarkerPreview`
397
+ // treats any non-null value as "a stack is open" and suppresses every hover preview until it's
398
+ // cleared. Keep it while any live stack still sits at that key (so losing a member — even the
399
+ // oldest — keeps the list open under the survivors), and clear it once none does.
327
400
  useEffect(() => {
328
- if (deepLinkHandled.current) return
329
- const id = new URLSearchParams(window.location.search).get('comment')
330
- if (!id) {
331
- deepLinkHandled.current = true
332
- return
401
+ const key = openStackId.get(editor)
402
+ if (!key) return
403
+ for (const id of pinStacks.keys()) {
404
+ const thread = threadsById.get(id)
405
+ if (!thread) continue
406
+ const point = anchorPagePoint(editor, thread.anchor)
407
+ if (point && pinStackKey(point) === key) return
333
408
  }
409
+ openStackId.set(editor, null)
410
+ }, [editor, pinStacks, threadsById])
334
411
 
335
- const record = getCommentRecord(editor, id)
336
- if (!record) return
337
-
338
- let thread: TLCommentThread | undefined
339
- if (record.typeName === 'comment') {
340
- thread = threadsById.get(record.threadId)
341
- } else {
342
- thread = record
343
- }
344
- if (!thread) return
412
+ // The requested thread, once it (and, for a comment id, its parent thread) has synced into the
413
+ // store; null while records are still arriving or when no request is pending.
414
+ const requestedRevealThread = useValue(
415
+ 'requested reveal thread',
416
+ () => {
417
+ const id = revealThreadRequest.get(editor)
418
+ if (!id) return null
419
+ const record = getCommentRecord(editor, id)
420
+ if (!record) return null
421
+ const thread =
422
+ record.typeName === 'comment' ? getCommentRecord(editor, record.threadId) : record
423
+ return thread?.typeName === 'comment-thread' ? thread : null
424
+ },
425
+ [editor]
426
+ )
345
427
 
346
- deepLinkHandled.current = true
347
- revealDeepLinkedThread(
348
- editor,
349
- thread,
350
- clusterModel.table,
351
- clusterZoomBounds,
352
- options,
353
- impreciseShapeAnchor
354
- )
355
- openThreadId.set(editor, thread.id)
356
- }, [clusterModel.table, clusterZoomBounds, editor, threadsById, impreciseShapeAnchor, options])
428
+ // Serve a pending reveal request: open the thread, zooming to the first cluster split that
429
+ // reveals its pin when it's currently folded into a badge. A reveal is an explicit ask to see
430
+ // the thread, so it also unhides pins — the popover opens on the on-canvas layer, which stays
431
+ // invisible while hidden.
432
+ useEffect(() => {
433
+ if (!requestedRevealThread) return
434
+ revealThreadRequest.set(editor, null)
435
+ commentsHidden.set(editor, false)
436
+ revealThreadPin(editor, requestedRevealThread, clusterModel.table, clusterZoomBounds, options)
437
+ openThreadId.set(editor, requestedRevealThread.id)
438
+ }, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
439
+
440
+ // Picking a thread out of a cluster's hover preview. Setting `openThreadId` alone would work —
441
+ // the thread leaves the cluster input and renders its own pin — but it would cut straight there
442
+ // from wherever the badge was. Zoom in on it first, the same move (and duration) the badge's
443
+ // own click makes, so the thread arrives instead of appearing.
444
+ const revealClusteredThread = useCallback(
445
+ (thread: TLCommentThread) => {
446
+ revealThreadPin(
447
+ editor,
448
+ thread,
449
+ clusterModel.table,
450
+ clusterZoomBounds,
451
+ options,
452
+ CLUSTER_EXPAND_ZOOM_MS
453
+ )
454
+ openThreadId.set(editor, thread.id)
455
+ },
456
+ [clusterModel.table, clusterZoomBounds, editor, options]
457
+ )
357
458
 
358
459
  // Clicking a badge zooms to just past the zoom at which that cluster first unclusters,
359
460
  // centered on its centroid. The event that created a visible cluster is the event that splits
@@ -366,13 +467,13 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
366
467
  const event = clusterModel.table.events.find((e) => e.result.id === node.id)
367
468
  if (!event || !Number.isFinite(event.zSplit)) return
368
469
  const zoom = clamp(
369
- event.zSplit * options.clusterSplitZoomFactor,
470
+ event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
370
471
  clusterZoomBounds.minZoom,
371
472
  clusterZoomBounds.maxZoom
372
473
  )
373
474
  centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
374
475
  },
375
- [clusterModel, clusterZoomBounds, editor, options]
476
+ [clusterModel, clusterZoomBounds, editor]
376
477
  )
377
478
 
378
479
  // Escape collapses the open thread. Capture-phase + stopPropagation so it runs ahead of the
@@ -384,7 +485,7 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
384
485
  // The mention picker owns Escape while it's open — let it dismiss the roster alone.
385
486
  if (isMentionPickerOpen()) return
386
487
  const target = e.target as HTMLElement | null
387
- if (target && target.closest('.cmt-editing')) return
488
+ if (target && target.closest('.tlui-cmt-editing')) return
388
489
  openThreadId.set(editor, null)
389
490
  e.preventDefault()
390
491
  e.stopPropagation()
@@ -393,8 +494,8 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
393
494
  return () => document.removeEventListener('keydown', onKeyDown, true)
394
495
  }, [editor])
395
496
 
396
- // Shift+C toggles comment-pin visibility on the canvas (matching Figma). Skipped while typing so
397
- // it never fires from inside a composer. Physical `KeyC` (layout-independent) with shift only.
497
+ // Shift+C toggles comment-pin visibility on the canvas. Skipped while typing so it never fires
498
+ // from inside a composer. Physical `KeyC` (layout-independent) with shift only.
398
499
  useEffect(() => {
399
500
  const onKeyDown = (e: KeyboardEvent) => {
400
501
  if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
@@ -411,27 +512,88 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
411
512
  // is read above so this component stays mounted and its shortcut/Escape effects keep running.
412
513
  if (hidden) return null
413
514
 
515
+ // Which threads are on screen this render, across every path below. A stack renders exactly
516
+ // once, owned by its first member that is actually on screen — members can arrive by different
517
+ // paths (a leaf via clustering while its open sibling renders via the open slot), so ownership
518
+ // can't be decided per-path.
519
+ // A cluster node that is exactly one coincident stack — every member shares a single pin-stack
520
+ // group, with no distinct-position comment mixed in. Such a node is a stack standing on its own
521
+ // (its neighbours have already split off as the view zoomed in), so it renders as the immediate
522
+ // cascading count-badge list rather than a zoom-to-split cluster badge. Returns the stack's full
523
+ // group — which can include an open or orphan member the node's own leaves omit — or null.
524
+ const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
525
+ const group = pinStacks.get(node.members[0])
526
+ if (!group) return null
527
+ return node.members.every((id) => group.includes(id)) ? group : null
528
+ }
529
+
530
+ const renderedThreadIds = new Set<string>()
531
+ if (options.enableClustering) {
532
+ for (const { node } of fadeNodes) {
533
+ if (node.count === 1) renderedThreadIds.add(node.id)
534
+ // A pure-stack node owns its members here (they aren't count-1 leaves), so register them so
535
+ // the owner logic can pick one — mirroring how count-1 leaves are added above.
536
+ else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
537
+ }
538
+ for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
539
+ for (const thread of heldThreads) renderedThreadIds.add(thread.id)
540
+ } else {
541
+ for (const thread of threads) renderedThreadIds.add(thread.id)
542
+ }
543
+ if (openThread) renderedThreadIds.add(openThread.id)
544
+
545
+ // A coincident-stack member renders as the group's single count-badge stack (if it owns it)
546
+ // or not at all; everything else is an ordinary pin.
547
+ const renderThreadPin = (thread: TLCommentThread): ReactNode => {
548
+ const group = pinStacks.get(thread.id)
549
+ if (group) {
550
+ const owner = group.find((id) => renderedThreadIds.has(id))
551
+ if (owner !== thread.id) return null
552
+ const stackThreads = group
553
+ .map((id) => threadsById.get(id))
554
+ .filter((t): t is TLCommentThread => t !== undefined)
555
+ return <ThreadStackPin editor={editor} threads={stackThreads} {...props} />
556
+ }
557
+ return <ThreadPin editor={editor} thread={thread} {...props} />
558
+ }
559
+
414
560
  // Render into the container (above the panels' stacking context) so the pins and popovers
415
- // live in the UI layer rather than being clipped by the canvas layer.
561
+ // live in the UI layer rather than being clipped by the canvas layer — but at the end of it,
562
+ // behind the editor's own children in the tab order. See `useTrailingPortalHost`.
563
+ if (!portalHost) return null
416
564
  return createPortal(
417
- <div ref={layerRef} className="cmt-canvas-layer">
565
+ <div ref={layerRef} className="tlui-cmt-canvas-layer">
418
566
  {options.enableClustering ? (
419
567
  <>
420
568
  {fadeNodes.map(({ node, phase }) => {
421
569
  let content: ReactNode
570
+ const stackGroup = node.count > 1 ? stackGroupOf(node) : null
422
571
  if (node.count === 1) {
423
572
  const thread = threadsById.get(node.id)
424
573
  if (!thread) return null
574
+ content = renderThreadPin(thread)
575
+ } else if (stackGroup) {
576
+ // A coincident stack standing alone: draw the cascading count-badge list now
577
+ // instead of a zoom-to-split cluster badge. Route it through the stack's owner so
578
+ // the open/orphan/held slots stay deduped — when the owner is one of them, that
579
+ // slot draws the stack and this node draws nothing.
580
+ const owner = stackGroup.find((id) => renderedThreadIds.has(id))
581
+ content =
582
+ owner && node.members.includes(owner)
583
+ ? renderThreadPin(threadsById.get(owner)!)
584
+ : null
585
+ } else {
425
586
  content = (
426
- <ThreadPin
587
+ <ClusterBadge
427
588
  editor={editor}
428
- thread={thread}
429
- {...props}
430
- regionOptions={regionOptions}
589
+ node={node}
590
+ onExpand={zoomToClusterSplit}
591
+ onSelectThread={revealClusteredThread}
592
+ threadsById={threadsById}
593
+ currentUserId={props.currentUserId}
594
+ resolveAuthor={props.resolveAuthor}
431
595
  />
432
596
  )
433
- } else {
434
- content = <ClusterBadge editor={editor} node={node} onExpand={zoomToClusterSplit} />
435
597
  }
436
598
  return (
437
599
  <div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
@@ -440,22 +602,10 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
440
602
  )
441
603
  })}
442
604
  {orphanThreads.map((thread) => (
443
- <ThreadPin
444
- key={thread.id}
445
- editor={editor}
446
- thread={thread}
447
- {...props}
448
- regionOptions={regionOptions}
449
- />
605
+ <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
450
606
  ))}
451
- {movedThreads.map((thread) => (
452
- <ThreadPin
453
- key={thread.id}
454
- editor={editor}
455
- thread={thread}
456
- {...props}
457
- regionOptions={regionOptions}
458
- />
607
+ {heldThreads.map((thread) => (
608
+ <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
459
609
  ))}
460
610
  </>
461
611
  ) : (
@@ -465,34 +615,22 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
465
615
  // otherwise it would mount a second, stacked pin.
466
616
  threads
467
617
  .filter((thread) => thread.id !== openId)
468
- .map((thread) => (
469
- <ThreadPin
470
- key={thread.id}
471
- editor={editor}
472
- thread={thread}
473
- {...props}
474
- regionOptions={regionOptions}
475
- />
476
- ))
618
+ .map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
477
619
  )}
478
620
  {openThread && (
479
- <ThreadPin
480
- key={`open:${openThread.id}`}
481
- editor={editor}
482
- thread={openThread}
483
- {...props}
484
- regionOptions={regionOptions}
485
- />
621
+ <Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
486
622
  )}
487
623
  <RegionDraftBox editor={editor} />
488
624
  {/* Keep the region visible while composing — the drag draft is gone by now, and no thread
489
625
  exists yet, so the pending anchor is what shows the area under the open composer. */}
490
- {pending?.anchor.type === 'region' && <RegionBox editor={editor} box={pending.anchor} />}
491
- {pending && props.currentUserId && (
626
+ {pending?.anchor.type === 'region' && showPendingComposer && (
627
+ <RegionBox editor={editor} box={pending.anchor} />
628
+ )}
629
+ {pending && showPendingComposer && (
492
630
  <PendingComposer editor={editor} pending={pending} {...props} />
493
631
  )}
494
632
  </div>,
495
- container
633
+ portalHost
496
634
  )
497
635
  }
498
636
 
@@ -597,7 +735,7 @@ function cancelClusterFadeFrame(frame: number) {
597
735
  }
598
736
 
599
737
  function clusterFadeClassName(phase: ClusterFadePhase): string {
600
- return `cmt-cluster-fade cmt-cluster-fade--${phase}`
738
+ return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
601
739
  }
602
740
 
603
741
  /**
@@ -627,12 +765,6 @@ function findMovedClusteredLeafIds(
627
765
  return moved
628
766
  }
629
767
 
630
- function hasRemovedLeaves(rendered: ClusterTable, latest: ClusterTable): boolean {
631
- if (rendered.leaves.length === 0) return false
632
- const latestIds = new Set(latest.leaves.map((leaf) => leaf.id))
633
- return rendered.leaves.some((leaf) => !latestIds.has(leaf.id))
634
- }
635
-
636
768
  function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
637
769
  const cameraOptions = editor.getCameraOptions()
638
770
  const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
@@ -643,20 +775,19 @@ function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: numbe
643
775
  }
644
776
  }
645
777
 
646
- function revealDeepLinkedThread(
778
+ function revealThreadPin(
647
779
  editor: Editor,
648
780
  thread: TLCommentThread,
649
781
  table: ClusterTable,
650
782
  zoomBounds: { minZoom: number; maxZoom: number },
651
783
  options: CommentingOptions,
652
- impreciseShapeAnchor: { x: number; y: number }
784
+ duration = 200
653
785
  ) {
654
786
  if (thread.pageId !== editor.getCurrentPageId()) {
655
787
  editor.setCurrentPage(thread.pageId as any)
656
788
  }
657
789
 
658
- // Match where the rendered pin sits (resolved prop-or-option), so the camera centers on the pin.
659
- const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
790
+ const point = anchorPagePoint(editor, thread.anchor)
660
791
  if (!point) return
661
792
 
662
793
  // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
@@ -669,16 +800,17 @@ function revealDeepLinkedThread(
669
800
  parentEvent.zSplit <= zoomBounds.maxZoom
670
801
  ) {
671
802
  const zoom = clamp(
672
- parentEvent.zSplit * options.clusterSplitZoomFactor,
803
+ parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
673
804
  zoomBounds.minZoom,
674
805
  zoomBounds.maxZoom
675
806
  )
676
- centerOnPointAtZoom(editor, point, zoom)
807
+ centerOnPointAtZoom(editor, point, zoom, duration)
677
808
  return
678
809
  }
679
810
  }
680
811
 
681
- editor.centerOnPoint(point, { animation: { duration: 200 } })
812
+ const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
813
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
682
814
  }
683
815
 
684
816
  function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
@@ -692,9 +824,11 @@ function centerOnPointAtZoom(
692
824
  duration = 200
693
825
  ) {
694
826
  const viewport = editor.getViewportScreenBounds()
827
+ // The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
828
+ const offset = commentCenterScreenOffset(editor)
695
829
  editor.setCamera(
696
830
  {
697
- x: viewport.w / (2 * zoom) - point.x,
831
+ x: (viewport.w / 2 - offset) / zoom - point.x,
698
832
  y: viewport.h / (2 * zoom) - point.y,
699
833
  z: zoom,
700
834
  },
@@ -714,11 +848,23 @@ const ClusterBadge = memo(function ClusterBadge({
714
848
  editor,
715
849
  node,
716
850
  onExpand,
717
- }: {
851
+ onSelectThread,
852
+ threadsById,
853
+ ...props
854
+ }: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
718
855
  editor: Editor
719
856
  node: ClusterNode
720
857
  onExpand(node: ClusterNode): void
858
+ onSelectThread(thread: TLCommentThread): void
859
+ threadsById: ReadonlyMap<string, TLCommentThread>
721
860
  }) {
861
+ const container = useContainer()
862
+ const msg = useTranslation()
863
+ const badgeRef = useRef<HTMLButtonElement>(null)
864
+ const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
865
+ // Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
866
+ // note on the layer.
867
+ usePassThroughWheelEvents(badgeRef)
722
868
  const point = useValue(
723
869
  'cluster badge point',
724
870
  () => {
@@ -729,26 +875,64 @@ const ClusterBadge = memo(function ClusterBadge({
729
875
  [editor, node]
730
876
  )
731
877
 
878
+ // `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
879
+ // the order a reader would expect. Only computed while the preview is up.
880
+ const previewThreads = useMemo(() => {
881
+ if (!previewShown) return []
882
+ const threads: TLCommentThread[] = []
883
+ for (const id of node.members) {
884
+ const thread = threadsById.get(id)
885
+ if (thread) threads.push(thread)
886
+ }
887
+ return sortThreadsForPreview(threads)
888
+ }, [previewShown, node.members, threadsById])
889
+
732
890
  if (!point) return null
733
891
 
734
892
  return (
735
- <div
736
- className="cmt-canvas-cluster"
737
- style={{ left: point.x, top: point.y }}
738
- onPointerDown={stop}
739
- onClick={(e) => {
740
- e.stopPropagation()
741
- onExpand(node)
742
- }}
743
- >
744
- <CountBadge count={node.count} />
745
- </div>
893
+ <>
894
+ <button
895
+ ref={badgeRef}
896
+ type="button"
897
+ className="tlui-cmt-button tlui-cmt-canvas-cluster"
898
+ style={{ left: point.x, top: point.y }}
899
+ aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
900
+ onPointerDown={(e) => {
901
+ if (isCanvasPanGesture(editor, e)) {
902
+ forwardPointerEventToCanvas(container, e)
903
+ return
904
+ }
905
+ e.stopPropagation()
906
+ }}
907
+ onClick={(e) => {
908
+ e.stopPropagation()
909
+ onExpand(node)
910
+ }}
911
+ {...previewHandlers}
912
+ onFocus={previewHandlers.onPointerEnter}
913
+ onBlur={previewHandlers.onPointerLeave}
914
+ >
915
+ <CountBadge count={node.count} />
916
+ </button>
917
+ {previewShown && previewThreads.length > 0 && (
918
+ <ThreadPreview
919
+ editor={editor}
920
+ threads={previewThreads}
921
+ container={container}
922
+ variant="list"
923
+ point={point}
924
+ onSelectThread={onSelectThread}
925
+ {...previewHandlers}
926
+ {...props}
927
+ />
928
+ )}
929
+ </>
746
930
  )
747
931
  })
748
932
 
749
933
  function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
750
934
  const viewport = editor.getViewportScreenBounds()
751
- const margin = getCommentingOptions(editor).clusterCullMargin
935
+ const margin = CLUSTER_CULL_MARGIN_PX
752
936
  return (
753
937
  point.x >= -margin &&
754
938
  point.y >= -margin &&
@@ -757,44 +941,12 @@ function isInInflatedViewport(editor: Editor, point: { x: number; y: number }):
757
941
  )
758
942
  }
759
943
 
760
- /** The open thread's popover, portaled above the UI panels. Over it, wheel and hover events pass
761
- * through to the canvas (unless the popover is scrolling its own content), like tldraw's panels. */
762
- function ThreadPopover({
763
- container,
764
- style,
765
- children,
766
- }: {
767
- container: HTMLElement
768
- style: CSSProperties
769
- children: ReactNode
770
- }) {
771
- const ref = useRef<HTMLDivElement>(null)
772
- usePassThroughWheelEvents(ref)
773
- usePassThroughMouseOverEvents(ref)
774
- return createPortal(
775
- <div ref={ref} className="cmt-canvas-popover" style={style} onPointerDown={stop}>
776
- {children}
777
- </div>,
778
- container
779
- )
780
- }
781
-
782
944
  /** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
783
945
  * a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
784
946
  * `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
785
- function RegionBox({
786
- editor,
787
- box,
788
- movable,
789
- onPreview,
790
- onCommit,
791
- }: {
792
- editor: Editor
793
- box: BoxModel
794
- movable?: boolean
795
- onPreview?(bounds: BoxModel | null): void
796
- onCommit?(bounds: BoxModel): void
797
- }) {
947
+ /** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
948
+ * handles, so the box itself takes no pointer events. */
949
+ function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
798
950
  const rect = useValue(
799
951
  'region rect',
800
952
  () => {
@@ -805,39 +957,7 @@ function RegionBox({
805
957
  },
806
958
  [editor, box.x, box.y, box.w, box.h]
807
959
  )
808
- // The grab point and the box at grab time, captured so the drag translates by a stable delta even
809
- // as the box prop reflows under the live preview.
810
- const grabRef = useRef<{ page: VecLike; box: BoxModel } | null>(null)
811
- const translated = (e: ReactPointerEvent<HTMLDivElement>): BoxModel => {
812
- const g = grabRef.current!
813
- const p = editor.screenToPage({ x: e.clientX, y: e.clientY })
814
- return { ...g.box, x: g.box.x + (p.x - g.page.x), y: g.box.y + (p.y - g.page.y) }
815
- }
816
- const startMove = (e: ReactPointerEvent<HTMLDivElement>) => {
817
- e.stopPropagation()
818
- grabRef.current = { page: editor.screenToPage({ x: e.clientX, y: e.clientY }), box }
819
- e.currentTarget.setPointerCapture(e.pointerId)
820
- }
821
- const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
822
- if (grabRef.current) onPreview?.(translated(e))
823
- }
824
- const endMove = (e: ReactPointerEvent<HTMLDivElement>) => {
825
- if (!grabRef.current) return
826
- const bounds = translated(e)
827
- grabRef.current = null
828
- if (e.currentTarget.hasPointerCapture(e.pointerId))
829
- e.currentTarget.releasePointerCapture(e.pointerId)
830
- onCommit?.(bounds)
831
- }
832
- return (
833
- <div
834
- className={movable ? 'cmt-canvas-region cmt-canvas-region--movable' : 'cmt-canvas-region'}
835
- style={rect}
836
- onPointerDown={movable ? startMove : undefined}
837
- onPointerMove={movable ? onMove : undefined}
838
- onPointerUp={movable ? endMove : undefined}
839
- />
840
- )
960
+ return <div className="tlui-cmt-canvas-region" style={rect} />
841
961
  }
842
962
 
843
963
  /** The live region being dragged out by the comment tool, or nothing when not dragging. */
@@ -847,27 +967,21 @@ function RegionDraftBox({ editor }: { editor: Editor }) {
847
967
  return <RegionBox editor={editor} box={box} />
848
968
  }
849
969
 
850
- // A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 (a side midpoint)
851
- // is *not* controlled by that handle: corners resize both axes, edges resize only their own.
970
+ // A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
971
+ // controlled by that handle the resize math reads the spot rather than special-casing corners.
852
972
  interface RegionHandle {
853
973
  x: number
854
974
  y: number
855
975
  cursor: string
856
976
  }
857
977
 
858
- // The four corners (both axes) and the four side midpoints (one axis each).
978
+ // The four corners, each resizing both axes.
859
979
  const REGION_CORNERS: readonly RegionHandle[] = [
860
980
  { x: 0, y: 0, cursor: 'nwse-resize' },
861
981
  { x: 1, y: 0, cursor: 'nesw-resize' },
862
982
  { x: 0, y: 1, cursor: 'nesw-resize' },
863
983
  { x: 1, y: 1, cursor: 'nwse-resize' },
864
984
  ]
865
- const REGION_EDGES: readonly RegionHandle[] = [
866
- { x: 0.5, y: 0, cursor: 'ns-resize' },
867
- { x: 1, y: 0.5, cursor: 'ew-resize' },
868
- { x: 0.5, y: 1, cursor: 'ns-resize' },
869
- { x: 0, y: 0.5, cursor: 'ew-resize' },
870
- ]
871
985
 
872
986
  // Screen-space slack around a region's bounds within which its box and handles stay revealed, so
873
987
  // the handles (which sit on the edge) are comfortably reachable.
@@ -939,7 +1053,7 @@ function RegionResizeHandles({
939
1053
  {points.map((h) => (
940
1054
  <div
941
1055
  key={h.key}
942
- className="cmt-canvas-region-handle"
1056
+ className="tlui-cmt-canvas-region-handle"
943
1057
  style={{ left: h.left, top: h.top, cursor: h.cursor }}
944
1058
  onPointerDown={startResize}
945
1059
  onPointerMove={onResize(h)}
@@ -953,41 +1067,29 @@ function RegionResizeHandles({
953
1067
  const ThreadPin = memo(function ThreadPin({
954
1068
  editor,
955
1069
  thread,
956
- regionOptions,
957
1070
  ...props
958
- }: Omit<CanvasCommentsProps, 'regionOptions'> & {
1071
+ }: CommentingContext & {
959
1072
  editor: Editor
960
1073
  thread: TLCommentThread
961
- regionOptions: RegionCommentOptions
962
1074
  }) {
963
- const {
964
- currentUserId,
965
- resolveName,
966
- onPostComment,
967
- isCommentUnread,
968
- onCommentRead,
969
- getMentionSuggestions,
970
- renderMentionSuggestion,
971
- } = props
1075
+ const { resolveAuthor } = props
972
1076
  const options = useCommentingOptions()
973
- const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
1077
+ const canComment = useCanComment(props.currentUserId)
974
1078
  const container = useContainer()
975
- const comments = useThreadComments(editor, thread.id)
976
1079
  const msg = useTranslation()
1080
+ const comments = useThreadComments(editor, thread.id)
977
1081
  // Only one thread's popover is open at a time — shared across pins via the atom.
978
1082
  const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
979
1083
  editor,
980
1084
  thread.id,
981
1085
  ])
982
- const [reply, setReply] = useState<TLRichText>(EMPTY_COMMENT)
983
- const [editingId, setEditingId] = useState<string | null>(null)
984
- const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
985
1086
  // While dragging the marker, its page point overrides the anchor's; committed on drop.
986
1087
  const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
987
1088
  // The live bounds while a corner handle is resizing the region, else null.
988
1089
  const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
989
- // Whether the pin marker is hovered only consulted by the 'pin-hover' reveal mode.
990
- const [pinHovered, setPinHovered] = useState(false)
1090
+ // Hovering the marker previews the thread's opening comment, on the delay every marker uses.
1091
+ const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
1092
+ const previewThreads = useMemo(() => [thread], [thread])
991
1093
  // The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
992
1094
  // Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
993
1095
  // handle never loses the affordance — the box stays `pointer-events: none`.
@@ -1003,25 +1105,40 @@ const ThreadPin = memo(function ThreadPin({
1003
1105
  },
1004
1106
  [editor, thread.anchor, thread.pageId]
1005
1107
  )
1006
- // A region's box and handles are revealed while open or mid-resize, plus per the reveal mode —
1007
- // while the pointer is within the region ('pointer') or the pin is hovered ('pin-hover').
1008
- const revealed =
1009
- open ||
1010
- resizeBounds != null ||
1011
- (regionOptions.reveal === 'pointer' && pointerInRegion) ||
1012
- (regionOptions.reveal === 'pin-hover' && pinHovered)
1013
- // The resize handles: side midpoints ('edges'), or the corners other than the pin's ('corners').
1108
+ // A region's box and handles are revealed while open, mid-resize, or while the pointer is
1109
+ // within the region.
1110
+ const revealed = open || resizeBounds != null || pointerInRegion
1111
+ // A region thread's pin corner is its own (the corner its creating drag released on), with
1112
+ // the default as the fallback for older records.
1113
+ const pinCorner =
1114
+ thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
1115
+ // A region resizes from its corners every corner but the pin's own, which the pin owns.
1014
1116
  const resizeHandles = useMemo(
1015
- () =>
1016
- regionOptions.resize === 'edges'
1017
- ? REGION_EDGES
1018
- : REGION_CORNERS.filter(
1019
- (c) => c.x !== regionOptions.pinCorner.x || c.y !== regionOptions.pinCorner.y
1020
- ),
1021
- [regionOptions.resize, regionOptions.pinCorner]
1117
+ () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
1118
+ [pinCorner]
1022
1119
  )
1023
- const dragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null)
1024
- const markerRef = useRef<HTMLDivElement>(null)
1120
+ const dragRef = useRef<{
1121
+ startX: number
1122
+ startY: number
1123
+ moved: boolean
1124
+ // The anchor's page-space offset from the grab point, so a drag translates the pin by the
1125
+ // cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
1126
+ offsetX: number
1127
+ offsetY: number
1128
+ } | null>(null)
1129
+ const markerRef = useRef<HTMLButtonElement>(null)
1130
+ // Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
1131
+ // see the note on the layer.
1132
+ usePassThroughWheelEvents(markerRef)
1133
+
1134
+ // The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
1135
+ // mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
1136
+ // clear the hint here or it stays on the shape indefinitely.
1137
+ useEffect(() => {
1138
+ return () => {
1139
+ if (dragRef.current) editor.setHintingShapes([])
1140
+ }
1141
+ }, [editor])
1025
1142
 
1026
1143
  // Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
1027
1144
  // pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
@@ -1032,15 +1149,17 @@ const ThreadPin = memo(function ThreadPin({
1032
1149
  const onPointerDown = (e: PointerEvent) => {
1033
1150
  const target = e.target as HTMLElement | null
1034
1151
  if (!target) return
1035
- if (target.closest('.cmt-canvas-popover')) return
1152
+ if (target.closest('.tlui-cmt-canvas-popover')) return
1036
1153
  const marker = markerRef.current
1037
1154
  if (marker && marker.contains(target)) return
1038
1155
  // A press on a region's resize handle or movable body edits this thread — don't dismiss it.
1039
- if (target.closest('.cmt-canvas-region-handle, .cmt-canvas-region--movable')) return
1156
+ if (target.closest('.tlui-cmt-canvas-region-handle, .tlui-cmt-canvas-region--movable')) return
1040
1157
  // A click inside a menu/popover layered above us (the sidebar's filter or overflow
1041
1158
  // dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
1042
1159
  // layer; defer to its own dismissal instead of closing the thread out from under it.
1043
- if (target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .cmt-mention-popup'))
1160
+ if (
1161
+ target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
1162
+ )
1044
1163
  return
1045
1164
  openThreadId.set(editor, null)
1046
1165
  }
@@ -1052,223 +1171,141 @@ const ThreadPin = memo(function ThreadPin({
1052
1171
  'pin point',
1053
1172
  () => {
1054
1173
  if (thread.pageId !== editor.getCurrentPageId()) return null
1055
- const pagePoint = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
1056
- return pagePoint ? editor.pageToViewport(pagePoint) : null
1174
+ const pagePoint = anchorPagePoint(editor, thread.anchor)
1175
+ if (!pagePoint) return null
1176
+ const viewportPoint = editor.pageToViewport(pagePoint)
1177
+ const inset = impreciseShapePinInset(editor, thread.anchor)
1178
+ return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
1057
1179
  },
1058
- [editor, thread.anchor, thread.pageId, impreciseShapeAnchor]
1180
+ [editor, thread.anchor, thread.pageId]
1059
1181
  )
1060
- const visible = point !== null
1061
-
1062
- // While the popover is open, every unread comment on display gets reported read — including
1063
- // replies that arrive while it stays open, since the effect re-runs as `comments` changes.
1064
- // The host's receipt write flips isCommentUnread to false, so re-runs find nothing to report.
1065
- useEffect(() => {
1066
- if (!open || !visible || !isCommentUnread || !onCommentRead) return
1067
- for (const comment of comments) {
1068
- if (isCommentUnread(comment.id)) {
1069
- onCommentRead(comment.id)
1070
- }
1071
- }
1072
- }, [open, visible, comments, isCommentUnread, onCommentRead])
1073
-
1074
1182
  if (!point) return null
1075
1183
 
1076
- const postReply = () => {
1077
- if (isCommentEmpty(reply) || !currentUserId) return
1078
- commitCommentMutation(editor, () => {
1079
- const comment = createComment({
1080
- threadId: thread.id,
1081
- pageId: thread.pageId,
1082
- authorId: currentUserId,
1083
- body: reply,
1084
- })
1085
- putCommentRecords(editor, [comment])
1086
- if (onPostComment) onPostComment(comment)
1087
- })
1088
- setReply(EMPTY_COMMENT)
1089
- }
1090
-
1091
- const toggleResolve = () => {
1092
- if (!currentUserId) return
1093
- commitCommentMutation(editor, () => {
1094
- putCommentRecords(editor, [
1095
- {
1096
- ...thread,
1097
- resolved: thread.resolved ? null : { at: Date.now(), by: currentUserId },
1098
- },
1099
- ])
1100
- })
1101
- }
1102
-
1103
- const deleteThread = () => {
1104
- openThreadId.set(editor, null)
1105
- commitCommentMutation(editor, () =>
1106
- removeCommentRecords(editor, [thread.id, ...comments.map((c) => c.id)])
1107
- )
1108
- }
1109
-
1110
- const startEdit = (comment: TLComment) => {
1111
- setEditingId(comment.id)
1112
- setEditText(comment.body)
1113
- }
1114
-
1115
- const saveEdit = () => {
1116
- const comment = comments.find((c) => c.id === editingId)
1117
- if (!comment || isCommentEmpty(editText)) return
1118
- commitCommentMutation(editor, () => {
1119
- putCommentRecords(editor, [{ ...comment, body: editText, editedAt: Date.now() }])
1120
- })
1121
- setEditingId(null)
1122
- }
1123
-
1124
- // Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
1125
- // with an edit affordance on your own comments.
1126
- const renderComment = (card: CommentCardProps, index: number): ReactNode => {
1127
- const comment = comments[index]
1128
- if (editingId === comment.id) {
1129
- return (
1130
- <div
1131
- className="cmt-editing"
1132
- onKeyDown={(e) => {
1133
- if (e.key === 'Escape') {
1134
- setEditingId(null)
1135
- e.stopPropagation()
1136
- }
1137
- }}
1138
- >
1139
- <CommentComposer
1140
- author={card.author}
1141
- placeholder={msg('comments.edit-placeholder')}
1142
- value={editText}
1143
- onChange={setEditText}
1144
- onSubmit={saveEdit}
1145
- sendLabel={msg('comments.save')}
1146
- disabled={isCommentEmpty(editText)}
1147
- getMentionSuggestions={getMentionSuggestions}
1148
- renderMentionSuggestion={renderMentionSuggestion}
1149
- autoFocus
1150
- />
1151
- </div>
1152
- )
1153
- }
1154
- return (
1155
- <CommentCard
1156
- {...card}
1157
- actions={
1158
- comment.authorId === currentUserId ? (
1159
- <button
1160
- className="cmt-thread__action"
1161
- title={msg('comments.edit')}
1162
- onClick={() => startEdit(comment)}
1163
- >
1164
- <TldrawUiIcon icon="dots-horizontal" label={msg('comments.edit')} small />
1165
- </button>
1166
- ) : undefined
1167
- }
1168
- />
1169
- )
1170
- }
1171
-
1172
- const headerActions = (
1173
- <>
1174
- {currentUserId && (
1175
- <button
1176
- className="cmt-thread__action"
1177
- title={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
1178
- onClick={toggleResolve}
1179
- >
1180
- <TldrawUiIcon
1181
- icon="check"
1182
- label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
1183
- small
1184
- />
1185
- </button>
1186
- )}
1187
- {currentUserId && (
1188
- <button
1189
- className="cmt-thread__action"
1190
- title={msg('comments.delete')}
1191
- onClick={deleteThread}
1192
- >
1193
- <TldrawUiIcon icon="trash" label={msg('comments.delete')} small />
1194
- </button>
1195
- )}
1196
- <button
1197
- className="cmt-thread__action"
1198
- title={msg('comments.dismiss')}
1199
- onClick={() => openThreadId.set(editor, null)}
1200
- >
1201
- <TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
1202
- </button>
1203
- </>
1204
- )
1205
-
1206
1184
  const PinContent = options.components.PinContent
1207
- // The `PinContent` component slot overrides the built-in author-initial default.
1185
+ // The `PinContent` component slot overrides the built-in author-avatar default.
1186
+ const threadAuthor = resolveAuthor(thread.createdBy)
1208
1187
  const pinContent = PinContent ? (
1209
1188
  <PinContent thread={thread} comments={comments} />
1210
1189
  ) : (
1211
- initialOf(resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR)
1190
+ <Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
1212
1191
  )
1192
+ const pinLabel = msg(
1193
+ thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
1194
+ ).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
1213
1195
 
1214
1196
  // Drag the marker to move the thread: its position is overridden locally while dragging, then
1215
1197
  // re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
1216
1198
  // a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
1217
1199
  // toggle the popover.
1218
- // Which affordances move a region, per the option: 'pin' → pin only, 'body' → body only, 'both'.
1219
1200
  const isRegion = thread.anchor.type === 'region'
1220
- const pinMovable = regionOptions.move !== 'body'
1221
- const bodyMovable = regionOptions.move !== 'pin'
1222
- const startDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1201
+ // The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
1202
+ const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
1203
+ // A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
1204
+ // hand it to the canvas untouched.
1205
+ if (isCanvasPanGesture(editor, e)) {
1206
+ forwardPointerEventToCanvas(container, e)
1207
+ return
1208
+ }
1223
1209
  e.stopPropagation()
1224
- dragRef.current = { startX: e.clientX, startY: e.clientY, moved: false }
1210
+ const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
1211
+ const anchorPage = anchorPagePoint(editor, thread.anchor)
1212
+ // The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
1213
+ // is inset from its anchor point — without this the pin jumps by the inset on drag start.
1214
+ const inset = impreciseShapePinInset(editor, thread.anchor)
1215
+ if (anchorPage && inset) {
1216
+ const zoom = editor.getZoomLevel()
1217
+ anchorPage.x += inset.x / zoom
1218
+ anchorPage.y += inset.y / zoom
1219
+ }
1220
+ dragRef.current = {
1221
+ startX: e.clientX,
1222
+ startY: e.clientY,
1223
+ moved: false,
1224
+ offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
1225
+ offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
1226
+ }
1225
1227
  e.currentTarget.setPointerCapture(e.pointerId)
1226
1228
  }
1227
- const onDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1229
+ const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
1228
1230
  const drag = dragRef.current
1229
1231
  if (!drag) return
1230
- // A region that moves by its body ignores pin drags (the pin only toggles the thread).
1231
- if (isRegion && !pinMovable) return
1232
+ // Moving a pin re-anchors the thread record a commenting write. Without the permission the
1233
+ // press stays a click (`moved` never sets, so release toggles the popover and never commits).
1234
+ if (!canComment) return
1232
1235
  if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
1233
1236
  drag.moved = true
1234
- setDragPagePoint(editor.screenToPage({ x: e.clientX, y: e.clientY }))
1237
+ const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
1238
+ const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
1239
+ setDragPagePoint(pagePoint)
1240
+ // Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
1241
+ // with. Regions translate rather than re-anchor, so they never hint.
1242
+ if (!isRegion) {
1243
+ const hit = commentTargetShapeAt(editor, pagePoint)
1244
+ editor.setHintingShapes(hit ? [hit.id] : [])
1245
+ }
1235
1246
  }
1236
- const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1247
+ // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
1248
+ // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
1249
+ const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
1237
1250
  const drag = dragRef.current
1238
1251
  dragRef.current = null
1239
1252
  if (e.currentTarget.hasPointerCapture(e.pointerId)) {
1240
1253
  e.currentTarget.releasePointerCapture(e.pointerId)
1241
1254
  }
1242
1255
  if (!drag) return
1256
+ setDragPagePoint(null)
1257
+ editor.setHintingShapes([])
1258
+ }
1259
+ const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
1260
+ const drag = dragRef.current
1261
+ dragRef.current = null
1262
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
1263
+ e.currentTarget.releasePointerCapture(e.pointerId)
1264
+ }
1265
+ if (!drag) return
1266
+ editor.setHintingShapes([])
1243
1267
  if (!drag.moved) {
1244
1268
  openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
1245
1269
  return
1246
1270
  }
1247
- const pagePoint = editor.screenToPage({ x: e.clientX, y: e.clientY })
1271
+ const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
1272
+ const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
1248
1273
  setDragPagePoint(null)
1249
1274
  let anchor: TLCommentThread['anchor']
1250
1275
  if (thread.anchor.type === 'region') {
1251
1276
  // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
1252
1277
  anchor = {
1253
1278
  ...thread.anchor,
1254
- x: pagePoint.x - regionOptions.pinCorner.x * thread.anchor.w,
1255
- y: pagePoint.y - regionOptions.pinCorner.y * thread.anchor.h,
1279
+ x: pagePoint.x - pinCorner.x * thread.anchor.w,
1280
+ y: pagePoint.y - pinCorner.y * thread.anchor.h,
1256
1281
  }
1257
1282
  } else {
1258
- const hit = editor.getShapeAtPoint(pagePoint, { hitInside: true })
1283
+ const hit = commentTargetShapeAt(editor, pagePoint)
1259
1284
  anchor = hit
1260
- ? shapeAnchorAt(editor, hit.id, pagePoint, e.altKey)
1285
+ ? shapeAnchorAt(
1286
+ editor,
1287
+ hit.id,
1288
+ pagePoint,
1289
+ getCommentingOptions(editor).shouldBePrecise(editor, {
1290
+ shapeId: hit.id,
1291
+ point: pagePoint,
1292
+ altKey: e.altKey,
1293
+ })
1294
+ )
1261
1295
  : { type: 'point', x: pagePoint.x, y: pagePoint.y }
1262
1296
  }
1263
- commitCommentMutation(editor, () => putCommentRecords(editor, [{ ...thread, anchor }]), 'drag')
1297
+ commitCommentMutation(editor, () => putRecordsInCommit(editor, [{ ...thread, anchor }]), 'drag')
1264
1298
  }
1265
1299
 
1266
1300
  // The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
1267
1301
  // move to the drag point; otherwise it sits at the stored anchor's viewport point.
1268
- const livePinPage = resizeBounds
1269
- ? regionPinPoint(resizeBounds, regionOptions.pinCorner)
1270
- : dragPagePoint
1271
- const renderPoint = livePinPage ? editor.pageToViewport(livePinPage) : point
1302
+ const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
1303
+ const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
1304
+ // A region's pin centres on its corner — overlapping the box — rather than hanging off it.
1305
+ // The marker anchors bottom-left, so step half its 34px size left and down (screen px).
1306
+ const renderPoint = isRegion
1307
+ ? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
1308
+ : renderPointBase
1272
1309
 
1273
1310
  // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
1274
1311
  // corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
@@ -1277,33 +1314,30 @@ const ThreadPin = memo(function ThreadPin({
1277
1314
  regionAnchor && dragPagePoint
1278
1315
  ? {
1279
1316
  ...regionAnchor,
1280
- x: dragPagePoint.x - regionOptions.pinCorner.x * regionAnchor.w,
1281
- y: dragPagePoint.y - regionOptions.pinCorner.y * regionAnchor.h,
1317
+ x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
1318
+ y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
1282
1319
  }
1283
1320
  : regionAnchor
1284
1321
  const regionBoxBounds = resizeBounds ?? movedRegion
1285
1322
  const commitResize = (bounds: BoxModel) => {
1286
1323
  setResizeBounds(null)
1287
- editor.run(
1288
- () => putCommentRecords(editor, [{ ...thread, anchor: { type: 'region', ...bounds } }]),
1289
- {
1290
- history: 'ignore',
1291
- }
1324
+ if (!canComment) return
1325
+ // Same commit path as a pin drag, so the configured `dragHistory` governs both — going
1326
+ // straight to `editor.run` here would make region resizes silently ignore the option.
1327
+ commitCommentMutation(
1328
+ editor,
1329
+ // Spread the existing anchor first so the region's pin corner survives a resize.
1330
+ () => putRecordsInCommit(editor, [{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
1331
+ 'drag'
1292
1332
  )
1293
1333
  }
1294
1334
 
1295
1335
  return (
1296
1336
  <>
1297
1337
  {regionBoxBounds && (dragPagePoint || revealed) && (
1298
- <RegionBox
1299
- editor={editor}
1300
- box={regionBoxBounds}
1301
- movable={bodyMovable && !dragPagePoint}
1302
- onPreview={setResizeBounds}
1303
- onCommit={commitResize}
1304
- />
1338
+ <RegionBox editor={editor} box={regionBoxBounds} />
1305
1339
  )}
1306
- {regionBoxBounds && revealed && !dragPagePoint && regionOptions.resize !== 'none' && (
1340
+ {regionBoxBounds && revealed && !dragPagePoint && canComment && (
1307
1341
  <RegionResizeHandles
1308
1342
  editor={editor}
1309
1343
  box={regionBoxBounds}
@@ -1313,60 +1347,70 @@ const ThreadPin = memo(function ThreadPin({
1313
1347
  />
1314
1348
  )}
1315
1349
  <div
1316
- className={open ? 'cmt-canvas-pin cmt-canvas-pin--open' : 'cmt-canvas-pin'}
1350
+ className={[
1351
+ 'tlui-cmt-canvas-pin',
1352
+ open && 'tlui-cmt-canvas-pin--open',
1353
+ dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
1354
+ ]
1355
+ .filter(Boolean)
1356
+ .join(' ')}
1317
1357
  style={{ left: renderPoint.x, top: renderPoint.y }}
1318
1358
  >
1319
- <div
1359
+ <button
1320
1360
  ref={markerRef}
1321
- className="cmt-canvas-pin__marker"
1361
+ type="button"
1362
+ className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
1363
+ aria-label={pinLabel}
1364
+ aria-expanded={open}
1322
1365
  onPointerDown={startDrag}
1323
1366
  onPointerMove={onDrag}
1324
1367
  onPointerUp={endDrag}
1325
- onPointerEnter={() => setPinHovered(true)}
1326
- onPointerLeave={() => setPinHovered(false)}
1368
+ onPointerCancel={cancelDrag}
1369
+ // Pointer activation is already handled by endDrag (which distinguishes a click
1370
+ // from a drag), so only take keyboard-synthesised clicks here — those report
1371
+ // `detail === 0` — or the thread would toggle twice per mouse click.
1372
+ onClick={(e) => {
1373
+ if (e.detail !== 0) return
1374
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
1375
+ }}
1376
+ onPointerEnter={previewHandlers.onPointerEnter}
1377
+ onPointerLeave={previewHandlers.onPointerLeave}
1378
+ // Focus stands in for hover, so tabbing to a marker gets the same preview.
1379
+ onFocus={previewHandlers.onPointerEnter}
1380
+ onBlur={previewHandlers.onPointerLeave}
1327
1381
  >
1328
1382
  <CommentPin resolved={thread.resolved != null} open={open}>
1329
1383
  {pinContent}
1330
1384
  </CommentPin>
1331
- </div>
1385
+ </button>
1332
1386
  {/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
1333
1387
  the pin itself stays in the canvas-in-front layer, beneath the UI. */}
1334
1388
  {open && (
1335
1389
  <ThreadPopover
1336
1390
  container={container}
1337
- style={{ left: renderPoint.x + 36, top: renderPoint.y - 28 }}
1391
+ style={{
1392
+ left: renderPoint.x + POPOVER_OFFSET.thread.x,
1393
+ top: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
1394
+ }}
1338
1395
  >
1339
- <CommentThread
1340
- header={msg('comments.thread-title')}
1341
- headerActions={headerActions}
1342
- renderComment={renderComment}
1343
- comments={comments.map((c) => toCardProps(c, props, options.components))}
1344
- resolvedBanner={
1345
- thread.resolved
1346
- ? msg('comments.resolved-by').replace(
1347
- '{name}',
1348
- resolveName(thread.resolved.by) ?? UNKNOWN_AUTHOR
1349
- )
1350
- : undefined
1351
- }
1352
- composer={
1353
- currentUserId && !thread.resolved
1354
- ? {
1355
- author: resolveName(currentUserId) ?? UNKNOWN_AUTHOR,
1356
- placeholder: msg('comments.reply-placeholder'),
1357
- sendLabel: msg('comments.send'),
1358
- value: reply,
1359
- onChange: setReply,
1360
- onSubmit: postReply,
1361
- disabled: isCommentEmpty(reply),
1362
- getMentionSuggestions,
1363
- renderMentionSuggestion,
1364
- }
1365
- : undefined
1366
- }
1367
- />
1396
+ <ThreadView editor={editor} thread={thread} {...props} />
1368
1397
  </ThreadPopover>
1369
1398
  )}
1399
+ {/* Not while dragging: the pin is being moved, not read, and a panel trailing the
1400
+ cursor would obscure the drop target. */}
1401
+ {previewShown && !dragPagePoint && (
1402
+ <ThreadPreview
1403
+ editor={editor}
1404
+ threads={previewThreads}
1405
+ container={container}
1406
+ variant="thread"
1407
+ point={renderPoint}
1408
+ onSelectThread={() => openThreadId.set(editor, thread.id)}
1409
+ {...previewHandlers}
1410
+ currentUserId={props.currentUserId}
1411
+ resolveAuthor={resolveAuthor}
1412
+ />
1413
+ )}
1370
1414
  </div>
1371
1415
  </>
1372
1416
  )
@@ -1376,12 +1420,25 @@ function PendingComposer({
1376
1420
  editor,
1377
1421
  pending,
1378
1422
  currentUserId,
1379
- resolveName,
1423
+ resolveAuthor,
1380
1424
  onPostComment,
1381
1425
  getMentionSuggestions,
1382
1426
  renderMentionSuggestion,
1383
- }: CanvasCommentsProps & { editor: Editor; pending: PendingComment }) {
1384
- const [text, setText] = useState<TLRichText>(EMPTY_COMMENT)
1427
+ }: CommentingContext & { editor: Editor; pending: PendingComment }) {
1428
+ const ComposerFallback = useCommentingOptions().components.ComposerFallback
1429
+ const canComment = useCanComment(currentUserId)
1430
+ const me = currentUserId ? resolveAuthor(currentUserId) : undefined
1431
+ // The leading pin previews the pin this draft becomes: a white pin holding the author's avatar.
1432
+ const draftAvatar = (
1433
+ <CommentPin>
1434
+ <Avatar author={me ?? UNKNOWN_COMMENT_AUTHOR} />
1435
+ </CommentPin>
1436
+ )
1437
+ // Click-away keeps the draft (saved on every change) and the next placement composer
1438
+ // restores it — the flip side of dismissing without a discard warning.
1439
+ const [text, setText] = useState<TLRichText>(
1440
+ () => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
1441
+ )
1385
1442
  const ref = useRef<HTMLDivElement>(null)
1386
1443
  const msg = useTranslation()
1387
1444
  const container = useContainer()
@@ -1402,7 +1459,7 @@ function PendingComposer({
1402
1459
  if (!el || !target) return
1403
1460
  // A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
1404
1461
  // not "outside" — keep the draft open so the pick can insert.
1405
- if (el.contains(target) || target.closest('.cmt-mention-popup')) return
1462
+ if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
1406
1463
  pendingComment.set(editor, null)
1407
1464
  }
1408
1465
  document.addEventListener('pointerdown', onPointerDown, true)
@@ -1424,36 +1481,52 @@ function PendingComposer({
1424
1481
  authorId: currentUserId,
1425
1482
  body: text,
1426
1483
  })
1427
- putCommentRecords(editor, [thread, comment])
1484
+ putRecordsInCommit(editor, [thread, comment])
1428
1485
  if (onPostComment) onPostComment(comment)
1429
1486
  })
1430
1487
  setText(EMPTY_COMMENT)
1488
+ clearCommentDraft(NEW_COMMENT_DRAFT)
1431
1489
  pendingComment.set(editor, null)
1432
1490
  }
1433
1491
 
1434
1492
  return createPortal(
1435
1493
  <div
1436
1494
  ref={ref}
1437
- className="cmt-canvas-composer"
1495
+ className={[
1496
+ 'tlui-cmt-canvas-composer',
1497
+ pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
1498
+ !canComment && 'tlui-cmt-canvas-composer--fallback',
1499
+ ]
1500
+ .filter(Boolean)
1501
+ .join(' ')}
1438
1502
  style={{ left: point.x, top: point.y }}
1439
1503
  onPointerDown={stop}
1504
+ onContextMenu={stop}
1440
1505
  onKeyDown={(e) => {
1441
1506
  if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
1442
1507
  }}
1443
1508
  >
1444
- <CommentComposer
1445
- author={currentUserId ? (resolveName(currentUserId) ?? UNKNOWN_AUTHOR) : ''}
1446
- placeholder={msg('comments.add-placeholder')}
1447
- sendLabel={msg('comments.send')}
1448
- value={text}
1449
- onChange={setText}
1450
- onSubmit={submit}
1451
- disabled={isCommentEmpty(text)}
1452
- getMentionSuggestions={getMentionSuggestions}
1453
- renderMentionSuggestion={renderMentionSuggestion}
1454
- autoFocus
1455
- leading={draftAvatar}
1456
- />
1509
+ {canComment ? (
1510
+ <CommentComposer
1511
+ author={me ?? UNKNOWN_COMMENT_AUTHOR}
1512
+ placeholder={msg('comments.add-placeholder')}
1513
+ sendLabel={msg('comments.send')}
1514
+ value={text}
1515
+ onChange={(value) => {
1516
+ setText(value)
1517
+ saveCommentDraft(NEW_COMMENT_DRAFT, value)
1518
+ }}
1519
+ onSubmit={submit}
1520
+ // No user, no author for the record — dead send button.
1521
+ disabled={isCommentEmpty(text) || !currentUserId}
1522
+ getMentionSuggestions={getMentionSuggestions}
1523
+ renderMentionSuggestion={renderMentionSuggestion}
1524
+ autoFocus
1525
+ leading={draftAvatar}
1526
+ />
1527
+ ) : (
1528
+ ComposerFallback && <ComposerFallback context="pending" />
1529
+ )}
1457
1530
  </div>,
1458
1531
  container
1459
1532
  )