@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02

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 (238) hide show
  1. package/commenting.css +63 -71
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +252 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +0 -1
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +77 -870
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +134 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +323 -202
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +12 -0
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +115 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +236 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +0 -1
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +54 -878
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +126 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +323 -202
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +12 -0
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +95 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +5 -5
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.tsx +8 -12
  189. package/src/canvas/comments-filter-menu.tsx +9 -16
  190. package/src/canvas/comments-overflow-menu.tsx +8 -3
  191. package/src/canvas/comments-overlay.tsx +154 -1344
  192. package/src/canvas/comments-sidebar.tsx +40 -19
  193. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  194. package/src/canvas/context.ts +4 -3
  195. package/src/canvas/hooks.test.ts +94 -0
  196. package/src/canvas/hooks.ts +17 -3
  197. package/src/canvas/license.ts +1 -1
  198. package/src/canvas/mobile-placement.ts +115 -0
  199. package/src/canvas/options.test.ts +124 -2
  200. package/src/canvas/options.ts +192 -58
  201. package/src/canvas/pending-composer.tsx +157 -0
  202. package/src/canvas/pin-stacking.test.ts +111 -1
  203. package/src/canvas/pin-stacking.ts +46 -0
  204. package/src/canvas/region-box.tsx +124 -0
  205. package/src/canvas/state.ts +20 -28
  206. package/src/canvas/thread-pin.tsx +418 -0
  207. package/src/canvas/thread-preview.tsx +77 -87
  208. package/src/canvas/thread-stack.tsx +19 -29
  209. package/src/canvas/thread-state.test.ts +51 -0
  210. package/src/canvas/thread-state.ts +56 -23
  211. package/src/canvas/thread-view.test.ts +72 -0
  212. package/src/canvas/thread-view.tsx +231 -133
  213. package/src/clustering/computeClusterTable.ts +12 -3
  214. package/src/clustering/replay.test.ts +0 -7
  215. package/src/clustering/replay.ts +131 -32
  216. package/src/clustering/runtime.test.ts +50 -6
  217. package/src/clustering/runtime.ts +42 -39
  218. package/src/clustering/schedule.test.ts +0 -6
  219. package/src/clustering/screen-offsets.test.ts +171 -0
  220. package/src/clustering/types.ts +10 -0
  221. package/src/index.ts +15 -2
  222. package/src/ui/byline.tsx +16 -6
  223. package/src/ui/comment-composer.tsx +25 -69
  224. package/src/ui/comment-pin.tsx +3 -16
  225. package/src/ui/comments-list.tsx +40 -29
  226. package/src/ui/comments.css +58 -60
  227. package/src/ui/format-time.test.ts +69 -0
  228. package/src/ui/format-time.ts +20 -0
  229. package/src/ui/icons.tsx +116 -0
  230. package/src/ui/reaction-picker.tsx +9 -16
  231. package/src/ui/reaction.tsx +10 -6
  232. package/src/ui/send-button.tsx +3 -8
  233. package/src/ui/visual-viewport.test.ts +36 -0
  234. package/src/ui/visual-viewport.ts +27 -0
  235. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  236. package/dist-esm/ui/tooltip-button.mjs +0 -12
  237. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  238. package/src/ui/tooltip-button.tsx +0 -20
@@ -1,86 +1,36 @@
1
- import { Avatar, isMentionPickerOpen } from '@tldraw/mentions'
2
- import {
3
- Fragment,
4
- memo,
5
- type PointerEvent as ReactPointerEvent,
6
- ReactNode,
7
- useCallback,
8
- useEffect,
9
- useLayoutEffect,
10
- useMemo,
11
- useRef,
12
- useState,
13
- } from 'react'
14
- import { createPortal } from 'react-dom'
15
- import {
16
- type BoxModel,
17
- createComment,
18
- createCommentThread,
19
- Editor,
20
- react,
21
- TLCommentThread,
22
- TLRichText,
23
- useContainer,
24
- useEditor,
25
- usePassThroughMouseOverEvents,
26
- usePassThroughWheelEvents,
27
- useTranslation,
28
- useValue,
29
- VecLike,
30
- } from 'tldraw'
31
- import { computeClusterTable } from '../clustering/computeClusterTable'
32
- import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
33
- import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
34
- import { CommentComposer } from '../ui/comment-composer'
35
- import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
36
- import { CommentPin } from '../ui/comment-pin'
37
- import { CountBadge } from '../ui/count-badge'
1
+ import { isMentionPickerOpen } from '@tldraw/mentions'
2
+ import { Fragment, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
3
+ import { EditorPortal, TLCommentThread, useEditor, useValue } from 'tldraw'
4
+ import type { ClusterNode } from '../clustering/types'
38
5
  import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
39
- import { collectClusterLeaves } from './cluster-input'
6
+ import { ClusterBadge } from './cluster-badge'
7
+ import { clusterFadeClassName } from './cluster-fade'
40
8
  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'
9
+ CLUSTER_EXPAND_ZOOM_MS,
10
+ revealThreadPin,
11
+ useClusterModel,
12
+ zoomToClusterSplit,
13
+ } from './cluster-model'
48
14
  import { getCommentRecord } from './comment-store'
49
- import { PendingComment } from './comment-tool'
50
15
  import { type CommentingContext } from './context'
51
- import { useCommentThreads, useThreadComments } from './hooks'
16
+ import { useCommentThreads } from './hooks'
52
17
  import { useCommentingEnabled } from './license'
53
- import {
54
- type CommentingOptions,
55
- getCommentingOptions,
56
- useCanComment,
57
- useCommentingOptions,
58
- } from './options'
59
- import { computePinStacks, pinStackKey } from './pin-stacking'
18
+ import { useCanComment, useCommentingOptions } from './options'
19
+ import { PendingComposer } from './pending-composer'
20
+ import { computePinStacks, isOpenStackKeyLive, pinStacksEqual } from './pin-stacking'
21
+ import { RegionBox, RegionDraftBox } from './region-box'
60
22
  import {
61
23
  commentsHidden,
62
24
  openStackId,
63
25
  openThreadId,
64
26
  pendingComment,
65
- regionDraft,
66
27
  revealThreadRequest,
67
28
  sidebarFilters,
68
29
  toggleCommentsHidden,
69
30
  usePendingComment,
70
31
  } from './state'
71
- import { ThreadPreview, sortThreadsForPreview, useMarkerPreview } from './thread-preview'
32
+ import { ThreadPin } from './thread-pin'
72
33
  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'
84
34
 
