@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,14 +1,13 @@
1
- import { ReactNode, useCallback, useRef } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { ReactNode, useCallback } from 'react'
3
2
  import {
3
+ EditorPortal,
4
4
  TLComment,
5
- useContainer,
5
+ TLCommentThread,
6
6
  useEditor,
7
- usePassThroughMouseOverEvents,
8
7
  useTranslation,
9
8
  useValue,
10
9
  } from 'tldraw'
11
- import { CommentListItemProps, CommentsList } from '../ui/comments-list'
10
+ import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
12
11
  import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
13
12
  import { CommentsFilterMenu } from './comments-filter-menu'
14
13
  import { CommentsVisibilityToggle } from './comments-visibility-toggle'
@@ -51,7 +50,6 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
51
50
  const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
52
51
  const editor = useEditor()
53
52
  const options = useCommentingOptions()
54
- const container = useContainer()
55
53
  const commentingEnabled = useCommentingEnabled()
56
54
  const msg = useTranslation()
57
55
  const threads = useCommentThreads(editor)
@@ -139,8 +137,28 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
139
137
  if (thread) focusThread(editor, thread)
140
138
  }
141
139
 
140
+ // The `ThreadRow` component slot overrides the built-in row. It gets the thread record alongside
141
+ // the summarized row, so host state on `thread.meta` is in reach without a second lookup. Indexed
142
+ // once rather than scanned per row — this runs for every row on every render of the panel.
143
+ const ThreadRow = options.components.ThreadRow
144
+ const renderItem = ThreadRow
145
+ ? (() => {
146
+ // Keyed by plain string: a row's id comes back as one, not as a branded thread id.
147
+ const threadsById = new Map<string, TLCommentThread>(
148
+ threads.map((thread) => [thread.id, thread])
149
+ )
150
+ return (rowProps: CommentListItemRenderProps) => {
151
+ const thread = threadsById.get(rowProps.id)
152
+ // Can't happen — the items are built from these threads — but a row with no thread has
153
+ // nothing to render, and the slot's contract says the record is there.
154
+ if (!thread) return null
155
+ return <ThreadRow {...rowProps} thread={thread} />
156
+ }
157
+ })()
158
+ : undefined
159
+
142
160
  return (
143
- <SidebarPanel container={container}>
161
+ <SidebarPanel>
144
162
  <CommentsList
145
163
  items={items}
146
164
  header={header ?? msg('comments.title')}
@@ -160,13 +178,14 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
160
178
  }
161
179
  resolvedLabel={msg('comments.resolved')}
162
180
  onSelect={focus}
181
+ renderItem={renderItem}
163
182
  />
164
183
  </SidebarPanel>
165
184
  )
166
185
  }
167
186
 
