@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,8 +1,8 @@
1
1
  import { useEffect, useMemo, useRef, type CSSProperties } from 'react'
2
- import { createPortal } from 'react-dom'
3
2
  import {
4
3
  Editor,
5
4
  EditorAtom,
5
+ EditorPortal,
6
6
  TLComment,
7
7
  TLCommentThread,
8
8
  usePassThroughWheelEvents,
@@ -19,26 +19,21 @@ import { POPOVER_OFFSET, toCardProps, useResolveName } from './thread-view'
19
19
 
20
20
  /**
21
21
  * Hover previews for every canvas marker — a single pin, a coincident stack, or a cluster badge.
22
- * Hovering shows the thread(s) behind the marker as cards; the marker's own click keeps whatever
23
- * it already did (open the thread, open the stack list, zoom to the cluster's split).
22
+ * Hovering shows the thread(s) behind the marker as cards; the marker's own click is unchanged.
24
23
  *
25
- * The panel is live, not a passive tooltip: the pointer can travel right into it and hover each
26
- * card, and clicking one opens that thread — the same affordance the stack list gives its cards.
27
- * Two pieces make that work. The close delay survives the trip across the gap, and the panel
28
- * carries an invisible bridge over that gap (see `.tlui-cmt-canvas-preview::before`) so the
29
- * journey never crosses dead space and retracts the panel mid-move.
24
+ * The panel is live, not a passive tooltip: the pointer can travel into it and click a card to open
25
+ * that thread. Two pieces make that work — the close delay survives the trip across the gap, and
26
+ * the panel carries an invisible bridge over it (see `.tlui-cmt-canvas-preview::before`).
30
27
  *
31
- * Opening a thread from a card needs nothing more than setting `openThreadId`, including for a
32
- * thread currently folded inside a cluster badge: `collectClusterLeaves` skips the open thread, so
33
- * it drops out of its badge and renders its own pin and popover.
28
+ * Opening a thread from a card needs nothing more than setting `openThreadId`, even for one folded
29
+ * inside a badge: `collectClusterLeaves` skips the open thread, so it drops out and renders itself.
34
30
  */
35
31
 
36
32
  /** How long the pointer must rest on a marker before its preview appears. */
37
33
  const PREVIEW_OPEN_DELAY_MS = 180
38
34
  /**
39
- * Grace period after the pointer leaves the marker *or* the panel. Long enough to cross the gap
40
- * between them by hand — the bridge element covers that gap geometrically, and this covers the
41
- * moment of transit between the two elements' enter/leave events.
35
+ * Grace period after the pointer leaves the marker *or* the panel long enough to cross the gap
36
+ * between them, which the bridge element covers geometrically.
42
37
  */
43
38
  const PREVIEW_CLOSE_DELAY_MS = 220
44
39
  /** Cards shown before the panel falls back to a "+N more" line. */
@@ -53,9 +48,8 @@ export interface ThreadPreviewCard {
53
48
  /**
54
49
  * The cards a marker's preview will show, and how many threads it will summarise as "+N more".
55
50
  *
56
- * A thread can exist before its opening comment does — a collaborator's, mid-sync and has nothing
57
- * to preview until it arrives. Those are dropped here rather than rendered as blank cards, and they
58
- * don't count toward the overflow tally either, so "+2 more" always means two readable threads.
51
+ * A thread can exist before its opening comment does — a collaborator's, mid-sync. Those are
52
+ * dropped rather than rendered blank, and don't count toward the overflow tally.
59
53
  */
60
54
  export function selectPreviewCards(
61
55
  threads: readonly TLCommentThread[],
@@ -81,17 +75,15 @@ export type ThreadPreviewVariant = 'thread' | 'list'
81
75
 
82
76
  /**
83
77
  * Which marker's preview is showing, or null. One atom for the whole layer, so previews are
84
- * mutually exclusive by construction: the close delay means an outgoing marker's timer can still
85
- * be pending when the next marker opens, and per-component state would briefly show both.
78
+ * mutually exclusive: the close delay can leave an outgoing marker's timer pending when the next
79
+ * opens, and per-component state would briefly show both.
86
80
  */
87
81
  const hoveredMarkerId = new EditorAtom<string | null>('commentHoveredMarkerId', () => null)
88
82
 
89
83
  /**
90
84
  * Hover state for one marker. Returns whether its preview should render, plus the pointer handlers
91
- * to spread onto the marker element.
92
- *
93
- * `markerId` must be stable and unique per marker across the layer — prefix by kind, since a stack
94
- * is keyed by its oldest member's thread id and would otherwise collide with that thread's own pin.
85
+ * to spread onto the marker element. `markerId` must be stable and unique per marker across the
86
+ * layer — prefix by kind, or a stack collides with its oldest member's own pin.
95
87
  */
96
88
  export function useMarkerPreview(editor: Editor, markerId: string) {
97
89
  const openTimer = useRef(0)
@@ -154,14 +146,12 @@ export function useMarkerPreview(editor: Editor, markerId: string) {
154
146
  }
155
147
 
156
148
  /**
157
- * The hover panel: each thread's opening comment as a read-only card, capped with a "+N more"
158
- * line. Mounted only while hovering, so the store subscription it needs to find those comments
159
- * costs nothing at rest.
149
+ * The hover panel: each thread's opening comment as a read-only card, capped with a "+N more" line.
150
+ * Mounted only while hovering, so its store subscription costs nothing at rest.
160
151
  */
161
152
  export function ThreadPreview({
162
153
  editor,
163
154
  threads,
164
- container,
165
155
  variant,
166
156
  point,
167
157
  onSelectThread,
@@ -172,7 +162,6 @@ export function ThreadPreview({
172
162
  editor: Editor
173
163
  /** The marker's threads, in the order they should read (oldest first). */
174
164
  threads: readonly TLCommentThread[]
175
- container: HTMLElement
176
165
  /** What the marker's click opens, which this panel imitates. */
177
166
  variant: ThreadPreviewVariant
178
167
  /** The marker's anchor point in viewport space — the same origin its popover is placed from. */
@@ -231,68 +220,69 @@ export function ThreadPreview({
231
220
  .filter(Boolean)
232
221
  .join(' ')
233
222
 
234
- return createPortal(
235
- // The root is the hover region — it carries the bridge back to the marker — while the panel
236
- // inside it is the visible surface. Keeping them apart is what lets the surface light up on
237
- // its own hover without the bridge (which reaches back over the marker) lighting it up too.
238
- <div
239
- ref={ref}
240
- className={`tlui-cmt-canvas-preview tlui-cmt-canvas-preview--${variant}`}
241
- style={
242
- {
243
- left: point.x + offset.x,
244
- top: point.y + offset.y,
245
- // The stylesheet sizes the hover bridge against this, so the gap it spans follows
246
- // the offset rather than being restated as a second magic number.
247
- '--tlui-cmt-preview-offset': `${offset.x}px`,
248
- } as CSSProperties
249
- }
250
- onPointerEnter={onPointerEnter}
251
- onPointerLeave={onPointerLeave}
252
- // The panel sits over the canvas; a press on it is not a canvas press.
253
- onPointerDown={(e) => e.stopPropagation()}
254
- >
255
- <div className={panelClass}>
256
- {cards.map(({ thread, first }) => {
257
- // The preview shows only the opening comment, so its reply count is what tells the
258
- // reader the thread continues past what they see.
259
- const replies = replyCountLabel(msg, (countByThread.get(thread.id) ?? 1) - 1)
260
- return (
261
- <div
262
- key={thread.id}
263
- className={
264
- isThread
265
- ? undefined
266
- : onSelectThread
267
- ? 'tlui-cmt-preview-card tlui-cmt-preview-card--selectable'
268
- : 'tlui-cmt-preview-card'
269
- }
270
- onClick={
271
- onSelectThread
272
- ? (e) => {
273
- e.stopPropagation()
274
- onSelectThread(thread)
275
- }
276
- : undefined
277
- }
278
- >
279
- <CommentCard
280
- {...toCardProps(first, props, options.components, resolveName)}
281
- footer={
282
- replies ? <span className="tlui-cmt-card__replies">{replies}</span> : undefined
223
+ return (
224
+ <EditorPortal>
225
+ {/* The root is the hover region it carries the bridge back to the marker while the panel
226
+ inside it is the visible surface. Keeping them apart is what lets the surface light up on
227
+ its own hover without the bridge (which reaches back over the marker) lighting it up too. */}
228
+ <div
229
+ ref={ref}
230
+ className={`tlui-cmt-canvas-preview tlui-cmt-canvas-preview--${variant}`}
231
+ style={
232
+ {
233
+ left: point.x + offset.x,
234
+ top: point.y + offset.y,
235
+ // The stylesheet sizes the hover bridge against this, so the gap it spans follows
236
+ // the offset rather than being restated as a second magic number.
237
+ '--tlui-cmt-preview-offset': `${offset.x}px`,
238
+ } as CSSProperties
239
+ }
240
+ onPointerEnter={onPointerEnter}
241
+ onPointerLeave={onPointerLeave}
242
+ // The panel sits over the canvas; a press on it is not a canvas press.
243
+ onPointerDown={(e) => e.stopPropagation()}
244
+ >
245
+ <div className={panelClass}>
246
+ {cards.map(({ thread, first }) => {
247
+ // The preview shows only the opening comment, so its reply count is what tells the
248
+ // reader the thread continues past what they see.
249
+ const replies = replyCountLabel(msg, (countByThread.get(thread.id) ?? 1) - 1)
250
+ return (
251
+ <div
252
+ key={thread.id}
253
+ className={
254
+ isThread
255
+ ? undefined
256
+ : onSelectThread
257
+ ? 'tlui-cmt-preview-card tlui-cmt-preview-card--selectable'
258
+ : 'tlui-cmt-preview-card'
259
+ }
260
+ onClick={
261
+ onSelectThread
262
+ ? (e) => {
263
+ e.stopPropagation()
264
+ onSelectThread(thread)
265
+ }
266
+ : undefined
283
267
  }
284
- />
268
+ >
269
+ <CommentCard
270
+ {...toCardProps(first, props, options.components, resolveName)}
271
+ footer={
272
+ replies ? <span className="tlui-cmt-card__replies">{replies}</span> : undefined
273
+ }
274
+ />
275
+ </div>
276
+ )
277
+ })}
278
+ {overflow > 0 && (
279
+ <div className="tlui-cmt-preview-more">
280
+ {msg('comments.preview-more').replace('{count}', String(overflow))}
285
281
  </div>
286
- )
287
- })}
288
- {overflow > 0 && (
289
- <div className="tlui-cmt-preview-more">
290
- {msg('comments.preview-more').replace('{count}', String(overflow))}
291
- </div>
292
- )}
282
+ )}
283
+ </div>
293
284
  </div>
294
- </div>,
295
- container
285
+ </EditorPortal>
296
286
  )
297
287
  }
298
288
 
@@ -2,7 +2,6 @@ import { type MouseEvent as ReactMouseEvent, memo, useEffect, useRef } from 'rea
2
2
  import {
3
3
  Editor,
4
4
  TLCommentThread,
5
- useContainer,
6
5
  usePassThroughWheelEvents,
7
6
  useTranslation,
8
7
  useValue,
@@ -16,7 +15,7 @@ import { useCommentingOptions } from './options'
16
15
  import { pinStackKey } from './pin-stacking'
17
16
  import { openStackId, openThreadId } from './state'
18
17
  import { ThreadPreview, useMarkerPreview } from './thread-preview'
19
- import { anchorPagePoint } from './thread-state'
18
+ import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
20
19
  import {
21
20
  POPOVER_OFFSET,
22
21
  ThreadPopover,
@@ -27,9 +26,8 @@ import {
27
26
 
28
27
  /**
29
28
  * The pin for threads whose anchors resolve to the same page point — pins zooming can never
30
- * separate, so instead of stacked markers they share one count badge. Clicking it opens a
31
- * popover listing each thread as a card; clicking a card expands that thread in place (via the
32
- * single open-thread state, so expanding one collapses another).
29
+ * separate, so they share one count badge. Clicking it lists each thread as a card; clicking a card
30
+ * expands that thread in place, via the single open-thread state.
33
31
  */
34
32
  export const ThreadStackPin = memo(function ThreadStackPin({
35
33
  editor,
@@ -40,7 +38,6 @@ export const ThreadStackPin = memo(function ThreadStackPin({
40
38
  /** The stack's threads, oldest first. All resolve to the same anchor point. */
41
39
  threads: readonly TLCommentThread[]
42
40
  }) {
43
- const container = useContainer()
44
41
  const msg = useTranslation()
45
42
  const badgeRef = useRef<HTMLButtonElement>(null)
46
43
  // The badge takes pointer events (to open on click), so wheel input over it would otherwise be
@@ -49,12 +46,9 @@ export const ThreadStackPin = memo(function ThreadStackPin({
49
46
  // Hovering the badge previews its threads; clicking still opens them as the interactive list.
50
47
  // The preview is what makes the badge legible before you commit to opening it.
51
48
  const { previewShown, previewHandlers } = useMarkerPreview(editor, `stack:${threads[0].id}`)
52
- // The list stays open while a member thread is expanded, and on its own after the member
53
- // collapses so Escape steps back: expanded thread card list closed. Held in editor
54
- // state (not component state) because this pin remounts as its owning render path changes.
55
- // Keyed by the coincident page point, not a member id, so the open state survives losing a
56
- // member (including the oldest): the survivors keep the same key. Falls back to a thread id
57
- // only when the anchor can't resolve (off page), where the list isn't shown anyway.
49
+ // The list stays open while a member thread is expanded, so Escape steps back: expanded thread ->
50
+ // card list -> closed. Held in editor state because this pin remounts as its owning render path
51
+ // changes, and keyed by the coincident page point so the open state survives losing a member.
58
52
  const stackId = useValue(
59
53
  'stack id',
60
54
  () => {
@@ -76,13 +70,14 @@ export const ThreadStackPin = memo(function ThreadStackPin({
76
70
  () => {
77
71
  const first = threads[0]
78
72
  if (first.pageId !== editor.getCurrentPageId()) return null
79
- // The badge hangs off its anchor point bottom-left (transform: translate(0, -100%)),
80
- // like a pin and like the cluster badge but it sits at the raw page point with no pin
81
- // inset. The inset only tucks an imprecise single pin inside its shape; applying it here
82
- // would offset the badge from where the cluster badge sits (cluster centroids average
83
- // raw anchor points) and make it hop as pins flip between them.
73
+ // The badge hangs off its anchor point bottom-left like a pin, and applies the same imprecise-shape
74
+ // inset the pins it stands in for would (see ThreadPin) otherwise the marker would snap from tucked
75
+ // inside the shape to the raw corner the moment a second comment turns a pin into a stack.
84
76
  const pagePoint = anchorPagePoint(editor, first.anchor)
85
- return pagePoint ? editor.pageToViewport(pagePoint) : null
77
+ if (!pagePoint) return null
78
+ const viewportPoint = editor.pageToViewport(pagePoint)
79
+ const inset = impreciseShapePinInset(editor, first.anchor)
80
+ return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
86
81
  },
87
82
  [editor, threads]
88
83
  )
@@ -134,12 +129,9 @@ export const ThreadStackPin = memo(function ThreadStackPin({
134
129
  }
135
130
  }
136
131
 
137
- // The expanded thread gains a "Comment" header that pushes its first comment down from where the
138
- // hover preview showed it. When that thread is the list's first, lift the whole list by the
139
- // header block so its "You" holds position across hover -> open: the single thread's
140
- // THREAD_HEADER_BLOCK (36) plus the 8px margin above the expanded entry, less the 2px top the
141
- // preview card sits its "You" down by. Only the first entry — lifting the list can't also hold a
142
- // lower thread's neighbours in place.
132
+ // The expanded thread gains a header that pushes its first comment down from where the hover preview
133
+ // showed it. When that thread is the list's first, lift the whole list so its "You" holds position
134
+ // across hover -> open. Only the first entry lifting can't also hold a lower thread's neighbours.
143
135
  const liftForHeader = threads[0]?.id === openId ? 42 : 0
144
136
 
145
137
  return (
@@ -167,7 +159,6 @@ export const ThreadStackPin = memo(function ThreadStackPin({
167
159
  <ThreadPreview
168
160
  editor={editor}
169
161
  threads={threads}
170
- container={container}
171
162
  // Lines the preview up with the stack list itself, so opening it leaves the cards
172
163
  // exactly where the preview had them.
173
164
  variant="list"
@@ -185,10 +176,9 @@ export const ThreadStackPin = memo(function ThreadStackPin({
185
176
  )}
186
177
  {open && (
187
178
  <ThreadPopover
188
- container={container}
189
- style={{
190
- left: point.x + POPOVER_OFFSET.list.x,
191
- top: point.y + POPOVER_OFFSET.list.y - liftForHeader,
179
+ base={{
180
+ x: point.x + POPOVER_OFFSET.list.x,
181
+ y: point.y + POPOVER_OFFSET.list.y - liftForHeader,
192
182
  }}
193
183
  >
194
184
  <div className="tlui-cmt-stack-list">
@@ -18,6 +18,8 @@ import {
18
18
  commentTargetShapeAt,
19
19
  impreciseShapePinInset,
20
20
  IMPRECISE_PIN_INSET_PX,
21
+ isBoxInInflatedViewport,
22
+ isInInflatedViewport,
21
23
  shapeAnchorAt,
22
24
  } from './thread-state'
23
25
 
@@ -207,3 +209,52 @@ describe('commentCenterScreenOffset', () => {
207
209
  expect(commentCenterScreenOffset(editor)).toBe(0)
208
210
  })
209
211
  })
212
+
213
+ describe('isInInflatedViewport', () => {
214
+ // The viewport is 1000x1000 (see beforeEach) and the margin is 120 screen px.
215
+ it('accepts a point on screen', () => {
216
+ expect(isInInflatedViewport(editor, { x: 500, y: 500 })).toBe(true)
217
+ })
218
+
219
+ it('accepts a point just off screen, so a pan finds it already mounted', () => {
220
+ expect(isInInflatedViewport(editor, { x: -119, y: 500 })).toBe(true)
221
+ expect(isInInflatedViewport(editor, { x: 1119, y: 500 })).toBe(true)
222
+ expect(isInInflatedViewport(editor, { x: 500, y: -119 })).toBe(true)
223
+ expect(isInInflatedViewport(editor, { x: 500, y: 1119 })).toBe(true)
224
+ })
225
+
226
+ it('rejects a point past the margin on any side', () => {
227
+ expect(isInInflatedViewport(editor, { x: -121, y: 500 })).toBe(false)
228
+ expect(isInInflatedViewport(editor, { x: 1121, y: 500 })).toBe(false)
229
+ expect(isInInflatedViewport(editor, { x: 500, y: -121 })).toBe(false)
230
+ expect(isInInflatedViewport(editor, { x: 500, y: 1121 })).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe('isBoxInInflatedViewport', () => {
235
+ it('accepts a box wholly on screen', () => {
236
+ expect(isBoxInInflatedViewport(editor, { x: 100, y: 100, w: 200, h: 200 })).toBe(true)
237
+ })
238
+
239
+ it('accepts a box straddling the edge, whose pin corner alone would be culled', () => {
240
+ // This is the case the point test gets wrong: the box's visible edge is on screen while
241
+ // its far corner — where a region thread's pin can sit — is thousands of px away.
242
+ expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 5100, h: 5100 })).toBe(true)
243
+ })
244
+
245
+ it('accepts a box larger than the viewport on every side', () => {
246
+ expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 10000, h: 10000 })).toBe(true)
247
+ })
248
+
249
+ it('rejects a box wholly past the margin', () => {
250
+ expect(isBoxInInflatedViewport(editor, { x: 1200, y: 100, w: 100, h: 100 })).toBe(false)
251
+ expect(isBoxInInflatedViewport(editor, { x: 100, y: -400, w: 100, h: 100 })).toBe(false)
252
+ })
253
+
254
+ it('follows the camera rather than page coordinates', () => {
255
+ const box = { x: 2000, y: 0, w: 100, h: 100 }
256
+ expect(isBoxInInflatedViewport(editor, box)).toBe(false)
257
+ editor.setCamera({ x: -2000, y: 0, z: 1 })
258
+ expect(isBoxInInflatedViewport(editor, box)).toBe(true)
259
+ })
260
+ })
@@ -17,6 +17,47 @@ import { POPOVER_OFFSET } from './thread-view'
17
17
  * most of the marker sits within the shape, with a small overhang past the corner. */
18
18
  export const IMPRECISE_PIN_INSET_PX = 20
19
19
 
20
+ /** Screen-pixel margin by which the viewport is inflated when culling canvas markers (thread pins
21
+ * and cluster badges), so a marker just off-screen is already mounted when a pan brings it in. */
22
+ const MARKER_CULL_MARGIN_PX = 120
23
+
24
+ /**
25
+ * Whether a viewport-space point sits within the viewport inflated by
26
+ * {@link MARKER_CULL_MARGIN_PX}. The cull test for screen-fixed markers: off-screen markers
27
+ * return null from their position signal and unmount instead of tracking every camera frame.
28
+ * @internal
29
+ */
30
+ export function isInInflatedViewport(editor: Editor, point: VecLike): boolean {
31
+ const viewport = editor.getViewportScreenBounds()
32
+ const margin = MARKER_CULL_MARGIN_PX
33
+ return (
34
+ point.x >= -margin &&
35
+ point.y >= -margin &&
36
+ point.x <= viewport.w + margin &&
37
+ point.y <= viewport.h + margin
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Whether any part of a page-space box overlaps the viewport inflated by
43
+ * {@link MARKER_CULL_MARGIN_PX}. The cull test for region-anchored threads, whose dashed box can
44
+ * be on screen while the pin corner itself is not.
45
+ * @internal
46
+ */
47
+ export function isBoxInInflatedViewport(editor: Editor, box: BoxModel): boolean {
48
+ const viewport = editor.getViewportScreenBounds()
49
+ const margin = MARKER_CULL_MARGIN_PX
50
+ // Zoom is positive, so the page box's corners keep their order through the transform.
51
+ const min = editor.pageToViewport({ x: box.x, y: box.y })
52
+ const max = editor.pageToViewport({ x: box.x + box.w, y: box.y + box.h })
53
+ return (
54
+ max.x >= -margin &&
55
+ max.y >= -margin &&
56
+ min.x <= viewport.w + margin &&
57
+ min.y <= viewport.h + margin
58
+ )
59
+ }
60
+
20
61
  /** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
21
62
  * extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
22
63
  * pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
@@ -61,13 +102,11 @@ export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CO
61
102
  }
62
103
 
63
104
  /**
64
- * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. For imprecise
65
- * shape anchors the pin uses the editor's {@link CommentingOptions.impreciseShapeAnchor} (a
66
- * normalized 0–1 spot, top-right by default) rather than the stored `x`/`y`.
105
+ * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. Imprecise shape
106
+ * anchors use {@link CommentingOptions.impreciseShapeAnchor} rather than the stored `x`/`y`.
67
107
  *
68
- * A shape anchor's `x`/`y` are normalized within the shape's own bounds and resolved through the
69
- * shape's page transform, so the pin rides every part of that transform rotating the shape
70
- * carries the pin around with it instead of leaving it behind in the bounding box.
108
+ * A shape anchor's `x`/`y` are normalized within the shape's bounds and resolved through its page
109
+ * transform, so the pin rides rotation instead of being left behind in the bounding box.
71
110
  * @public
72
111
  */
73
112
  export function anchorPagePoint(
@@ -97,15 +136,12 @@ export function anchorPagePoint(
97
136
  /**
98
137
  * The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
99
138
  *
100
- * Uses the editor's hit-test margin, the same slack select and hover use. Without it, shapes whose
101
- * geometry is an open path — arrows, lines, draw strokes are unhittable in practice: their
102
- * geometry reports a positive distance for every point off the stroke, so a zero margin only
103
- * matches a pixel-perfect click right on the line.
139
+ * Uses the editor's hit-test margin, the same slack select and hover use without it, open-path
140
+ * shapes (arrows, lines, draw strokes) are unhittable in practice.
104
141
  *
105
- * `hitFrameInside` lets a click inside a frame's body anchor to the frame without it a frame is
106
- * hit only on its edge/label (the select-tool convention), so the frame's interior would fall
107
- * through to a bare point. A child shape under the pointer still wins (children sort above the
108
- * frame), so only a frame's empty interior anchors the frame.
142
+ * `hitFrameInside` lets a click inside a frame's body anchor to the frame, which it otherwise
143
+ * wouldn't (the select-tool convention hits only the edge/label). A child shape under the pointer
144
+ * still wins, so only a frame's empty interior anchors the frame.
109
145
  *
110
146
  * @internal
111
147
  */
@@ -119,11 +155,9 @@ export function commentTargetShapeAt(editor: Editor, page: VecLike): TLShape | u
119
155
 
120
156
  /**
121
157
  * A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
122
- * shape's own bounds taken in the shape's own space, so a pin placed on a rotated shape records
123
- * the spot it was dropped on rather than a spot in the bounding box. Remembered either way: when
124
- * `precise` the pin sits at exactly `x`/`y`; otherwise it sits at the consumer's imprecise default
125
- * (top-right out of the box). Placement gestures get `precise` from the `shouldBePrecise`
126
- * commenting option (always precise, by default).
158
+ * shape's own bounds, taken in the shape's own space, so a pin on a rotated shape records the spot
159
+ * it was dropped on. Remembered either way: when `precise` the pin sits at exactly `x`/`y`,
160
+ * otherwise at the consumer's imprecise default.
127
161
  * @public
128
162
  */
129
163
  export function shapeAnchorAt(
@@ -168,10 +202,9 @@ const THREAD_UI_EXTENT_PX = POPOVER_OFFSET.thread.x + 300
168
202
 
169
203
  /**
170
204
  * How far right of a centered-on pin the true viewport center should sit, in screen px. While the
171
- * comments sidebar covers the viewport's right edge, centering a pin dead-center puts the thread
172
- * popover that opens on it under the sidebar — so centering aims the pin at the middle of the
173
- * uncovered area instead, nudged further left if the thread UI would still reach the sidebar, but
174
- * never past the viewport's left edge. Zero when the sidebar is closed or not on screen.
205
+ * comments sidebar covers the viewport's right edge, dead-centering a pin would put its thread
206
+ * popover under the sidebar — so centering aims at the middle of the uncovered area instead, never
207
+ * past the viewport's left edge. Zero when the sidebar is closed or not on screen.
175
208
  * @internal
176
209
  */
177
210
  export function commentCenterScreenOffset(editor: Editor): number {
@@ -0,0 +1,72 @@
1
+ import type { CommentAuthor } from '@tldraw/mentions'
2
+ import type { TLComment } from 'tldraw'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { absoluteThreadLink, toCardProps } from './thread-view'
5
+
6
+ const AUTHOR: CommentAuthor = { name: 'Ada' }
7
+
8
+ function comment(overrides: Partial<TLComment> = {}): TLComment {
9
+ return {
10
+ id: 'comment:1',
11
+ typeName: 'comment',
12
+ threadId: 'comment-thread:1',
13
+ pageId: 'page:one',
14
+ authorId: 'user:1',
15
+ body: { type: 'doc', content: [] },
16
+ createdAt: Date.parse('2026-07-30T12:00:00.000Z'),
17
+ editedAt: null,
18
+ meta: {},
19
+ ...overrides,
20
+ } as unknown as TLComment
21
+ }
22
+
23
+ const context = {
24
+ currentUserId: 'user:1',
25
+ resolveAuthor: (id: string) => (id === 'user:1' ? AUTHOR : undefined),
26
+ }
27
+
28
+ const resolveName = (id: string) => context.resolveAuthor(id)?.name
29
+
30
+ describe('toCardProps', () => {
31
+ it('marks your own comments and resolves the author', () => {
32
+ const card = toCardProps(comment(), context, {}, resolveName)
33
+ expect(card.you).toBe(true)
34
+ expect(card.author).toBe(AUTHOR)
35
+ expect(card.date).toBe('2026-07-30T12:00:00.000Z')
36
+ expect(card.edited).toBe(false)
37
+ })
38
+
39
+ it('falls back to the unknown author when the id does not resolve', () => {
40
+ const card = toCardProps(comment({ authorId: 'user:gone' }), context, {}, resolveName)
41
+ expect(card.you).toBe(false)
42
+ expect(card.author).not.toBe(AUTHOR)
43
+ })
44
+ })
45
+
46
+ describe('absoluteThreadLink', () => {
47
+ const BASE = 'https://example.com/f/abc123?page=2'
48
+
49
+ // A host's `getThreadHref` is an href — the sidebar rows use it as one — so it's allowed to be
50
+ // relative. Copying that verbatim would put "/f/abc123?comment=…" on someone's clipboard.
51
+ it('resolves a root-relative href against the current document', () => {
52
+ expect(absoluteThreadLink('/f/abc123?comment=comment-thread%3A1', BASE)).toBe(
53
+ 'https://example.com/f/abc123?comment=comment-thread%3A1'
54
+ )
55
+ })
56
+
57
+ it('leaves an already absolute href alone', () => {
58
+ const href = 'https://other.example/f/xyz?comment=comment-thread%3A1'
59
+ expect(absoluteThreadLink(href, BASE)).toBe(href)
60
+ })
61
+
62
+ it('resolves a path-relative href', () => {
63
+ expect(absoluteThreadLink('?comment=comment-thread%3A1', BASE)).toBe(
64
+ 'https://example.com/f/abc123?comment=comment-thread%3A1'
65
+ )
66
+ })
67
+
68
+ // Better a link that's odd than a copy button that silently does nothing.
69
+ it('copies an unparseable href as-is', () => {
70
+ expect(absoluteThreadLink('::not a url::', '::also not::')).toBe('::not a url::')
71
+ })
72
+ })