85
35
  /**
86
36
  * The host wiring for {@link CanvasComments} — see {@link CommentingContext}, which the sidebar
@@ -90,121 +40,43 @@ import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
90
40
  */
91
41
  export type CanvasCommentsProps = CommentingContext
92
42
 
93
- const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
94
-
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
-
110
- const CLUSTER_FADE_MS = 150
111
- /** Duration of the click-a-badge zoom-to-split animation. */
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
125
-
126
43
  /**
127
44
  * A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
128
45
  * thread popover (with a reply composer) on click, and shows a composer where the comment tool
129
46
  * placed a new thread. Reads/writes comment records straight from `editor.store`.
130
47
  *
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.
48
+ * It's the batteries-included default: every visible piece is a slot on
49
+ * `CommentTool.configure({ components })`, and the pieces it composes (`CommentPin`,
50
+ * `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a consumer can
51
+ * rebuild it from parts instead. The host wiring is the {@link CommentingContext}, which
52
+ * `CanvasCommentsSidebar` takes too.
139
53
  *
140
54
  * @public @react
141
55
  */
142
56
  export function CanvasComments(props: CanvasCommentsProps) {
143
- // Gate the whole layer on the license before doing any work. The inner component holds all the
144
- // other hooks, so mounting/unmounting it as the license resolves keeps hook order stable here.
57
+ // The inner component holds every other hook, so mounting it as the license resolves keeps hook
58
+ // order stable here.
145
59
  const commentingEnabled = useCommentingEnabled()
146
60
  if (!commentingEnabled) return null
147
61
  return <CanvasCommentsLayer {...props} />
148
62
  }
149
63
 
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
64
  function CanvasCommentsLayer(props: CommentingContext) {
182
65
  const editor = useEditor()
183
66
  const options = useCommentingOptions()
184
- const container = useContainer()
185
- const portalHost = useTrailingPortalHost(container)
186
- const layerRef = useRef<HTMLDivElement>(null)
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.
192
- usePassThroughMouseOverEvents(layerRef)
193
67
  const allThreads = useCommentThreads(editor)
194
68
  const pending = usePendingComment()
195
69
  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).
70
+ // Nothing renders a pending comment when composing is blocked and there's no fallback slot, and
71
+ // the dismiss handlers live inside PendingComposer so clear the atom rather than strand it.
200
72
  const canRenderComposer = canComment || options.components.ComposerFallback != null
201
73
  const showPendingComposer = pending != null && canRenderComposer
202
74
  useEffect(() => {
203
75
  if (pending && !showPendingComposer) pendingComment.set(editor, null)
204
76
  }, [editor, pending, showPendingComposer])
205
77
  const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
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.
78
+ // Matches the sidebar's `showResolved` filter. The open thread stays in — resolving from its own
79
+ // popover shouldn't make the pin vanish under it.
208
80
  const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
209
81
  editor,
210
82
  ])
@@ -213,175 +85,35 @@ function CanvasCommentsLayer(props: CommentingContext) {
213
85
  [allThreads, showResolved, openId]
214
86
  )
215
87
  useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
