@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
@@ -55,3 +55,49 @@ export function computePinStacks(
55
55
  }
56
56
  return stacks
57
57
  }
58
+
59
+ /**
60
+ * Value equality for pin-stack maps, so the overlay can hold the map's identity across a recompute
61
+ * that changed no grouping. Membership only: two equal maps can describe stacks at different page
62
+ * points, so anything keyed on a stack's *point* must read the anchors itself, as
63
+ * {@link isOpenStackKeyLive} does.
64
+ * @internal
65
+ */
66
+ export function pinStacksEqual(
67
+ a: ReadonlyMap<string, readonly string[]>,
68
+ b: ReadonlyMap<string, readonly string[]>
69
+ ): boolean {
70
+ if (a === b) return true
71
+ if (a.size !== b.size) return false
72
+ for (const [id, group] of b) {
73
+ const prevGroup = a.get(id)
74
+ if (!prevGroup) return false
75
+ if (prevGroup === group) continue
76
+ if (prevGroup.length !== group.length) return false
77
+ for (let i = 0; i < group.length; i++) {
78
+ if (prevGroup[i] !== group[i]) return false
79
+ }
80
+ }
81
+ return true
82
+ }
83
+
84
+ /**
85
+ * Whether an open-stack key still names a stack on the canvas — false once that stack is emptied,
86
+ * moved, or off the page, at which point the caller must clear the key. Reads the anchors, so a
87
+ * reactive caller re-evaluates on a move that {@link pinStacksEqual} can't see.
88
+ * @internal
89
+ */
90
+ export function isOpenStackKeyLive(
91
+ editor: Editor,
92
+ key: string,
93
+ stacks: ReadonlyMap<string, readonly string[]>,
94
+ threadsById: ReadonlyMap<string, TLCommentThread>
95
+ ): boolean {
96
+ for (const id of stacks.keys()) {
97
+ const thread = threadsById.get(id)
98
+ if (!thread) continue
99
+ const point = anchorPagePoint(editor, thread.anchor)
100
+ if (point && pinStackKey(point) === key) return true
101
+ }
102
+ return false
103
+ }
@@ -0,0 +1,124 @@
1
+ import { type PointerEvent as ReactPointerEvent, useRef } from 'react'
2
+ import { type BoxModel, Editor, useValue, VecLike } from 'tldraw'
3
+ import { regionDraft } from './state'
4
+
5
+ /** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
6
+ * handles, so the box itself takes no pointer events. Positioned in viewport space, as a sibling
7
+ * of the pins in the canvas layer. */
8
+ export function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
9
+ const rect = useValue(
10
+ 'region rect',
11
+ () => {
12
+ // Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
13
+ const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
14
+ const zoom = editor.getZoomLevel()
15
+ return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
16
+ },
17
+ [editor, box.x, box.y, box.w, box.h]
18
+ )
19
+ return <div className="tlui-cmt-canvas-region" style={rect} />
20
+ }
21
+
22
+ /** The live region being dragged out by the comment tool, or nothing when not dragging. */
23
+ export function RegionDraftBox({ editor }: { editor: Editor }) {
24
+ const box = useValue('region draft', () => regionDraft.get(editor), [editor])
25
+ if (!box) return null
26
+ return <RegionBox editor={editor} box={box} />
27
+ }
28
+
29
+ // A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
30
+ // controlled by that handle — the resize math reads the spot rather than special-casing corners.
31
+ export interface RegionHandle {
32
+ x: number
33
+ y: number
34
+ cursor: string
35
+ }
36
+
37
+ // The four corners, each resizing both axes.
38
+ export const REGION_CORNERS: readonly RegionHandle[] = [
39
+ { x: 0, y: 0, cursor: 'nwse-resize' },
40
+ { x: 1, y: 0, cursor: 'nesw-resize' },
41
+ { x: 0, y: 1, cursor: 'nesw-resize' },
42
+ { x: 1, y: 1, cursor: 'nwse-resize' },
43
+ ]
44
+
45
+ // Screen-space slack around a region's bounds within which its box and handles stay revealed, so
46
+ // the handles (which sit on the edge) are comfortably reachable.
47
+ export const REGION_HANDLE_MARGIN_PX = 12
48
+
49
+ /** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
50
+ * handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
51
+ * handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
52
+ function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
53
+ const controlsX = handle.x !== 0.5
54
+ const controlsY = handle.y !== 0.5
55
+ const fixedX = box.x + (1 - handle.x) * box.w
56
+ const fixedY = box.y + (1 - handle.y) * box.h
57
+ return {
58
+ x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
59
+ y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
60
+ w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
61
+ h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
62
+ }
63
+ }
64
+
65
+ /** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
66
+ * option. Previews live, commits on release. */
67
+ export function RegionResizeHandles({
68
+ editor,
69
+ box,
70
+ handles,
71
+ onPreview,
72
+ onCommit,
73
+ }: {
74
+ editor: Editor
75
+ box: BoxModel
76
+ handles: readonly RegionHandle[]
77
+ onPreview(bounds: BoxModel | null): void
78
+ onCommit(bounds: BoxModel): void
79
+ }) {
80
+ // The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
81
+ // the fixed edges mid-drag.
82
+ const boxRef = useRef<BoxModel | null>(null)
83
+ const points = useValue(
84
+ 'region handle points',
85
+ () =>
86
+ handles.map((h) => {
87
+ const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
88
+ return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
89
+ }),
90
+ [editor, box.x, box.y, box.w, box.h, handles]
91
+ )
92
+ const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
93
+ e.stopPropagation()
94
+ boxRef.current = box
95
+ e.currentTarget.setPointerCapture(e.pointerId)
96
+ }
97
+ const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
98
+ resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
99
+ const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
100
+ if (boxRef.current) onPreview(resizedTo(h, e))
101
+ }
102
+ const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
103
+ if (!boxRef.current) return
104
+ const bounds = resizedTo(h, e)
105
+ boxRef.current = null
106
+ if (e.currentTarget.hasPointerCapture(e.pointerId))
107
+ e.currentTarget.releasePointerCapture(e.pointerId)
108
+ onCommit(bounds)
109
+ }
110
+ return (
111
+ <>
112
+ {points.map((h) => (
113
+ <div
114
+ key={h.key}
115
+ className="tlui-cmt-canvas-region-handle"
116
+ style={{ left: h.left, top: h.top, cursor: h.cursor }}
117
+ onPointerDown={startResize}
118
+ onPointerMove={onResize(h)}
119
+ onPointerUp={endResize(h)}
120
+ />
121
+ ))}
122
+ </>
123
+ )
124
+ }
@@ -3,20 +3,19 @@ import type { PendingComment } from './comment-tool'
3
3
  import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
