@tldraw/commenting 0.0.0-bootstrap

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 (242) hide show
  1. package/README.md +11 -0
  2. package/commenting.css +832 -0
  3. package/dist-cjs/canvas/cluster-input.js +40 -0
  4. package/dist-cjs/canvas/cluster-input.js.map +7 -0
  5. package/dist-cjs/canvas/comment-body.js +31 -0
  6. package/dist-cjs/canvas/comment-body.js.map +7 -0
  7. package/dist-cjs/canvas/comment-render.js +72 -0
  8. package/dist-cjs/canvas/comment-render.js.map +7 -0
  9. package/dist-cjs/canvas/comment-store.js +48 -0
  10. package/dist-cjs/canvas/comment-store.js.map +7 -0
  11. package/dist-cjs/canvas/comment-tool.js +160 -0
  12. package/dist-cjs/canvas/comment-tool.js.map +7 -0
  13. package/dist-cjs/canvas/comments-filter-menu.js +109 -0
  14. package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
  15. package/dist-cjs/canvas/comments-menu-item.js +42 -0
  16. package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
  17. package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
  18. package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
  19. package/dist-cjs/canvas/comments-overlay.js +1081 -0
  20. package/dist-cjs/canvas/comments-overlay.js.map +7 -0
  21. package/dist-cjs/canvas/comments-sidebar.js +134 -0
  22. package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
  23. package/dist-cjs/canvas/hooks.js +45 -0
  24. package/dist-cjs/canvas/hooks.js.map +7 -0
  25. package/dist-cjs/canvas/license.js +28 -0
  26. package/dist-cjs/canvas/license.js.map +7 -0
  27. package/dist-cjs/canvas/options.js +45 -0
  28. package/dist-cjs/canvas/options.js.map +7 -0
  29. package/dist-cjs/canvas/region-options.js +49 -0
  30. package/dist-cjs/canvas/region-options.js.map +7 -0
  31. package/dist-cjs/canvas/rich-text.js +28 -0
  32. package/dist-cjs/canvas/rich-text.js.map +7 -0
  33. package/dist-cjs/canvas/sidebar-filters.js +30 -0
  34. package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
  35. package/dist-cjs/canvas/state.js +76 -0
  36. package/dist-cjs/canvas/state.js.map +7 -0
  37. package/dist-cjs/canvas/thread-state.js +81 -0
  38. package/dist-cjs/canvas/thread-state.js.map +7 -0
  39. package/dist-cjs/clustering/computeClusterTable.js +83 -0
  40. package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
  41. package/dist-cjs/clustering/mst.js +117 -0
  42. package/dist-cjs/clustering/mst.js.map +7 -0
  43. package/dist-cjs/clustering/replay.js +275 -0
  44. package/dist-cjs/clustering/replay.js.map +7 -0
  45. package/dist-cjs/clustering/runtime.js +134 -0
  46. package/dist-cjs/clustering/runtime.js.map +7 -0
  47. package/dist-cjs/clustering/schedule.js +170 -0
  48. package/dist-cjs/clustering/schedule.js.map +7 -0
  49. package/dist-cjs/clustering/types.js +17 -0
  50. package/dist-cjs/clustering/types.js.map +7 -0
  51. package/dist-cjs/index.d.ts +868 -0
  52. package/dist-cjs/index.js +126 -0
  53. package/dist-cjs/index.js.map +7 -0
  54. package/dist-cjs/ui/avatar.js +43 -0
  55. package/dist-cjs/ui/avatar.js.map +7 -0
  56. package/dist-cjs/ui/byline.js +35 -0
  57. package/dist-cjs/ui/byline.js.map +7 -0
  58. package/dist-cjs/ui/comment-card.js +37 -0
  59. package/dist-cjs/ui/comment-card.js.map +7 -0
  60. package/dist-cjs/ui/comment-composer.js +136 -0
  61. package/dist-cjs/ui/comment-composer.js.map +7 -0
  62. package/dist-cjs/ui/comment-extensions.js +37 -0
  63. package/dist-cjs/ui/comment-extensions.js.map +7 -0
  64. package/dist-cjs/ui/comment-mention.js +42 -0
  65. package/dist-cjs/ui/comment-mention.js.map +7 -0
  66. package/dist-cjs/ui/comment-pin.js +29 -0
  67. package/dist-cjs/ui/comment-pin.js.map +7 -0
  68. package/dist-cjs/ui/comment-text.js +29 -0
  69. package/dist-cjs/ui/comment-text.js.map +7 -0
  70. package/dist-cjs/ui/comment-thread.js +45 -0
  71. package/dist-cjs/ui/comment-thread.js.map +7 -0
  72. package/dist-cjs/ui/comments-list.js +106 -0
  73. package/dist-cjs/ui/comments-list.js.map +7 -0
  74. package/dist-cjs/ui/count-badge.js +28 -0
  75. package/dist-cjs/ui/count-badge.js.map +7 -0
  76. package/dist-cjs/ui/empty-state.js +31 -0
  77. package/dist-cjs/ui/empty-state.js.map +7 -0
  78. package/dist-cjs/ui/format-time.js +46 -0
  79. package/dist-cjs/ui/format-time.js.map +7 -0
  80. package/dist-cjs/ui/mention-list.js +70 -0
  81. package/dist-cjs/ui/mention-list.js.map +7 -0
  82. package/dist-cjs/ui/mention-suggestion.js +206 -0
  83. package/dist-cjs/ui/mention-suggestion.js.map +7 -0
  84. package/dist-cjs/ui/mention.js +31 -0
  85. package/dist-cjs/ui/mention.js.map +7 -0
  86. package/dist-cjs/ui/reaction.js +31 -0
  87. package/dist-cjs/ui/reaction.js.map +7 -0
  88. package/dist-cjs/ui/reactions.js +34 -0
  89. package/dist-cjs/ui/reactions.js.map +7 -0
  90. package/dist-cjs/ui/render-markdown.js +63 -0
  91. package/dist-cjs/ui/render-markdown.js.map +7 -0
  92. package/dist-cjs/ui/send-button.js +28 -0
  93. package/dist-cjs/ui/send-button.js.map +7 -0
  94. package/dist-esm/canvas/cluster-input.mjs +20 -0
  95. package/dist-esm/canvas/cluster-input.mjs.map +7 -0
  96. package/dist-esm/canvas/comment-body.mjs +11 -0
  97. package/dist-esm/canvas/comment-body.mjs.map +7 -0
  98. package/dist-esm/canvas/comment-render.mjs +52 -0
  99. package/dist-esm/canvas/comment-render.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-store.mjs +28 -0
  101. package/dist-esm/canvas/comment-store.mjs.map +7 -0
  102. package/dist-esm/canvas/comment-tool.mjs +142 -0
  103. package/dist-esm/canvas/comment-tool.mjs.map +7 -0
  104. package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
  105. package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
  106. package/dist-esm/canvas/comments-menu-item.mjs +22 -0
  107. package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
  108. package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
  109. package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
  110. package/dist-esm/canvas/comments-overlay.mjs +1094 -0
  111. package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
  112. package/dist-esm/canvas/comments-sidebar.mjs +120 -0
  113. package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
  114. package/dist-esm/canvas/hooks.mjs +25 -0
  115. package/dist-esm/canvas/hooks.mjs.map +7 -0
  116. package/dist-esm/canvas/license.mjs +8 -0
  117. package/dist-esm/canvas/license.mjs.map +7 -0
  118. package/dist-esm/canvas/options.mjs +27 -0
  119. package/dist-esm/canvas/options.mjs.map +7 -0
  120. package/dist-esm/canvas/region-options.mjs +29 -0
  121. package/dist-esm/canvas/region-options.mjs.map +7 -0
  122. package/dist-esm/canvas/rich-text.mjs +8 -0
  123. package/dist-esm/canvas/rich-text.mjs.map +7 -0
  124. package/dist-esm/canvas/sidebar-filters.mjs +10 -0
  125. package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
  126. package/dist-esm/canvas/state.mjs +60 -0
  127. package/dist-esm/canvas/state.mjs.map +7 -0
  128. package/dist-esm/canvas/thread-state.mjs +61 -0
  129. package/dist-esm/canvas/thread-state.mjs.map +7 -0
  130. package/dist-esm/clustering/computeClusterTable.mjs +63 -0
  131. package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
  132. package/dist-esm/clustering/mst.mjs +97 -0
  133. package/dist-esm/clustering/mst.mjs.map +7 -0
  134. package/dist-esm/clustering/replay.mjs +255 -0
  135. package/dist-esm/clustering/replay.mjs.map +7 -0
  136. package/dist-esm/clustering/runtime.mjs +114 -0
  137. package/dist-esm/clustering/runtime.mjs.map +7 -0
  138. package/dist-esm/clustering/schedule.mjs +150 -0
  139. package/dist-esm/clustering/schedule.mjs.map +7 -0
  140. package/dist-esm/clustering/types.mjs +1 -0
  141. package/dist-esm/clustering/types.mjs.map +7 -0
  142. package/dist-esm/index.d.mts +868 -0
  143. package/dist-esm/index.mjs +139 -0
  144. package/dist-esm/index.mjs.map +7 -0
  145. package/dist-esm/ui/avatar.mjs +23 -0
  146. package/dist-esm/ui/avatar.mjs.map +7 -0
  147. package/dist-esm/ui/byline.mjs +15 -0
  148. package/dist-esm/ui/byline.mjs.map +7 -0
  149. package/dist-esm/ui/comment-card.mjs +17 -0
  150. package/dist-esm/ui/comment-card.mjs.map +7 -0
  151. package/dist-esm/ui/comment-composer.mjs +116 -0
  152. package/dist-esm/ui/comment-composer.mjs.map +7 -0
  153. package/dist-esm/ui/comment-extensions.mjs +17 -0
  154. package/dist-esm/ui/comment-extensions.mjs.map +7 -0
  155. package/dist-esm/ui/comment-mention.mjs +22 -0
  156. package/dist-esm/ui/comment-mention.mjs.map +7 -0
  157. package/dist-esm/ui/comment-pin.mjs +9 -0
  158. package/dist-esm/ui/comment-pin.mjs.map +7 -0
  159. package/dist-esm/ui/comment-text.mjs +9 -0
  160. package/dist-esm/ui/comment-text.mjs.map +7 -0
  161. package/dist-esm/ui/comment-thread.mjs +25 -0
  162. package/dist-esm/ui/comment-thread.mjs.map +7 -0
  163. package/dist-esm/ui/comments-list.mjs +86 -0
  164. package/dist-esm/ui/comments-list.mjs.map +7 -0
  165. package/dist-esm/ui/count-badge.mjs +8 -0
  166. package/dist-esm/ui/count-badge.mjs.map +7 -0
  167. package/dist-esm/ui/empty-state.mjs +11 -0
  168. package/dist-esm/ui/empty-state.mjs.map +7 -0
  169. package/dist-esm/ui/format-time.mjs +26 -0
  170. package/dist-esm/ui/format-time.mjs.map +7 -0
  171. package/dist-esm/ui/mention-list.mjs +50 -0
  172. package/dist-esm/ui/mention-list.mjs.map +7 -0
  173. package/dist-esm/ui/mention-suggestion.mjs +186 -0
  174. package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
  175. package/dist-esm/ui/mention.mjs +11 -0
  176. package/dist-esm/ui/mention.mjs.map +7 -0
  177. package/dist-esm/ui/reaction.mjs +11 -0
  178. package/dist-esm/ui/reaction.mjs.map +7 -0
  179. package/dist-esm/ui/reactions.mjs +14 -0
  180. package/dist-esm/ui/reactions.mjs.map +7 -0
  181. package/dist-esm/ui/render-markdown.mjs +45 -0
  182. package/dist-esm/ui/render-markdown.mjs.map +7 -0
  183. package/dist-esm/ui/send-button.mjs +8 -0
  184. package/dist-esm/ui/send-button.mjs.map +7 -0
  185. package/package.json +71 -0
  186. package/src/canvas/canvas.css +148 -0
  187. package/src/canvas/cluster-input.test.ts +223 -0
  188. package/src/canvas/cluster-input.ts +29 -0
  189. package/src/canvas/comment-body.tsx +21 -0
  190. package/src/canvas/comment-render.test.ts +90 -0
  191. package/src/canvas/comment-render.ts +92 -0
  192. package/src/canvas/comment-store.ts +55 -0
  193. package/src/canvas/comment-tool.tsx +200 -0
  194. package/src/canvas/comments-filter-menu.tsx +104 -0
  195. package/src/canvas/comments-menu-item.tsx +24 -0
  196. package/src/canvas/comments-overflow-menu.tsx +62 -0
  197. package/src/canvas/comments-overlay.tsx +1460 -0
  198. package/src/canvas/comments-sidebar.tsx +184 -0
  199. package/src/canvas/hooks.ts +28 -0
  200. package/src/canvas/license.ts +15 -0
  201. package/src/canvas/options.test.ts +127 -0
  202. package/src/canvas/options.ts +111 -0
  203. package/src/canvas/region-options.ts +57 -0
  204. package/src/canvas/rich-text.ts +15 -0
  205. package/src/canvas/sidebar-filters.ts +25 -0
  206. package/src/canvas/state.ts +102 -0
  207. package/src/canvas/thread-state.ts +91 -0
  208. package/src/clustering/computeClusterTable.test.ts +299 -0
  209. package/src/clustering/computeClusterTable.ts +82 -0
  210. package/src/clustering/locality.test.ts +189 -0
  211. package/src/clustering/mst.test.ts +383 -0
  212. package/src/clustering/mst.ts +134 -0
  213. package/src/clustering/replay.test.ts +547 -0
  214. package/src/clustering/replay.ts +326 -0
  215. package/src/clustering/runtime.test.ts +596 -0
  216. package/src/clustering/runtime.ts +153 -0
  217. package/src/clustering/schedule.test.ts +514 -0
  218. package/src/clustering/schedule.ts +184 -0
  219. package/src/clustering/types.ts +102 -0
  220. package/src/index.ts +96 -0
  221. package/src/ui/avatar.tsx +31 -0
  222. package/src/ui/byline.tsx +23 -0
  223. package/src/ui/comment-card.tsx +32 -0
  224. package/src/ui/comment-composer.tsx +179 -0
  225. package/src/ui/comment-extensions.ts +25 -0
  226. package/src/ui/comment-mention.ts +47 -0
  227. package/src/ui/comment-pin.tsx +21 -0
  228. package/src/ui/comment-text.tsx +12 -0
  229. package/src/ui/comment-thread.tsx +56 -0
  230. package/src/ui/comments-list.tsx +139 -0
  231. package/src/ui/comments.css +678 -0
  232. package/src/ui/count-badge.tsx +9 -0
  233. package/src/ui/empty-state.tsx +16 -0
  234. package/src/ui/format-time.ts +29 -0
  235. package/src/ui/mention-list.tsx +106 -0
  236. package/src/ui/mention-suggestion.test.ts +18 -0
  237. package/src/ui/mention-suggestion.tsx +285 -0
  238. package/src/ui/mention.tsx +9 -0
  239. package/src/ui/reaction.tsx +16 -0
  240. package/src/ui/reactions.tsx +15 -0
  241. package/src/ui/render-markdown.tsx +72 -0
  242. package/src/ui/send-button.tsx +15 -0
