@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,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CommentListItemProps } from '../ui/comments-list'
3
+ import { sortSidebarRows } from './comments-sidebar'
4
+
5
+ function row(id: string, lastActivity: number, resolved = false) {
6
+ return {
7
+ item: {
8
+ id,
9
+ resolved,
10
+ author: { id: 'user:1', name: 'A' },
11
+ preview: '',
12
+ date: '',
13
+ } as unknown as CommentListItemProps,
14
+ lastActivity,
15
+ }
16
+ }
17
+
18
+ describe('sortSidebarRows', () => {
19
+ it('orders threads by their most recent comment, not their first', () => {
20
+ // t1 was started first but was just replied to; t2 has been quiet since it was created.
21
+ const sorted = sortSidebarRows([row('t1', 300), row('t2', 200)])
22
+ expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2'])
23
+ })
24
+
25
+ it('sinks resolved threads below unresolved ones however recent they are', () => {
26
+ const sorted = sortSidebarRows([row('t1', 100), row('t2', 999, true), row('t3', 200)])
27
+ expect(sorted.map((r) => r.item.id)).toEqual(['t3', 't1', 't2'])
28
+ })
29
+
30
+ it('orders resolved threads among themselves by recency too', () => {
31
+ const sorted = sortSidebarRows([row('t1', 100, true), row('t2', 300, true)])
32
+ expect(sorted.map((r) => r.item.id)).toEqual(['t2', 't1'])
33
+ })
34
+
35
+ it('breaks activity ties by id so the list does not reshuffle', () => {
36
+ const sorted = sortSidebarRows([row('t3', 5), row('t1', 5), row('t2', 5)])
37
+ expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2', 't3'])
38
+ })
39
+
40
+ it('does not mutate the input', () => {
41
+ const input = [row('t1', 100), row('t2', 200)]
42
+ sortSidebarRows(input)
43
+ expect(input.map((r) => r.item.id)).toEqual(['t1', 't2'])
44
+ })
45
+ })
@@ -1,8 +1,7 @@
1
- import { ReactNode, useRef } from 'react'
1
+ import { ReactNode, useCallback, useRef } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import {
4
4
  TLComment,
5
- TLCommentId,
6
5
  useContainer,
7
6
  useEditor,
8
7
  usePassThroughMouseOverEvents,
@@ -10,63 +9,55 @@ import {
10
9
  useValue,
11
10
  } from 'tldraw'
12
11
  import { CommentListItemProps, CommentsList } from '../ui/comments-list'
13
- import { UNKNOWN_AUTHOR } from './comment-render'
12
+ import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
14
13
  import { CommentsFilterMenu } from './comments-filter-menu'
15
- import { CommentsOverflowMenu } from './comments-overflow-menu'
14
+ import { CommentsVisibilityToggle } from './comments-visibility-toggle'
15
+ import { type CommentingContext } from './context'
16
16
  import { useComments, useCommentThreads } from './hooks'
17
17
  import { useCommentingEnabled } from './license'
18
18
  import { useCommentingOptions } from './options'
19
19
  import { richTextToPlaintext } from './rich-text'
20
- import { openThreadId, sidebarFilters } from './state'
20
+ import { commentsSidebarOpen, openThreadId, sidebarFilters } from './state'
21
21
  import { focusThread } from './thread-state'
22
22
 
23
- /** @public */
24
- export interface CanvasCommentsSidebarProps {
25
- /** Map an author id to a display name, or `undefined` when the id can't be named. */
26
- resolveName(id: string): string | undefined
27
- /** The signed-in user's id. Enables the "only your threads" filter when present. */
28
- currentUserId?: string
29
- /**
30
- * Whether a comment is unread for the current user (return true for unread). Enables the
31
- * "only unread" filter when present.
32
- */
33
- isCommentUnread?(commentId: TLCommentId): boolean
34
- /** Tool ids that show the sidebar. Defaults to the comment tool. */
35
- tools?: string[]
23
+ /**
24
+ * The host wiring for {@link CanvasCommentsSidebar}: the {@link CommentingContext} fields it reads,
25
+ * plus the panel's own slots. A non-null `currentUserId` enables the "only your threads" filter, and
26
+ * an `isCommentUnread` the "only unread" one. `CanvasComments` takes the same fields, so a host
27
+ * mounting both can spread one object into each.
28
+ *
29
+ * @public
30
+ */
31
+ export interface CanvasCommentsSidebarProps extends Pick<
32
+ CommentingContext,
33
+ 'currentUserId' | 'resolveAuthor' | 'isCommentUnread' | 'getThreadHref'
34
+ > {
36
35
  /** Header above the list. */
37
36
  header?: ReactNode
38
37
  /** Shown when the page has no threads. */
39
38
  empty?: ReactNode
40
- /** Where imprecise shape pins sit, so navigation centres on the same spot. Default top-right. */
41
- impreciseShapeAnchor?: { x: number; y: number }
42
39
  }
43
40
 
44
41
  /**
45
- * A comments list panel for the current page, shown while the comment tool is active. Clicking a
46
- * thread brings its pin into view and opens it. Batteries-included over the store (a sibling to
47
- * `CanvasComments`); `CommentsList` is exported for a differently-placed or always-on list.
42
+ * A comments list panel for the current page, shown while {@link commentsSidebarOpen} is set (e.g.
43
+ * toggled by a button). Clicking a thread brings its pin into view and opens it. Batteries-included
44
+ * over the store (a sibling to `CanvasComments`); `CommentsList` is exported for a differently-placed
45
+ * or always-on list.
48
46
  * @public @react
49
47
  */
50
48
  export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
51
- const {
52
- resolveName,
53
- currentUserId,
54
- isCommentUnread,
55
- tools,
56
- header,
57
- empty,
58
- impreciseShapeAnchor,
59
- } = props
49
+ const { resolveAuthor, currentUserId, isCommentUnread, header, empty, getThreadHref } = props
50
+ // Name-only view of the resolver, for the plaintext previews (which resolve @-mentions).
51
+ const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
60
52
  const editor = useEditor()
61
53
  const options = useCommentingOptions()
62
- const sidebarTools = tools ?? ['comment']
63
54
  const container = useContainer()
64
55
  const commentingEnabled = useCommentingEnabled()
65
56
  const msg = useTranslation()
66
57
  const threads = useCommentThreads(editor)
67
58
  const comments = useComments(editor)
68
59
  const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
69
- const activeTool = useValue('tool id', () => editor.getCurrentToolId(), [editor])
60
+ const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
70
61
  const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
71
62
  const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
72
63
  const pageNames = useValue(
@@ -75,7 +66,7 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
75
66
  [editor]
76
67
  )
77
68
 
78
- if (!commentingEnabled || !sidebarTools.includes(activeTool)) return null
69
+ if (!commentingEnabled || !open) return null
79
70
 
80
71
  // Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
81
72
  const byThread = new Map<string, TLComment[]>()
@@ -91,13 +82,12 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
91
82
  (thread) => !filters.onlyCurrentPage || thread.pageId === currentPageId
92
83
  )
93
84
 
94
- const items: CommentListItemProps[] = pageThreads
85
+ const rows: SidebarRow[] = pageThreads
95
86
  .filter((thread) => filters.showResolved || thread.resolved == null)
96
87
  // "Only mine" is ignored without a known user — otherwise a persisted onlyMine=true would
97
88
  // empty the list for a signed-out viewer, with the (hidden) toggle giving no way to clear it.
98
89
  .filter(
99
- (thread) =>
100
- !filters.onlyMine || currentUserId === undefined || thread.createdBy === currentUserId
90
+ (thread) => !filters.onlyMine || currentUserId === null || thread.createdBy === currentUserId
101
91
  )
102
92
  // "Only unread" is likewise ignored without a read-status source.
103
93
  .filter(
@@ -109,6 +99,8 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
109
99
  .map((thread) => {
110
100
  const threadComments = byThread.get(thread.id) ?? []
111
101
  const first = threadComments[0]
102
+ // Comments arrive oldest-first, so the last one is the thread's most recent activity.
103
+ const last = threadComments[threadComments.length - 1]
112
104
  let preview: ReactNode = ''
113
105
  // The `ThreadPreview` component slot overrides the built-in plaintext default.
114
106
  const ThreadPreview = options.components.ThreadPreview
@@ -120,27 +112,31 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
120
112
  )
121
113
  }
122
114
  return {
123
- id: thread.id,
124
- author: resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR,
125
- preview,
126
- date: new Date((first ?? thread).createdAt).toISOString(),
127
- resolved: thread.resolved != null,
128
- page: pageNames.get(thread.pageId),
129
- count: threadComments.length,
130
- selected: openId === thread.id,
115
+ item: {
116
+ id: thread.id,
117
+ author: resolveAuthor(thread.createdBy) ?? UNKNOWN_COMMENT_AUTHOR,
118
+ preview,
119
+ date: new Date((first ?? thread).createdAt).toISOString(),
120
+ resolved: thread.resolved != null,
121
+ // The page label only earns its place when it adds information: multiple pages
122
+ // exist, and the thread is somewhere other than where you already are.
123
+ page:
124
+ pageNames.size > 1 && thread.pageId !== currentPageId
125
+ ? pageNames.get(thread.pageId)
126
+ : undefined,
127
+ count: threadComments.length,
128
+ selected: openId === thread.id,
129
+ href: getThreadHref?.(thread.id),
130
+ },
131
+ lastActivity: (last ?? thread).createdAt,
131
132
  }
132
133
  })