216
- // Threads held out of clustering because their anchor moved while folded inside a badge
217
- // (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
218
- // as live pins riding their anchor and rejoin clustering on the next zoom-out.
219
- const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
220
- const adoptOnRebuild = useRef(false)
221
- const clusterLeaves = useValue(
222
- 'comment cluster leaves',
223
- () =>
224
- collectClusterLeaves(
225
- editor,
226
- threads.filter((thread) => !heldThreadIds.has(thread.id)),
227
- openThreadId.get(editor)
228
- ),
229
- [editor, threads, heldThreadIds]
230
- )
231
- const clusterZoomBounds = useValue(
232
- 'comment cluster zoom bounds',
233
- () => getClusterZoomBounds(editor),
234
- [editor]
235
- )
236
- const latestModel = useMemo(() => {
237
- const table = computeClusterTable(clusterLeaves, clusterZoomBounds)
238
- const runtime = createClusterRuntime(table)
239
- runtime.seed(editor.getZoomLevel())
240
- return { runtime, table }
241
- }, [clusterLeaves, clusterZoomBounds, editor])
242
- // The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild
243
- // (add / move / delete / open / pop-out) is computed immediately as `latestModel` — the MST
244
- // stays correct — but the on-screen partition is `renderedModel`, and it only ever changes via
245
- // (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
246
- // (c) LOCAL detach patches: a leaf that left the input (deleted, opened, popped out) is
247
- // detached from its own badge in place — count and centroid update for that badge alone,
248
- // and nothing else on the canvas moves.
249
- const [renderedModel, setRenderedModel] = useState(latestModel)
250
- let clusterModel = renderedModel
251
- // A page switch replaces the whole scene: hard-reset rather than detach the world.
252
- const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
253
- const pageRef = useRef(pageId)
254
- if (pageRef.current !== pageId) {
255
- pageRef.current = pageId
256
- adoptOnRebuild.current = false
257
- latestModel.runtime.seed(editor.getZoomLevel())
258
- if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
259
- setRenderedModel(latestModel)
260
- clusterModel = latestModel
261
- }
262
- // adoptOnRebuild is set by the rejoin reaction below, outside React's render cycle, paired
263
- // with clearing heldThreadIds. Only trust it once that pairing is actually visible here
264
- // (heldThreadIds confirmed empty) — an unrelated re-render can land in the gap between the
265
- // ref being set and the state update it was paired with being applied.
266
- const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
267
- if (renderedModel !== latestModel && rejoinPending) {
268
- adoptOnRebuild.current = false
269
- // Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
270
- // nothing changes state because of the swap alone. Idempotent, so safe during render.
271
- latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
272
- setRenderedModel(latestModel)
273
- clusterModel = latestModel
274
- } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
275
- // Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
276
- // survive to force-adopt a later, unrelated rebuild.
277
- adoptOnRebuild.current = false
278
- }
279
- // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
280
- // is baked into the model), so when its live position drifts from the baked one, hold it out.
281
- // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
282
- const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
283
- if (newlyMovedIds.length > 0) {
284
- const next = new Set(heldThreadIds)
285
- for (const id of newlyMovedIds) next.add(id)
286
- setHeldThreadIds(next)
287
- }
288
- // Local partition maintenance — the only non-zoom visual change, and it is local by
289
- // construction: any displayed leaf that has left the cluster input (deleted, thread opened,
290
- // popped out above) is detached from its badge in place. The corrected rebuild is already
291
- // sitting in latestModel awaiting the next zoom-out.
292
- {
293
- const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
294
- for (const leaf of clusterModel.table.leaves) {
295
- if (!latestLeafIds.has(leaf.id)) {
296
- clusterModel.runtime.detachLeaf(leaf.id)
297
- }
298
- }
299
- }
300
- // Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
301
- // includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
302
- // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
303
- useEffect(() => {
304
- if (heldThreadIds.size === 0) return
305
- let lastZoom = editor.getZoomLevel()
306
- return react('rejoin moved comment pins on zoom out', () => {
307
- const zoom = editor.getZoomLevel()
308
- const prevZoom = lastZoom
309
- lastZoom = zoom
310
- if (zoom >= prevZoom) return
311
- adoptOnRebuild.current = true
312
- setHeldThreadIds(EMPTY_SET)
313
- })
314
- }, [heldThreadIds, editor])
315
- // Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
316
- // a merge, and merging only happens while zooming out. While zooming in, the stale table still
317
- // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
318
- useEffect(() => {
319
- if (clusterModel === latestModel) return
320
- let lastZoom = editor.getZoomLevel()
321
- return react('adopt pending cluster model on zoom out', () => {
322
- const zoom = editor.getZoomLevel()
323
- const prevZoom = lastZoom
324
- lastZoom = zoom
325
- if (zoom >= prevZoom) return
326
- latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
327
- setRenderedModel(latestModel)
328
- })
329
- }, [clusterModel, latestModel, editor])
330
- // Threads in the current input that the displayed partition doesn't show anywhere (new
331
- // comments, reopened threads, undone deletions): render as plain pins until the next
332
- // zoom-out folds them in. Membership is judged against the *displayed* partition (with
333
- // detaches applied), not the rendered table, so a detached-then-restored leaf reappears.
334
- const partitionVersion = clusterModel.runtime.version
335
- const orphanThreads = useMemo(() => {
336
- if (clusterModel === latestModel) return []
337
- const displayed = new Set<string>()
338
- for (const node of clusterModel.runtime.getVisible().values()) {
339
- for (const member of node.members) displayed.add(member)
340
- }
341
- const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
342
- return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
343
- // The runtime mutates its partition in place; partitionVersion is its change stamp.
344
- // eslint-disable-next-line react-hooks/exhaustive-deps
345
- }, [clusterModel, latestModel, threads, partitionVersion])
346
- const heldThreads = useMemo(
347
- () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
348
- [threads, heldThreadIds, openId]
349
- )
350
- // Subscribe to the runtime's partition version, not the raw zoom: onCamera runs on every zoom
351
- // tick (O(1) threshold checks) but the version only moves when the partition actually changes
352
- // — so this component only re-renders on cluster changes, not on every camera frame. The memo
353
- // below keys on a fresh inline read of the version rather than the subscribed value, because
354
- // render-time detaches (above) bump it after the subscription's computed already evaluated.
355
- useValue(
356
- 'comment cluster version',
357
- () => {
358
- clusterModel.runtime.onCamera(editor.getZoomLevel())
359
- return clusterModel.runtime.version
360
- },
361
- [clusterModel, editor]
362
- )
363
- const visibleNodes = useMemo(() => {
364
- return Array.from(clusterModel.runtime.getVisible().values())
365
- // The runtime mutates its partition in place; partitionVersion is its change stamp.
366
- // eslint-disable-next-line react-hooks/exhaustive-deps
367
- }, [clusterModel, partitionVersion])
368
- const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
88
+ const {
89
+ model: clusterModel,
90
+ zoomBounds: clusterZoomBounds,
91
+ fadeNodes,
92
+ orphanThreads,
93
+ heldThreads,
94
+ } = useClusterModel(editor, threads, openId)
369
95
  const threadsById = useMemo(
370
96
  () => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
371
97
  [threads]
372
98
  )
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
- ])
99
+ // Pins with the *same* anchor point coincide at every zoom, so they render as one count-badge
100
+ // stack. Keyed on page-space anchors, so camera moves never recompute this. Holding the map's
101
+ // identity while the grouping is unchanged keeps a reply from re-rendering every pin but the
102
+ // map has no positions in it, so anything needing a stack's *point* must read the anchors itself.
103
+ const pinStacksRef = useRef<Map<string, readonly string[]>>(new Map())
104
+ const pinStacks = useValue(
105
+ 'comment pin stacks',
106
+ () => {
107
+ const stacks = computePinStacks(editor, threads)
108
+ if (pinStacksEqual(pinStacksRef.current, stacks)) return pinStacksRef.current
109
+ pinStacksRef.current = stacks
110
+ return stacks
111
+ },
112
+ [editor, threads]
113
+ )
380
114
  const openThread = openId ? threadsById.get(openId) : null
381
115
  const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
382
116
 
383
- // Reset the transient UI state (open thread, open stack, half-placed comment, unserved reveal)
384
- // when this unmounts.
385
117
  useEffect(() => {
386
118
  return () => {
387
119
  openThreadId.set(editor, null)
@@ -391,23 +123,22 @@ function CanvasCommentsLayer(props: CommentingContext) {
391
123
  }
392
124
  }, [editor])
393
125
 
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.
126
+ // Clear a stale open-stack key: only the stack's own mounted handlers clear it, so collapsing to
127
+ // a single pin strands it — and `useMarkerPreview` reads any non-null value as "a stack is open"
128
+ // and suppresses every hover preview. Kept while any live stack still sits at that key. Reads the
129
+ // anchors rather than keying off `pinStacks`, whose identity survives a stack moving as a whole.
130
+ const openStackKeyIsStale = useValue(
131
+ 'open stack key stale',
132
+ () => {
133
+ const key = openStackId.get(editor)
134
+ if (!key) return false
135
+ return !isOpenStackKeyLive(editor, key, pinStacks, threadsById)
136
+ },
137
+ [editor, pinStacks, threadsById]
138
+ )
400
139
  useEffect(() => {
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
408
- }
409
- openStackId.set(editor, null)
410
- }, [editor, pinStacks, threadsById])
140
+ if (openStackKeyIsStale) openStackId.set(editor, null)
141
+ }, [editor, openStackKeyIsStale])
411
142
 
412
143
  // The requested thread, once it (and, for a comment id, its parent thread) has synced into the
413
144
  // store; null while records are still arriving or when no request is pending.
@@ -425,10 +156,8 @@ function CanvasCommentsLayer(props: CommentingContext) {
425
156
  [editor]
426
157
  )