@@ -0,0 +1,1460 @@
1
+ import {
2
+ memo,
3
+ type CSSProperties,
4
+ type PointerEvent as ReactPointerEvent,
5
+ ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import { createPortal } from 'react-dom'
13
+ import {
14
+ type BoxModel,
15
+ createComment,
16
+ createCommentThread,
17
+ Editor,
18
+ getFirstCharacter,
19
+ react,
20
+ TLComment,
21
+ TLCommentId,
22
+ TLCommentThread,
23
+ TLRichText,
24
+ TldrawUiIcon,
25
+ useContainer,
26
+ useEditor,
27
+ usePassThroughMouseOverEvents,
28
+ usePassThroughWheelEvents,
29
+ useTranslation,
30
+ useValue,
31
+ VecLike,
32
+ } from 'tldraw'
33
+ import { computeClusterTable } from '../clustering/computeClusterTable'
34
+ import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
35
+ import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
36
+ import { CommentCard, CommentCardProps } from '../ui/comment-card'
37
+ import { CommentComposer } from '../ui/comment-composer'
38
+ import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
39
+ import { CommentPin } from '../ui/comment-pin'
40
+ import { CommentThread } from '../ui/comment-thread'
41
+ import { CountBadge } from '../ui/count-badge'
42
+ import { MentionMember } from '../ui/mention-list'
43
+ import { isMentionPickerOpen } from '../ui/mention-suggestion'
44
+ 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'
48
+ import { PendingComment } from './comment-tool'
49
+ import { useCommentThreads, useThreadComments } from './hooks'
50
+ import { useCommentingEnabled } from './license'
51
+ import {
52
+ type CommentingComponents,
53
+ type CommentingOptions,
54
+ getCommentingOptions,
55
+ useCommentingOptions,
56
+ } from './options'
57
+ import {
58
+ DEFAULT_REGION_COMMENT_OPTIONS,
59
+ RegionCommentOptions,
60
+ setRegionCommentOptions,
61
+ } from './region-options'
62
+ import {
63
+ commentsHidden,
64
+ commitCommentMutation,
65
+ openThreadId,
66
+ pendingComment,
67
+ regionDraft,
68
+ toggleCommentsHidden,
69
+ usePendingComment,
70
+ } from './state'
71
+ import { anchorPagePoint, regionPinPoint, shapeAnchorAt } from './thread-state'
72
+
73
+ /**
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`.
77
+ *
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
+ * @public
83
+ */
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
+ }
108
+
109
+ const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
110
+
111
+ const initialOf = (name: string): string => (getFirstCharacter(name.trim()) || '?').toUpperCase()
112
+ const CLUSTER_FADE_MS = 150
113
+ /** Duration of the click-a-badge zoom-to-split animation. */
114
+ const CLUSTER_EXPAND_ZOOM_MS = 450
115
+
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 */
160
+ export function CanvasComments(props: CanvasCommentsProps) {
161
+ // Gate the whole layer on the license before doing any work. The inner component holds all the
162
+ // other hooks, so mounting/unmounting it as the license resolves keeps hook order stable here.
163
+ const commentingEnabled = useCommentingEnabled()
164
+ if (!commentingEnabled) return null
165
+ return <CanvasCommentsLayer {...props} />
166
+ }
167
+
168
+ function CanvasCommentsLayer(props: CanvasCommentsProps) {
169
+ const editor = useEditor()
170
+ const options = useCommentingOptions()
171
+ 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])
179
+ 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)
183
+ usePassThroughMouseOverEvents(layerRef)
184
+ const deepLinkHandled = useRef(false)
185
+ const threads = useCommentThreads(editor)
186
+ const pending = usePendingComment()
187
+ 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)
193
+ const adoptOnRebuild = useRef(false)
194
+ const clusterLeaves = useValue(
195
+ 'comment cluster leaves',
196
+ () =>
197
+ collectClusterLeaves(
198
+ editor,
199
+ threads.filter((thread) => !movedThreadIds.has(thread.id)),
200
+ openThreadId.get(editor),
201
+ impreciseShapeAnchor
202
+ ),
203
+ [editor, threads, impreciseShapeAnchor, movedThreadIds]
204
+ )
205
+ const clusterZoomBounds = useValue(
206
+ 'comment cluster zoom bounds',
207
+ () => getClusterZoomBounds(editor),
208
+ [editor]
209
+ )
210
+ const latestModel = useMemo(() => {
211
+ const table = computeClusterTable(clusterLeaves, clusterZoomBounds)
212
+ const runtime = createClusterRuntime(table)
213
+ runtime.seed(editor.getZoomLevel())
214
+ return { runtime, table }
215
+ }, [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.
222
+ const [renderedModel, setRenderedModel] = useState(latestModel)
223
+ let clusterModel = renderedModel
224
+ if (
225
+ renderedModel !== latestModel &&
226
+ (adoptOnRebuild.current || hasRemovedLeaves(renderedModel.table, latestModel.table))
227
+ ) {
228
+ 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.
233
+ latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
234
+ setRenderedModel(latestModel)
235
+ clusterModel = latestModel
236
+ }
237
+ // 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.
241
+ const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
242
+ 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)
246
+ for (const id of newlyMovedIds) next.add(id)
247
+ setMovedThreadIds(next)
248
+ }
249
+ // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
250
+ // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
251
+ // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
252
+ useEffect(() => {
253
+ if (movedThreadIds.size === 0) return
254
+ let lastZoom = editor.getZoomLevel()
255
+ return react('rejoin moved comment pins on zoom out', () => {
256
+ const zoom = editor.getZoomLevel()
257
+ const prevZoom = lastZoom
258
+ lastZoom = zoom
259
+ if (zoom >= prevZoom) return
260
+ adoptOnRebuild.current = true
261
+ setMovedThreadIds(EMPTY_SET)
262
+ })
263
+ }, [movedThreadIds, editor])
264
+ // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
265
+ // a merge, and merging only happens while zooming out. While zooming in, the stale table still
266
+ // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
267
+ useEffect(() => {
268
+ if (clusterModel === latestModel) return
269
+ let lastZoom = editor.getZoomLevel()
270
+ return react('adopt pending cluster model on zoom out', () => {
271
+ const zoom = editor.getZoomLevel()
272
+ const prevZoom = lastZoom
273
+ lastZoom = zoom
274
+ if (zoom >= prevZoom) return
275
+ latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
276
+ setRenderedModel(latestModel)
277
+ })
278
+ }, [clusterModel, latestModel, editor])
279
+ const orphanThreads = useMemo(() => {
280
+ if (clusterModel === latestModel) return []
281
+ const renderedIds = new Set(clusterModel.table.leaves.map((leaf) => leaf.id))
282
+ 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]
288
+ )
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',
295
+ () => {
296
+ clusterModel.runtime.onCamera(editor.getZoomLevel())
297
+ return clusterModel.runtime.k
298
+ },
299
+ [clusterModel, editor]
300
+ )
301
+ 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])
309
+ const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
310
+ const threadsById = useMemo(
311
+ () => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
312
+ [threads]
313
+ )
314
+ const openThread = openId ? threadsById.get(openId) : null
315
+ const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
316
+
317
+ // Reset the transient UI state (open thread, half-placed comment) when this unmounts.
318
+ useEffect(() => {
319
+ return () => {
320
+ openThreadId.set(editor, null)
321
+ pendingComment.set(editor, null)
322
+ }
323
+ }, [editor])
324
+
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.
327
+ 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
333
+ }
334
+
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
345
+
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])
357
+
358
+ // Clicking a badge zooms to just past the zoom at which that cluster first unclusters,
359
+ // centered on its centroid. The event that created a visible cluster is the event that splits
360
+ // it, and (by the table's sort + monotone thresholds) it has the smallest zSplit of everything
361
+ // applied inside it — so its zSplit is exactly the first split within those comments. The
362
+ // animated zoom-in then drives the runtime cursor like any manual zoom, so the badge splits
363
+ // (and can be drilled into further) with no extra bookkeeping.
364
+ const zoomToClusterSplit = useCallback(
365
+ (node: ClusterNode) => {
366
+ const event = clusterModel.table.events.find((e) => e.result.id === node.id)
367
+ if (!event || !Number.isFinite(event.zSplit)) return
368
+ const zoom = clamp(
369
+ event.zSplit * options.clusterSplitZoomFactor,
370
+ clusterZoomBounds.minZoom,
371
+ clusterZoomBounds.maxZoom
372
+ )
373
+ centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
374
+ },
375
+ [clusterModel, clusterZoomBounds, editor, options]
376
+ )
377
+
378
+ // Escape collapses the open thread. Capture-phase + stopPropagation so it runs ahead of the
379
+ // editor (which would otherwise cancel the current tool or clear the selection). If a comment is
380
+ // being edited, let its own Escape handler exit edit mode first, keeping the thread open.
381
+ useEffect(() => {
382
+ const onKeyDown = (e: KeyboardEvent) => {
383
+ if (e.key !== 'Escape' || openThreadId.get(editor) === null) return
384
+ // The mention picker owns Escape while it's open — let it dismiss the roster alone.
385
+ if (isMentionPickerOpen()) return
386
+ const target = e.target as HTMLElement | null
387
+ if (target && target.closest('.cmt-editing')) return
388
+ openThreadId.set(editor, null)
389
+ e.preventDefault()
390
+ e.stopPropagation()
391
+ }
392
+ document.addEventListener('keydown', onKeyDown, true)
393
+ return () => document.removeEventListener('keydown', onKeyDown, true)
394
+ }, [editor])
395
+
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.
398
+ useEffect(() => {
399
+ const onKeyDown = (e: KeyboardEvent) => {
400
+ if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
401
+ const target = e.target as HTMLElement | null
402
+ if (target && target.closest('input, textarea, [contenteditable="true"]')) return
403
+ toggleCommentsHidden(editor)
404
+ e.preventDefault()
405
+ }
406
+ document.addEventListener('keydown', onKeyDown, true)
407
+ return () => document.removeEventListener('keydown', onKeyDown, true)
408
+ }, [editor])
409
+
410
+ // Hidden: the whole canvas layer (pins, open popover, pending composer) is withheld. The signal
411
+ // is read above so this component stays mounted and its shortcut/Escape effects keep running.
412
+ if (hidden) return null
413
+
414
+ // 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.
416
+ return createPortal(
417
+ <div ref={layerRef} className="cmt-canvas-layer">
418
+ {options.enableClustering ? (
419
+ <>
420
+ {fadeNodes.map(({ node, phase }) => {
421
+ let content: ReactNode
422
+ if (node.count === 1) {
423
+ const thread = threadsById.get(node.id)
424
+ if (!thread) return null
425
+ content = (
426
+ <ThreadPin
427
+ editor={editor}
428
+ thread={thread}
429
+ {...props}
430
+ regionOptions={regionOptions}
431
+ />
432
+ )
433
+ } else {
434
+ content = <ClusterBadge editor={editor} node={node} onExpand={zoomToClusterSplit} />
435
+ }
436
+ return (
437
+ <div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
438
+ {content}
439
+ </div>
440
+ )
441
+ })}
442
+ {orphanThreads.map((thread) => (
443
+ <ThreadPin
444
+ key={thread.id}
445
+ editor={editor}
446
+ thread={thread}
447
+ {...props}
448
+ regionOptions={regionOptions}
449
+ />
450
+ ))}
451
+ {movedThreads.map((thread) => (
452
+ <ThreadPin
453
+ key={thread.id}
454
+ editor={editor}
455
+ thread={thread}
456
+ {...props}
457
+ regionOptions={regionOptions}
458
+ />
459
+ ))}
460
+ </>
461
+ ) : (
462
+ // Clustering off: every thread renders as its own live pin (each returns null when it's
463
+ // not on the current page or its anchor is missing). The open thread is excluded here and
464
+ // rendered once below, mirroring how the clustering path keeps it out of the cluster leaves —
465
+ // otherwise it would mount a second, stacked pin.
466
+ threads
467
+ .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
+ ))
477
+ )}
478
+ {openThread && (
479
+ <ThreadPin
480
+ key={`open:${openThread.id}`}
481
+ editor={editor}
482
+ thread={openThread}
483
+ {...props}
484
+ regionOptions={regionOptions}
485
+ />
486
+ )}
487
+ <RegionDraftBox editor={editor} />
488
+ {/* Keep the region visible while composing — the drag draft is gone by now, and no thread
489
+ 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 && (
492
+ <PendingComposer editor={editor} pending={pending} {...props} />
493
+ )}
494
+ </div>,
495
+ container
496
+ )
497
+ }
498
+
499
+ const EMPTY_SET: ReadonlySet<string> = new Set()
500
+ const MOVED_LEAF_EPSILON = 1e-6
501
+ type ClusterFadePhase = 'entering' | 'present' | 'exiting'
502
+
503
+ interface ClusterFadeNode {
504
+ node: ClusterNode
505
+ phase: ClusterFadePhase
506
+ }
507
+
508
+ function useFadeVisibleNodes(
509
+ nodes: readonly ClusterNode[],
510
+ resetKey: { runtime: ClusterRuntime; table: ClusterTable }
511
+ ): ClusterFadeNode[] {
512
+ const resetKeyRef = useRef(resetKey)
513
+ const didReset = resetKeyRef.current !== resetKey
514
+ if (didReset) {
515
+ resetKeyRef.current = resetKey
516
+ }
517
+
518
+ const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
519
+ const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
520
+
521
+ useEffect(() => {
522
+ setFadeNodes(toPresentFadeNodes(nodes))
523
+ // Resets only on a new model (resetKey); node-list changes within the same model are
524
+ // handled by the reconcile effect below, which fades entries in/out instead of snapping.
525
+ // eslint-disable-next-line react-hooks/exhaustive-deps
526
+ }, [resetKey])
527
+
528
+ useEffect(() => {
529
+ if (didReset) return
530
+ setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
531
+ }, [didReset, nodes])
532
+
533
+ const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
534
+ useEffect(() => {
535
+ if (!hasEntering) return
536
+ const frame = requestClusterFadeFrame(() => {
537
+ setFadeNodes((previous) =>
538
+ previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
539
+ )
540
+ })
541
+ return () => cancelClusterFadeFrame(frame)
542
+ }, [hasEntering, renderedNodes])
543
+
544
+ const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
545
+ useEffect(() => {
546
+ if (!hasExiting) return
547
+ const timeout = window.setTimeout(() => {
548
+ setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
549
+ }, CLUSTER_FADE_MS)
550
+ return () => window.clearTimeout(timeout)
551
+ }, [hasExiting, renderedNodes])
552
+
553
+ return renderedNodes
554
+ }
555
+
556
+ function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
557
+ return nodes.map((node) => ({ node, phase: 'present' }))
558
+ }
559
+
560
+ function reconcileFadeNodes(
561
+ previous: readonly ClusterFadeNode[],
562
+ nextNodes: readonly ClusterNode[]
563
+ ): ClusterFadeNode[] {
564
+ const previousById = new Map(previous.map((item) => [item.node.id, item]))
565
+ const nextIds = new Set(nextNodes.map((node) => node.id))
566
+ const next: ClusterFadeNode[] = []
567
+
568
+ for (const node of nextNodes) {
569
+ const previousItem = previousById.get(node.id)
570
+ next.push({
571
+ node,
572
+ phase:
573
+ previousItem && previousItem.phase !== 'exiting'
574
+ ? previousItem.phase
575
+ : previousItem
576
+ ? 'present'
577
+ : 'entering',
578
+ })
579
+ }
580
+
581
+ for (const item of previous) {
582
+ if (nextIds.has(item.node.id)) continue
583
+ next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
584
+ }
585
+
586
+ return next
587
+ }
588
+
589
+ function requestClusterFadeFrame(callback: FrameRequestCallback): number {
590
+ if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback)
591
+ return window.setTimeout(() => callback(0), 16)
592
+ }
593
+
594
+ function cancelClusterFadeFrame(frame: number) {
595
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frame)
596
+ else window.clearTimeout(frame)
597
+ }
598
+
599
+ function clusterFadeClassName(phase: ClusterFadePhase): string {
600
+ return `cmt-cluster-fade cmt-cluster-fade--${phase}`
601
+ }
602
+
603
+ /**
604
+ * Leaves folded inside a badge whose live anchor no longer matches the position the rendered
605
+ * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
606
+ * stay deferred; a badge can't follow a member, so these must pop out of clustering.
607
+ */
608
+ function findMovedClusteredLeafIds(
609
+ rendered: { runtime: ClusterRuntime; table: ClusterTable },
610
+ latest: { table: ClusterTable }
611
+ ): string[] {
612
+ if (rendered.table === latest.table) return []
613
+ const visible = rendered.runtime.getVisible()
614
+ const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
615
+ const moved: string[] = []
616
+ for (const leaf of rendered.table.leaves) {
617
+ if (visible.has(leaf.id)) continue
618
+ const current = latestById.get(leaf.id)
619
+ if (!current) continue
620
+ if (
621
+ Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
622
+ Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
623
+ ) {
624
+ moved.push(leaf.id)
625
+ }
626
+ }
627
+ return moved
628
+ }
629
+
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
+ function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
637
+ const cameraOptions = editor.getCameraOptions()
638
+ const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
639
+ const zoomSteps = cameraOptions.zoomSteps
640
+ return {
641
+ minZoom: zoomSteps[0] * baseZoom,
642
+ maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
643
+ }
644
+ }
645
+
646
+ function revealDeepLinkedThread(
647
+ editor: Editor,
648
+ thread: TLCommentThread,
649
+ table: ClusterTable,
650
+ zoomBounds: { minZoom: number; maxZoom: number },
651
+ options: CommentingOptions,
652
+ impreciseShapeAnchor: { x: number; y: number }
653
+ ) {
654
+ if (thread.pageId !== editor.getCurrentPageId()) {
655
+ editor.setCurrentPage(thread.pageId as any)
656
+ }
657
+
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)
660
+ if (!point) return
661
+
662
+ // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
663
+ // badge never exists) and just center on the pin.
664
+ if (options.enableClustering) {
665
+ const parentEvent = findDirectParentEvent(table, thread.id)
666
+ if (
667
+ parentEvent &&
668
+ Number.isFinite(parentEvent.zSplit) &&
669
+ parentEvent.zSplit <= zoomBounds.maxZoom
670
+ ) {
671
+ const zoom = clamp(
672
+ parentEvent.zSplit * options.clusterSplitZoomFactor,
673
+ zoomBounds.minZoom,
674
+ zoomBounds.maxZoom
675
+ )
676
+ centerOnPointAtZoom(editor, point, zoom)
677
+ return
678
+ }
679
+ }
680
+
681
+ editor.centerOnPoint(point, { animation: { duration: 200 } })
682
+ }
683
+
684
+ function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
685
+ return table.events.find((event) => event.children.some((child) => child.id === threadId))
686
+ }
687
+
688
+ function centerOnPointAtZoom(
689
+ editor: Editor,
690
+ point: { x: number; y: number },
691
+ zoom: number,
692
+ duration = 200
693
+ ) {
694
+ const viewport = editor.getViewportScreenBounds()
695
+ editor.setCamera(
696
+ {
697
+ x: viewport.w / (2 * zoom) - point.x,
698
+ y: viewport.h / (2 * zoom) - point.y,
699
+ z: zoom,
700
+ },
701
+ { animation: { duration } }
702
+ )
703
+ }
704
+
705
+ function clamp(value: number, min: number, max: number): number {
706
+ return Math.max(min, Math.min(max, value))
707
+ }
708
+
709
+ // Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
710
+ // badges skip re-rendering when the parent re-renders for reasons that don't concern them
711
+ // (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
712
+ // works — each component subscribes to its own viewport position via signals, not via props.
713
+ const ClusterBadge = memo(function ClusterBadge({
714
+ editor,
715
+ node,
716
+ onExpand,
717
+ }: {
718
+ editor: Editor
719
+ node: ClusterNode
720
+ onExpand(node: ClusterNode): void
721
+ }) {
722
+ const point = useValue(
723
+ 'cluster badge point',
724
+ () => {
725
+ const pagePoint = editor.pageToViewport(node.centroid)
726
+ if (!isInInflatedViewport(editor, pagePoint)) return null
727
+ return pagePoint
728
+ },
729
+ [editor, node]
730
+ )
731
+
732
+ if (!point) return null
733
+
734
+ 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>
746
+ )
747
+ })
748
+
749
+ function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
750
+ const viewport = editor.getViewportScreenBounds()
751
+ const margin = getCommentingOptions(editor).clusterCullMargin
752
+ return (
753
+ point.x >= -margin &&
754
+ point.y >= -margin &&
755
+ point.x <= viewport.w + margin &&
756
+ point.y <= viewport.h + margin
757
+ )
758
+ }
759
+
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
+ /** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
783
+ * a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
784
+ * `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
+ }) {
798
+ const rect = useValue(
799
+ 'region rect',
800
+ () => {
801
+ // Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
802
+ const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
803
+ const zoom = editor.getZoomLevel()
804
+ return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
805
+ },
806
+ [editor, box.x, box.y, box.w, box.h]
807
+ )
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
+ )
841
+ }
842
+
843
+ /** The live region being dragged out by the comment tool, or nothing when not dragging. */
844
+ function RegionDraftBox({ editor }: { editor: Editor }) {
845
+ const box = useValue('region draft', () => regionDraft.get(editor), [editor])
846
+ if (!box) return null
847
+ return <RegionBox editor={editor} box={box} />
848
+ }
849
+
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.
852
+ interface RegionHandle {
853
+ x: number
854
+ y: number
855
+ cursor: string
856
+ }
857
+
858
+ // The four corners (both axes) and the four side midpoints (one axis each).
859
+ const REGION_CORNERS: readonly RegionHandle[] = [
860
+ { x: 0, y: 0, cursor: 'nwse-resize' },
861
+ { x: 1, y: 0, cursor: 'nesw-resize' },
862
+ { x: 0, y: 1, cursor: 'nesw-resize' },
863
+ { x: 1, y: 1, cursor: 'nwse-resize' },
864
+ ]
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
+
872
+ // Screen-space slack around a region's bounds within which its box and handles stay revealed, so
873
+ // the handles (which sit on the edge) are comfortably reachable.
874
+ const REGION_HANDLE_MARGIN_PX = 12
875
+
876
+ /** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
877
+ * handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
878
+ * handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
879
+ function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
880
+ const controlsX = handle.x !== 0.5
881
+ const controlsY = handle.y !== 0.5
882
+ const fixedX = box.x + (1 - handle.x) * box.w
883
+ const fixedY = box.y + (1 - handle.y) * box.h
884
+ return {
885
+ x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
886
+ y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
887
+ w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
888
+ h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
889
+ }
890
+ }
891
+
892
+ /** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
893
+ * option. Previews live, commits on release. */
894
+ function RegionResizeHandles({
895
+ editor,
896
+ box,
897
+ handles,
898
+ onPreview,
899
+ onCommit,
900
+ }: {
901
+ editor: Editor
902
+ box: BoxModel
903
+ handles: readonly RegionHandle[]
904
+ onPreview(bounds: BoxModel | null): void
905
+ onCommit(bounds: BoxModel): void
906
+ }) {
907
+ // The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
908
+ // the fixed edges mid-drag.
909
+ const boxRef = useRef<BoxModel | null>(null)
910
+ const points = useValue(
911
+ 'region handle points',
912
+ () =>
913
+ handles.map((h) => {
914
+ const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
915
+ return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
916
+ }),
917
+ [editor, box.x, box.y, box.w, box.h, handles]
918
+ )
919
+ const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
920
+ e.stopPropagation()
921
+ boxRef.current = box
922
+ e.currentTarget.setPointerCapture(e.pointerId)
923
+ }
924
+ const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
925
+ resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
926
+ const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
927
+ if (boxRef.current) onPreview(resizedTo(h, e))
928
+ }
929
+ const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
930
+ if (!boxRef.current) return
931
+ const bounds = resizedTo(h, e)
932
+ boxRef.current = null
933
+ if (e.currentTarget.hasPointerCapture(e.pointerId))
934
+ e.currentTarget.releasePointerCapture(e.pointerId)
935
+ onCommit(bounds)
936
+ }
937
+ return (
938
+ <>
939
+ {points.map((h) => (
940
+ <div
941
+ key={h.key}
942
+ className="cmt-canvas-region-handle"
943
+ style={{ left: h.left, top: h.top, cursor: h.cursor }}
944
+ onPointerDown={startResize}
945
+ onPointerMove={onResize(h)}
946
+ onPointerUp={endResize(h)}
947
+ />
948
+ ))}
949
+ </>
950
+ )
951
+ }
952
+
953
+ const ThreadPin = memo(function ThreadPin({
954
+ editor,
955
+ thread,
956
+ regionOptions,
957
+ ...props
958
+ }: Omit<CanvasCommentsProps, 'regionOptions'> & {
959
+ editor: Editor
960
+ thread: TLCommentThread
961
+ regionOptions: RegionCommentOptions
962
+ }) {
963
+ const {
964
+ currentUserId,
965
+ resolveName,
966
+ onPostComment,
967
+ isCommentUnread,
968
+ onCommentRead,
969
+ getMentionSuggestions,
970
+ renderMentionSuggestion,
971
+ } = props
972
+ const options = useCommentingOptions()
973
+ const impreciseShapeAnchor = props.impreciseShapeAnchor ?? options.impreciseShapeAnchor
974
+ const container = useContainer()
975
+ const comments = useThreadComments(editor, thread.id)
976
+ const msg = useTranslation()
977
+ // Only one thread's popover is open at a time — shared across pins via the atom.
978
+ const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
979
+ editor,
980
+ thread.id,
981
+ ])
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
+ // While dragging the marker, its page point overrides the anchor's; committed on drop.
986
+ const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
987
+ // The live bounds while a corner handle is resizing the region, else null.
988
+ 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)
991
+ // The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
992
+ // Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
993
+ // handle never loses the affordance — the box stays `pointer-events: none`.
994
+ const pointerInRegion = useValue(
995
+ 'pointer in region',
996
+ () => {
997
+ if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
998
+ return false
999
+ const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
1000
+ const p = editor.inputs.getCurrentPagePoint()
1001
+ const a = thread.anchor
1002
+ return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
1003
+ },
1004
+ [editor, thread.anchor, thread.pageId]
1005
+ )
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').
1014
+ 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]
1022
+ )
1023
+ const dragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null)
1024
+ const markerRef = useRef<HTMLDivElement>(null)
1025
+
1026
+ // Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
1027
+ // pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
1028
+ // popover portals elsewhere in the DOM. The pin marker is excluded so its own click-to-toggle
1029
+ // handles it instead of this closing then the toggle reopening.
1030
+ useEffect(() => {
1031
+ if (!open) return
1032
+ const onPointerDown = (e: PointerEvent) => {
1033
+ const target = e.target as HTMLElement | null
1034
+ if (!target) return
1035
+ if (target.closest('.cmt-canvas-popover')) return
1036
+ const marker = markerRef.current
1037
+ if (marker && marker.contains(target)) return
1038
+ // 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
1040
+ // A click inside a menu/popover layered above us (the sidebar's filter or overflow
1041
+ // dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
1042
+ // 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'))
1044
+ return
1045
+ openThreadId.set(editor, null)
1046
+ }
1047
+ document.addEventListener('pointerdown', onPointerDown, true)
1048
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
1049
+ }, [open, editor])
1050
+
1051
+ const point = useValue(
1052
+ 'pin point',
1053
+ () => {
1054
+ if (thread.pageId !== editor.getCurrentPageId()) return null
1055
+ const pagePoint = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
1056
+ return pagePoint ? editor.pageToViewport(pagePoint) : null
1057
+ },
1058
+ [editor, thread.anchor, thread.pageId, impreciseShapeAnchor]
1059
+ )
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
+ if (!point) return null
1075
+
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
+ const PinContent = options.components.PinContent
1207
+ // The `PinContent` component slot overrides the built-in author-initial default.
1208
+ const pinContent = PinContent ? (
1209
+ <PinContent thread={thread} comments={comments} />
1210
+ ) : (
1211
+ initialOf(resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR)
1212
+ )
1213
+
1214
+ // Drag the marker to move the thread: its position is overridden locally while dragging, then
1215
+ // re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
1216
+ // a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
1217
+ // toggle the popover.
1218
+ // Which affordances move a region, per the option: 'pin' → pin only, 'body' → body only, 'both'.
1219
+ const isRegion = thread.anchor.type === 'region'
1220
+ const pinMovable = regionOptions.move !== 'body'
1221
+ const bodyMovable = regionOptions.move !== 'pin'
1222
+ const startDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1223
+ e.stopPropagation()
1224
+ dragRef.current = { startX: e.clientX, startY: e.clientY, moved: false }
1225
+ e.currentTarget.setPointerCapture(e.pointerId)
1226
+ }
1227
+ const onDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1228
+ const drag = dragRef.current
1229
+ 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
+ if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
1233
+ drag.moved = true
1234
+ setDragPagePoint(editor.screenToPage({ x: e.clientX, y: e.clientY }))
1235
+ }
1236
+ const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1237
+ const drag = dragRef.current
1238
+ dragRef.current = null
1239
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
1240
+ e.currentTarget.releasePointerCapture(e.pointerId)
1241
+ }
1242
+ if (!drag) return
1243
+ if (!drag.moved) {
1244
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
1245
+ return
1246
+ }
1247
+ const pagePoint = editor.screenToPage({ x: e.clientX, y: e.clientY })
1248
+ setDragPagePoint(null)
1249
+ let anchor: TLCommentThread['anchor']
1250
+ if (thread.anchor.type === 'region') {
1251
+ // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
1252
+ anchor = {
1253
+ ...thread.anchor,
1254
+ x: pagePoint.x - regionOptions.pinCorner.x * thread.anchor.w,
1255
+ y: pagePoint.y - regionOptions.pinCorner.y * thread.anchor.h,
1256
+ }
1257
+ } else {
1258
+ const hit = editor.getShapeAtPoint(pagePoint, { hitInside: true })
1259
+ anchor = hit
1260
+ ? shapeAnchorAt(editor, hit.id, pagePoint, e.altKey)
1261
+ : { type: 'point', x: pagePoint.x, y: pagePoint.y }
1262
+ }
1263
+ commitCommentMutation(editor, () => putCommentRecords(editor, [{ ...thread, anchor }]), 'drag')
1264
+ }
1265
+
1266
+ // The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
1267
+ // 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
1272
+
1273
+ // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
1274
+ // corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
1275
+ const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
1276
+ const movedRegion =
1277
+ regionAnchor && dragPagePoint
1278
+ ? {
1279
+ ...regionAnchor,
1280
+ x: dragPagePoint.x - regionOptions.pinCorner.x * regionAnchor.w,
1281
+ y: dragPagePoint.y - regionOptions.pinCorner.y * regionAnchor.h,
1282
+ }
1283
+ : regionAnchor
1284
+ const regionBoxBounds = resizeBounds ?? movedRegion
1285
+ const commitResize = (bounds: BoxModel) => {
1286
+ setResizeBounds(null)
1287
+ editor.run(
1288
+ () => putCommentRecords(editor, [{ ...thread, anchor: { type: 'region', ...bounds } }]),
1289
+ {
1290
+ history: 'ignore',
1291
+ }
1292
+ )
1293
+ }
1294
+
1295
+ return (
1296
+ <>
1297
+ {regionBoxBounds && (dragPagePoint || revealed) && (
1298
+ <RegionBox
1299
+ editor={editor}
1300
+ box={regionBoxBounds}
1301
+ movable={bodyMovable && !dragPagePoint}
1302
+ onPreview={setResizeBounds}
1303
+ onCommit={commitResize}
1304
+ />
1305
+ )}
1306
+ {regionBoxBounds && revealed && !dragPagePoint && regionOptions.resize !== 'none' && (
1307
+ <RegionResizeHandles
1308
+ editor={editor}
1309
+ box={regionBoxBounds}
1310
+ handles={resizeHandles}
1311
+ onPreview={setResizeBounds}
1312
+ onCommit={commitResize}
1313
+ />
1314
+ )}
1315
+ <div
1316
+ className={open ? 'cmt-canvas-pin cmt-canvas-pin--open' : 'cmt-canvas-pin'}
1317
+ style={{ left: renderPoint.x, top: renderPoint.y }}
1318
+ >
1319
+ <div
1320
+ ref={markerRef}
1321
+ className="cmt-canvas-pin__marker"
1322
+ onPointerDown={startDrag}
1323
+ onPointerMove={onDrag}
1324
+ onPointerUp={endDrag}
1325
+ onPointerEnter={() => setPinHovered(true)}
1326
+ onPointerLeave={() => setPinHovered(false)}
1327
+ >
1328
+ <CommentPin resolved={thread.resolved != null} open={open}>
1329
+ {pinContent}
1330
+ </CommentPin>
1331
+ </div>
1332
+ {/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
1333
+ the pin itself stays in the canvas-in-front layer, beneath the UI. */}
1334
+ {open && (
1335
+ <ThreadPopover
1336
+ container={container}
1337
+ style={{ left: renderPoint.x + 36, top: renderPoint.y - 28 }}
1338
+ >
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
+ />
1368
+ </ThreadPopover>
1369
+ )}
1370
+ </div>
1371
+ </>
1372
+ )
1373
+ })
1374
+
1375
+ function PendingComposer({
1376
+ editor,
1377
+ pending,
1378
+ currentUserId,
1379
+ resolveName,
1380
+ onPostComment,
1381
+ getMentionSuggestions,
1382
+ renderMentionSuggestion,
1383
+ }: CanvasCommentsProps & { editor: Editor; pending: PendingComment }) {
1384
+ const [text, setText] = useState<TLRichText>(EMPTY_COMMENT)
1385
+ const ref = useRef<HTMLDivElement>(null)
1386
+ const msg = useTranslation()
1387
+ const container = useContainer()
1388
+ // Over this floating panel, scroll and hover reach the canvas (except where it scrolls itself).
1389
+ usePassThroughWheelEvents(ref)
1390
+ usePassThroughMouseOverEvents(ref)
1391
+
1392
+ const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
1393
+ editor,
1394
+ pending.point,
1395
+ ])
1396
+
1397
+ // Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
1398
+ useEffect(() => {
1399
+ const onPointerDown = (e: PointerEvent) => {
1400
+ const el = ref.current
1401
+ const target = e.target as HTMLElement | null
1402
+ if (!el || !target) return
1403
+ // A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
1404
+ // not "outside" — keep the draft open so the pick can insert.
1405
+ if (el.contains(target) || target.closest('.cmt-mention-popup')) return
1406
+ pendingComment.set(editor, null)
1407
+ }
1408
+ document.addEventListener('pointerdown', onPointerDown, true)
1409
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
1410
+ }, [editor])
1411
+
1412
+ const submit = () => {
1413
+ if (isCommentEmpty(text) || !currentUserId) return
1414
+ commitCommentMutation(editor, () => {
1415
+ const pageId = editor.getCurrentPageId()
1416
+ const thread = createCommentThread({
1417
+ pageId,
1418
+ anchor: pending.anchor,
1419
+ createdBy: currentUserId,
1420
+ })
1421
+ const comment = createComment({
1422
+ threadId: thread.id,
1423
+ pageId,
1424
+ authorId: currentUserId,
1425
+ body: text,
1426
+ })
1427
+ putCommentRecords(editor, [thread, comment])
1428
+ if (onPostComment) onPostComment(comment)
1429
+ })
1430
+ setText(EMPTY_COMMENT)
1431
+ pendingComment.set(editor, null)
1432
+ }
1433
+
1434
+ return createPortal(
1435
+ <div
1436
+ ref={ref}
1437
+ className="cmt-canvas-composer"
1438
+ style={{ left: point.x, top: point.y }}
1439
+ onPointerDown={stop}
1440
+ onKeyDown={(e) => {
1441
+ if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
1442
+ }}
1443
+ >
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
+ />
1457
+ </div>,
1458
+ container
1459
+ )
1460
+ }