133
- // unresolved first, then most-recent first
134
- .sort((a, b) => {
135
- if (!!a.resolved !== !!b.resolved) return a.resolved ? 1 : -1
136
- return b.date.localeCompare(a.date)
137
- })
134
+
135
+ const items = sortSidebarRows(rows).map((row) => row.item)
138
136
 
139
137
  const focus = (id: string) => {
140
138
  const thread = threads.find((t) => t.id === id)
141
- // Resolve prop-or-option like the overlay pins do, so sidebar navigation centers on the same
142
- // spot the pin renders at when the anchor is configured via `CommentTool.configure`.
143
- if (thread) focusThread(editor, thread, impreciseShapeAnchor ?? options.impreciseShapeAnchor)
139
+ if (thread) focusThread(editor, thread)
144
140
  }
145
141
 
146
142
  return (
@@ -149,12 +145,12 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
149
145
  items={items}
150
146
  header={header ?? msg('comments.title')}
151
147
  headerAction={
152
- <div className="cmt-list__header-actions">
148
+ <div className="tlui-cmt-list__header-actions">
153
149
  <CommentsFilterMenu
154
- canFilterByAuthor={currentUserId !== undefined}
150
+ canFilterByAuthor={currentUserId !== null}
155
151
  canFilterByUnread={isCommentUnread !== undefined}
156
152
  />
157
- <CommentsOverflowMenu />
153
+ <CommentsVisibilityToggle />
158
154
  </div>
159
155
  }
160
156
  empty={
@@ -169,6 +165,28 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
169
165
  )
170
166
  }
