@tldraw/commenting 5.3.0-internal.c5c7f1d817d0 → 5.3.0-next.2fa9c61a8de6

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 (268) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1040 -230
  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-drafts.js +54 -0
  8. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  9. package/dist-cjs/canvas/comment-mutations.js +123 -0
  10. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  11. package/dist-cjs/canvas/comment-reactions.js +163 -0
  12. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  13. package/dist-cjs/canvas/comment-render.js +5 -3
  14. package/dist-cjs/canvas/comment-render.js.map +2 -2
  15. package/dist-cjs/canvas/comment-store.js +19 -9
  16. package/dist-cjs/canvas/comment-store.js.map +2 -2
  17. package/dist-cjs/canvas/comment-tool.js +64 -14
  18. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  19. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  20. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  21. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  22. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overlay.js +377 -437
  24. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  25. package/dist-cjs/canvas/comments-sidebar.js +34 -31
  26. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  27. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  28. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  29. package/dist-cjs/canvas/context.js +17 -0
  30. package/dist-cjs/canvas/context.js.map +7 -0
  31. package/dist-cjs/canvas/hooks.js +22 -3
  32. package/dist-cjs/canvas/hooks.js.map +2 -2
  33. package/dist-cjs/canvas/options.js +19 -2
  34. package/dist-cjs/canvas/options.js.map +2 -2
  35. package/dist-cjs/canvas/pin-stacking.js +56 -0
  36. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  37. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  38. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  39. package/dist-cjs/canvas/state.js +28 -14
  40. package/dist-cjs/canvas/state.js.map +2 -2
  41. package/dist-cjs/canvas/thread-preview.js +185 -0
  42. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  43. package/dist-cjs/canvas/thread-stack.js +199 -0
  44. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  45. package/dist-cjs/canvas/thread-state.js +67 -21
  46. package/dist-cjs/canvas/thread-state.js.map +2 -2
  47. package/dist-cjs/canvas/thread-view.js +397 -0
  48. package/dist-cjs/canvas/thread-view.js.map +7 -0
  49. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  50. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  51. package/dist-cjs/clustering/runtime.js +9 -9
  52. package/dist-cjs/clustering/runtime.js.map +2 -2
  53. package/dist-cjs/clustering/types.js.map +1 -1
  54. package/dist-cjs/index.d.ts +763 -353
  55. package/dist-cjs/index.js +46 -28
  56. package/dist-cjs/index.js.map +2 -2
  57. package/dist-cjs/ui/byline.js +3 -1
  58. package/dist-cjs/ui/byline.js.map +2 -2
  59. package/dist-cjs/ui/comment-card.js +12 -5
  60. package/dist-cjs/ui/comment-card.js.map +2 -2
  61. package/dist-cjs/ui/comment-composer.js +112 -17
  62. package/dist-cjs/ui/comment-composer.js.map +2 -2
  63. package/dist-cjs/ui/comment-pin.js +20 -2
  64. package/dist-cjs/ui/comment-pin.js.map +2 -2
  65. package/dist-cjs/ui/comment-thread.js +4 -2
  66. package/dist-cjs/ui/comment-thread.js.map +2 -2
  67. package/dist-cjs/ui/comments-list.js +22 -10
  68. package/dist-cjs/ui/comments-list.js.map +2 -2
  69. package/dist-cjs/ui/count-badge.js +3 -2
  70. package/dist-cjs/ui/count-badge.js.map +2 -2
  71. package/dist-cjs/ui/emoji-picker.js +54 -0
  72. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  73. package/dist-cjs/ui/format-time.js +4 -1
  74. package/dist-cjs/ui/format-time.js.map +2 -2
  75. package/dist-cjs/ui/reaction-picker.js +73 -0
  76. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  77. package/dist-cjs/ui/reaction.js +50 -4
  78. package/dist-cjs/ui/reaction.js.map +2 -2
  79. package/dist-cjs/ui/reactions.js +20 -15
  80. package/dist-cjs/ui/reactions.js.map +2 -2
  81. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  82. package/dist-cjs/ui/reply-count.js.map +7 -0
  83. package/dist-cjs/ui/send-button.js +20 -1
  84. package/dist-cjs/ui/send-button.js.map +2 -2
  85. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  86. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  87. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  89. package/dist-esm/canvas/cluster-input.mjs +2 -2
  90. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  91. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  92. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  93. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  94. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  95. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  96. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  97. package/dist-esm/canvas/comment-render.mjs +5 -3
  98. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  99. package/dist-esm/canvas/comment-store.mjs +19 -9
  100. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  101. package/dist-esm/canvas/comment-tool.mjs +67 -17
  102. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  103. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  104. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  105. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  106. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  107. package/dist-esm/canvas/comments-overlay.mjs +398 -442
  108. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  109. package/dist-esm/canvas/comments-sidebar.mjs +37 -34
  110. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  112. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  113. package/dist-esm/canvas/context.mjs +1 -0
  114. package/dist-esm/canvas/context.mjs.map +7 -0
  115. package/dist-esm/canvas/hooks.mjs +28 -5
  116. package/dist-esm/canvas/hooks.mjs.map +2 -2
  117. package/dist-esm/canvas/options.mjs +21 -3
  118. package/dist-esm/canvas/options.mjs.map +2 -2
  119. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  120. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  121. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  122. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  123. package/dist-esm/canvas/state.mjs +29 -19
  124. package/dist-esm/canvas/state.mjs.map +2 -2
  125. package/dist-esm/canvas/thread-preview.mjs +170 -0
  126. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  127. package/dist-esm/canvas/thread-stack.mjs +190 -0
  128. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  129. package/dist-esm/canvas/thread-state.mjs +71 -22
  130. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-view.mjs +402 -0
  132. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  133. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  134. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  135. package/dist-esm/clustering/runtime.mjs +9 -9
  136. package/dist-esm/clustering/runtime.mjs.map +2 -2
  137. package/dist-esm/index.d.mts +763 -353
  138. package/dist-esm/index.mjs +87 -41
  139. package/dist-esm/index.mjs.map +2 -2
  140. package/dist-esm/ui/byline.mjs +3 -1
  141. package/dist-esm/ui/byline.mjs.map +2 -2
  142. package/dist-esm/ui/comment-card.mjs +12 -5
  143. package/dist-esm/ui/comment-card.mjs.map +2 -2
  144. package/dist-esm/ui/comment-composer.mjs +122 -16
  145. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  146. package/dist-esm/ui/comment-pin.mjs +20 -2
  147. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  148. package/dist-esm/ui/comment-thread.mjs +4 -2
  149. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  150. package/dist-esm/ui/comments-list.mjs +39 -25
  151. package/dist-esm/ui/comments-list.mjs.map +2 -2
  152. package/dist-esm/ui/count-badge.mjs +3 -2
  153. package/dist-esm/ui/count-badge.mjs.map +2 -2
  154. package/dist-esm/ui/emoji-picker.mjs +34 -0
  155. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  156. package/dist-esm/ui/format-time.mjs +4 -1
  157. package/dist-esm/ui/format-time.mjs.map +2 -2
  158. package/dist-esm/ui/reaction-picker.mjs +60 -0
  159. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  160. package/dist-esm/ui/reaction.mjs +51 -5
  161. package/dist-esm/ui/reaction.mjs.map +2 -2
  162. package/dist-esm/ui/reactions.mjs +21 -16
  163. package/dist-esm/ui/reactions.mjs.map +2 -2
  164. package/dist-esm/ui/reply-count.mjs +9 -0
  165. package/dist-esm/ui/reply-count.mjs.map +7 -0
  166. package/dist-esm/ui/send-button.mjs +20 -1
  167. package/dist-esm/ui/send-button.mjs.map +2 -2
  168. package/dist-esm/ui/tooltip-button.mjs +12 -0
  169. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  170. package/package.json +6 -6
  171. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  172. package/src/canvas/anchor-lifecycle.ts +205 -0
  173. package/src/canvas/canvas.css +439 -24
  174. package/src/canvas/cluster-input.test.ts +31 -23
  175. package/src/canvas/cluster-input.ts +3 -4
  176. package/src/canvas/comment-drafts.test.ts +55 -0
  177. package/src/canvas/comment-drafts.ts +46 -0
  178. package/src/canvas/comment-mutations.test.ts +410 -0
  179. package/src/canvas/comment-mutations.ts +288 -0
  180. package/src/canvas/comment-reactions.test.ts +132 -0
  181. package/src/canvas/comment-reactions.tsx +245 -0
  182. package/src/canvas/comment-render.ts +9 -5
  183. package/src/canvas/comment-store.ts +67 -27
  184. package/src/canvas/comment-tool.test.ts +162 -0
  185. package/src/canvas/comment-tool.tsx +105 -23
  186. package/src/canvas/comments-filter-menu.tsx +23 -32
  187. package/src/canvas/comments-overflow-menu.tsx +12 -19
  188. package/src/canvas/comments-overlay.tsx +602 -562
  189. package/src/canvas/comments-sidebar.test.ts +45 -0
  190. package/src/canvas/comments-sidebar.tsx +75 -57
  191. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  192. package/src/canvas/context.ts +47 -0
  193. package/src/canvas/hooks.ts +52 -11
  194. package/src/canvas/options.test.ts +52 -2
  195. package/src/canvas/options.ts +133 -9
  196. package/src/canvas/pin-stacking.test.ts +114 -0
  197. package/src/canvas/pin-stacking.ts +57 -0
  198. package/src/canvas/sidebar-filters.ts +4 -3
  199. package/src/canvas/state.ts +92 -35
  200. package/src/canvas/thread-preview.test.ts +91 -0
  201. package/src/canvas/thread-preview.tsx +304 -0
  202. package/src/canvas/thread-stack.tsx +252 -0
  203. package/src/canvas/thread-state.test.ts +209 -0
  204. package/src/canvas/thread-state.ts +137 -36
  205. package/src/canvas/thread-view.tsx +516 -0
  206. package/src/clustering/computeClusterTable.test.ts +24 -23
  207. package/src/clustering/computeClusterTable.ts +5 -5
  208. package/src/clustering/runtime.test.ts +34 -0
  209. package/src/clustering/runtime.ts +20 -15
  210. package/src/clustering/types.ts +9 -9
  211. package/src/index.ts +76 -40
  212. package/src/ui/byline.tsx +4 -2
  213. package/src/ui/comment-card.tsx +17 -7
  214. package/src/ui/comment-composer.tsx +160 -13
  215. package/src/ui/comment-pin.tsx +22 -2
  216. package/src/ui/comment-thread.tsx +6 -1
  217. package/src/ui/comments-list.test.ts +31 -0
  218. package/src/ui/comments-list.tsx +32 -12
  219. package/src/ui/comments.css +489 -204
  220. package/src/ui/count-badge.tsx +9 -2
  221. package/src/ui/emoji-picker.test.ts +25 -0
  222. package/src/ui/emoji-picker.tsx +76 -0
  223. package/src/ui/format-time.ts +7 -2
  224. package/src/ui/reaction-picker.tsx +109 -0
  225. package/src/ui/reaction.tsx +116 -4
  226. package/src/ui/reactions.tsx +68 -13
  227. package/src/ui/reply-count.ts +16 -0
  228. package/src/ui/send-button.tsx +18 -3
  229. package/src/ui/tooltip-button.tsx +20 -0
  230. package/dist-cjs/canvas/region-options.js +0 -49
  231. package/dist-cjs/canvas/region-options.js.map +0 -7
  232. package/dist-cjs/ui/avatar.js +0 -51
  233. package/dist-cjs/ui/avatar.js.map +0 -7
  234. package/dist-cjs/ui/comment-mention.js +0 -42
  235. package/dist-cjs/ui/comment-mention.js.map +0 -7
  236. package/dist-cjs/ui/comment-text.js.map +0 -7
  237. package/dist-cjs/ui/mention-list.js +0 -70
  238. package/dist-cjs/ui/mention-list.js.map +0 -7
  239. package/dist-cjs/ui/mention-suggestion.js +0 -206
  240. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  241. package/dist-cjs/ui/mention.js.map +0 -7
  242. package/dist-cjs/ui/render-markdown.js +0 -63
  243. package/dist-cjs/ui/render-markdown.js.map +0 -7
  244. package/dist-esm/canvas/region-options.mjs +0 -29
  245. package/dist-esm/canvas/region-options.mjs.map +0 -7
  246. package/dist-esm/ui/avatar.mjs +0 -31
  247. package/dist-esm/ui/avatar.mjs.map +0 -7
  248. package/dist-esm/ui/comment-mention.mjs +0 -22
  249. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  250. package/dist-esm/ui/comment-text.mjs +0 -9
  251. package/dist-esm/ui/comment-text.mjs.map +0 -7
  252. package/dist-esm/ui/mention-list.mjs +0 -50
  253. package/dist-esm/ui/mention-list.mjs.map +0 -7
  254. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  255. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  256. package/dist-esm/ui/mention.mjs +0 -11
  257. package/dist-esm/ui/mention.mjs.map +0 -7
  258. package/dist-esm/ui/render-markdown.mjs +0 -45
  259. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  260. package/src/canvas/region-options.ts +0 -57
  261. package/src/ui/avatar.tsx +0 -38
  262. package/src/ui/comment-mention.ts +0 -47
  263. package/src/ui/comment-text.tsx +0 -12
  264. package/src/ui/mention-list.tsx +0 -106
  265. package/src/ui/mention-suggestion.test.ts +0 -18
  266. package/src/ui/mention-suggestion.tsx +0 -285
  267. package/src/ui/mention.tsx +0 -9
  268. 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,27 +147,72 @@ 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
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])
189
216
  // Threads held out of clustering because their anchor moved while folded inside a badge