168
- /** A list row paired with the sort key that isn't part of what the row displays. */
169
- interface SidebarRow {
187
+ /** A list row paired with the sort key that isn't part of what the row displays. @public */
188
+ export interface SidebarRow {
170
189
  item: CommentListItemProps
171
190
  /** When the thread's most recent comment was posted — what the list orders by. */
172
191
  lastActivity: number
@@ -177,6 +196,10 @@ interface SidebarRow {
177
196
  * Recency is the thread's *latest* comment, not its first, so a thread someone just replied to rises
178
197
  * to the top instead of staying wherever it was started. (The row still shows the thread's opening
179
198
  * comment and its date — that's what identifies the thread; only the ordering follows the replies.)
199
+ *
200
+ * Exported so a hand-built list can match the sidebar's ordering instead of re-deriving it.
201
+ *
202
+ * @public
180
203
  */
181
204
  export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[] {
182
205
  return [...rows].sort(
@@ -188,15 +211,13 @@ export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRo
188
211
  }
189
212
 
190
213
  /** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
191
- * wheel-transparent panels — a wheel over it doesn't pan the canvas. Hover still passes through so
192
- * shapes beneath it stay interactive. */
193
- function SidebarPanel({ container, children }: { container: HTMLElement; children: ReactNode }) {
194
- const ref = useRef<HTMLDivElement>(null)
195
- usePassThroughMouseOverEvents(ref)
196
- return createPortal(
197
- <div ref={ref} className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
198
- {children}
199
- </div>,
200
- container
214
+ * wheel-transparent panels — a wheel over it doesn't pan the canvas. */
215
+ function SidebarPanel({ children }: { children: ReactNode }) {
216
+ return (
217
+ <EditorPortal>
218
+ <div className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
219
+ {children}
220
+ </div>
221
+ </EditorPortal>
201
222
  )
202
223
  }
@@ -1,5 +1,5 @@
1
- import { useEditor, useTranslation, useValue } from 'tldraw'
2
- import { TooltipButton } from '../ui/tooltip-button'
1
+ import { TldrawUiButton, useEditor, useTranslation, useValue } from 'tldraw'
2
+ import { EyeClosedIcon, EyeOpenIcon } from '../ui/icons'
3
3
  import { commentsHidden, toggleCommentsHidden } from './state'
4
4
 
5
5
  /** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
@@ -12,38 +12,14 @@ export function CommentsVisibilityToggle() {
12
12
  const label = hidden ? msg('comments.show') : msg('comments.hide')
13
13
 
14
14
  return (
15
- <TooltipButton
15
+ <TldrawUiButton
16
+ type="icon"
16
17
  tooltip={label}
18
+ title={label}
17
19
  className="tlui-cmt-header-btn"
18
20
  onClick={() => toggleCommentsHidden(editor)}
19
21
  >
20
22
  {hidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
21
- </TooltipButton>
22
- )
23
- }
24
-
25
- function EyeOpenIcon() {
26
- return (
27
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
28
- <path
29
- d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.5C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
30
- fill="currentColor"
31
- fillRule="evenodd"
32
- clipRule="evenodd"
33
- />
34
- </svg>
35
- )
36
- }
37
-
38
- function EyeClosedIcon() {
39
- return (
40
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
41
- <path
42
- d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 11 7.50001 11C6.99965 11 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.39889 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 10 7.50001 10C10.3078 10 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
43
- fill="currentColor"
44
- fillRule="evenodd"
45
- clipRule="evenodd"
46
- />
47
- </svg>
23
+ </TldrawUiButton>
48
24
  )
49
25
  }
@@ -30,10 +30,11 @@ export interface CommentingContext {
30
30
  /** Whether a comment is unread for the current user (return true for unread). */
31
31
  isCommentUnread?(commentId: TLCommentId): boolean
32
32
  /**
33
- * Called for each unread comment shown to the user in an open thread popover, so hosts can
34
- * record a read receipt. Needs `isCommentUnread` to know what's unread.
33
+ * Called with every unread comment shown to the user in an open thread popover, batched per
34
+ * report, so hosts can record read receipts without a write per comment. Needs
35
+ * `isCommentUnread` to know what's unread.
35
36
  */
36
- onCommentRead?(commentId: TLCommentId): void
37
+ onCommentsRead?(commentIds: TLCommentId[]): void
37
38
  /** Resolve the members matching an `@`-query in the composers (sync or async). */
38
39
  getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
39
40
  /** Override a mention-picker row's content. */
@@ -0,0 +1,94 @@
1
+ import {
2
+ commentSchemaRecords,
3
+ createComment,
4
+ createCommentThread,
5
+ createTLSchema,
6
+ createTLStore,
7
+ defaultBindingUtils,
8
+ defaultShapeUtils,
9
+ defaultTools,
10
+ Editor,
11
+ toRichText,
12
+ } from 'tldraw'
13
+ import { afterEach, describe, expect, it } from 'vitest'
14
+ import { putCommentRecords } from './comment-mutations'
15
+ import { getCommentsByThread } from './hooks'
16
+
17
+ let editors: Editor[] = []
18
+
19
+ afterEach(() => {
20
+ for (const editor of editors) editor.dispose()
21
+ editors = []
22
+ })
23
+
24
+ function makeEditor(): Editor {
25
+ const editor = new Editor({
26
+ store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
27
+ shapeUtils: defaultShapeUtils,
28
+ bindingUtils: defaultBindingUtils,
29
+ tools: defaultTools,
30
+ getContainer: () => document.body,
31
+ })
32
+ editors.push(editor)
33
+ return editor
34
+ }
35
+
36
+ function makeThreadWithComment(editor: Editor, text: string) {
37
+ const pageId = editor.getCurrentPageId()
38
+ const thread = createCommentThread({
39
+ pageId,
40
+ anchor: { type: 'point', x: 0, y: 0 },
41
+ createdBy: 'user1',
42
+ })
43
+ const comment = createComment({
44
+ threadId: thread.id,
45
+ pageId,
46
+ authorId: 'user1',
47
+ body: toRichText(text),
48
+ })
49
+ return { thread, comment }
50
+ }
51
+
52
+ describe('getCommentsByThread', () => {
53
+ it('groups live comments by thread, oldest first', () => {
54
+ const editor = makeEditor()
55
+ const { thread, comment } = makeThreadWithComment(editor, 'first')
56
+ const reply = createComment({
57
+ threadId: thread.id,
58
+ pageId: editor.getCurrentPageId(),
59
+ authorId: 'user2',
60
+ body: toRichText('second'),
61
+ now: comment.createdAt + 1,
62
+ })
63
+ // Inserted newest-first to prove the grouping sorts by createdAt rather than store order.
64
+ putCommentRecords(editor, [thread, reply, comment])
65
+ expect(getCommentsByThread(editor).get().get(thread.id)).toEqual([comment, reply])
66
+ })
67
+
68
+ it("reuses a thread's array when another thread's comments change", () => {
69
+ const editor = makeEditor()
70
+ const a = makeThreadWithComment(editor, 'thread a')
71
+ const b = makeThreadWithComment(editor, 'thread b')
72
+ putCommentRecords(editor, [a.thread, a.comment, b.thread, b.comment])
73
+
74
+ const byThread = getCommentsByThread(editor)
75
+ const before = byThread.get()
76
+
77
+ const replyToB = createComment({
78
+ threadId: b.thread.id,
79
+ pageId: editor.getCurrentPageId(),
80
+ authorId: 'user2',
81
+ body: toRichText('reply'),
82
+ now: b.comment.createdAt + 1,
83
+ })
84
+ putCommentRecords(editor, [replyToB])
85
+ const after = byThread.get()
86
+
87
+ expect(after).not.toBe(before)
88
+ // The unchanged thread keeps its exact array, so its subscribers don't re-render...
89
+ expect(after.get(a.thread.id)).toBe(before.get(a.thread.id))
90
+ // ...while the changed thread gets a fresh one with the reply in place.
91
+ expect(after.get(b.thread.id)).not.toBe(before.get(b.thread.id))
92
+ expect(after.get(b.thread.id)).toEqual([b.comment, replyToB])
93
+ })
94
+ })
@@ -2,6 +2,7 @@ import {
2
2
  computed,
3
3
  Computed,
4
4
  Editor,
5
+ isUninitialized,
5
6
  TLComment,
6
7
  TLCommentThread,
7
8
  TLCommentThreadId,
@@ -29,17 +30,30 @@ export function useCommentThreads(editor: Editor): TLCommentThread[] {
29
30
  */
30
31
  const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
31
32
 
32
- function getCommentsByThread(editor: Editor) {
33
+ /** Exported for tests; use {@link useThreadComments} to consume. @internal */
34
+ export function getCommentsByThread(editor: Editor) {
33
35
  return commentsByThread.get(editor, () =>
34
- computed('comments by thread', () => {
36
+ computed('comments by thread', (prev) => {
35
37
  const byThread = new Map<TLCommentThreadId, TLComment[]>()
36
38
  for (const comment of getLiveComments(editor)) {
37
39
  const existing = byThread.get(comment.threadId)
38
40
  if (existing) existing.push(comment)
39
41
  else byThread.set(comment.threadId, [comment])
40
42
  }
41
- for (const comments of byThread.values()) {
43
+ for (const [threadId, comments] of byThread) {
42
44
  comments.sort((a, b) => a.createdAt - b.createdAt)
45
+ // Reuse the previous per-thread array when that thread's comment list is unchanged
46
+ // (record identities are stable while unchanged), so `useThreadComments` subscribers
47
+ // on other threads don't re-render when one thread gains a reply.
48
+ if (isUninitialized(prev)) continue
49
+ const prevComments = prev.get(threadId)
50
+ if (
51
+ prevComments &&
52
+ prevComments.length === comments.length &&
53
+ comments.every((comment, i) => prevComments[i] === comment)
54
+ ) {
55
+ byThread.set(threadId, prevComments)
56
+ }
43
57
  }
44
58
  return byThread
45
59
  })
@@ -7,7 +7,7 @@ import { useLicenseContext, useLicenseFeatureFlag } from 'tldraw'
7
7
  * pending, so gated UI stays hidden until the license is confirmed.
8
8
  *
9
9
  * The built-in commenting components (`CanvasComments`, `CanvasCommentsSidebar`, and the comment
10
- * tool's toolbar button) gate on this. Use it to gate any custom commenting UI the same way.
10
+ * tool's Quick Action) gate on this. Use it to gate any custom commenting UI the same way.
11
11
  * @public
12
12
  */
13
13
  export function useCommentingEnabled(): boolean {
@@ -0,0 +1,115 @@
1
+ import { RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
2
+ import { PORTRAIT_BREAKPOINT, useBreakpoint, useContainer } from 'tldraw'
3
+ import { getVisibleViewport } from '../ui/visual-viewport'
4
+
5
+ /**
6
+ * Mobile mode for the commenting surfaces — the same breakpoint gate as tldraw's mobile toolbar and
7
+ * style panel (which also honors `forceMobile` on `<Tldraw>`). The commenting layer can mount
8
+ * without tldraw's default UI (`hideUi`, custom UI), where the breakpoint provider is absent and
9
+ * `useBreakpoint` throws; fall back to desktop there, matching the pre-mobile rendering those hosts
10
+ * always had.
11
+ */
12
+ export function useIsMobileCommenting(): boolean {
13
+ let breakpoint: number
14
+ try {
15
+ // The call is unconditional — the try only guards the provider's absence, not hook order.
16
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
17
+ breakpoint = useBreakpoint()
18
+ } catch {
19
+ return false
20
+ }
21
+ return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
22
+ }
23
+
24
+ const VIEWPORT_MARGIN = 8
25
+
26
+ /**
27
+ * Keep a canvas-anchored panel — the pending composer or an open thread popover — within the
28
+ * visible viewport on mobile. The software keyboard shrinks the *visual* viewport while leaving the
29
+ * layout viewport (and CSS `dvh`) untouched, so a panel placed at a fixed offset from its pin can
30
+ * end up behind the keyboard. On mobile this clamps the panel's top-left into the visible box so it
31
+ * stays on-screen above the keyboard. Desktop is untouched: when `enabled` is false the base point
32
+ * is returned unchanged, tracking the camera exactly as before.
33
+ *
34
+ * `base` is the panel's desired top-left in container-relative viewport coordinates (what the call
35
+ * site would otherwise write straight into `left`/`top`).
36
+ */
37
+ export function useMobilePlacement(
38
+ ref: RefObject<HTMLElement | null>,
39
+ base: { x: number; y: number },
40
+ enabled: boolean
41
+ ): { left: number; top: number } {
42
+ const container = useContainer()
43
+ // Destructured so the effects below depend on the two numbers rather than the object, which the
44
+ // call sites build fresh on every render.
45
+ const { x: baseX, y: baseY } = base
46
+ const [placed, setPlaced] = useState<{ left: number; top: number }>(() => ({
47
+ left: baseX,
48
+ top: baseY,
49
+ }))
50
+ // The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
51
+ // across those frames, so the observers below are set up once per panel rather than being torn
52
+ // down and rebuilt each frame.
53
+ const baseRef = useRef({ x: baseX, y: baseY })
54
+
55
+ const update = useCallback(() => {
56
+ if (!enabled) return
57
+ const el = ref.current
58
+ if (!el) return
59
+ const win = container.ownerDocument.defaultView ?? window
60
+ const { x, y } = baseRef.current
61
+
62
+ // Panel coordinates are container-relative; the visual viewport is window-relative.
63
+ const cRect = container.getBoundingClientRect()
64
+ const vp = getVisibleViewport(win)
65
+ const top = vp.top - cRect.top + VIEWPORT_MARGIN
66
+ const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
67
+ const left = vp.left - cRect.left + VIEWPORT_MARGIN
68
+ const right = vp.right - cRect.left - VIEWPORT_MARGIN
69
+
70
+ const w = el.offsetWidth
71
+ const h = el.offsetHeight
72
+
73
+ const nextLeft = Math.max(left, Math.min(x, right - w))
74
+ // The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
75
+ // bottom would be hidden) but must never push it down below its natural spot. `y` is the
76
+ // floor: any spurious rise in `top` (e.g. iOS scrolling the page to reveal a focused input)
77
+ // is capped here, so a panel that already clears the keyboard doesn't move at all.
78
+ const nextTop = Math.min(y, Math.max(top, bottom - h))
79
+ setPlaced((prev) =>
80
+ prev.left === nextLeft && prev.top === nextTop ? prev : { left: nextLeft, top: nextTop }
81
+ )
82
+ }, [container, ref, enabled])
83
+
84
+ // Re-place as the camera moves the panel's base point.
85
+ useLayoutEffect(() => {
86
+ baseRef.current = { x: baseX, y: baseY }
87
+ update()
88
+ }, [baseX, baseY, update])
89
+
90
+ // Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
91
+ // pinch-zoom), or when the window resizes.
92
+ useLayoutEffect(() => {
93
+ if (!enabled) return
94
+ const el = ref.current
95
+ if (!el) return
96
+ const win = container.ownerDocument.defaultView ?? window
97
+ const ro = new ResizeObserver(update)
98
+ ro.observe(el)
99
+ const vv = win.visualViewport
100
+ vv?.addEventListener('resize', update)
101
+ vv?.addEventListener('scroll', update)
102
+ win.addEventListener('resize', update)
103
+ return () => {
104
+ ro.disconnect()
105
+ vv?.removeEventListener('resize', update)
106
+ vv?.removeEventListener('scroll', update)
107
+ win.removeEventListener('resize', update)
108
+ }
109
+ }, [container, ref, enabled, update])
110
+
111
+ // Desktop keeps its fixed placement, recomputed each render so it tracks the pin as the camera
112
+ // moves.
113
+ if (!enabled) return { left: baseX, top: baseY }
114
+ return placed
115
+ }
@@ -1,12 +1,15 @@
1
- import type { Editor } from 'tldraw'
2
- import { describe, expect, it } from 'vitest'
1
+ import { createComment, createCommentThread, toRichText, type Editor, type TLPageId } from 'tldraw'
2
+ import { describe, expect, it, vi } from 'vitest'
3
3
  import { commitCommentMutation } from './comment-mutations'
4
4
  import { CommentTool } from './comment-tool'
5
5
  import {
6
+ defaultCanModifyComment,
6
7
  defaultCommentingOptions,
7
8
  getCanComment,
9
+ getCanModifyComment,
8
10
  getCommentingOptions,
9
11
  type CommentingOptions,
12
+ type CommentModificationContext,
10
13
  } from './options'
11
14
  import { openThreadId, pendingComment } from './state'
12
15
 
@@ -126,6 +129,125 @@ describe('getCanComment', () => {
126
129
  const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
127
130
  expect(getCanComment(editor, null)).toBe(true)
128
131
  })
132
+
133
+ // This is read during render, so a throwing host rule would take the comments layer with it.
134
+ it('denies rather than throws when the callback throws', () => {
135
+ const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
136
+ const { editor } = stubEditor({
137
+ ...defaultCommentingOptions,
138
+ canComment: () => {
139
+ throw new Error('lookup failed')
140
+ },
141
+ })
142
+ expect(getCanComment(editor, 'alice')).toBe(false)
143
+ expect(onError).toHaveBeenCalled()
144
+ onError.mockRestore()
145
+ })
146
+ })
147
+
148
+ // Plain records for the permission checks — the factories are pure, so no store is involved.
149
+ const PAGE_ID = 'page:test' as TLPageId
150
+
151
+ function makeThread(createdBy: string) {
152
+ return createCommentThread({
153
+ pageId: PAGE_ID,
154
+ anchor: { type: 'point', x: 0, y: 0 },
155
+ createdBy,
156
+ })
157
+ }
158
+
159
+ function makeComment(authorId: string) {
160
+ return createComment({
161
+ threadId: makeThread(authorId).id,
162
+ pageId: PAGE_ID,
163
+ authorId,
164
+ body: toRichText('hello'),
165
+ })
166
+ }
167
+
168
+ describe('getCanModifyComment', () => {
169
+ it('defaults to the record owner: the comment author edits and deletes', () => {
170
+ const { editor } = stubEditor(defaultCommentingOptions)
171
+ const comment = makeComment('alice')
172
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
173
+ expect(getCanModifyComment(editor, 'alice', { action: 'delete-comment', comment })).toBe(true)
174
+ expect(getCanModifyComment(editor, 'bob', { action: 'edit-comment', comment })).toBe(false)
175
+ expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
176
+ })
177
+
178
+ it('defaults to the thread creator for a thread delete', () => {
179
+ const { editor } = stubEditor(defaultCommentingOptions)
180
+ const thread = makeThread('alice')
181
+ expect(getCanModifyComment(editor, 'alice', { action: 'delete-thread', thread })).toBe(true)
182
+ expect(getCanModifyComment(editor, 'bob', { action: 'delete-thread', thread })).toBe(false)
183
+ })
184
+
185
+ it('withholds everything from a viewer with no identity', () => {
186
+ const { editor } = stubEditor(defaultCommentingOptions)
187
+ const comment = makeComment('alice')
188
+ const thread = makeThread('alice')
189
+ expect(getCanModifyComment(editor, null, { action: 'edit-comment', comment })).toBe(false)
190
+ expect(getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })).toBe(
191
+ false
192
+ )
193
+ expect(getCanModifyComment(editor, null, { action: 'delete-thread', thread })).toBe(false)
194
+ })
195
+
196
+ it('passes the editor, the viewer, and the targeted write to the callback', () => {
197
+ const calls: CommentModificationContext[] = []
198
+ const { editor } = stubEditor({
199
+ ...defaultCommentingOptions,
200
+ canModifyComment: (ctx) => {
201
+ calls.push(ctx)
202
+ return true
203
+ },
204
+ })
205
+ const comment = makeComment('alice')
206
+ getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })
207
+ // An undefined viewer normalizes to null, as it does for `canComment`.
208
+ expect(calls).toEqual([{ editor, currentUserId: null, action: 'delete-comment', comment }])
209
+ })
210
+
211
+ it("lets a callback widen the default: a moderator deleting anyone's comment", () => {
212
+ const { editor } = stubEditor({
213
+ ...defaultCommentingOptions,
214
+ canModifyComment: (ctx) =>
215
+ (ctx.action !== 'edit-comment' && ctx.currentUserId === 'mod') ||
216
+ defaultCanModifyComment(ctx),
217
+ })
218
+ const comment = makeComment('alice')
219
+ const thread = makeThread('alice')
220
+ expect(getCanModifyComment(editor, 'mod', { action: 'delete-comment', comment })).toBe(true)
221
+ expect(getCanModifyComment(editor, 'mod', { action: 'delete-thread', thread })).toBe(true)
222
+ // Widening deletion doesn't hand the moderator anyone else's edit affordance.
223
+ expect(getCanModifyComment(editor, 'mod', { action: 'edit-comment', comment })).toBe(false)
224
+ // The author keeps their own affordances.
225
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
226
+ // Everyone else still gets nothing.
227
+ expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
228
+ })
229
+
230
+ it('lets a callback narrow the default', () => {
231
+ const { editor } = stubEditor({ ...defaultCommentingOptions, canModifyComment: () => false })
232
+ const comment = makeComment('alice')
233
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
234
+ })
235
+
236
+ // A throw withholds the affordance rather than the whole layer, and never offers a write a
237
+ // server enforcing the same rule would reject anyway.
238
+ it('denies rather than throws when the callback throws', () => {
239
+ const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
240
+ const { editor } = stubEditor({
241
+ ...defaultCommentingOptions,
242
+ canModifyComment: () => {
243
+ throw new Error('lookup failed')
244
+ },
245
+ })
246
+ const comment = makeComment('alice')
247
+ expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
248
+ expect(onError).toHaveBeenCalled()
249
+ onError.mockRestore()
250
+ })
129
251
  })
130
252
 
131
253
  describe('commitCommentMutation', () => {