4
4
 
5
5
  /**
6
- * Transient commenting UI state, scoped per editor via {@link EditorAtom}. Editor-scoping (rather
7
- * than module-global atoms) keeps two editors on one page or an editor and a second instance —
8
- * from sharing open-thread, visibility, and filter state. Reachable from both React (`useEditor()`)
9
- * and the comment tool (`this.editor`).
6
+ * Transient commenting UI state, scoped per editor via {@link EditorAtom} so two editors on one
7
+ * page don't share open-thread, visibility, and filter state. Reachable from both React
8
+ * (`useEditor()`) and the comment tool (`this.editor`).
10
9
  */
11
10
 
12
11
  /** The id of the one open thread (only one popover is open at a time), or null when all closed.
13
12
  * @public */
14
13
  export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
15
14
 
16
- /** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread
17
- * id), or null. Editor state rather than component state: the stack pin remounts when its owning
18
- * render path changes (e.g. a member thread opens), and the open list must survive that.
19
- * @internal */
15
+ /** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread id),
16
+ * or null. Editor state rather than component state: the stack pin remounts when its owning render
17
+ * path changes, and the open list must survive that.
18
+ * @internal */
20
19
  export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
21
20
 
22
21
  /** The comment currently being placed (composer open, not yet posted), or null.
@@ -32,13 +31,12 @@ export const pendingComment = new EditorAtom<PendingComment | null>('pendingComm
32
31
  export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
33
32
 
34
33
  /**
35
- * Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it
36
- * to jump to a thread from outside the canvas — a notification, a deep link, your own list.
34
+ * Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it to
35
+ * jump to a thread from outside the canvas — a notification, a deep link, your own list.
37
36
  *
38
- * The request is served by `CanvasComments`, so it works before the records have arrived: the
39
- * layer waits for them to sync in, switches pages if it needs to, unhides pins, zooms in far
40
- * enough to split the thread out of any cluster it's folded into, and then opens it. That also
41
- * means nothing happens if `CanvasComments` isn't mounted.
37
+ * The request is served by `CanvasComments`, so it works before the records have arrived: the layer
38
+ * waits for them, switches pages, unhides pins, and zooms far enough to split the thread out of any
39
+ * cluster. That also means nothing happens if `CanvasComments` isn't mounted.
42
40
  *
43
41
  * To open a thread you already hold and skip the wait, see {@link focusThread}.
44
42
  *
@@ -55,12 +53,10 @@ export function revealThread(editor: Editor, threadOrCommentId: string): void {
55
53
 
56
54
  /**
57
55
  * The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
58
- * yet, or null when there's nothing outstanding. A request also clears when `CanvasComments`
59
- * unmounts, since nothing is left to serve it.
56
+ * yet, or null. A request also clears when `CanvasComments` unmounts.
60
57
  *
61
- * This is a plain, untracked read. In React, use {@link useRevealThreadPending} but reach for
62
- * this one inside a timer or callback that needs the value as of *now* rather than as of the
63
- * render it closed over.
58
+ * This is a plain, untracked read in React, use {@link useRevealThreadPending}, unless you need
59
+ * the value as of *now* rather than as of the render you closed over.
64
60
  *
65
61
  * @public
66
62
  */