190
217
  // (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
191
218
  // as live pins riding their anchor and rejoin clustering on the next zoom-out.
@@ -197,10 +224,9 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
197
224
  collectClusterLeaves(
198
225
  editor,
199
226
  threads.filter((thread) => !heldThreadIds.has(thread.id)),
200
- openThreadId.get(editor),
201
- impreciseShapeAnchor
227
+ openThreadId.get(editor)
202
228
  ),
203
- [editor, threads, impreciseShapeAnchor, heldThreadIds]
229
+ [editor, threads, heldThreadIds]
204
230
  )
205
231
  const clusterZoomBounds = useValue(
206
232
  'comment cluster zoom bounds',
@@ -344,49 +370,91 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
344
370
  () => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
345
371
  [threads]
346
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
+ ])
347
380
  const openThread = openId ? threadsById.get(openId) : null
348
381
  const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
349
382
 
350
- // 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.
351
385
  useEffect(() => {
352
386
  return () => {
353
387
  openThreadId.set(editor, null)
388
+ openStackId.set(editor, null)
354
389
  pendingComment.set(editor, null)
390
+ revealThreadRequest.set(editor, null)
355
391
  }
356
392
  }, [editor])
357
393
 
358
- // Open the thread named by a deep link (?comment=<thread or comment id>). If the thread is
359
- // 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.
360
400
  useEffect(() => {
361
- if (deepLinkHandled.current) return
362
- const id = new URLSearchParams(window.location.search).get('comment')
363
- if (!id) {
364
- deepLinkHandled.current = true
365
- 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
366
408
  }
409
+ openStackId.set(editor, null)
410
+ }, [editor, pinStacks, threadsById])
367
411
 
