@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,11 +1,12 @@
1
- import { ReactNode, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
2
  import {
4
3
  createComment,
5
4
  Editor,
5
+ EditorPortal,
6
6
  TLComment,
7
7
  TLCommentThread,
8
8
  TLRichText,
9
+ TldrawUiButton,
9
10
  TldrawUiDropdownMenuContent,
10
11
  TldrawUiDropdownMenuGroup,
11
12
  TldrawUiDropdownMenuItem,
@@ -13,15 +14,14 @@ import {
13
14
  TldrawUiDropdownMenuTrigger,
14
15
  TldrawUiIcon,
15
16
  useContainer,
16
- usePassThroughMouseOverEvents,
17
17
  usePassThroughWheelEvents,
18
18
  useTranslation,
19
+ useValue,
19
20
  } from 'tldraw'
20
21
  import { CommentCard, CommentCardProps } from '../ui/comment-card'
21
22
  import { CommentComposer } from '../ui/comment-composer'
22
23
  import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
23
24
  import { CommentThread } from '../ui/comment-thread'
24
- import { TooltipButton } from '../ui/tooltip-button'
25
25
  import { CommentBody } from './comment-body'
26
26
  import {
27
27
  clearCommentDraft,
@@ -34,7 +34,6 @@ import {
34
34
  deleteComment,
35
35
  deleteThread,
36
36
  editComment,
37
- putRecordsInCommit,
38
37
  reopenThread,
39
38
  resolveThread,
40
39
  } from './comment-mutations'
@@ -42,7 +41,14 @@ import { CommentReactionPicker, CommentReactions } from './comment-reactions'
42
41
  import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
43
42
  import { type CommentingContext } from './context'
44
43
  import { useThreadComments } from './hooks'
45
- import { type CommentingComponents, useCanComment, useCommentingOptions } from './options'
44
+ import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
45
+ import {
46
+ type CommentingComponents,
47
+ getCanModifyComment,
48
+ useCanComment,
49
+ useCanModifyComment,
50
+ useCommentingOptions,
51
+ } from './options'
46
52
  import { openThreadId } from './state'
47
53
 
48
54
  const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
@@ -55,6 +61,45 @@ export function useResolveName(resolveAuthor: CommentingContext['resolveAuthor']
55
61
  return useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
56
62
  }
57
63
 
64
+ /** How long the copy-link item reads "Link copied" before reverting. */
65
+ const LINK_COPIED_MS = 2000
66
+
67
+ /**
68
+ * What a thread's `getThreadHref` should put on the clipboard. Hosts may hand back a relative href,
69
+ * which is meaningless once pasted elsewhere, so it's resolved against the current document first.
70
+ * An href the URL parser can't make sense of is copied as-is rather than dropped.
71
+ *
72
+ * @internal
73
+ */
74
+ export function absoluteThreadLink(href: string, base = window.location.href): string {
75
+ try {
76
+ return new URL(href, base).toString()
77
+ } catch {
78
+ return href
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Copy a thread's link, with a flag that stays set briefly afterwards so the control can confirm.
84
+ * Only set once the write resolves, so a withheld clipboard doesn't claim a copy that didn't happen.
85
+ */
86
+ function useCopyLink(href: string | undefined) {
87
+ const [copied, setCopied] = useState(false)
88
+ useEffect(() => {
89
+ if (!copied) return
90
+ const timeout = window.setTimeout(() => setCopied(false), LINK_COPIED_MS)
91
+ return () => window.clearTimeout(timeout)
92
+ }, [copied])
93
+ const copy = useCallback(() => {
94
+ if (href === undefined) return
95
+ navigator.clipboard?.writeText(absoluteThreadLink(href)).then(
96
+ () => setCopied(true),
97
+ () => setCopied(false)
98
+ )
99
+ }, [href])
100
+ return [copied, copy] as const
101
+ }
102
+
58
103
  export function toCardProps(
59
104
  comment: TLComment,
60
105
  props: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'>,
@@ -78,9 +123,8 @@ export function toCardProps(
78
123
  }
79
124
  }
80
125
 
81
- /** A pin is this square (mirrors `--tlui-cmt-pin-size`). Needed because the two marker kinds are
82
- * different sizes *and* anchor at different points, and lining their previews up means
83
- * correcting for that. Keep in sync with the stylesheet. */
126
+ /** A pin is this square (mirrors `--tlui-cmt-pin-size`). The two marker kinds differ in size *and*
127
+ * anchor point, so lining their previews up means correcting for both. Keep in sync with the CSS. */
84
128
  const PIN_SIZE = 28
85
129
 
86
130
  /** A cluster/stack badge is this square (`--tlui-cmt-marker-size`) — pin-sized, so the marker
@@ -95,59 +139,59 @@ const CARD_TOP_Y = -28
95
139
  const PREVIEW_GAP = 6
96
140
 
97
141
  /**
98
- * Where a marker's popover sits relative to the marker's anchor point.
99
- *
100
- * The hover preview places itself at these same origins, so the two views of a thread differ only
101
- * by the header the popover has — which its own stylesheet then compensates for. Moving a popover
102
- * here moves its preview with it.
142
+ * Where a marker's popover sits relative to the marker's anchor point. The hover preview uses the
143
+ * same origins, so moving a popover here moves its preview with it.
103
144
  *
104
- * Both marker kinds hang off their point the same way (`translate(0, -100%)`): the anchor is the
105
- * visual's bottom-left corner. They differ only in size, so each offset clears its own width plus
106
- * the shared gap, and re-bases the shared card-top measure from its bottom anchor to its middle
107
- * keeping the two previews' top cards on the same line.
145
+ * Both marker kinds hang off their point the same way (`translate(0, -100%)`), differing only in
146
+ * size so each offset clears its own width plus the shared gap, and re-bases the shared card-top
147
+ * measure from its bottom anchor to its middle.
108
148
  */
109
149
  export const POPOVER_OFFSET = {
110
150
  thread: { x: PIN_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - PIN_SIZE / 2 },
111
151
  list: { x: MARKER_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - MARKER_SIZE / 2 },
112
152
  } as const
113
153
 
114
- /** The open thread's popover container, portaled above the UI panels. Over it, wheel and hover
115
- * events pass through to the canvas (unless it scrolls its own content), like tldraw's panels. */
154
+ /** The open thread's popover container, portaled above the UI panels. A wheel over it passes
155
+ * through to the canvas (unless it scrolls its own content), like tldraw's panels. */
116
156
  export function ThreadPopover({
117
- container,
118
- style,
157
+ base,
119
158
  children,
120
159
  }: {
121
- container: HTMLElement
122
- style: CSSProperties
160
+ base: { x: number; y: number }
123
161
  children: ReactNode
124
162
  }) {
125
163
  const ref = useRef<HTMLDivElement>(null)
126
164
  usePassThroughWheelEvents(ref)
127
- usePassThroughMouseOverEvents(ref)
128
- return createPortal(
129
- // contextmenu also stops here: portals bubble React events to the canvas's context-menu
130
- // trigger (the layer mounts inside it), which would open the canvas menu over this panel.
131
- <div
132
- ref={ref}
133
- className="tlui-cmt-canvas-popover"
134
- style={style}
135
- onPointerDown={stop}
136
- onContextMenu={stop}
137
- >
138
- {children}
139
- </div>,
140
- container
165
+ // On mobile the popover slides off its fixed offset to stay above the software keyboard and
166
+ // on-screen; desktop keeps the fixed offset (base returned unchanged).
167
+ const isMobile = useIsMobileCommenting()
168
+ const placed = useMobilePlacement(ref, base, isMobile)
169
+ return (
170
+ <EditorPortal>
171
+ {/* contextmenu also stops here: portals bubble React events to the canvas's context-menu
172
+ trigger (the layer mounts inside it), which would open the canvas menu over this panel. */}
173
+ <div
174
+ ref={ref}
175
+ className="tlui-cmt-canvas-popover"
176
+ style={{ left: placed.left, top: placed.top }}
177
+ onPointerDown={stop}
178
+ onContextMenu={stop}
179
+ >
180
+ {children}
181
+ </div>
182
+ </EditorPortal>
141
183
  )
142
184
  }
143
185
 
144
186
  /**
145
187
  * One thread's interactive view: its comments, the reply composer, edit-in-place on your own
146
- * comments, and the resolve/delete actions. Reads and writes comment records via the editor's
147
- * store; read receipts are reported for every unread comment while mounted, so only mount it
148
- * where the thread is actually being shown.
188
+ * comments, and the resolve/delete actions. Read receipts are reported for every unread comment
189
+ * while mounted, so only mount it where the thread is actually being shown.
190
+ *
191
+ * Memoized because the popover position rides the pin's per-frame render point, so the pin
192
+ * re-renders on every camera frame while a thread is open.
149
193
  */
150
- export function ThreadView({
194
+ export const ThreadView = memo(function ThreadView({
151
195
  editor,
152
196
  thread,
153
197
  ...props
@@ -157,19 +201,52 @@ export function ThreadView({
157
201
  resolveAuthor,
158
202
  onPostComment,
159
203
  isCommentUnread,
160
- onCommentRead,
204
+ onCommentsRead,
161
205
  getMentionSuggestions,
162
206
  renderMentionSuggestion,
207
+ getThreadHref,
163
208
  } = props
164
209
  const options = useCommentingOptions()
165
210
  const comments = useThreadComments(editor, thread.id)
166
211
  const msg = useTranslation()
167
212
  const resolveName = useResolveName(resolveAuthor)
213
+ // Rebuilt only when the comments change, not on every render — each of which would otherwise
214
+ // re-allocate a date string and body element per comment.
215
+ const cards = useMemo(
216
+ () =>
217
+ comments.map((c) =>
218
+ toCardProps(c, { currentUserId, resolveAuthor }, options.components, resolveName)
219
+ ),
220
+ [comments, currentUserId, resolveAuthor, options.components, resolveName]
221
+ )
168
222
  const me = currentUserId ? resolveAuthor(currentUserId) : undefined
169
- // Composing, editing, deleting, and resolving are all commenting writes: gated on the viewer's
170
- // permission. Where it's withheld the composer gives way to the ComposerFallback slot (a
171
- // sign-in prompt, say) and the action affordances are hidden.
223
+ // Composing, editing, deleting, and resolving are all gated on the viewer's permission. Where it's
224
+ // withheld the composer gives way to the ComposerFallback slot and the affordances are hidden.
172
225
  const canComment = useCanComment(currentUserId)
226
+ // Editing and deleting are further per-record: the author's to do by default, wider or narrower
227
+ // under a `canModifyComment` callback. Computed for the thread's comments in one pass, since
228
+ // `renderComment` is a callback rather than a component and can't call a hook per comment.
229
+ const commentPermissions = useValue(
230
+ 'comment permissions',
231
+ () =>
232
+ new Map(
233
+ comments.map((comment) => [
234
+ comment.id,
235
+ {
236
+ edit: getCanModifyComment(editor, currentUserId, { action: 'edit-comment', comment }),
237
+ delete: getCanModifyComment(editor, currentUserId, {
238
+ action: 'delete-comment',
239
+ comment,
240
+ }),
241
+ },
242
+ ])
243
+ ),
244
+ [editor, currentUserId, comments]
245
+ )
246
+ // Deleting a thread is its creator's by default (and server-enforced); `canModifyComment` is what
247
+ // widens that. Still a commenting write, so `canComment` stays the outer gate.
248
+ const canModifyThread = useCanModifyComment(currentUserId, { action: 'delete-thread', thread })
249
+ const canDeleteThread = canComment && canModifyThread
173
250
  const ComposerFallback = options.components.ComposerFallback
174
251
  // An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
175
252
  // the flip side of dismissing without a discard warning.
@@ -180,15 +257,12 @@ export function ThreadView({
180
257
  const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
181
258
  const canReply = canComment && !thread.resolved
182
259
  const container = useContainer()
183
- // Where focus goes when the edit composer closes, resolved at that moment rather than held as an
184
- // element: the card (and its edit button with it) unmounts while the composer stands in its
185
- // place, so the node captured on the way in is gone by the way out.
260
+ // Resolved when the composer closes rather than held as an element: the card unmounts while the
261
+ // composer stands in its place, so the node captured on the way in is gone by the way out.
186
262
  const editReturnFocus = useRef<(() => HTMLElement | null) | null>(null)
187
263
 
188
- // Tab from the canvas drops the caret in the reply box. Clicking a pin opens the thread but
189
- // leaves focus on the editor container, so the first Tab would otherwise walk the app's own UI
190
- // instead of the panel the click just opened. Capture phase, ahead of the editor's own handling.
191
- // Fires once per open thread, so Tab inside the thread stays plain tab-through.
264
+ // Tab from the canvas drops the caret in the reply box: a pin click leaves focus on the editor
265
+ // container, so the first Tab would otherwise walk the app's UI. Capture phase, once per open thread.
192
266
  const [focusReply, setFocusReply] = useState(false)
193
267
  const tabTaken = useRef(false)
194
268
  const swallowTabUp = useRef(false)
@@ -204,9 +278,8 @@ export function ThreadView({
204
278
  const onKeyDown = (e: KeyboardEvent) => {
205
279
  if (e.key !== 'Tab' || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
206
280
  if (e.defaultPrevented || tabTaken.current) return
207
- // Focus rests on the container (or nothing at all) after a pin click. Anywhere else means
208
- // it's already somewhere deliberate the thread's own controls, the sidebar, a panel —
209
- // and Tab belongs to whatever holds it.
281
+ // Focus rests on the container (or nothing) after a pin click. Anywhere else is deliberate, and
282
+ // Tab belongs to whatever holds it.
210
283
  if (e.target !== container && e.target !== doc.body) return
211
284
  tabTaken.current = true
212
285
  swallowTabUp.current = true
@@ -214,9 +287,8 @@ export function ThreadView({
214
287
  e.preventDefault()
215
288
  e.stopPropagation()
216
289
  }
217
- // The select tool navigates shapes on Tab's *keyup*, and the composer isn't focused until the
218
- // next frame so a quick tap with shapes selected would both focus the reply and step the
219
- // selection. Swallow the release of the press we took, and only that one.
290
+ // The select tool navigates shapes on Tab's *keyup*, so a quick tap would both focus the reply and
291
+ // step the selection. Swallow the release of the press we took, and only that one.
220
292
  const onKeyUp = (e: KeyboardEvent) => {
221
293
  if (e.key !== 'Tab' || !swallowTabUp.current) return
222
294
  swallowTabUp.current = false
@@ -231,12 +303,9 @@ export function ThreadView({
231
303
  }
232
304
  }, [canReply, container])
233
305
 
234
- // Leaving the edit composer — Escape, or a save — unmounts it while it holds focus, and the
235
- // browser drops focus on the editor container. That's outside the thread, so a Tab from there
236
- // walks the app's UI instead of carrying on from where the edit left off (the thread's one
237
- // Tab-into-the-reply-box has usually been spent by then). Hand focus back to whatever opened the
238
- // composer instead: the card's edit button, or the reply box when the edit came from arrow-up.
239
- // The card's actions row reveals itself on `:focus-within`, so the button focus is visible.
306
+ // Leaving the edit composer unmounts it while it holds focus, dropping focus to the editor
307
+ // container outside the thread, so a Tab from there walks the app's UI. Hand focus back to
308
+ // whatever opened the composer instead.
240
309
  useEffect(() => {
241
310
  if (editingId !== null) return
242
311
  const resolve = editReturnFocus.current
@@ -245,32 +314,33 @@ export function ThreadView({
245
314
  resolve?.()?.focus()
246
315
  }, [editingId])
247
316
 
248
- // Every unread comment on display gets reported read including replies that arrive while
249
- // the view stays mounted, since the effect re-runs as `comments` changes. The host's receipt
250
- // write flips isCommentUnread to false, so re-runs find nothing to report.
317
+ // Reported as one batch so the host can record the receipts in a single write. The write flips
318
+ // isCommentUnread to false, so re-runs find nothing to report.
251
319
  useEffect(() => {
252
- if (!isCommentUnread || !onCommentRead) return
253
- for (const comment of comments) {
254
- if (isCommentUnread(comment.id)) {
255
- onCommentRead(comment.id)
256
- }
320
+ if (!isCommentUnread || !onCommentsRead) return
321
+ const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
322
+ if (unreadIds.length > 0) {
323
+ onCommentsRead(unreadIds)
257
324
  }
258
- }, [comments, isCommentUnread, onCommentRead])
325
+ }, [comments, isCommentUnread, onCommentsRead])
259
326
 
260
327
  const postReply = () => {
261
328
  if (isCommentEmpty(reply) || !currentUserId) return
262
- commitCommentMutation(editor, () => {
329
+ const comment = commitCommentMutation(editor, ({ put }) => {
263
330
  const comment = createComment({
264
331
  threadId: thread.id,
265
332
  pageId: thread.pageId,
266
333
  authorId: currentUserId,
267
334
  body: reply,
268
335
  })
269
- putRecordsInCommit(editor, [comment])
270
- if (onPostComment) onPostComment(comment)
336
+ put([comment])
337
+ return comment
271
338
  })
272
339
  setReply(EMPTY_COMMENT)
273
340
  clearCommentDraft(replyDraftSlot(thread.id))
341
+ // The host's callback is its own operation, not part of the post's history scope. It runs
342
+ // last so a throwing host can't strand the composer holding a draft of a posted reply.
343
+ onPostComment?.(comment)
274
344
  }
275
345
 
276
346
  const toggleResolve = () => {
@@ -279,17 +349,21 @@ export function ThreadView({
279
349
  else resolveThread(editor, thread, currentUserId)
280
350
  }
281
351
 
352
+ // No `currentUserId` check: unlike a resolve, a delete stamps nothing on the record, so who may
353
+ // make it is `canModifyComment`'s call alone (which withholds it from an unidentified viewer by
354
+ // default).
282
355
  const removeThread = () => {
283
- if (!currentUserId) return
284
356
  deleteThread(editor, thread)
285
357
  }
286
358
 
359
+ const ThreadActions = options.components.ThreadActions
360
+ // The host's URL for this thread. Its presence is what puts "copy link" in the header menu.
361
+ const threadHref = getThreadHref?.(thread.id)
362
+ const [linkCopied, copyThreadLink] = useCopyLink(threadHref)
363
+
287
364
  const startEdit = (comment: TLComment, { fromMoreMenu = false } = {}) => {
288
- // The ⋯ menu's button is the thing to come back to when Edit opened the composer looked up
289
- // again on the way out, since the menu item that was clicked (and the card) unmount while the
290
- // composer stands in their place. Otherwise come back to whatever held focus: arrow-up-to-edit
291
- // comes straight from the reply box, which stays put. The document body and the editor
292
- // container are where focus falls when nothing holds it, so neither is somewhere to return to.
365
+ // Edit from the ⋯ menu comes back to that button, looked up again on the way out since the card
366
+ // unmounts. Otherwise return to whatever held focus, ignoring the body and the editor container.
293
367
  const active = container.ownerDocument.activeElement
294
368
  editReturnFocus.current = fromMoreMenu
295
369
  ? () => container.querySelector<HTMLElement>(`[data-cmt-more-for="${comment.id}"]`)
@@ -308,9 +382,10 @@ export function ThreadView({
308
382
  }
309
383
 
310
384
  // Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
311
- // with an edit affordance on your own comments.
385
+ // with the affordances the viewer is allowed on it.
312
386
  const renderComment = (card: CommentCardProps, index: number): ReactNode => {
313
387
  const comment = comments[index]
388
+ const permissions = commentPermissions.get(comment.id)
314
389
  if (editingId === comment.id) {
315
390
  return (
316
391
  <div
@@ -352,12 +427,13 @@ export function ThreadView({
352
427
  actions={
353
428
  canComment && (
354
429
  <>
355
- <CommentReactionPicker comment={comment} currentUserId={currentUserId} />
356
- {comment.authorId === currentUserId && (
430
+ {(permissions?.edit || permissions?.delete) && (
357
431
  <TldrawUiDropdownMenuRoot id={`comment-actions-${comment.id}`}>
358
432
  <TldrawUiDropdownMenuTrigger>
359
- <TooltipButton
433
+ <TldrawUiButton
434
+ type="icon"
360
435
  tooltip={msg('comments.more-options')}
436
+ title={msg('comments.more-options')}
361
437
  className="tlui-cmt-thread__action"
362
438
  data-cmt-more-for={comment.id}
363
439
  >
@@ -366,7 +442,7 @@ export function ThreadView({
366
442
  label={msg('comments.more-options')}
367
443
  small
368
444
  />
369
- </TooltipButton>
445
+ </TldrawUiButton>
370
446
  </TldrawUiDropdownMenuTrigger>
371
447
  <TldrawUiDropdownMenuContent
372
448
  className="tlui-cmt-menu"
@@ -376,28 +452,34 @@ export function ThreadView({
376
452
  sideOffset={4}
377
453
  >
378
454
  <TldrawUiDropdownMenuGroup>
379
- <TldrawUiDropdownMenuItem>
380
- <button
381
- type="button"
382
- className="tlui-cmt-menu-item"
383
- onClick={() => startEdit(comment, { fromMoreMenu: true })}
384
- >
385
- <span>{msg('comments.edit')}</span>
386
- </button>
387
- </TldrawUiDropdownMenuItem>
388
- <TldrawUiDropdownMenuItem>
389
- <button
390
- type="button"
391
- className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
392
- onClick={() => deleteComment(editor, comment)}
393
- >
394
- <span>{msg('action.delete')}</span>
395
- </button>
396
- </TldrawUiDropdownMenuItem>
455
+ {permissions?.edit && (
456
+ <TldrawUiDropdownMenuItem>
457
+ <button
458
+ type="button"
459
+ className="tlui-cmt-menu-item"
460
+ onClick={() => startEdit(comment, { fromMoreMenu: true })}
461
+ >
462
+ <span>{msg('comments.edit')}</span>
463
+ </button>
464
+ </TldrawUiDropdownMenuItem>
465
+ )}
466
+ {permissions?.delete && (
467
+ <TldrawUiDropdownMenuItem>
468
+ <button
469
+ type="button"
470
+ className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
471
+ onClick={() => deleteComment(editor, comment)}
472
+ >
473
+ <span>{msg('action.delete')}</span>
474
+ </button>
475
+ </TldrawUiDropdownMenuItem>
476
+ )}
397
477
  </TldrawUiDropdownMenuGroup>
398
478
  </TldrawUiDropdownMenuContent>
399
479
  </TldrawUiDropdownMenuRoot>
400
480
  )}
481
+ {/* Last so react is always the rightmost pill. */}
482
+ <CommentReactionPicker comment={comment} currentUserId={currentUserId} />
401
483
  </>
402
484
  )
403
485
  }
@@ -407,18 +489,22 @@ export function ThreadView({
407
489
 
408
490
  // Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
409
491
  // resolve stamps into `resolved.by`.
492
+ const resolveLabel = msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')
410
493
  const headerActions = (
411
494
  <>
412
- {/* Deleting a thread is creator-only (server-enforced), and it's the menu's only item. */}
413
- {canComment && currentUserId && currentUserId === thread.createdBy && (
495
+ {/* Host verbs — assign, link a ticket sit ahead of the built-in actions. */}
496
+ {ThreadActions && <ThreadActions thread={thread} comments={comments} />}
497
+ {(threadHref !== undefined || canDeleteThread) && (
414
498
  <TldrawUiDropdownMenuRoot id={`comment-thread-actions-${thread.id}`}>
415
499
  <TldrawUiDropdownMenuTrigger>
416
- <TooltipButton
500
+ <TldrawUiButton
501
+ type="icon"
417
502
  tooltip={msg('comments.more-options')}
503
+ title={msg('comments.more-options')}
418
504
  className="tlui-cmt-thread__action"
419
505
  >
420
506
  <TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
421
- </TooltipButton>
507
+ </TldrawUiButton>
422
508
  </TldrawUiDropdownMenuTrigger>
423
509
  <TldrawUiDropdownMenuContent
424
510
  className="tlui-cmt-menu"
@@ -428,39 +514,51 @@ export function ThreadView({
428
514
  sideOffset={4}
429
515
  >
430
516
  <TldrawUiDropdownMenuGroup>
431
- <TldrawUiDropdownMenuItem>
432
- <button
433
- type="button"
434
- className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
435
- onClick={removeThread}
436
- >
437
- <span>{msg('comments.delete')}</span>
438
- </button>
439
- </TldrawUiDropdownMenuItem>
517
+ {/* A link is a read affordance: offered to anyone who can see the thread,
518
+ including a viewer who can't comment. `noClose` keeps the menu up so the
519
+ item can confirm the copy in place. */}
520
+ {threadHref !== undefined && (
521
+ <TldrawUiDropdownMenuItem noClose>
522
+ <button type="button" className="tlui-cmt-menu-item" onClick={copyThreadLink}>
523
+ <span>{msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}</span>
524
+ </button>
525
+ </TldrawUiDropdownMenuItem>
526
+ )}
527
+ {canDeleteThread && (
528
+ <TldrawUiDropdownMenuItem>
529
+ <button
530
+ type="button"
531
+ className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
532
+ onClick={removeThread}
533
+ >
534
+ <span>{msg('comments.delete')}</span>
535
+ </button>
536
+ </TldrawUiDropdownMenuItem>
537
+ )}
440
538
  </TldrawUiDropdownMenuGroup>
441
539
  </TldrawUiDropdownMenuContent>
442
540
  </TldrawUiDropdownMenuRoot>
443
541
  )}
444
542
  {canComment && currentUserId && (
445
- <TooltipButton
446
- tooltip={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
543
+ <TldrawUiButton
544
+ type="icon"
545
+ tooltip={resolveLabel}
546
+ title={resolveLabel}
447
547
  className="tlui-cmt-thread__action"
448
548
  onClick={toggleResolve}
449
549
  >
450
- <TldrawUiIcon
451
- icon="check"
452
- label={msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')}
453
- small
454
- />
455
- </TooltipButton>
550
+ <TldrawUiIcon icon="check" label={resolveLabel} small />
551
+ </TldrawUiButton>
456
552
  )}
457
- <TooltipButton
553
+ <TldrawUiButton
554
+ type="icon"
458
555
  tooltip={msg('comments.dismiss')}
556
+ title={msg('comments.dismiss')}
459
557
  className="tlui-cmt-thread__action"
460
558
  onClick={() => openThreadId.set(editor, null)}
461
559
  >
462
560
  <TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
463
- </TooltipButton>
561
+ </TldrawUiButton>
464
562
  </>
465
563
  )
466
564
 
@@ -469,7 +567,7 @@ export function ThreadView({
469
567
  header={msg('comments.thread-title')}
470
568
  headerActions={headerActions}
471
569
  renderComment={renderComment}
472
- comments={comments.map((c) => toCardProps(c, props, options.components, resolveName))}
570
+ comments={cards}
473
571
  resolvedBanner={
474
572
  thread.resolved
475
573
  ? msg('comments.resolved-by').replace(
@@ -491,10 +589,10 @@ export function ThreadView({
491
589
  },
492
590
  onSubmit: postReply,
493
591
  // Up in the empty reply box edits the comment directly above it, chat-style —
494
- // only when that comment is yours (the same gate as the Edit link).
592
+ // only when you're allowed to edit it (the same gate as the Edit link).
495
593
  onArrowUpWhenEmpty: () => {
496
594
  const last = comments[comments.length - 1]
497
- if (last && last.authorId === currentUserId) startEdit(last)
595
+ if (last && commentPermissions.get(last.id)?.edit) startEdit(last)
498
596
  },
499
597
  // No user, no author for the record — dead send button.
500
598
  disabled: isCommentEmpty(reply) || !currentUserId,
@@ -513,4 +611,4 @@ export function ThreadView({
513
611
  }
514
612
  />
515
613
  )
516
- }
614
+ })
@@ -1,7 +1,13 @@
1
1
  import { mstEdges } from './mst'
2
2
  import { cappedReplay } from './replay'
3
3
  import { contract, finalize } from './schedule'
4
- import type { ClusterNode, ClusterOptions, ClusterTable, LeafInput } from './types'
4
+ import type {
5
+ ClusterNode,
6
+ ClusterOptions,
7
+ ClusterTable,
8
+ LeafInput,
9
+ LeafScreenOffsets,
10
+ } from './types'
5
11
 
6
12
  interface ResolvedClusterOptions {
7
13
  Tc: number
@@ -16,7 +22,10 @@ interface ResolvedClusterOptions {
16
22
  /** @internal */
17
23
  export function computeClusterTable(
18
24
  leaves: readonly LeafInput[],
19
- options: ClusterOptions
25
+ options: ClusterOptions,
26
+ // Render offsets for markers that draw off their anchor (imprecise pins), keyed by leaf id.
27
+ // Omitted or empty, the run is identical — output and code paths — to an offset-unaware one.
28
+ screenOffsets?: LeafScreenOffsets
20
29
  ): ClusterTable {
21
30
  const opts = resolveOptions(options)
22
31
  const leafNodes = leaves.map(leafToNode)
@@ -26,7 +35,7 @@ export function computeClusterTable(
26
35
  return { events: [], leaves: leafNodes }
27
36
  }
28
37
 
29
- const raw = cappedReplay(leaves, edges, { Tc: opts.Tc, Dmax: opts.Dmax })
38
+ const raw = cappedReplay(leaves, edges, { Tc: opts.Tc, Dmax: opts.Dmax }, screenOffsets)
30
39
  const contracted = contract(raw, opts.eps)
31
40
  const events = finalize(contracted, {
32
41
  Tc: opts.Tc,