427
158
 
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.
159
+ // Serve a pending reveal request, zooming to the first cluster split that reveals the pin if it's
160
+ // folded into a badge. Also unhides pins: the popover opens on the layer that hiding withholds.
432
161
  useEffect(() => {
433
162
  if (!requestedRevealThread) return
434
163
  revealThreadRequest.set(editor, null)
@@ -437,10 +166,8 @@ function CanvasCommentsLayer(props: CommentingContext) {
437
166
  openThreadId.set(editor, requestedRevealThread.id)
438
167
  }, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
439
168
 
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.
169
+ // Picking a thread out of a cluster's hover preview. `openThreadId` alone would work, but would
170
+ // cut straight there from the badge zoom in first, the same move the badge's own click makes.
444
171
  const revealClusteredThread = useCallback(
445
172
  (thread: TLCommentThread) => {
446
173
  revealThreadPin(
@@ -456,29 +183,15 @@ function CanvasCommentsLayer(props: CommentingContext) {
456
183
  [clusterModel.table, clusterZoomBounds, editor, options]
457
184
  )
458
185
 
459
- // Clicking a badge zooms to just past the zoom at which that cluster first unclusters,
460
- // centered on its centroid. The event that created a visible cluster is the event that splits
461
- // it, and (by the table's sort + monotone thresholds) it has the smallest zSplit of everything
462
- // applied inside it — so its zSplit is exactly the first split within those comments. The
463
- // animated zoom-in then drives the runtime cursor like any manual zoom, so the badge splits
464
- // (and can be drilled into further) with no extra bookkeeping.
465
- const zoomToClusterSplit = useCallback(
186
+ const expandCluster = useCallback(
466
187
  (node: ClusterNode) => {
467
- const event = clusterModel.table.events.find((e) => e.result.id === node.id)
468
- if (!event || !Number.isFinite(event.zSplit)) return
469
- const zoom = clamp(
470
- event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
471
- clusterZoomBounds.minZoom,
472
- clusterZoomBounds.maxZoom
473
- )
474
- centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
188
+ zoomToClusterSplit(editor, clusterModel.table, clusterZoomBounds, node)
475
189
  },
476
- [clusterModel, clusterZoomBounds, editor]
190
+ [clusterModel.table, clusterZoomBounds, editor]
477
191
  )
478
192
 
479
- // Escape collapses the open thread. Capture-phase + stopPropagation so it runs ahead of the
480
- // editor (which would otherwise cancel the current tool or clear the selection). If a comment is
481
- // being edited, let its own Escape handler exit edit mode first, keeping the thread open.
193
+ // Escape collapses the open thread. Capture-phase so it runs ahead of the editor, which would
194
+ // otherwise cancel the tool or clear the selection.
482
195
  useEffect(() => {
483
196
  const onKeyDown = (e: KeyboardEvent) => {
484
197
  if (e.key !== 'Escape' || openThreadId.get(editor) === null) return
@@ -494,8 +207,8 @@ function CanvasCommentsLayer(props: CommentingContext) {
494
207
  return () => document.removeEventListener('keydown', onKeyDown, true)
495
208
  }, [editor])
496
209
 
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.
210
+ // Shift+C toggles pin visibility. Physical `KeyC` so it's layout-independent, and skipped while
211
+ // typing so it never fires from inside a composer.
499
212
  useEffect(() => {
500
213
  const onKeyDown = (e: KeyboardEvent) => {
501
214
  if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
@@ -508,31 +221,26 @@ function CanvasCommentsLayer(props: CommentingContext) {
508
221
  return () => document.removeEventListener('keydown', onKeyDown, true)
509
222
  }, [editor])
510
223
 
511
- // Hidden: the whole canvas layer (pins, open popover, pending composer) is withheld. The signal
512
- // is read above so this component stays mounted and its shortcut/Escape effects keep running.
224
+ // The signal is read above so this stays mounted and its shortcut/Escape effects keep running.
513
225
  if (hidden) return null
514
226
 
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.
227
+ // The node's pin-stack group when every member shares one a stack standing on its own, which
228
+ // renders as a cascading count-badge list rather than a zoom-to-split cluster badge. The group
229
+ // can include an open or orphan member the node's own leaves omit.
524
230
  const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
525
231
  const group = pinStacks.get(node.members[0])
526
232
  if (!group) return null
527
233
  return node.members.every((id) => group.includes(id)) ? group : null
528
234
  }
529
235
 
236
+ // Which threads are on screen this render. A stack renders once, owned by its first on-screen
237
+ // member — members arrive by different paths, so ownership can't be decided per-path.
530
238
  const renderedThreadIds = new Set<string>()
531
239
  if (options.enableClustering) {
532
240
  for (const { node } of fadeNodes) {
533
241
  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.
242
+ // A pure-stack node's members aren't count-1 leaves, so register them here for the owner
243
+ // logic to pick from.
536
244
  else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
537
245
  }
538
246
  for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
@@ -557,977 +265,79 @@ function CanvasCommentsLayer(props: CommentingContext) {
557
265
  return <ThreadPin editor={editor} thread={thread} {...props} />
558
266
  }
559
267
 
560
- // Render into the container (above the panels' stacking context) so the pins and popovers
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
564
- return createPortal(
565
- <div ref={layerRef} className="tlui-cmt-canvas-layer">
566
- {options.enableClustering ? (
567
- <>
568
- {fadeNodes.map(({ node, phase }) => {
569
- let content: ReactNode
570
- const stackGroup = node.count > 1 ? stackGroupOf(node) : null
571
- if (node.count === 1) {
572
- const thread = threadsById.get(node.id)
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 {
586
- content = (
587
- <ClusterBadge
588
- editor={editor}
589
- node={node}
590
- onExpand={zoomToClusterSplit}
591
- onSelectThread={revealClusteredThread}
592
- threadsById={threadsById}
593
- currentUserId={props.currentUserId}
594
- resolveAuthor={props.resolveAuthor}
595
- />
596
- )
597
- }
598
- return (
599
- <div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
600
- {content}
601
- </div>
602
- )
603
- })}
604
- {orphanThreads.map((thread) => (
605
- <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
606
- ))}
607
- {heldThreads.map((thread) => (
608
- <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
609
- ))}
610
- </>
611
- ) : (
612
- // Clustering off: every thread renders as its own live pin (each returns null when it's
613
- // not on the current page or its anchor is missing). The open thread is excluded here and
614
- // rendered once below, mirroring how the clustering path keeps it out of the cluster leaves —
615
- // otherwise it would mount a second, stacked pin.
616
- threads
617
- .filter((thread) => thread.id !== openId)
618
- .map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
619
- )}
620
- {openThread && (
621
- <Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
622
- )}
623
- <RegionDraftBox editor={editor} />
624
- {/* Keep the region visible while composing — the drag draft is gone by now, and no thread
625
- exists yet, so the pending anchor is what shows the area under the open composer. */}
626
- {pending?.anchor.type === 'region' && showPendingComposer && (
627
- <RegionBox editor={editor} box={pending.anchor} />
628
- )}
629
- {pending && showPendingComposer && (
630
- <PendingComposer editor={editor} pending={pending} {...props} />
631
- )}
632
- </div>,
633
- portalHost
634
- )
635
- }
636
-
637
- const EMPTY_SET: ReadonlySet<string> = new Set()
638
- const MOVED_LEAF_EPSILON = 1e-6
639
- type ClusterFadePhase = 'entering' | 'present' | 'exiting'
640
-
641
- interface ClusterFadeNode {
642
- node: ClusterNode
643
- phase: ClusterFadePhase
644
- }
645
-
646
- function useFadeVisibleNodes(
647
- nodes: readonly ClusterNode[],
648
- resetKey: { runtime: ClusterRuntime; table: ClusterTable }
649
- ): ClusterFadeNode[] {
650
- const resetKeyRef = useRef(resetKey)
651
- const didReset = resetKeyRef.current !== resetKey
652
- if (didReset) {
653
- resetKeyRef.current = resetKey
654
- }
655
-
656
- const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
657
- const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
658
-
659
- useEffect(() => {
660
- setFadeNodes(toPresentFadeNodes(nodes))
661
- // Resets only on a new model (resetKey); node-list changes within the same model are
662
- // handled by the reconcile effect below, which fades entries in/out instead of snapping.
663
- // eslint-disable-next-line react-hooks/exhaustive-deps
664
- }, [resetKey])
665
-
666
- useEffect(() => {
667
- if (didReset) return
668
- setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
669
- }, [didReset, nodes])
670
-
671
- const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
672
- useEffect(() => {
673
- if (!hasEntering) return
674
- const frame = requestClusterFadeFrame(() => {
675
- setFadeNodes((previous) =>
676
- previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
677
- )
678
- })
679
- return () => cancelClusterFadeFrame(frame)
680
- }, [hasEntering, renderedNodes])
681
-
682
- const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
683
- useEffect(() => {
684
- if (!hasExiting) return
685
- const timeout = window.setTimeout(() => {
686
- setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
687
- }, CLUSTER_FADE_MS)
688
- return () => window.clearTimeout(timeout)
689
- }, [hasExiting, renderedNodes])
690
-
691
- return renderedNodes
692
- }
693
-
694
- function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
695
- return nodes.map((node) => ({ node, phase: 'present' }))
696
- }
697
-
698
- function reconcileFadeNodes(
699
- previous: readonly ClusterFadeNode[],
700
- nextNodes: readonly ClusterNode[]
701
- ): ClusterFadeNode[] {
702
- const previousById = new Map(previous.map((item) => [item.node.id, item]))
703
- const nextIds = new Set(nextNodes.map((node) => node.id))
704
- const next: ClusterFadeNode[] = []
705
-
706
- for (const node of nextNodes) {
707
- const previousItem = previousById.get(node.id)
708
- next.push({
709
- node,
710
- phase:
711
- previousItem && previousItem.phase !== 'exiting'
712
- ? previousItem.phase
713
- : previousItem
714
- ? 'present'
715
- : 'entering',
716
- })
717
- }
718
-
719
- for (const item of previous) {
720
- if (nextIds.has(item.node.id)) continue
721
- next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
722
- }
723
-
724
- return next
725
- }
726
-
727
- function requestClusterFadeFrame(callback: FrameRequestCallback): number {
728
- if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback)
729
- return window.setTimeout(() => callback(0), 16)
730
- }
731
-
732
- function cancelClusterFadeFrame(frame: number) {
733
- if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frame)
734
- else window.clearTimeout(frame)
735
- }
736
-
737
- function clusterFadeClassName(phase: ClusterFadePhase): string {
738
- return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
739
- }
740
-
741
- /**
742
- * Leaves folded inside a badge whose live anchor no longer matches the position the rendered
743
- * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
744
- * stay deferred; a badge can't follow a member, so these must pop out of clustering.
745
- */
746
- function findMovedClusteredLeafIds(
747
- rendered: { runtime: ClusterRuntime; table: ClusterTable },
748
- latest: { table: ClusterTable }
749
- ): string[] {
750
- if (rendered.table === latest.table) return []
751
- const visible = rendered.runtime.getVisible()
752
- const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
753
- const moved: string[] = []
754
- for (const leaf of rendered.table.leaves) {
755
- if (visible.has(leaf.id)) continue
756
- const current = latestById.get(leaf.id)
757
- if (!current) continue
758
- if (
759
- Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
760
- Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
761
- ) {
762
- moved.push(leaf.id)
763
- }
764
- }
765
- return moved
766
- }
767
-
768
- function getClusterZoomBounds(editor: Editor): { minZoom: number; maxZoom: number } {
769
- const cameraOptions = editor.getCameraOptions()
770
- const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
771
- const zoomSteps = cameraOptions.zoomSteps
772
- return {
773
- minZoom: zoomSteps[0] * baseZoom,
774
- maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
775
- }
776
- }
777
-
778
- function revealThreadPin(
779
- editor: Editor,
780
- thread: TLCommentThread,
781
- table: ClusterTable,
782
- zoomBounds: { minZoom: number; maxZoom: number },
783
- options: CommentingOptions,
784
- duration = 200
785
- ) {
786
- if (thread.pageId !== editor.getCurrentPageId()) {
787
- editor.setCurrentPage(thread.pageId as any)
788
- }
789
-
790
- const point = anchorPagePoint(editor, thread.anchor)
791
- if (!point) return
792
-
793
- // With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
794
- // badge never exists) and just center on the pin.
795
- if (options.enableClustering) {
796
- const parentEvent = findDirectParentEvent(table, thread.id)
797
- if (
798
- parentEvent &&
799
- Number.isFinite(parentEvent.zSplit) &&
800
- parentEvent.zSplit <= zoomBounds.maxZoom
801
- ) {
802
- const zoom = clamp(
803
- parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
804
- zoomBounds.minZoom,
805
- zoomBounds.maxZoom
806
- )
807
- centerOnPointAtZoom(editor, point, zoom, duration)
808
- return
809
- }
810
- }
811
-
812
- const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
813
- editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
814
- }
815
-
816
- function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
817
- return table.events.find((event) => event.children.some((child) => child.id === threadId))
818
- }
819
-
820
- function centerOnPointAtZoom(
821
- editor: Editor,
822
- point: { x: number; y: number },
823
- zoom: number,
824
- duration = 200
825
- ) {
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)
829
- editor.setCamera(
830
- {
831
- x: (viewport.w / 2 - offset) / zoom - point.x,
832
- y: viewport.h / (2 * zoom) - point.y,
833
- z: zoom,
834
- },
835
- { animation: { duration } }
836
- )
837
- }
838
-
839
- function clamp(value: number, min: number, max: number): number {
840
- return Math.max(min, Math.min(max, value))
841
- }
842
-
843
- // Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
844
- // badges skip re-rendering when the parent re-renders for reasons that don't concern them
845
- // (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
846
- // works — each component subscribes to its own viewport position via signals, not via props.
847
- const ClusterBadge = memo(function ClusterBadge({
848
- editor,
849
- node,
850
- onExpand,
851
- onSelectThread,
852
- threadsById,
853
- ...props
854
- }: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
855
- editor: Editor
856
- node: ClusterNode
857
- onExpand(node: ClusterNode): void
858
- onSelectThread(thread: TLCommentThread): void
859
- threadsById: ReadonlyMap<string, TLCommentThread>
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)
868
- const point = useValue(
869
- 'cluster badge point',
870
- () => {
871
- const pagePoint = editor.pageToViewport(node.centroid)
872
- if (!isInInflatedViewport(editor, pagePoint)) return null
873
- return pagePoint
874
- },
875
- [editor, node]
876
- )
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
-
890
- if (!point) return null
891
-
892
- return (
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
- </>
930
- )
931
- })
932
-
933
- function isInInflatedViewport(editor: Editor, point: { x: number; y: number }): boolean {
934
- const viewport = editor.getViewportScreenBounds()
935
- const margin = CLUSTER_CULL_MARGIN_PX
936
- return (
937
- point.x >= -margin &&
938
- point.y >= -margin &&
939
- point.x <= viewport.w + margin &&
940
- point.y <= viewport.h + margin
941
- )
942
- }
943
-
944
- /** A dashed rectangle over a region anchor's bounds, in viewport space. Sits in the canvas layer as
945
- * a sibling of the pins. `pointer-events` stays off (canvas interaction passes through) unless
946
- * `movable`, in which case dragging the body translates the region — previews live, commits on drop. */
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 }) {
950
- const rect = useValue(
951
- 'region rect',
952
- () => {
953
- // Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
954
- const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
955
- const zoom = editor.getZoomLevel()
956
- return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
957
- },
958
- [editor, box.x, box.y, box.w, box.h]
959
- )
960
- return <div className="tlui-cmt-canvas-region" style={rect} />
961
- }
962
-
963
- /** The live region being dragged out by the comment tool, or nothing when not dragging. */
964
- function RegionDraftBox({ editor }: { editor: Editor }) {
965
- const box = useValue('region draft', () => regionDraft.get(editor), [editor])
966
- if (!box) return null
967
- return <RegionBox editor={editor} box={box} />
968
- }
969
-
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.
972
- interface RegionHandle {
973
- x: number
974
- y: number
975
- cursor: string
976
- }
977
-
978
- // The four corners, each resizing both axes.
979
- const REGION_CORNERS: readonly RegionHandle[] = [
980
- { x: 0, y: 0, cursor: 'nwse-resize' },
981
- { x: 1, y: 0, cursor: 'nesw-resize' },
982
- { x: 0, y: 1, cursor: 'nesw-resize' },
983
- { x: 1, y: 1, cursor: 'nwse-resize' },
984
- ]
985
-
986
- // Screen-space slack around a region's bounds within which its box and handles stay revealed, so
987
- // the handles (which sit on the edge) are comfortably reachable.
988
- const REGION_HANDLE_MARGIN_PX = 12
989
-
990
- /** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
991
- * handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
992
- * handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
993
- function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
994
- const controlsX = handle.x !== 0.5
995
- const controlsY = handle.y !== 0.5
996
- const fixedX = box.x + (1 - handle.x) * box.w
997
- const fixedY = box.y + (1 - handle.y) * box.h
998
- return {
999
- x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
1000
- y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
1001
- w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
1002
- h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
1003
- }
1004
- }
1005
-
1006
- /** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
1007
- * option. Previews live, commits on release. */
1008
- function RegionResizeHandles({
1009
- editor,
1010
- box,
1011
- handles,
1012
- onPreview,
1013
- onCommit,
1014
- }: {
1015
- editor: Editor
1016
- box: BoxModel
1017
- handles: readonly RegionHandle[]
1018
- onPreview(bounds: BoxModel | null): void
1019
- onCommit(bounds: BoxModel): void
1020
- }) {
1021
- // The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
1022
- // the fixed edges mid-drag.
1023
- const boxRef = useRef<BoxModel | null>(null)
1024
- const points = useValue(
1025
- 'region handle points',
1026
- () =>
1027
- handles.map((h) => {
1028
- const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
1029
- return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
1030
- }),
1031
- [editor, box.x, box.y, box.w, box.h, handles]
1032
- )
1033
- const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
1034
- e.stopPropagation()
1035
- boxRef.current = box
1036
- e.currentTarget.setPointerCapture(e.pointerId)
1037
- }
1038
- const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
1039
- resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
1040
- const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
1041
- if (boxRef.current) onPreview(resizedTo(h, e))
1042
- }
1043
- const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
1044
- if (!boxRef.current) return
1045
- const bounds = resizedTo(h, e)
1046
- boxRef.current = null
1047
- if (e.currentTarget.hasPointerCapture(e.pointerId))
1048
- e.currentTarget.releasePointerCapture(e.pointerId)
1049
- onCommit(bounds)
1050
- }
1051
- return (
1052
- <>
1053
- {points.map((h) => (
1054
- <div
1055
- key={h.key}
1056
- className="tlui-cmt-canvas-region-handle"
1057
- style={{ left: h.left, top: h.top, cursor: h.cursor }}
1058
- onPointerDown={startResize}
1059
- onPointerMove={onResize(h)}
1060
- onPointerUp={endResize(h)}
1061
- />
1062
- ))}
1063
- </>
1064
- )
1065
- }
1066
-
1067
- const ThreadPin = memo(function ThreadPin({
1068
- editor,
1069
- thread,
1070
- ...props
1071
- }: CommentingContext & {
1072
- editor: Editor
1073
- thread: TLCommentThread
1074
- }) {
1075
- const { resolveAuthor } = props
1076
- const options = useCommentingOptions()
1077
- const canComment = useCanComment(props.currentUserId)
1078
- const container = useContainer()
1079
- const msg = useTranslation()
1080
- const comments = useThreadComments(editor, thread.id)
1081
- // Only one thread's popover is open at a time — shared across pins via the atom.
1082
- const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
1083
- editor,
1084
- thread.id,
1085
- ])
1086
- // While dragging the marker, its page point overrides the anchor's; committed on drop.
1087
- const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
1088
- // The live bounds while a corner handle is resizing the region, else null.
1089
- const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
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])
1093
- // The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
1094
- // Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
1095
- // handle never loses the affordance — the box stays `pointer-events: none`.
1096
- const pointerInRegion = useValue(
1097
- 'pointer in region',
1098
- () => {
1099
- if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
1100
- return false
1101
- const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
1102
- const p = editor.inputs.getCurrentPagePoint()
1103
- const a = thread.anchor
1104
- 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
1105
- },
1106
- [editor, thread.anchor, thread.pageId]
1107
- )
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.
1116
- const resizeHandles = useMemo(
1117
- () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
1118
- [pinCorner]
1119
- )
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])
1142
-
1143
- // Clicking outside the open popover (and off its own pin) closes the thread — mirrors the
1144
- // pending composer's dismiss. Capture phase + a class check rather than stopPropagation, since the
1145
- // popover portals elsewhere in the DOM. The pin marker is excluded so its own click-to-toggle
1146
- // handles it instead of this closing then the toggle reopening.
1147
- useEffect(() => {
1148
- if (!open) return
1149
- const onPointerDown = (e: PointerEvent) => {
1150
- const target = e.target as HTMLElement | null
1151
- if (!target) return
1152
- if (target.closest('.tlui-cmt-canvas-popover')) return
1153
- const marker = markerRef.current
1154
- if (marker && marker.contains(target)) return
1155
- // A press on a region's resize handle or movable body edits this thread — don't dismiss it.
1156
- if (target.closest('.tlui-cmt-canvas-region-handle, .tlui-cmt-canvas-region--movable')) return
1157
- // A click inside a menu/popover layered above us (the sidebar's filter or overflow
1158
- // dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
1159
- // layer; defer to its own dismissal instead of closing the thread out from under it.
1160
- if (
1161
- target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
1162
- )
1163
- return
1164
- openThreadId.set(editor, null)
1165
- }
1166
- document.addEventListener('pointerdown', onPointerDown, true)
1167
- return () => document.removeEventListener('pointerdown', onPointerDown, true)
1168
- }, [open, editor])
1169
-
1170
- const point = useValue(
1171
- 'pin point',
1172
- () => {
1173
- if (thread.pageId !== editor.getCurrentPageId()) return 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
1179
- },
1180
- [editor, thread.anchor, thread.pageId]
1181
- )
1182
- if (!point) return null
1183
-
1184
- const PinContent = options.components.PinContent
1185
- // The `PinContent` component slot overrides the built-in author-avatar default.
1186
- const threadAuthor = resolveAuthor(thread.createdBy)
1187
- const pinContent = PinContent ? (
1188
- <PinContent thread={thread} comments={comments} />
1189
- ) : (
1190
- <Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
1191
- )
1192
- const pinLabel = msg(
1193
- thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
1194
- ).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
1195
-
1196
- // Drag the marker to move the thread: its position is overridden locally while dragging, then
1197
- // re-anchored on drop. A point/shape thread re-anchors to whatever it's dropped on (a shape, else
1198
- // a point); a region thread translates, keeping its size. A pointer that barely moves is a click —
1199
- // toggle the popover.
1200
- const isRegion = thread.anchor.type === 'region'
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
- }
1209
- e.stopPropagation()
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
- }
1227
- e.currentTarget.setPointerCapture(e.pointerId)
1228
- }
1229
- const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
1230
- const drag = dragRef.current
1231
- if (!drag) 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
1235
- if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
1236
- drag.moved = true
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
- }
1246
- }
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>) => {
1250
- const drag = dragRef.current
1251
- dragRef.current = null
1252
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
1253
- e.currentTarget.releasePointerCapture(e.pointerId)
1254
- }
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([])
1267
- if (!drag.moved) {
1268
- openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
1269
- return
1270
- }
1271
- const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
1272
- const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
1273
- setDragPagePoint(null)
1274
- let anchor: TLCommentThread['anchor']
1275
- if (thread.anchor.type === 'region') {
1276
- // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
1277
- anchor = {
1278
- ...thread.anchor,
1279
- x: pagePoint.x - pinCorner.x * thread.anchor.w,
1280
- y: pagePoint.y - pinCorner.y * thread.anchor.h,
1281
- }
1282
- } else {
1283
- const hit = commentTargetShapeAt(editor, pagePoint)
1284
- anchor = hit
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
- )
1295
- : { type: 'point', x: pagePoint.x, y: pagePoint.y }
1296
- }
1297
- commitCommentMutation(editor, () => putRecordsInCommit(editor, [{ ...thread, anchor }]), 'drag')
1298
- }
1299
-
1300
- // The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
1301
- // move to the drag point; otherwise it sits at the stored anchor's viewport 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
1309
-
1310
- // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
1311
- // corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
1312
- const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
1313
- const movedRegion =
1314
- regionAnchor && dragPagePoint
1315
- ? {
1316
- ...regionAnchor,
1317
- x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
1318
- y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
1319
- }
1320
- : regionAnchor
1321
- const regionBoxBounds = resizeBounds ?? movedRegion
1322
- const commitResize = (bounds: BoxModel) => {
1323
- setResizeBounds(null)
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'
1332
- )
1333
- }
1334
-
268
+ // Portalled rather than rendered into this component's slot: pins sit below the collaborator
269
+ // cursors and popovers above the UI panels, and no single canvas layer spans both. The portal
270
+ // also fixes where the layer lands among the container's children, keeping it behind the UI's
271
+ // skip link in the tab order.
1335
272
  return (
1336
- <>
1337
- {regionBoxBounds && (dragPagePoint || revealed) && (
1338
- <RegionBox editor={editor} box={regionBoxBounds} />
1339
- )}
1340
- {regionBoxBounds && revealed && !dragPagePoint && canComment && (
1341
- <RegionResizeHandles
1342
- editor={editor}
1343
- box={regionBoxBounds}
1344
- handles={resizeHandles}
1345
- onPreview={setResizeBounds}
1346
- onCommit={commitResize}
1347
- />
1348
- )}
1349
- <div
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(' ')}
1357
- style={{ left: renderPoint.x, top: renderPoint.y }}
1358
- >
1359
- <button
1360
- ref={markerRef}
1361
- type="button"
1362
- className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
1363
- aria-label={pinLabel}
1364
- aria-expanded={open}
1365
- onPointerDown={startDrag}
1366
- onPointerMove={onDrag}
1367
- onPointerUp={endDrag}
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}
1381
- >
1382
- <CommentPin resolved={thread.resolved != null} open={open}>
1383
- {pinContent}
1384
- </CommentPin>
1385
- </button>
1386
- {/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
1387
- the pin itself stays in the canvas-in-front layer, beneath the UI. */}
1388
- {open && (
1389
- <ThreadPopover
1390
- container={container}
1391
- style={{
1392
- left: renderPoint.x + POPOVER_OFFSET.thread.x,
1393
- top: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
1394
- }}
1395
- >
1396
- <ThreadView editor={editor} thread={thread} {...props} />
1397
- </ThreadPopover>
273
+ <EditorPortal>
274
+ {/* Wheel pass-through lives on each interactive element, not this root: the root spans the
275
+ whole canvas, so a pin past its bottom/right edge inflates scrollHeight and the wheel
276
+ hook's is-this-scrollable guard silently disables pass-through. */}
277
+ <div className="tlui-cmt-canvas-layer">
278
+ {options.enableClustering ? (
279
+ <>
280
+ {fadeNodes.map(({ node, phase }) => {
281
+ let content: ReactNode
282
+ const stackGroup = node.count > 1 ? stackGroupOf(node) : null
283
+ if (node.count === 1) {
284
+ const thread = threadsById.get(node.id)
285
+ if (!thread) return null
286
+ content = renderThreadPin(thread)
287
+ } else if (stackGroup) {
288
+ // Routed through the stack's owner so the open/orphan/held slots stay deduped:
289
+ // when the owner is one of them, that slot draws the stack and this draws nothing.
290
+ const owner = stackGroup.find((id) => renderedThreadIds.has(id))
291
+ content =
292
+ owner && node.members.includes(owner)
293
+ ? renderThreadPin(threadsById.get(owner)!)
294
+ : null
295
+ } else {
296
+ content = (
297
+ <ClusterBadge
298
+ editor={editor}
299
+ node={node}
300
+ onExpand={expandCluster}
301
+ onSelectThread={revealClusteredThread}
302
+ threadsById={threadsById}
303
+ currentUserId={props.currentUserId}
304
+ resolveAuthor={props.resolveAuthor}
305
+ />
306
+ )
307
+ }
308
+ return (
309
+ <div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
310
+ {content}
311
+ </div>
312
+ )
313
+ })}
314
+ {orphanThreads.map((thread) => (
315
+ <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
316
+ ))}
317
+ {heldThreads.map((thread) => (
318
+ <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
319
+ ))}
320
+ </>
321
+ ) : (
322
+ // Clustering off: every thread renders its own pin. The open thread is excluded and
323
+ // rendered once below, or it would mount a second, stacked pin.
324
+ threads
325
+ .filter((thread) => thread.id !== openId)
326
+ .map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
1398
327
  )}
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
- />
328
+ {openThread && (
329
+ <Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
330
+ )}
331
+ <RegionDraftBox editor={editor} />
332
+ {/* Keep the region visible while composing — the drag draft is gone by now, and no thread
333
+ exists yet, so the pending anchor is what shows the area under the open composer. */}
334
+ {pending?.anchor.type === 'region' && showPendingComposer && (
335
+ <RegionBox editor={editor} box={pending.anchor} />
336
+ )}
337
+ {pending && showPendingComposer && (
338
+ <PendingComposer editor={editor} pending={pending} {...props} />
1413
339
  )}
1414
340
  </div>
1415
- </>
1416
- )
1417
- })
1418
-
1419
- function PendingComposer({
1420
- editor,
1421
- pending,
1422
- currentUserId,
1423
- resolveAuthor,
1424
- onPostComment,
1425
- getMentionSuggestions,
1426
- renderMentionSuggestion,
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
- )
1442
- const ref = useRef<HTMLDivElement>(null)
1443
- const msg = useTranslation()
1444
- const container = useContainer()
1445
- // Over this floating panel, scroll and hover reach the canvas (except where it scrolls itself).
1446
- usePassThroughWheelEvents(ref)
1447
- usePassThroughMouseOverEvents(ref)
1448
-
1449
- const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
1450
- editor,
1451
- pending.point,
1452
- ])
1453
-
1454
- // Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
1455
- useEffect(() => {
1456
- const onPointerDown = (e: PointerEvent) => {
1457
- const el = ref.current
1458
- const target = e.target as HTMLElement | null
1459
- if (!el || !target) return
1460
- // A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
1461
- // not "outside" — keep the draft open so the pick can insert.
1462
- if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
1463
- pendingComment.set(editor, null)
1464
- }
1465
- document.addEventListener('pointerdown', onPointerDown, true)
1466
- return () => document.removeEventListener('pointerdown', onPointerDown, true)
1467
- }, [editor])
1468
-
1469
- const submit = () => {
1470
- if (isCommentEmpty(text) || !currentUserId) return
1471
- commitCommentMutation(editor, () => {
1472
- const pageId = editor.getCurrentPageId()
1473
- const thread = createCommentThread({
1474
- pageId,
1475
- anchor: pending.anchor,
1476
- createdBy: currentUserId,
1477
- })
1478
- const comment = createComment({
1479
- threadId: thread.id,
1480
- pageId,
1481
- authorId: currentUserId,
1482
- body: text,
1483
- })
1484
- putRecordsInCommit(editor, [thread, comment])
1485
- if (onPostComment) onPostComment(comment)
1486
- })
1487
- setText(EMPTY_COMMENT)
1488
- clearCommentDraft(NEW_COMMENT_DRAFT)
1489
- pendingComment.set(editor, null)
1490
- }
1491
-
1492
- return createPortal(
1493
- <div
1494
- ref={ref}
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(' ')}
1502
- style={{ left: point.x, top: point.y }}
1503
- onPointerDown={stop}
1504
- onContextMenu={stop}
1505
- onKeyDown={(e) => {
1506
- if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
1507
- }}
1508
- >
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
- )}
1530
- </div>,
1531
- container
341
+ </EditorPortal>
1532
342
  )
1533
343
  }