368
- const record = getCommentRecord(editor, id)
369
- if (!record) return
370
-
371
- let thread: TLCommentThread | undefined
372
- if (record.typeName === 'comment') {
373
- thread = threadsById.get(record.threadId)
374
- } else {
375
- thread = record
376
- }
377
- 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
+ )
378
427
 
379
- deepLinkHandled.current = true
380
- revealDeepLinkedThread(
381
- editor,
382
- thread,
383
- clusterModel.table,
384
- clusterZoomBounds,
385
- options,
386
- impreciseShapeAnchor
387
- )
388
- openThreadId.set(editor, thread.id)
389
- }, [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
+ )
390
458
 
391
459
  // Clicking a badge zooms to just past the zoom at which that cluster first unclusters,
392
460
  // centered on its centroid. The event that created a visible cluster is the event that splits
@@ -399,13 +467,13 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
399
467
  const event = clusterModel.table.events.find((e) => e.result.id === node.id)
400
468
  if (!event || !Number.isFinite(event.zSplit)) return
401
469
  const zoom = clamp(
402
- event.zSplit * options.clusterSplitZoomFactor,
470
+ event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
403
471
  clusterZoomBounds.minZoom,
404
472
  clusterZoomBounds.maxZoom
405
473
  )
406
474
  centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
407
475
  },
408
- [clusterModel, clusterZoomBounds, editor, options]
476
+ [clusterModel, clusterZoomBounds, editor]
409
477
  )
410
478
 
411
479
  // Escape collapses the open thread. Capture-phase + stopPropagation so it runs ahead of the
@@ -426,8 +494,8 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
426
494
  return () => document.removeEventListener('keydown', onKeyDown, true)
427
495
  }, [editor])
428
496
 