@@ -71,11 +67,9 @@ export function getRevealThreadPending(editor: Editor): string | null {
71
67
  /**
72
68
  * Reactive React hook for {@link getRevealThreadPending}.
73
69
  *
74
- * Use it to notice a reveal that never lands — most often a deep link to a comment that has since
75
- * been deleted. Give it a grace period before you act: a request also sits here while its records
76
- * are still syncing in, which is the normal case on a cold load. Re-check with
77
- * {@link getRevealThreadPending} when the grace period elapses, since the request can clear inside
78
- * it without this hook's value having caught up yet.
70
+ * Use it to notice a reveal that never lands — usually a deep link to a deleted comment. Give it a
71
+ * grace period first, since a request also sits here while its records sync in, and re-check with
72
+ * {@link getRevealThreadPending} when it elapses.
79
73
  *
80
74
  * @public
81
75
  */
@@ -96,10 +90,8 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
96
90
  export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
97
91
 
98
92
  /**
99
- * Whether the comments sidebar (the thread list) is open. Driven by an explicit control a button
100
- * next to Share on dotcom — rather than by which tool is active, so browsing threads is separate
101
- * from placing them. The comment tool additionally closes it on enter, keeping placement
102
- * canvas-focused.
93
+ * Whether the comments sidebar (the thread list) is open. Driven by an explicit control rather than
94
+ * by which tool is active, so browsing threads is separate from placing them.
103
95
  * @public
104
96
  */
105
97
  export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
@@ -0,0 +1,418 @@
1
+ import { Avatar } from '@tldraw/mentions'
2
+ import {
3
+ memo,
4
+ type PointerEvent as ReactPointerEvent,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react'
10
+ import {
11
+ type BoxModel,
12
+ Editor,
13
+ TLCommentThread,
14
+ useContainer,
15
+ usePassThroughWheelEvents,
16
+ useTranslation,
17
+ useValue,
18
+ } from 'tldraw'
19
+ import { CommentPin } from '../ui/comment-pin'
20
+ import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
21
+ import { commitCommentMutation } from './comment-mutations'
22
+ import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
23
+ import { type CommentingContext } from './context'
24
+ import { useThreadComments } from './hooks'
25
+ import { getCommentingOptions, useCanComment, useCommentingOptions } from './options'
26
+ import {
27
+ REGION_CORNERS,
28
+ REGION_HANDLE_MARGIN_PX,
29
+ RegionBox,
30
+ RegionResizeHandles,
31
+ } from './region-box'
32
+ import { openThreadId } from './state'
33
+ import { ThreadPreview, useMarkerPreview } from './thread-preview'
34
+ import {
35
+ anchorPagePoint,
36
+ commentTargetShapeAt,
37
+ impreciseShapePinInset,
38
+ isBoxInInflatedViewport,
39
+ isInInflatedViewport,
40
+ REGION_PIN_CORNER,
41
+ regionAnchorPinCorner,
42
+ regionPinPoint,
43
+ shapeAnchorAt,
44
+ } from './thread-state'
45
+ import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
46
+
47
+ /** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
48
+ * first comment then lands where the preview's sat. Re-measure if the header height or the preview
49
+ * panel padding changes. */
50
+ const THREAD_HEADER_BLOCK = 36
51
+
52
+ /**
53
+ * A single thread's pin: the marker at its anchor, the thread popover when it's open, the hover
54
+ * preview, and — for a region anchor — the dashed box and its resize handles. Dragging the marker
55
+ * re-anchors the thread.
56
+ *
57
+ * Memoized so a pin skips re-rendering when the layer re-renders for unrelated reasons; camera
58
+ * tracking still works, since the pin subscribes to its own viewport position via signals.
59
+ */
60
+ export const ThreadPin = memo(function ThreadPin({
61
+ editor,
62
+ thread,
63
+ ...props
64
+ }: CommentingContext & {
65
+ editor: Editor
66
+ thread: TLCommentThread
67
+ }) {
68
+ const { resolveAuthor } = props
69
+ const options = useCommentingOptions()
70
+ const canComment = useCanComment(props.currentUserId)
71
+ const container = useContainer()
72
+ const msg = useTranslation()
73
+ const comments = useThreadComments(editor, thread.id)
74
+ // Only one thread's popover is open at a time — shared across pins via the atom.
75
+ const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
76
+ editor,
77
+ thread.id,
78
+ ])
79
+ // While dragging the marker, its page point overrides the anchor's; committed on drop.
80
+ const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
81
+ const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
82
+ // Hovering the marker previews the thread's opening comment, on the delay every marker uses.
83
+ const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
84
+ const previewThreads = useMemo(() => [thread], [thread])
85
+ // The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
86
+ // Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
87
+ // handle never loses the affordance — the box stays `pointer-events: none`.
88
+ const pointerInRegion = useValue(
89
+ 'pointer in region',
90
+ () => {
91
+ if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
92
+ return false
93
+ const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
94
+ const p = editor.inputs.getCurrentPagePoint()
95
+ const a = thread.anchor
96
+ 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
97
+ },
98
+ [editor, thread.anchor, thread.pageId]
99
+ )
100
+ // A region's box and handles are revealed while open, mid-resize, or while the pointer is
101
+ // within the region.
102
+ const revealed = open || resizeBounds != null || pointerInRegion
103
+ // A region thread's pin corner is its own (the corner its creating drag released on), with
104
+ // the default as the fallback for older records.
105
+ const pinCorner =
106
+ thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
107
+ // A region resizes from its corners — every corner but the pin's own, which the pin owns.
108
+ const resizeHandles = useMemo(
109
+ () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
110
+ [pinCorner]
111
+ )
112
+ const dragRef = useRef<{
113
+ startX: number
114
+ startY: number
115
+ moved: boolean
116
+ // The anchor's page-space offset from the grab point, so a drag translates the pin by the
117
+ // cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
118
+ offsetX: number
119
+ offsetY: number
120
+ } | null>(null)
121
+ const markerRef = useRef<HTMLButtonElement>(null)
122
+ // Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
123
+ // see the note on the layer.
124
+ usePassThroughWheelEvents(markerRef)
125
+
126
+ // The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
127
+ // mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
128
+ // clear the hint here or it stays on the shape indefinitely.
129
+ useEffect(() => {
130
+ return () => {
131
+ if (dragRef.current) editor.setHintingShapes([])
132
+ }
133
+ }, [editor])
134
+
135
+ // Clicking outside the open popover closes the thread. Capture phase + a class check rather than
136
+ // stopPropagation, since the popover portals elsewhere in the DOM. The marker is excluded so its own
137
+ // click-to-toggle handles it instead of this closing and the toggle reopening.
138
+ useEffect(() => {
139
+ if (!open) return
140
+ const onPointerDown = (e: PointerEvent) => {
141
+ const target = e.target as HTMLElement | null
142
+ if (!target) return
143
+ if (target.closest('.tlui-cmt-canvas-popover')) return
144
+ const marker = markerRef.current
145
+ if (marker && marker.contains(target)) return
146
+ // A press on a region's resize handle edits this thread — don't dismiss it.
147
+ if (target.closest('.tlui-cmt-canvas-region-handle')) return
148
+ // A click inside a menu/popover layered above us (the sidebar's filter or overflow
149
+ // dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
150
+ // layer; defer to its own dismissal instead of closing the thread out from under it.
151
+ if (
152
+ target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
153
+ )
154
+ return
155
+ openThreadId.set(editor, null)
156
+ }
157
+ document.addEventListener('pointerdown', onPointerDown, true)
158
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
159
+ }, [open, editor])
160
+
161
+ // The pin must not unmount mid-interaction: an open thread's popover hangs off it, and a drag
162
+ // or resize holds pointer capture on it — so those states are exempt from the viewport cull.
163
+ const exemptFromCull = open || dragPagePoint != null || resizeBounds != null
164
+ const point = useValue(
165
+ 'pin point',
166
+ () => {
167
+ if (thread.pageId !== editor.getCurrentPageId()) return null
168
+ const pagePoint = anchorPagePoint(editor, thread.anchor)
169
+ if (!pagePoint) return null
170
+ const viewportPoint = editor.pageToViewport(pagePoint)
171
+ const inset = impreciseShapePinInset(editor, thread.anchor)
172
+ const point = inset
173
+ ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y }
174
+ : viewportPoint
175
+ // Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
176
+ // region thread stays mounted while any part of its box is on screen, since the box renders here too.
177
+ if (!exemptFromCull) {
178
+ const visible =
179
+ thread.anchor.type === 'region'
180
+ ? isBoxInInflatedViewport(editor, thread.anchor)
181
+ : isInInflatedViewport(editor, point)
182
+ if (!visible) return null
183
+ }
184
+ return point
185
+ },
186
+ [editor, thread.anchor, thread.pageId, exemptFromCull]
187
+ )
188
+ if (!point) return null
189
+
190
+ const PinContent = options.components.PinContent
191
+ const threadAuthor = resolveAuthor(thread.createdBy)
192
+ const pinContent = PinContent ? (
193
+ <PinContent thread={thread} comments={comments} />
194
+ ) : (
195
+ <Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
196
+ )
197
+ const pinLabel = msg(
198
+ thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
199
+ ).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
200
+
201
+ // Drag the marker to move the thread: position is overridden locally while dragging, then re-anchored
202
+ // on drop. A region translates keeping its size; a barely-moved pointer is a click.
203
+ const isRegion = thread.anchor.type === 'region'
204
+ // The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
205
+ const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
206
+ // A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
207
+ // hand it to the canvas untouched.
208
+ if (isCanvasPanGesture(editor, e)) {
209
+ forwardPointerEventToCanvas(container, e)
210
+ return
211
+ }
212
+ e.stopPropagation()
213
+ const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
214
+ const anchorPage = anchorPagePoint(editor, thread.anchor)
215
+ // The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
216
+ // is inset from its anchor point — without this the pin jumps by the inset on drag start.
217
+ const inset = impreciseShapePinInset(editor, thread.anchor)
218
+ if (anchorPage && inset) {
219
+ const zoom = editor.getZoomLevel()
220
+ anchorPage.x += inset.x / zoom
221
+ anchorPage.y += inset.y / zoom
222
+ }
223
+ dragRef.current = {
224
+ startX: e.clientX,
225
+ startY: e.clientY,
226
+ moved: false,
227
+ offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
228
+ offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
229
+ }
230
+ e.currentTarget.setPointerCapture(e.pointerId)
231
+ }
232
+ const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
233
+ const drag = dragRef.current
234
+ if (!drag) return
235
+ // Moving a pin re-anchors the thread record — a commenting write. Without the permission the
236
+ // press stays a click (`moved` never sets, so release toggles the popover and never commits).
237
+ if (!canComment) return
238
+ if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
239
+ drag.moved = true
240
+ const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
241
+ const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
242
+ setDragPagePoint(pagePoint)
243
+ // Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
244
+ // with. Regions translate rather than re-anchor, so they never hint.
245
+ if (!isRegion) {
246
+ const hit = commentTargetShapeAt(editor, pagePoint)
247
+ editor.setHintingShapes(hit ? [hit.id] : [])
248
+ }
249
+ }
250
+ // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
251
+ // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
252
+ const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
253
+ const drag = dragRef.current
254
+ dragRef.current = null
255
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
256
+ e.currentTarget.releasePointerCapture(e.pointerId)
257
+ }
258
+ if (!drag) return
259
+ setDragPagePoint(null)
260
+ editor.setHintingShapes([])
261
+ }
262
+ const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
263
+ const drag = dragRef.current
264
+ dragRef.current = null
265
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
266
+ e.currentTarget.releasePointerCapture(e.pointerId)
267
+ }
268
+ if (!drag) return
269
+ editor.setHintingShapes([])
270
+ if (!drag.moved) {
271
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
272
+ return
273
+ }
274
+ const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
275
+ const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
276
+ setDragPagePoint(null)
277
+ let anchor: TLCommentThread['anchor']
278
+ if (thread.anchor.type === 'region') {
279
+ // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
280
+ anchor = {
281
+ ...thread.anchor,
282
+ x: pagePoint.x - pinCorner.x * thread.anchor.w,
283
+ y: pagePoint.y - pinCorner.y * thread.anchor.h,
284
+ }
285
+ } else {
286
+ const hit = commentTargetShapeAt(editor, pagePoint)
287
+ anchor = hit
288
+ ? shapeAnchorAt(
289
+ editor,
290
+ hit.id,
291
+ pagePoint,
292
+ getCommentingOptions(editor).shouldBePrecise(editor, {
293
+ shapeId: hit.id,
294
+ point: pagePoint,
295
+ altKey: e.altKey,
296
+ })
297
+ )
298
+ : { type: 'point', x: pagePoint.x, y: pagePoint.y }
299
+ }
300
+ commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')
301
+ }
302
+
303
+ // The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
304
+ // move to the drag point; otherwise it sits at the stored anchor's viewport point.
305
+ const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
306
+ const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
307
+ // A region's pin centres on its corner — overlapping the box — rather than hanging off it.
308
+ // The marker anchors bottom-left, so step half its 34px size left and down (screen px).
309
+ const renderPoint = isRegion
310
+ ? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
311
+ : renderPointBase
312
+
313
+ // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
314
+ // corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
315
+ const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
316
+ const movedRegion =
317
+ regionAnchor && dragPagePoint
318
+ ? {
319
+ ...regionAnchor,
320
+ x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
321
+ y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
322
+ }
323
+ : regionAnchor
324
+ const regionBoxBounds = resizeBounds ?? movedRegion
325
+ const commitResize = (bounds: BoxModel) => {
326
+ setResizeBounds(null)
327
+ if (!canComment) return
328
+ // Same commit path as a pin drag, so the configured `dragHistory` governs both — going
329
+ // straight to `editor.run` here would make region resizes silently ignore the option.
330
+ commitCommentMutation(
331
+ editor,
332
+ // Spread the existing anchor first so the region's pin corner survives a resize.
333
+ ({ put }) => put([{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
334
+ 'drag'
335
+ )
336
+ }
337
+
338
+ return (
339
+ <>
340
+ {regionBoxBounds && (dragPagePoint || revealed) && (
341
+ <RegionBox editor={editor} box={regionBoxBounds} />
342
+ )}
343
+ {regionBoxBounds && revealed && !dragPagePoint && canComment && (
344
+ <RegionResizeHandles
345
+ editor={editor}
346
+ box={regionBoxBounds}
347
+ handles={resizeHandles}
348
+ onPreview={setResizeBounds}
349
+ onCommit={commitResize}
350
+ />
351
+ )}
352
+ <div
353
+ className={[
354
+ 'tlui-cmt-canvas-pin',
355
+ open && 'tlui-cmt-canvas-pin--open',
356
+ dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
357
+ ]
358
+ .filter(Boolean)
359
+ .join(' ')}
360
+ style={{ left: renderPoint.x, top: renderPoint.y }}
361
+ >
362
+ <button
363
+ ref={markerRef}
364
+ type="button"
365
+ className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
366
+ aria-label={pinLabel}
367
+ aria-expanded={open}
368
+ onPointerDown={startDrag}
369
+ onPointerMove={onDrag}
370
+ onPointerUp={endDrag}
371
+ onPointerCancel={cancelDrag}
372
+ // Pointer activation is already handled by endDrag (which distinguishes a click
373
+ // from a drag), so only take keyboard-synthesised clicks here — those report
374
+ // `detail === 0` — or the thread would toggle twice per mouse click.
375
+ onClick={(e) => {
376
+ if (e.detail !== 0) return
377
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
378
+ }}
379
+ onPointerEnter={previewHandlers.onPointerEnter}
380
+ onPointerLeave={previewHandlers.onPointerLeave}
381
+ // Focus stands in for hover, so tabbing to a marker gets the same preview.
382
+ onFocus={previewHandlers.onPointerEnter}
383
+ onBlur={previewHandlers.onPointerLeave}
384
+ >
385
+ <CommentPin resolved={thread.resolved != null} open={open}>
386
+ {pinContent}
387
+ </CommentPin>
388
+ </button>
389
+ {/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
390
+ the pin itself stays in the canvas-in-front layer, beneath the UI. */}
391
+ {open && (
392
+ <ThreadPopover
393
+ base={{
394
+ x: renderPoint.x + POPOVER_OFFSET.thread.x,
395
+ y: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
396
+ }}
397
+ >
398
+ <ThreadView editor={editor} thread={thread} {...props} />
399
+ </ThreadPopover>
400
+ )}
401
+ {/* Not while dragging: the pin is being moved, not read, and a panel trailing the
402
+ cursor would obscure the drop target. */}
403
+ {previewShown && !dragPagePoint && (
404
+ <ThreadPreview
405
+ editor={editor}
406
+ threads={previewThreads}
407
+ variant="thread"
408
+ point={renderPoint}
409
+ onSelectThread={() => openThreadId.set(editor, thread.id)}
410
+ {...previewHandlers}
411
+ currentUserId={props.currentUserId}
412
+ resolveAuthor={resolveAuthor}
413
+ />
414
+ )}
415
+ </div>
416
+ </>
417
+ )
418
+ })