171
167
 
168
+ /** A list row paired with the sort key that isn't part of what the row displays. */
169
+ interface SidebarRow {
170
+ item: CommentListItemProps
171
+ /** When the thread's most recent comment was posted — what the list orders by. */
172
+ lastActivity: number
173
+ }
174
+
175
+ /**
176
+ * Order the list: unresolved threads first, then by most recent activity, id as a stable tiebreak.
177
+ * Recency is the thread's *latest* comment, not its first, so a thread someone just replied to rises
178
+ * to the top instead of staying wherever it was started. (The row still shows the thread's opening
179
+ * comment and its date — that's what identifies the thread; only the ordering follows the replies.)
180
+ */
181
+ export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[] {
182
+ return [...rows].sort(
183
+ (a, b) =>
184
+ Number(!!a.item.resolved) - Number(!!b.item.resolved) ||
185
+ b.lastActivity - a.lastActivity ||
186
+ (a.item.id < b.item.id ? -1 : 1)
187
+ )
188
+ }
189
+
172
190
  /** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
173
191
  * wheel-transparent panels — a wheel over it doesn't pan the canvas. Hover still passes through so
174
192
  * shapes beneath it stay interactive. */
@@ -176,7 +194,7 @@ function SidebarPanel({ container, children }: { container: HTMLElement; childre
176
194
  const ref = useRef<HTMLDivElement>(null)
177
195
  usePassThroughMouseOverEvents(ref)
178
196
  return createPortal(
179
- <div ref={ref} className="cmt-canvas-sidebar">
197
+ <div ref={ref} className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
180
198
  {children}
181
199
  </div>,
182
200
  container
@@ -0,0 +1,49 @@
1
+ import { useEditor, useTranslation, useValue } from 'tldraw'
2
+ import { TooltipButton } from '../ui/tooltip-button'
3
+ import { commentsHidden, toggleCommentsHidden } from './state'
4
+
5
+ /** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
6
+ * are hidden. The same state as the Shift+C shortcut.
7
+ * @public @react */
8
+ export function CommentsVisibilityToggle() {
9
+ const editor = useEditor()
10
+ const msg = useTranslation()
11
+ const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
12
+ const label = hidden ? msg('comments.show') : msg('comments.hide')
13
+
14
+ return (
15
+ <TooltipButton
16
+ tooltip={label}
17
+ className="tlui-cmt-header-btn"
18
+ onClick={() => toggleCommentsHidden(editor)}
19
+ >
20
+ {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>
48
+ )
49
+ }
@@ -0,0 +1,47 @@
1
+ import { type CommentAuthor, type MentionMember } from '@tldraw/mentions'
2
+ import { type ReactNode } from 'react'
3
+ import { type TLComment, type TLCommentId, type TLCommentThreadId } from 'tldraw'
4
+
5
+ /**
6
+ * The live, host-supplied half of commenting: who the viewer is, how author ids become names, read
7
+ * status, and the mention roster. (The static half is {@link CommentingOptions}, configured once on
8
+ * the tool.)
9
+ *
10
+ * Every commenting surface reads the same set, so a host mounting more than one — `CanvasComments`
11
+ * and `CanvasCommentsSidebar`, say — can build it once and spread it into each rather than
12
+ * repeating the props:
13
+ *
14
+ * ```tsx
15
+ * const commenting: CommentingContext = { currentUserId, resolveAuthor, isCommentUnread }
16
+ *
17
+ * <CanvasComments {...commenting} />
18
+ * <CanvasCommentsSidebar {...commenting} />
19
+ * ```
20
+ *
21
+ * @public
22
+ */
23
+ export interface CommentingContext {
24
+ /** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
25
+ currentUserId: string | null
26
+ /** Map an author id to their display info, or `undefined` when the id can't be resolved. */
27
+ resolveAuthor(id: string): CommentAuthor | undefined
28
+ /** Called after any comment (a new thread's first comment, or a reply) is posted. */
29
+ onPostComment?(comment: TLComment): void
30
+ /** Whether a comment is unread for the current user (return true for unread). */
31
+ isCommentUnread?(commentId: TLCommentId): boolean
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.
35
+ */
36
+ onCommentRead?(commentId: TLCommentId): void
37
+ /** Resolve the members matching an `@`-query in the composers (sync or async). */
38
+ getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
39
+ /** Override a mention-picker row's content. */
40
+ renderMentionSuggestion?(member: MentionMember): ReactNode
41
+ /**
42
+ * The host's URL for a thread, so surfaces can link to it — e.g. the sidebar's rows render as
43
+ * anchors when this is present, letting ctrl/cmd-click and middle-click open the thread in a
44
+ * new tab (a plain click still selects the thread in place; the href isn't followed).
45
+ */
46
+ getThreadHref?(threadId: TLCommentThreadId): string | undefined
47
+ }
@@ -1,28 +1,69 @@
1
- import { Editor, TLComment, TLCommentThread, TLCommentThreadId, useValue } from 'tldraw'
2
- import { getComments, getCommentThreads } from './comment-store'
1
+ import {
2
+ computed,
3
+ Computed,
4
+ Editor,
5
+ TLComment,
6
+ TLCommentThread,
7
+ TLCommentThreadId,
8
+ useValue,
9
+ WeakCache,
10
+ } from 'tldraw'
11
+ import { getLiveComments, getLiveCommentThreads } from './comment-store'
3
12
 
4
- /** All comment threads in the store, reactively. @public */
13
+ /**
14
+ * The comment threads that should render (pins, sidebar), reactively — the live set described by
15
+ * {@link getLiveCommentThreads}. Use `getCommentThreads` for the unfiltered set, including
16
+ * soft-deleted threads awaiting the server's prune.
17
+ *
18
+ * @public
19
+ */
5
20
  export function useCommentThreads(editor: Editor): TLCommentThread[] {
6
- return useValue('comment threads', () => getCommentThreads(editor), [editor])
21
+ return useValue('comment threads', () => getLiveCommentThreads(editor), [editor])
7
22
  }
8
23
 
9
- /** A thread's comments, oldest first, reactively. @public */
24
+ /**
25
+ * Every live comment grouped by thread, oldest first within each thread. One computed shared by
26
+ * every mounted thread: each `useThreadComments` would otherwise scan and sort the whole comment
27
+ * set, so N open threads did N full passes on every comment mutation. Cached per editor so the
28
+ * grouping survives re-renders and is rebuilt only when the comment set actually changes.
29
+ */
30
+ const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
31
+
32
+ function getCommentsByThread(editor: Editor) {
33
+ return commentsByThread.get(editor, () =>
34
+ computed('comments by thread', () => {
35
+ const byThread = new Map<TLCommentThreadId, TLComment[]>()
36
+ for (const comment of getLiveComments(editor)) {
37
+ const existing = byThread.get(comment.threadId)
38
+ if (existing) existing.push(comment)
39
+ else byThread.set(comment.threadId, [comment])
40
+ }
41
+ for (const comments of byThread.values()) {
42
+ comments.sort((a, b) => a.createdAt - b.createdAt)
43
+ }
44
+ return byThread
45
+ })
46
+ )
47
+ }
48
+
49
+ /** A thread's live comments, oldest first, reactively. @public */
10
50
  export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[] {
11
51
  return useValue(
12
52
  'thread comments',
13
- () =>
14
- getComments(editor)
15
- .filter((c) => c.threadId === threadId)
16
- .sort((a, b) => a.createdAt - b.createdAt),
53
+ () => getCommentsByThread(editor).get().get(threadId) ?? EMPTY_COMMENTS,
17
54
  [editor, threadId]
18
55
  )
19
56
  }
20
57
 
21
- /** Every comment in the store, oldest first, reactively. Group by `threadId` for per-thread lists. @public */
58
+ /** Shared empty result so a thread with no comments doesn't churn referential equality. */
59
+ const EMPTY_COMMENTS: TLComment[] = []
60
+
61
+ /** Every live comment in the store ({@link getLiveComments}), oldest first, reactively. Group by
62
+ * `threadId` for per-thread lists. @public */
22
63
  export function useComments(editor: Editor): TLComment[] {
23
64
  return useValue(
24
65
  'all comments',
25
- () => getComments(editor).sort((a, b) => a.createdAt - b.createdAt),
66
+ () => getLiveComments(editor).sort((a, b) => a.createdAt - b.createdAt),
26
67
  [editor]
27
68
  )
28
69
  }
@@ -1,8 +1,14 @@
1
1
  import type { Editor } from 'tldraw'
2
2
  import { describe, expect, it } from 'vitest'
3
+ import { commitCommentMutation } from './comment-mutations'
3
4
  import { CommentTool } from './comment-tool'
4
- import { defaultCommentingOptions, getCommentingOptions, type CommentingOptions } from './options'
5
- import { commitCommentMutation, openThreadId, pendingComment } from './state'
5
+ import {
6
+ defaultCommentingOptions,
7
+ getCanComment,
8
+ getCommentingOptions,
9
+ type CommentingOptions,
10
+ } from './options'
11
+ import { openThreadId, pendingComment } from './state'
6
12
 
7
13
  // The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
8
14
  // instantiate a configured tool and read its merged options.
@@ -78,6 +84,50 @@ describe('getCommentingOptions', () => {
78
84
  })
79
85
  })
80
86
 
87
+ describe('getCanComment', () => {
88
+ it('defaults to requiring a current user', () => {
89
+ const { editor } = stubEditor(defaultCommentingOptions)
90
+ expect(getCanComment(editor, 'alice')).toBe(true)
91
+ expect(getCanComment(editor, null)).toBe(false)
92
+ expect(getCanComment(editor, undefined)).toBe(false)
93
+ })
94
+
95
+ it('normalizes an undefined viewer to null for the callback', () => {
96
+ const calls: Array<string | null> = []
97
+ const { editor } = stubEditor({
98
+ ...defaultCommentingOptions,
99
+ canComment: (ctx) => {
100
+ calls.push(ctx.currentUserId)
101
+ return false
102
+ },
103
+ })
104
+ getCanComment(editor, undefined)
105
+ expect(calls).toEqual([null])
106
+ })
107
+
108
+ it('delegates to the canComment callback, passing the editor and viewer', () => {
109
+ const calls: Array<{ editor: Editor; currentUserId: string | null }> = []
110
+ const { editor } = stubEditor({
111
+ ...defaultCommentingOptions,
112
+ canComment: (ctx) => {
113
+ calls.push(ctx)
114
+ return ctx.currentUserId === 'alice'
115
+ },
116
+ })
117
+ expect(getCanComment(editor, 'alice')).toBe(true)
118
+ expect(getCanComment(editor, 'bob')).toBe(false)
119
+ expect(calls).toEqual([
120
+ { editor, currentUserId: 'alice' },
121
+ { editor, currentUserId: 'bob' },
122
+ ])
123
+ })
124
+
125
+ it('lets the callback fully replace the signed-in default', () => {
126
+ const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
127
+ expect(getCanComment(editor, null)).toBe(true)
128
+ })
129
+ })
130
+
81
131
  describe('commitCommentMutation', () => {
82
132
  it('uses options.history for a mutation and returns the callback result', () => {
83
133
  const { editor, runCalls } = stubEditor({