@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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 (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,304 @@
1
+ import { useEffect, useMemo, useRef, type CSSProperties } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import {
4
+ Editor,
5
+ EditorAtom,
6
+ TLComment,
7
+ TLCommentThread,
8
+ usePassThroughWheelEvents,
9
+ useTranslation,
10
+ useValue,
11
+ } from 'tldraw'
12
+ import { CommentCard } from '../ui/comment-card'
13
+ import { replyCountLabel } from '../ui/reply-count'
14
+ import { type CommentingContext } from './context'
15
+ import { useComments } from './hooks'
16
+ import { useCommentingOptions } from './options'
17
+ import { openStackId, openThreadId } from './state'
18
+ import { POPOVER_OFFSET, toCardProps, useResolveName } from './thread-view'
19
+
20
+ /**
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).
24
+ *
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.
30
+ *
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.
34
+ */
35
+
36
+ /** How long the pointer must rest on a marker before its preview appears. */
37
+ const PREVIEW_OPEN_DELAY_MS = 180
38
+ /**
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.
42
+ */
43
+ const PREVIEW_CLOSE_DELAY_MS = 220
44
+ /** Cards shown before the panel falls back to a "+N more" line. */
45
+ const PREVIEW_MAX_THREADS = 5
46
+
47
+ /** A thread paired with the comment that opens it — one card of a preview. */
48
+ export interface ThreadPreviewCard {
49
+ thread: TLCommentThread
50
+ first: TLComment
51
+ }
52
+
53
+ /**
54
+ * The cards a marker's preview will show, and how many threads it will summarise as "+N more".
55
+ *
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.
59
+ */
60
+ export function selectPreviewCards(
61
+ threads: readonly TLCommentThread[],
62
+ firstCommentOf: (thread: TLCommentThread) => TLComment | undefined,
63
+ max = PREVIEW_MAX_THREADS
64
+ ): { cards: ThreadPreviewCard[]; overflow: number } {
65
+ const readable: ThreadPreviewCard[] = []
66
+ for (const thread of threads) {
67
+ const first = firstCommentOf(thread)
68
+ if (first) readable.push({ thread, first })
69
+ }
70
+ return { cards: readable.slice(0, max), overflow: Math.max(0, readable.length - max) }
71
+ }
72
+
73
+ /**
74
+ * What a marker's click opens, which is what its preview imitates — the panel's surface, its
75
+ * width, and how it lays a comment out.
76
+ *
77
+ * `'thread'` previews a single pin: a thread panel without the header. `'list'` previews a
78
+ * coincident stack or a cluster: one card per thread, like the stack list.
79
+ */
80
+ export type ThreadPreviewVariant = 'thread' | 'list'
81
+
82
+ /**
83
+ * 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.
86
+ */
87
+ const hoveredMarkerId = new EditorAtom<string | null>('commentHoveredMarkerId', () => null)
88
+
89
+ /**
90
+ * 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.
95
+ */
96
+ export function useMarkerPreview(editor: Editor, markerId: string) {
97
+ const openTimer = useRef(0)
98
+ const closeTimer = useRef(0)
99
+
100
+ // Previews are a resting-state affordance. While a thread or stack list is open, that view is
101
+ // the thing being read — a preview floating over it would just compete with it.
102
+ const suppressed = useValue(
103
+ 'marker preview suppressed',
104
+ () => openThreadId.get(editor) !== null || openStackId.get(editor) !== null,
105
+ [editor]
106
+ )
107
+ const shown = useValue('marker preview shown', () => hoveredMarkerId.get(editor) === markerId, [
108
+ editor,
109
+ markerId,
110
+ ])
111
+
112
+ useEffect(() => {
113
+ const open = openTimer
114
+ const close = closeTimer
115
+ return () => {
116
+ window.clearTimeout(open.current)
117
+ window.clearTimeout(close.current)
118
+ // Don't strand the atom on a marker that has unmounted — zoomed into a cluster, deleted,
119
+ // or folded away — or no other marker's preview could ever show.
120
+ if (hoveredMarkerId.get(editor) === markerId) hoveredMarkerId.set(editor, null)
121
+ }
122
+ }, [editor, markerId])
123
+
124
+ // Suppression can start *while* a preview is up: clicking the hovered pin opens its thread.
125
+ // Retract the preview rather than leaving it stranded behind the popover that just opened.
126
+ useEffect(() => {
127
+ if (suppressed && hoveredMarkerId.get(editor) === markerId) {
128
+ hoveredMarkerId.set(editor, null)
129
+ }
130
+ }, [suppressed, editor, markerId])
131
+
132
+ const onPointerEnter = () => {
133
+ window.clearTimeout(openTimer.current)
134
+ window.clearTimeout(closeTimer.current)
135
+ if (suppressed) return
136
+ openTimer.current = window.setTimeout(
137
+ () => hoveredMarkerId.set(editor, markerId),
138
+ PREVIEW_OPEN_DELAY_MS
139
+ )
140
+ }
141
+
142
+ const onPointerLeave = () => {
143
+ window.clearTimeout(openTimer.current)
144
+ window.clearTimeout(closeTimer.current)
145
+ closeTimer.current = window.setTimeout(() => {
146
+ if (hoveredMarkerId.get(editor) === markerId) hoveredMarkerId.set(editor, null)
147
+ }, PREVIEW_CLOSE_DELAY_MS)
148
+ }
149
+
150
+ return {
151
+ previewShown: shown && !suppressed,
152
+ previewHandlers: { onPointerEnter, onPointerLeave },
153
+ }
154
+ }
155
+
156
+ /**
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.
160
+ */
161
+ export function ThreadPreview({
162
+ editor,
163
+ threads,
164
+ container,
165
+ variant,
166
+ point,
167
+ onSelectThread,
168
+ onPointerEnter,
169
+ onPointerLeave,
170
+ ...props
171
+ }: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
172
+ editor: Editor
173
+ /** The marker's threads, in the order they should read (oldest first). */
174
+ threads: readonly TLCommentThread[]
175
+ container: HTMLElement
176
+ /** What the marker's click opens, which this panel imitates. */
177
+ variant: ThreadPreviewVariant
178
+ /** The marker's anchor point in viewport space — the same origin its popover is placed from. */
179
+ point: { x: number; y: number }
180
+ /** Open a thread from its card. Omit to leave the cards inert. */
181
+ onSelectThread?(thread: TLCommentThread): void
182
+ /** The owning marker's hover handlers, so the panel counts as part of its hover region. */
183
+ onPointerEnter?(): void
184
+ onPointerLeave?(): void
185
+ }) {
186
+ const options = useCommentingOptions()
187
+ const msg = useTranslation()
188
+ const comments = useComments(editor)
189
+ const resolveName = useResolveName(props.resolveAuthor)
190
+
191
+ // The panel floats over the canvas and scrolls nothing of its own, so a wheel on it should zoom
192
+ // and pan the canvas underneath — like every other tldraw panel, and like the popover it
193
+ // previews. Without this the canvas would freeze wherever the preview happened to be.
194
+ const ref = useRef<HTMLDivElement>(null)
195
+ usePassThroughWheelEvents(ref)
196
+
197
+ // Each thread's opening comment and its total comment count. `useComments` is oldest-first, so
198
+ // the first hit per thread is that thread's first comment. One pass over every comment beats a
199
+ // per-thread hook — the thread count here is driven by cluster size, which has no fixed bound.
200
+ const { firstByThread, countByThread } = useMemo(() => {
201
+ const first = new Map<string, TLComment>()
202
+ const count = new Map<string, number>()
203
+ for (const comment of comments) {
204
+ if (!first.has(comment.threadId)) first.set(comment.threadId, comment)
205
+ count.set(comment.threadId, (count.get(comment.threadId) ?? 0) + 1)
206
+ }
207
+ return { firstByThread: first, countByThread: count }
208
+ }, [comments])
209
+
210
+ const { cards, overflow } = useMemo(
211
+ () => selectPreviewCards(threads, (thread) => firstByThread.get(thread.id)),
212
+ [threads, firstByThread]
213
+ )
214
+
215
+ // Nothing readable yet — render no panel rather than an empty one. It matters most for the
216
+ // thread variant, where the panel *is* the card's surface: an empty one would paint as a blank
217
+ // box floating on the canvas.
218
+ if (cards.length === 0) return null
219
+
220
+ // Placed at the same origin as the popover this previews, so the two only differ by what their
221
+ // stylesheets do — see `.tlui-cmt-canvas-preview__panel--thread` for the header compensation.
222
+ const offset = POPOVER_OFFSET[variant]
223
+ // A thread preview is one comment on the panel itself, so the panel carries the hover and the
224
+ // click. A list preview puts each thread on its own card, as the stack list does.
225
+ const isThread = variant === 'thread'
226
+ const panelClass = [
227
+ 'tlui-cmt-canvas-preview__panel',
228
+ `tlui-cmt-canvas-preview__panel--${variant}`,
229
+ isThread && onSelectThread ? 'tlui-cmt-canvas-preview__panel--selectable' : '',
230
+ ]
231
+ .filter(Boolean)
232
+ .join(' ')
233
+
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
283
+ }
284
+ />
285
+ </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
+ )}
293
+ </div>
294
+ </div>,
295
+ container
296
+ )
297
+ }
298
+
299
+ /** Order a marker's threads for reading: oldest first, id as the tiebreak so it's stable. */
300
+ export function sortThreadsForPreview(
301
+ threads: readonly TLCommentThread[]
302
+ ): readonly TLCommentThread[] {
303
+ return [...threads].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
304
+ }
@@ -0,0 +1,252 @@
1
+ import { type MouseEvent as ReactMouseEvent, memo, useEffect, useRef } from 'react'
2
+ import {
3
+ Editor,
4
+ TLCommentThread,
5
+ useContainer,
6
+ usePassThroughWheelEvents,
7
+ useTranslation,
8
+ useValue,
9
+ } from 'tldraw'
10
+ import { CommentCard } from '../ui/comment-card'
11
+ import { CountBadge } from '../ui/count-badge'
12
+ import { UNKNOWN_AUTHOR } from './comment-render'
13
+ import { type CommentingContext } from './context'
14
+ import { useThreadComments } from './hooks'
15
+ import { useCommentingOptions } from './options'
16
+ import { pinStackKey } from './pin-stacking'
17
+ import { openStackId, openThreadId } from './state'
18
+ import { ThreadPreview, useMarkerPreview } from './thread-preview'
19
+ import { anchorPagePoint } from './thread-state'
20
+ import {
21
+ POPOVER_OFFSET,
22
+ ThreadPopover,
23
+ ThreadView,
24
+ toCardProps,
25
+ useResolveName,
26
+ } from './thread-view'
27
+
28
+ /**
29
+ * 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).
33
+ */
34
+ export const ThreadStackPin = memo(function ThreadStackPin({
35
+ editor,
36
+ threads,
37
+ ...props
38
+ }: CommentingContext & {
39
+ editor: Editor
40
+ /** The stack's threads, oldest first. All resolve to the same anchor point. */
41
+ threads: readonly TLCommentThread[]
42
+ }) {
43
+ const container = useContainer()
44
+ const msg = useTranslation()
45
+ const badgeRef = useRef<HTMLButtonElement>(null)
46
+ // The badge takes pointer events (to open on click), so wheel input over it would otherwise be
47
+ // swallowed instead of zooming — pass it through to the canvas, as the pin and cluster badge do.
48
+ usePassThroughWheelEvents(badgeRef)
49
+ // Hovering the badge previews its threads; clicking still opens them as the interactive list.
50
+ // The preview is what makes the badge legible before you commit to opening it.
51
+ 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.
58
+ const stackId = useValue(
59
+ 'stack id',
60
+ () => {
61
+ const pagePoint = anchorPagePoint(editor, threads[0].anchor)
62
+ return pagePoint ? pinStackKey(pagePoint) : threads[0].id
63
+ },
64
+ [editor, threads]
65
+ )
66
+ const listOpen = useValue('stack list open', () => openStackId.get(editor) === stackId, [
67
+ editor,
68
+ stackId,
69
+ ])
70
+ const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
71
+ const openMember = threads.find((thread) => thread.id === openId)
72
+ const open = listOpen || openMember !== undefined
73
+
74
+ const point = useValue(
75
+ 'stack point',
76
+ () => {
77
+ const first = threads[0]
78
+ 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.
84
+ const pagePoint = anchorPagePoint(editor, first.anchor)
85
+ return pagePoint ? editor.pageToViewport(pagePoint) : null
86
+ },
87
+ [editor, threads]
88
+ )
89
+
90
+ // Clicking outside the popover (and off the badge) closes the whole stack — mirrors the
91
+ // single pin's dismiss. Capture phase + class checks, since the popover portals elsewhere.
92
+ useEffect(() => {
93
+ if (!open) return
94
+ const onPointerDown = (e: PointerEvent) => {
95
+ const target = e.target as HTMLElement | null
96
+ if (!target) return
97
+ if (target.closest('.tlui-cmt-canvas-popover')) return
98
+ const badge = badgeRef.current
99
+ if (badge && badge.contains(target)) return
100
+ // A click inside a menu/popover layered above us belongs to that layer; defer to its
101
+ // own dismissal instead of closing the stack out from under it.
102
+ if (
103
+ target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
104
+ )
105
+ return
106
+ openStackId.set(editor, null)
107
+ openThreadId.set(editor, null)
108
+ }
109
+ document.addEventListener('pointerdown', onPointerDown, true)
110
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
111
+ }, [open, editor])
112
+
113
+ // Escape with only the card list showing closes it. When a member thread is expanded, the
114
+ // layer's Escape handler collapses that first and marks the event consumed — stepping back
115
+ // to the list rather than closing everything at once.
116
+ useEffect(() => {
117
+ if (!listOpen) return
118
+ const onKeyDown = (e: KeyboardEvent) => {
119
+ if (e.key !== 'Escape' || e.defaultPrevented) return
120
+ openStackId.set(editor, null)
121
+ }
122
+ document.addEventListener('keydown', onKeyDown, true)
123
+ return () => document.removeEventListener('keydown', onKeyDown, true)
124
+ }, [listOpen, editor])
125
+
126
+ if (!point) return null
127
+
128
+ const toggle = () => {
129
+ if (open) {
130
+ openStackId.set(editor, null)
131
+ openThreadId.set(editor, null)
132
+ } else {
133
+ openStackId.set(editor, stackId)
134
+ }
135
+ }
136
+
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.
143
+ const liftForHeader = threads[0]?.id === openId ? 42 : 0
144
+
145
+ return (
146
+ <>
147
+ <div className="tlui-cmt-canvas-pin" style={{ left: point.x, top: point.y }}>
148
+ <button
149
+ ref={badgeRef}
150
+ type="button"
151
+ className="tlui-cmt-button tlui-cmt-canvas-stack-badge"
152
+ aria-label={msg('comments.stack-label').replace('{count}', String(threads.length))}
153
+ aria-expanded={open}
154
+ onPointerDown={(e) => e.stopPropagation()}
155
+ onClick={(e) => {
156
+ e.stopPropagation()
157
+ toggle()
158
+ }}
159
+ {...previewHandlers}
160
+ onFocus={previewHandlers.onPointerEnter}
161
+ onBlur={previewHandlers.onPointerLeave}
162
+ >
163
+ <CountBadge count={threads.length} open={open} />
164
+ </button>
165
+ </div>
166
+ {previewShown && !open && (
167
+ <ThreadPreview
168
+ editor={editor}
169
+ threads={threads}
170
+ container={container}
171
+ // Lines the preview up with the stack list itself, so opening it leaves the cards
172
+ // exactly where the preview had them.
173
+ variant="list"
174
+ point={point}
175
+ // Picking a card from the preview lands in the same place clicking the badge and
176
+ // then the card would: the list open, that thread expanded within it.
177
+ onSelectThread={(thread) => {
178
+ openStackId.set(editor, stackId)
179
+ openThreadId.set(editor, thread.id)
180
+ }}
181
+ {...previewHandlers}
182
+ currentUserId={props.currentUserId}
183
+ resolveAuthor={props.resolveAuthor}
184
+ />
185
+ )}
186
+ {open && (
187
+ <ThreadPopover
188
+ container={container}
189
+ style={{
190
+ left: point.x + POPOVER_OFFSET.list.x,
191
+ top: point.y + POPOVER_OFFSET.list.y - liftForHeader,
192
+ }}
193
+ >
194
+ <div className="tlui-cmt-stack-list">
195
+ {threads.map((thread) =>
196
+ thread.id === openId ? (
197
+ <div key={thread.id} className="tlui-cmt-stack-list__thread">
198
+ <ThreadView editor={editor} thread={thread} {...props} />
199
+ </div>
200
+ ) : (
201
+ <StackThreadCard
202
+ key={thread.id}
203
+ editor={editor}
204
+ thread={thread}
205
+ {...props}
206
+ onOpen={() => openThreadId.set(editor, thread.id)}
207
+ />
208
+ )
209
+ )}
210
+ </div>
211
+ </ThreadPopover>
212
+ )}
213
+ </>
214
+ )
215
+ })
216
+
217
+ /** A collapsed stack entry: the thread's first comment as a card; clicking expands the thread. */
218
+ function StackThreadCard({
219
+ editor,
220
+ thread,
221
+ onOpen,
222
+ ...props
223
+ }: CommentingContext & { editor: Editor; thread: TLCommentThread; onOpen(): void }) {
224
+ const msg = useTranslation()
225
+ const options = useCommentingOptions()
226
+ const comments = useThreadComments(editor, thread.id)
227
+ const resolveName = useResolveName(props.resolveAuthor)
228
+ const first = comments[0]
229
+ if (!first) return null
230
+ const open = (e: ReactMouseEvent) => {
231
+ e.stopPropagation()
232
+ onOpen()
233
+ }
234
+ return (
235
+ <div className="tlui-cmt-stack-list__card" onClick={open}>
236
+ {/* The card's keyboard affordance. A button *wrapping* the card would put the comment
237
+ body inside it, and a body renders its links as real anchors — interactive content
238
+ nested in a button, which is an invalid content model and reads to assistive tech as a
239
+ broken control. So the button covers the card as a sibling instead, leaving the body's
240
+ own links above it and still reachable. */}
241
+ <button
242
+ type="button"
243
+ className="tlui-cmt-button tlui-cmt-stack-list__card-action"
244
+ aria-label={msg(
245
+ thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
246
+ ).replace('{name}', props.resolveAuthor(thread.createdBy)?.name ?? UNKNOWN_AUTHOR)}
247
+ onClick={open}
248
+ />
249
+ <CommentCard {...toCardProps(first, props, options.components, resolveName)} />
250
+ </div>
251
+ )
252
+ }