429
- // Shift+C toggles comment-pin visibility on the canvas (matching Figma). Skipped while typing so
430
- // 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.
431
499
  useEffect(() => {
432
500
  const onKeyDown = (e: KeyboardEvent) => {
433
501
  if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
@@ -444,27 +512,88 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
444
512
  // is read above so this component stays mounted and its shortcut/Escape effects keep running.
445
513
  if (hidden) return null
446
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
+
447
560
  // Render into the container (above the panels' stacking context) so the pins and popovers
448
- // 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
449
564
  return createPortal(
450
565
  <div ref={layerRef} className="tlui-cmt-canvas-layer">
451
566
  {options.enableClustering ? (
452
567
  <>
453
568
  {fadeNodes.map(({ node, phase }) => {
454
569
  let content: ReactNode
570
+ const stackGroup = node.count > 1 ? stackGroupOf(node) : null
455
571
  if (node.count === 1) {
456
572
  const thread = threadsById.get(node.id)
457
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 {
458
586
  content = (
459
- <ThreadPin
587
+ <ClusterBadge
460
588
  editor={editor}
461
- thread={thread}
462
- {...props}
463
- regionOptions={regionOptions}
589
+ node={node}
590
+ onExpand={zoomToClusterSplit}
591
+ onSelectThread={revealClusteredThread}
592
+ threadsById={threadsById}
593
+ currentUserId={props.currentUserId}
594
+ resolveAuthor={props.resolveAuthor}
464
595
  />
465
596
  )
466
- } else {
467
- content = <ClusterBadge editor={editor} node={node} onExpand={zoomToClusterSplit} />
468
597
  }
469
598
  return (
470
599
  <div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
@@ -473,22 +602,10 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
473
602
  )
474
603
  })}
475
604
  {orphanThreads.map((thread) => (
476
- <ThreadPin
477
- key={thread.id}
478
- editor={editor}
479
- thread={thread}
480
- {...props}
481
- regionOptions={regionOptions}
482
- />
605
+ <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
483
606
  ))}
484
607
  {heldThreads.map((thread) => (
485
- <ThreadPin
486
- key={thread.id}
487
- editor={editor}
488
- thread={thread}
489
- {...props}
490
- regionOptions={regionOptions}
491
- />
608
+ <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
492
609
  ))}
493
610
  </>
494
611
  ) : (
@@ -498,34 +615,22 @@ function CanvasCommentsLayer(props: CanvasCommentsProps) {
498
615
  // otherwise it would mount a second, stacked pin.
499
616
  threads
500
617
  .filter((thread) => thread.id !== openId)
501
- .map((thread) => (
502
- <ThreadPin
503
- key={thread.id}
504
- editor={editor}
505
- thread={thread}
506
- {...props}
507
- regionOptions={regionOptions}
508
- />
509
- ))
618
+ .map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
510
619
  )}
511
620
  {openThread && (
512
- <ThreadPin
513
- key={`open:${openThread.id}`}
514
- editor={editor}
515
- thread={openThread}
516
- {...props}
517
- regionOptions={regionOptions}
518
- />
621
+ <Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
519
622
  )}
520
623
  <RegionDraftBox editor={editor} />
521
624
  {/* Keep the region visible while composing — the drag draft is gone by now, and no thread
522
625
  exists yet, so the pending anchor is what shows the area under the open composer. */}
523
- {pending?.anchor.type === 'region' && <RegionBox editor={editor} box={pending.anchor} />}
524
- {pending && props.currentUserId && (
626
+ {pending?.anchor.type === 'region' && showPendingComposer && (
627
+ <RegionBox editor={editor} box={pending.anchor} />
628
+ )}
629
+ {pending && showPendingComposer && (
525
630
  <PendingComposer editor={editor} pending={pending} {...props} />
526
631
  )}
527
632
  </div>,
528
- container
633
+ portalHost
529
634
  )
530
635
  }
531
636
 
@@ -670,20 +775,19 @@ function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: numbe
670
775
  }
671
776
  }
672
777
 
673
- function revealDeepLinkedThread(
778
+ function revealThreadPin(
674
779
  editor: Editor,
675
780
  thread: TLCommentThread,
676
781
  table: ClusterTable,
677
782
  zoomBounds: { minZoom: number; maxZoom: number },
678
783
  options: CommentingOptions,
679
- impreciseShapeAnchor: { x: number; y: number }
784
+ duration = 200
680
785
  ) {
681
786
  if (thread.pageId !== editor.getCurrentPageId()) {
682
787
  editor.setCurrentPage(thread.pageId as any)
683
788
  }
684
789
 
685
- // Match where the rendered pin sits (resolved prop-or-option), so the camera centers on the pin.
686
- const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
790
+ const point = anchorPagePoint(editor, thread.anchor)
687
791
  if (!point) return
688
792
 
689
793
  // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
@@ -696,16 +800,17 @@ function revealDeepLinkedThread(
696
800
  parentEvent.zSplit <= zoomBounds.maxZoom
697
801
  ) {
698
802
  const zoom = clamp(
699
- parentEvent.zSplit * options.clusterSplitZoomFactor,
803
+ parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
700
804
  zoomBounds.minZoom,
701
805
  zoomBounds.maxZoom
702
806
  )
703
- centerOnPointAtZoom(editor, point, zoom)
807
+ centerOnPointAtZoom(editor, point, zoom, duration)
704
808
  return
705
809
  }
706
810
  }
707
811
 
708
- 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 } })
709
814
  }
710
815
 
711
816
  function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
@@ -719,9 +824,11 @@ function centerOnPointAtZoom(
719
824
  duration = 200
720
825
  ) {
721
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)
722
829
  editor.setCamera(
723
830
  {
724
- x: viewport.w / (2 * zoom) - point.x,
831
+ x: (viewport.w / 2 - offset) / zoom - point.x,
725
832
  y: viewport.h / (2 * zoom) - point.y,
726
833
  z: zoom,
727
834
  },
@@ -741,11 +848,23 @@ const ClusterBadge = memo(function ClusterBadge({
741
848
  editor,
742
849
  node,
743
850
  onExpand,
744
- }: {
851
+ onSelectThread,
852
+ threadsById,
853
+ ...props
854
+ }: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
745
855
  editor: Editor
746
856
  node: ClusterNode
747
857
  onExpand(node: ClusterNode): void
858
+ onSelectThread(thread: TLCommentThread): void
859
+ threadsById: ReadonlyMap<string, TLCommentThread>
748
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)
749
868
  const point = useValue(
750
869
  'cluster badge point',
751
870
  () => {
@@ -756,26 +875,64 @@ const ClusterBadge = memo(function ClusterBadge({
756
875
  [editor, node]
757
876
  )
758
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
+
759
890
  if (!point) return null
760
891
 
761
892
  return (
762
- <div
763
- className="tlui-cmt-canvas-cluster"
764
- style={{ left: point.x, top: point.y }}
765
- onPointerDown={stop}
766
- onClick={(e) => {
767
- e.stopPropagation()
768
- onExpand(node)
769
- }}
770
- >
771
- <CountBadge count={node.count} />
772
- </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
+ </>
773
930
  )
774
931
  })
775
932
 
776
933
  function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
777
934
  const viewport = editor.getViewportScreenBounds()
778
- const margin = getCommentingOptions(editor).clusterCullMargin
935
+ const margin = CLUSTER_CULL_MARGIN_PX
779
936
  return (
780
937
  point.x >= -margin &&
781
938
  point.y >= -margin &&
@@ -784,44 +941,12 @@ function isInInflatedViewport(editor: Editor, point: { x: number; y: number }):
784
941
  )
785
942
  }
786
943
 
787
- /** The open thread's popover, portaled above the UI panels. Over it, wheel and hover events pass
788
- * through to the canvas (unless the popover is scrolling its own content), like tldraw's panels. */
789
- function ThreadPopover({
790
- container,
791
- style,
792
- children,
793
- }: {
794
- container: HTMLElement
795
- style: CSSProperties
796
- children: ReactNode
797
- }) {
798
- const ref = useRef<HTMLDivElement>(null)
799
- usePassThroughWheelEvents(ref)
800
- usePassThroughMouseOverEvents(ref)
801
- return createPortal(
802
- <div ref={ref} className="tlui-cmt-canvas-popover" style={style} onPointerDown={stop}>
803
- {children}
804
- </div>,
805
- container
806
- )
807
- }
808
-
809
944
  /** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
810
945
  * a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
811
946
  * `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
812
- function RegionBox({
813
- editor,
814
- box,
815
- movable,
816
- onPreview,
817
- onCommit,
818
- }: {
819
- editor: Editor
820
- box: BoxModel
821
- movable?: boolean
822
- onPreview?(bounds: BoxModel | null): void
823
- onCommit?(bounds: BoxModel): void
824
- }) {
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 }) {
825
950
  const rect = useValue(
826
951
  'region rect',
827
952
  () => {
@@ -832,43 +957,7 @@ function RegionBox({
832
957
  },
833
958
  [editor, box.x, box.y, box.w, box.h]
834
959
  )
835
- // The grab point and the box at grab time, captured so the drag translates by a stable delta even
836
- // as the box prop reflows under the live preview.
837
- const grabRef = useRef<{ page: VecLike; box: BoxModel } | null>(null)
838
- const translated = (e: ReactPointerEvent<HTMLDivElement>): BoxModel => {
839
- const g = grabRef.current!
840
- const p = editor.screenToPage({ x: e.clientX, y: e.clientY })
841
- return { ...g.box, x: g.box.x + (p.x - g.page.x), y: g.box.y + (p.y - g.page.y) }
842
- }
843
- const startMove = (e: ReactPointerEvent<HTMLDivElement>) => {
844
- e.stopPropagation()
845
- grabRef.current = { page: editor.screenToPage({ x: e.clientX, y: e.clientY }), box }
846
- e.currentTarget.setPointerCapture(e.pointerId)
847
- }
848
- const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
849
- if (grabRef.current) onPreview?.(translated(e))
850
- }
851
- const endMove = (e: ReactPointerEvent<HTMLDivElement>) => {
852
- if (!grabRef.current) return
853
- const bounds = translated(e)
854
- grabRef.current = null
855
- if (e.currentTarget.hasPointerCapture(e.pointerId))
856
- e.currentTarget.releasePointerCapture(e.pointerId)
857
- onCommit?.(bounds)
858
- }
859
- return (
860
- <div
861
- className={
862
- movable
863
- ? 'tlui-cmt-canvas-region tlui-cmt-canvas-region--movable'
864
- : 'tlui-cmt-canvas-region'
865
- }
866
- style={rect}
867
- onPointerDown={movable ? startMove : undefined}
868
- onPointerMove={movable ? onMove : undefined}
869
- onPointerUp={movable ? endMove : undefined}
870
- />
871
- )
960
+ return <div className="tlui-cmt-canvas-region" style={rect} />
872
961
  }
873
962
 
874
963
  /** The live region being dragged out by the comment tool, or nothing when not dragging. */
@@ -878,27 +967,21 @@ function RegionDraftBox({ editor }: { editor: Editor }) {
878
967
  return <RegionBox editor={editor} box={box} />
879
968
  }
880
969
 
881
- // A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 (a side midpoint)
882
- // 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.
883
972
  interface RegionHandle {
884
973
  x: number
885
974
  y: number
886
975
  cursor: string
887
976
  }
888
977
 
889
- // The four corners (both axes) and the four side midpoints (one axis each).
978
+ // The four corners, each resizing both axes.
890
979
  const REGION_CORNERS: readonly RegionHandle[] = [
891
980
  { x: 0, y: 0, cursor: 'nwse-resize' },
892
981
  { x: 1, y: 0, cursor: 'nesw-resize' },
893
982
  { x: 0, y: 1, cursor: 'nesw-resize' },
894
983
  { x: 1, y: 1, cursor: 'nwse-resize' },
895
984
  ]
896
- const REGION_EDGES: readonly RegionHandle[] = [
897
- { x: 0.5, y: 0, cursor: 'ns-resize' },
898
- { x: 1, y: 0.5, cursor: 'ew-resize' },
899
- { x: 0.5, y: 1, cursor: 'ns-resize' },
900
- { x: 0, y: 0.5, cursor: 'ew-resize' },
901
- ]
902
985
 
903
986
  // Screen-space slack around a region's bounds within which its box and handles stay revealed, so
904
987
  // the handles (which sit on the edge) are comfortably reachable.
@@ -984,41 +1067,29 @@ function RegionResizeHandles({
984
1067
  const ThreadPin = memo(function ThreadPin({
985
1068
  editor,
986
1069
  thread,
987
- regionOptions,
988
1070
  ...props
989
- }: Omit<CanvasCommentsProps, 'regionOptions'> & {
1071
+ }: CommentingContext & {
990
1072
  editor: Editor
991
1073
  thread: TLCommentThread
992
- regionOptions: RegionCommentOptions
993
1074
  }) {
994
- const {
995
- currentUserId,
996
- resolveName,
997
- onPostComment,
998
- isCommentUnread,
999
- onCommentRead,
1000
- getMentionSuggestions,
1001
- renderMentionSuggestion,
1002
- } = props
1075
+ const { resolveAuthor } = props
1003
1076
  const options = useCommentingOptions()
1004
- const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
1077
+ const canComment = useCanComment(props.currentUserId)
1005
1078
  const container = useContainer()
1006
- const comments = useThreadComments(editor, thread.id)
1007
1079
  const msg = useTranslation()
1080
+ const comments = useThreadComments(editor, thread.id)
1008
1081
  // Only one thread's popover is open at a time — shared across pins via the atom.
1009
1082
  const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
1010
1083
  editor,
1011
1084
  thread.id,
1012
1085
  ])
1013
- const [reply, setReply] = useState<TLRichText>(EMPTY_COMMENT)
1014
- const [editingId, setEditingId] = useState<string | null>(null)
1015
- const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
1016
1086
  // While dragging the marker, its page point overrides the anchor's; committed on drop.
1017
1087
  const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
1018
1088
  // The live bounds while a corner handle is resizing the region, else null.
1019
1089
  const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
1020
- // Whether the pin marker is hovered — only consulted by the 'pin-hover' reveal mode.
1021
- 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])
1022
1093
  // The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
1023
1094
  // Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
1024
1095
  // handle never loses the affordance — the box stays `pointer-events: none`.
@@ -1034,25 +1105,40 @@ const ThreadPin = memo(function ThreadPin({
1034
1105
  },
1035
1106
  [editor, thread.anchor, thread.pageId]
1036
1107
  )
1037
- // A region's box and handles are revealed while open or mid-resize, plus — per the reveal mode —
1038
- // while the pointer is within the region ('pointer') or the pin is hovered ('pin-hover').
1039
- const revealed =
1040
- open ||
1041
- resizeBounds != null ||
1042
- (regionOptions.reveal === 'pointer' && pointerInRegion) ||
1043
- (regionOptions.reveal === 'pin-hover' && pinHovered)
1044
- // 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.
1045
1116
  const resizeHandles = useMemo(
1046
- () =>
1047
- regionOptions.resize === 'edges'
1048
- ? REGION_EDGES
1049
- : REGION_CORNERS.filter(
1050
- (c) => c.x !== regionOptions.pinCorner.x || c.y !== regionOptions.pinCorner.y
1051
- ),
1052
- [regionOptions.resize, regionOptions.pinCorner]
1117
+ () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
1118
+ [pinCorner]
1053
1119
  )
1054
- const dragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null)
1055
- 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])
1056
1142
 
1057
1143
  // Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
1058
1144
  // pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
@@ -1085,223 +1171,141 @@ const ThreadPin = memo(function ThreadPin({
1085
1171
  'pin point',
1086
1172
  () => {
1087
1173
  if (thread.pageId !== editor.getCurrentPageId()) return null
1088
- const pagePoint = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
1089
- 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
1090
1179
  },
1091
- [editor, thread.anchor, thread.pageId, impreciseShapeAnchor]
1180
+ [editor, thread.anchor, thread.pageId]
1092
1181
  )
1093
- const visible = point !== null
1094
-
1095
- // While the popover is open, every unread comment on display gets reported read — including
1096
- // replies that arrive while it stays open, since the effect re-runs as `comments` changes.
1097
- // The host's receipt write flips isCommentUnread to false, so re-runs find nothing to report.
1098
- useEffect(() => {
1099
- if (!open || !visible || !isCommentUnread || !onCommentRead) return
1100
- for (const comment of comments) {
1101
- if (isCommentUnread(comment.id)) {
1102
- onCommentRead(comment.id)
1103
- }
1104
- }
1105
- }, [open, visible, comments, isCommentUnread, onCommentRead])
1106
-
1107
1182
  if (!point) return null
1108
1183
 
1109
- const postReply = () => {
1110
- if (isCommentEmpty(reply) || !currentUserId) return
1111
- commitCommentMutation(editor, () => {
1112
- const comment = createComment({
1113
- threadId: thread.id,
1114
- pageId: thread.pageId,
1115
- authorId: currentUserId,
1116
- body: reply,
1117
- })
1118
- putCommentRecords(editor, [comment])
1119
- if (onPostComment) onPostComment(comment)
1120
- })
1121
- setReply(EMPTY_COMMENT)
1122
- }
1123
-
1124
- const toggleResolve = () => {
1125
- if (!currentUserId) return
1126
- commitCommentMutation(editor, () => {
1127
- putCommentRecords(editor, [
1128
- {
1129
- ...thread,
1130
- resolved: thread.resolved ? null : { at: Date.now(), by: currentUserId },
1131
- },
1132
- ])
1133
- })
1134
- }
1135
-
1136
- const deleteThread = () => {
1137
- openThreadId.set(editor, null)
1138
- commitCommentMutation(editor, () =>
1139
- removeCommentRecords(editor, [thread.id, ...comments.map((c) => c.id)])
1140
- )
1141
- }
1142
-
1143
- const startEdit = (comment: TLComment) => {
1144
- setEditingId(comment.id)
1145
- setEditText(comment.body)
1146
- }
1147
-
1148
- const saveEdit = () => {
1149
- const comment = comments.find((c) => c.id === editingId)
1150
- if (!comment || isCommentEmpty(editText)) return
1151
- commitCommentMutation(editor, () => {
1152
- putCommentRecords(editor, [{ ...comment, body: editText, editedAt: Date.now() }])
1153
- })
1154
- setEditingId(null)
1155
- }
1156
-
1157
- // Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
1158
- // with an edit affordance on your own comments.
1159
- const renderComment = (card: CommentCardProps, index: number): ReactNode => {
1160
- const comment = comments[index]
1161
- if (editingId === comment.id) {
1162
- return (
1163
- <div
1164
- className="tlui-cmt-editing"
1165
- onKeyDown={(e) => {
1166
- if (e.key === 'Escape') {
1167
- setEditingId(null)
1168
- e.stopPropagation()
1169
- }
1170
- }}
1171
- >
1172
- <CommentComposer
1173
- author={card.author}
1174
- placeholder={msg('comments.edit-placeholder')}
1175
- value={editText}
1176
- onChange={setEditText}
1177
- onSubmit={saveEdit}
1178
- sendLabel={msg('comments.save')}
1179
- disabled={isCommentEmpty(editText)}
1180
- getMentionSuggestions={getMentionSuggestions}
1181
- renderMentionSuggestion={renderMentionSuggestion}
1182
- autoFocus
1183
- />
1184
- </div>
1185
- )
1186
- }
1187
- return (
1188
- <CommentCard
1189
- {...card}
1190
- actions={
1191
- comment.authorId === currentUserId ? (
1192
- <button
1193
- className="tlui-cmt-thread__action"
1194
- title={msg('comments.edit')}
1195
- onClick={() => startEdit(comment)}
1196
- >
1197
- <TldrawUiIcon icon="dots-horizontal" label={msg('comments.edit')} small />
1198
- </button>
1199
- ) : undefined
1200
- }
1201
- />
1202
- )
1203
- }
1204
-
1205
- const headerActions = (
1206
- <>
1207
- {currentUserId && (
1208
- <button
1209
- className="tlui-cmt-thread__action"
1210
- title={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
1211
- onClick={toggleResolve}
1212
- >
1213
- <TldrawUiIcon
1214
- icon="check"
1215
- label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
1216
- small
1217
- />
1218
- </button>
1219
- )}
1220
- {currentUserId && (
1221
- <button
1222
- className="tlui-cmt-thread__action"
1223
- title={msg('comments.delete')}
1224
- onClick={deleteThread}
1225
- >
1226
- <TldrawUiIcon icon="trash" label={msg('comments.delete')} small />
1227
- </button>
1228
- )}
1229
- <button
1230
- className="tlui-cmt-thread__action"
1231
- title={msg('comments.dismiss')}
1232
- onClick={() => openThreadId.set(editor, null)}
1233
- >
1234
- <TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
1235
- </button>
1236
- </>
1237
- )
1238
-
1239
1184
  const PinContent = options.components.PinContent
1240
- // 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)
1241
1187
  const pinContent = PinContent ? (
1242
1188
  <PinContent thread={thread} comments={comments} />
1243
1189
  ) : (
1244
- initialOf(resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR)
1190
+ <Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
1245
1191
  )
1192
+ const pinLabel = msg(
1193
+ thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
1194
+ ).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
1246
1195
 
1247
1196
  // Drag the marker to move the thread: its position is overridden locally while dragging, then
1248
1197
  // re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
1249
1198
  // a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
1250
1199
  // toggle the popover.
1251
- // Which affordances move a region, per the option: 'pin' → pin only, 'body' → body only, 'both'.
1252
1200
  const isRegion = thread.anchor.type === 'region'
1253
- const pinMovable = regionOptions.move !== 'body'
1254
- const bodyMovable = regionOptions.move !== 'pin'
1255
- 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
+ }
1256
1209
  e.stopPropagation()
1257
- 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
+ }
1258
1227
  e.currentTarget.setPointerCapture(e.pointerId)
1259
1228
  }
1260
- const onDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1229
+ const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
1261
1230
  const drag = dragRef.current
1262
1231
  if (!drag) return
1263
- // A region that moves by its body ignores pin drags (the pin only toggles the thread).
1264
- 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
1265
1235
  if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
1266
1236
  drag.moved = true
1267
- 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
+ }
1268
1246
  }
1269
- 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>) => {
1270
1250
  const drag = dragRef.current
1271
1251
  dragRef.current = null
1272
1252
  if (e.currentTarget.hasPointerCapture(e.pointerId)) {
1273
1253
  e.currentTarget.releasePointerCapture(e.pointerId)
1274
1254
  }
1275
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([])
1276
1267
  if (!drag.moved) {
1277
1268
  openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
1278
1269
  return
1279
1270
  }
1280
- 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 }
1281
1273
  setDragPagePoint(null)
1282
1274
  let anchor: TLCommentThread['anchor']
1283
1275
  if (thread.anchor.type === 'region') {
1284
1276
  // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
1285
1277
  anchor = {
1286
1278
  ...thread.anchor,
1287
- x: pagePoint.x - regionOptions.pinCorner.x * thread.anchor.w,
1288
- 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,
1289
1281
  }
1290
1282
  } else {
1291
- const hit = editor.getShapeAtPoint(pagePoint, { hitInside: true })
1283
+ const hit = commentTargetShapeAt(editor, pagePoint)
1292
1284
  anchor = hit
1293
- ? 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
+ )
1294
1295
  : { type: 'point', x: pagePoint.x, y: pagePoint.y }
1295
1296
  }
1296
- commitCommentMutation(editor, () => putCommentRecords(editor, [{ ...thread, anchor }]), 'drag')
1297
+ commitCommentMutation(editor, () => putRecordsInCommit(editor, [{ ...thread, anchor }]), 'drag')
1297
1298
  }
1298
1299
 
1299
1300
  // The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
1300
1301
  // move to the drag point; otherwise it sits at the stored anchor's viewport point.
1301
- const livePinPage = resizeBounds
1302
- ? regionPinPoint(resizeBounds, regionOptions.pinCorner)
1303
- : dragPagePoint
1304
- 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
1305
1309
 
1306
1310
  // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
1307
1311
  // corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
@@ -1310,33 +1314,30 @@ const ThreadPin = memo(function ThreadPin({
1310
1314
  regionAnchor && dragPagePoint
1311
1315
  ? {
1312
1316
  ...regionAnchor,
1313
- x: dragPagePoint.x - regionOptions.pinCorner.x * regionAnchor.w,
1314
- y: dragPagePoint.y - regionOptions.pinCorner.y * regionAnchor.h,
1317
+ x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
1318
+ y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
1315
1319
  }
1316
1320
  : regionAnchor
1317
1321
  const regionBoxBounds = resizeBounds ?? movedRegion
1318
1322
  const commitResize = (bounds: BoxModel) => {
1319
1323
  setResizeBounds(null)
1320
- editor.run(
1321
- () => putCommentRecords(editor, [{ ...thread, anchor: { type: 'region', ...bounds } }]),
1322
- {
1323
- history: 'ignore',
1324
- }
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'
1325
1332
  )
1326
1333
  }
1327
1334
 
1328
1335
  return (
1329
1336
  <>
1330
1337
  {regionBoxBounds && (dragPagePoint || revealed) && (
1331
- <RegionBox
1332
- editor={editor}
1333
- box={regionBoxBounds}
1334
- movable={bodyMovable && !dragPagePoint}
1335
- onPreview={setResizeBounds}
1336
- onCommit={commitResize}
1337
- />
1338
+ <RegionBox editor={editor} box={regionBoxBounds} />
1338
1339
  )}
1339
- {regionBoxBounds && revealed && !dragPagePoint && regionOptions.resize !== 'none' && (
1340
+ {regionBoxBounds && revealed && !dragPagePoint && canComment && (
1340
1341
  <RegionResizeHandles
1341
1342
  editor={editor}
1342
1343
  box={regionBoxBounds}
@@ -1346,60 +1347,70 @@ const ThreadPin = memo(function ThreadPin({
1346
1347
  />
1347
1348
  )}
1348
1349
  <div
1349
- className={open ? 'tlui-cmt-canvas-pin tlui-cmt-canvas-pin--open' : 'tlui-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(' ')}
1350
1357
  style={{ left: renderPoint.x, top: renderPoint.y }}
1351
1358
  >
1352
- <div
1359
+ <button
1353
1360
  ref={markerRef}
1354
- className="tlui-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}
1355
1365
  onPointerDown={startDrag}
1356
1366
  onPointerMove={onDrag}
1357
1367
  onPointerUp={endDrag}
1358
- onPointerEnter={() => setPinHovered(true)}
1359
- 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}
1360
1381
  >
1361
1382
  <CommentPin resolved={thread.resolved != null} open={open}>
1362
1383
  {pinContent}
1363
1384
  </CommentPin>
1364
- </div>
1385
+ </button>
1365
1386
  {/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
1366
1387
  the pin itself stays in the canvas-in-front layer, beneath the UI. */}
1367
1388
  {open && (
1368
1389
  <ThreadPopover
1369
1390
  container={container}
1370
- 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
+ }}
1371
1395
  >
1372
- <CommentThread
1373
- header={msg('comments.thread-title')}
1374
- headerActions={headerActions}
1375
- renderComment={renderComment}
1376
- comments={comments.map((c) => toCardProps(c, props, options.components))}
1377
- resolvedBanner={
1378
- thread.resolved
1379
- ? msg('comments.resolved-by').replace(
1380
- '{name}',
1381
- resolveName(thread.resolved.by) ?? UNKNOWN_AUTHOR
1382
- )
1383
- : undefined
1384
- }
1385
- composer={
1386
- currentUserId && !thread.resolved
1387
- ? {
1388
- author: resolveName(currentUserId) ?? UNKNOWN_AUTHOR,
1389
- placeholder: msg('comments.reply-placeholder'),
1390
- sendLabel: msg('comments.send'),
1391
- value: reply,
1392
- onChange: setReply,
1393
- onSubmit: postReply,
1394
- disabled: isCommentEmpty(reply),
1395
- getMentionSuggestions,
1396
- renderMentionSuggestion,
1397
- }
1398
- : undefined
1399
- }
1400
- />
1396
+ <ThreadView editor={editor} thread={thread} {...props} />
1401
1397
  </ThreadPopover>
1402
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
+ )}
1403
1414
  </div>
1404
1415
  </>
1405
1416
  )
@@ -1409,12 +1420,25 @@ function PendingComposer({
1409
1420
  editor,
1410
1421
  pending,
1411
1422
  currentUserId,
1412
- resolveName,
1423
+ resolveAuthor,
1413
1424
  onPostComment,
1414
1425
  getMentionSuggestions,
1415
1426
  renderMentionSuggestion,
1416
- }: CanvasCommentsProps & { editor: Editor; pending: PendingComment }) {
1417
- 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
+ )
1418
1442
  const ref = useRef<HTMLDivElement>(null)
1419
1443
  const msg = useTranslation()
1420
1444
  const container = useContainer()
@@ -1457,36 +1481,52 @@ function PendingComposer({
1457
1481
  authorId: currentUserId,
1458
1482
  body: text,
1459
1483
  })
1460
- putCommentRecords(editor, [thread, comment])
1484
+ putRecordsInCommit(editor, [thread, comment])
1461
1485
  if (onPostComment) onPostComment(comment)
1462
1486
  })
1463
1487
  setText(EMPTY_COMMENT)
1488
+ clearCommentDraft(NEW_COMMENT_DRAFT)
1464
1489
  pendingComment.set(editor, null)
1465
1490
  }
1466
1491
 
1467
1492
  return createPortal(
1468
1493
  <div
1469
1494
  ref={ref}
1470
- className="tlui-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(' ')}
1471
1502
  style={{ left: point.x, top: point.y }}
1472
1503
  onPointerDown={stop}
1504
+ onContextMenu={stop}
1473
1505
  onKeyDown={(e) => {
1474
1506
  if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
1475
1507
  }}
1476
1508
  >
1477
- <CommentComposer
1478
- author={currentUserId ? (resolveName(currentUserId) ?? UNKNOWN_AUTHOR) : ''}
1479
- placeholder={msg('comments.add-placeholder')}
1480
- sendLabel={msg('comments.send')}
1481
- value={text}
1482
- onChange={setText}
1483
- onSubmit={submit}
1484
- disabled={isCommentEmpty(text)}
1485
- getMentionSuggestions={getMentionSuggestions}
1486
- renderMentionSuggestion={renderMentionSuggestion}
1487
- autoFocus
1488
- leading={draftAvatar}
1489
- />
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
+ )}
1490
1530
  </div>,
1491
1531
  container
1492
1532
  )