@tldraw/commenting 0.0.0-bootstrap

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 (242) hide show
  1. package/README.md +11 -0
  2. package/commenting.css +832 -0
  3. package/dist-cjs/canvas/cluster-input.js +40 -0
  4. package/dist-cjs/canvas/cluster-input.js.map +7 -0
  5. package/dist-cjs/canvas/comment-body.js +31 -0
  6. package/dist-cjs/canvas/comment-body.js.map +7 -0
  7. package/dist-cjs/canvas/comment-render.js +72 -0
  8. package/dist-cjs/canvas/comment-render.js.map +7 -0
  9. package/dist-cjs/canvas/comment-store.js +48 -0
  10. package/dist-cjs/canvas/comment-store.js.map +7 -0
  11. package/dist-cjs/canvas/comment-tool.js +160 -0
  12. package/dist-cjs/canvas/comment-tool.js.map +7 -0
  13. package/dist-cjs/canvas/comments-filter-menu.js +109 -0
  14. package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
  15. package/dist-cjs/canvas/comments-menu-item.js +42 -0
  16. package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
  17. package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
  18. package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
  19. package/dist-cjs/canvas/comments-overlay.js +1081 -0
  20. package/dist-cjs/canvas/comments-overlay.js.map +7 -0
  21. package/dist-cjs/canvas/comments-sidebar.js +134 -0
  22. package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
  23. package/dist-cjs/canvas/hooks.js +45 -0
  24. package/dist-cjs/canvas/hooks.js.map +7 -0
  25. package/dist-cjs/canvas/license.js +28 -0
  26. package/dist-cjs/canvas/license.js.map +7 -0
  27. package/dist-cjs/canvas/options.js +45 -0
  28. package/dist-cjs/canvas/options.js.map +7 -0
  29. package/dist-cjs/canvas/region-options.js +49 -0
  30. package/dist-cjs/canvas/region-options.js.map +7 -0
  31. package/dist-cjs/canvas/rich-text.js +28 -0
  32. package/dist-cjs/canvas/rich-text.js.map +7 -0
  33. package/dist-cjs/canvas/sidebar-filters.js +30 -0
  34. package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
  35. package/dist-cjs/canvas/state.js +76 -0
  36. package/dist-cjs/canvas/state.js.map +7 -0
  37. package/dist-cjs/canvas/thread-state.js +81 -0
  38. package/dist-cjs/canvas/thread-state.js.map +7 -0
  39. package/dist-cjs/clustering/computeClusterTable.js +83 -0
  40. package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
  41. package/dist-cjs/clustering/mst.js +117 -0
  42. package/dist-cjs/clustering/mst.js.map +7 -0
  43. package/dist-cjs/clustering/replay.js +275 -0
  44. package/dist-cjs/clustering/replay.js.map +7 -0
  45. package/dist-cjs/clustering/runtime.js +134 -0
  46. package/dist-cjs/clustering/runtime.js.map +7 -0
  47. package/dist-cjs/clustering/schedule.js +170 -0
  48. package/dist-cjs/clustering/schedule.js.map +7 -0
  49. package/dist-cjs/clustering/types.js +17 -0
  50. package/dist-cjs/clustering/types.js.map +7 -0
  51. package/dist-cjs/index.d.ts +868 -0
  52. package/dist-cjs/index.js +126 -0
  53. package/dist-cjs/index.js.map +7 -0
  54. package/dist-cjs/ui/avatar.js +43 -0
  55. package/dist-cjs/ui/avatar.js.map +7 -0
  56. package/dist-cjs/ui/byline.js +35 -0
  57. package/dist-cjs/ui/byline.js.map +7 -0
  58. package/dist-cjs/ui/comment-card.js +37 -0
  59. package/dist-cjs/ui/comment-card.js.map +7 -0
  60. package/dist-cjs/ui/comment-composer.js +136 -0
  61. package/dist-cjs/ui/comment-composer.js.map +7 -0
  62. package/dist-cjs/ui/comment-extensions.js +37 -0
  63. package/dist-cjs/ui/comment-extensions.js.map +7 -0
  64. package/dist-cjs/ui/comment-mention.js +42 -0
  65. package/dist-cjs/ui/comment-mention.js.map +7 -0
  66. package/dist-cjs/ui/comment-pin.js +29 -0
  67. package/dist-cjs/ui/comment-pin.js.map +7 -0
  68. package/dist-cjs/ui/comment-text.js +29 -0
  69. package/dist-cjs/ui/comment-text.js.map +7 -0
  70. package/dist-cjs/ui/comment-thread.js +45 -0
  71. package/dist-cjs/ui/comment-thread.js.map +7 -0
  72. package/dist-cjs/ui/comments-list.js +106 -0
  73. package/dist-cjs/ui/comments-list.js.map +7 -0
  74. package/dist-cjs/ui/count-badge.js +28 -0
  75. package/dist-cjs/ui/count-badge.js.map +7 -0
  76. package/dist-cjs/ui/empty-state.js +31 -0
  77. package/dist-cjs/ui/empty-state.js.map +7 -0
  78. package/dist-cjs/ui/format-time.js +46 -0
  79. package/dist-cjs/ui/format-time.js.map +7 -0
  80. package/dist-cjs/ui/mention-list.js +70 -0
  81. package/dist-cjs/ui/mention-list.js.map +7 -0
  82. package/dist-cjs/ui/mention-suggestion.js +206 -0
  83. package/dist-cjs/ui/mention-suggestion.js.map +7 -0
  84. package/dist-cjs/ui/mention.js +31 -0
  85. package/dist-cjs/ui/mention.js.map +7 -0
  86. package/dist-cjs/ui/reaction.js +31 -0
  87. package/dist-cjs/ui/reaction.js.map +7 -0
  88. package/dist-cjs/ui/reactions.js +34 -0
  89. package/dist-cjs/ui/reactions.js.map +7 -0
  90. package/dist-cjs/ui/render-markdown.js +63 -0
  91. package/dist-cjs/ui/render-markdown.js.map +7 -0
  92. package/dist-cjs/ui/send-button.js +28 -0
  93. package/dist-cjs/ui/send-button.js.map +7 -0
  94. package/dist-esm/canvas/cluster-input.mjs +20 -0
  95. package/dist-esm/canvas/cluster-input.mjs.map +7 -0
  96. package/dist-esm/canvas/comment-body.mjs +11 -0
  97. package/dist-esm/canvas/comment-body.mjs.map +7 -0
  98. package/dist-esm/canvas/comment-render.mjs +52 -0
  99. package/dist-esm/canvas/comment-render.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-store.mjs +28 -0
  101. package/dist-esm/canvas/comment-store.mjs.map +7 -0
  102. package/dist-esm/canvas/comment-tool.mjs +142 -0
  103. package/dist-esm/canvas/comment-tool.mjs.map +7 -0
  104. package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
  105. package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
  106. package/dist-esm/canvas/comments-menu-item.mjs +22 -0
  107. package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
  108. package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
  109. package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
  110. package/dist-esm/canvas/comments-overlay.mjs +1094 -0
  111. package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
  112. package/dist-esm/canvas/comments-sidebar.mjs +120 -0
  113. package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
  114. package/dist-esm/canvas/hooks.mjs +25 -0
  115. package/dist-esm/canvas/hooks.mjs.map +7 -0
  116. package/dist-esm/canvas/license.mjs +8 -0
  117. package/dist-esm/canvas/license.mjs.map +7 -0
  118. package/dist-esm/canvas/options.mjs +27 -0
  119. package/dist-esm/canvas/options.mjs.map +7 -0
  120. package/dist-esm/canvas/region-options.mjs +29 -0
  121. package/dist-esm/canvas/region-options.mjs.map +7 -0
  122. package/dist-esm/canvas/rich-text.mjs +8 -0
  123. package/dist-esm/canvas/rich-text.mjs.map +7 -0
  124. package/dist-esm/canvas/sidebar-filters.mjs +10 -0
  125. package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
  126. package/dist-esm/canvas/state.mjs +60 -0
  127. package/dist-esm/canvas/state.mjs.map +7 -0
  128. package/dist-esm/canvas/thread-state.mjs +61 -0
  129. package/dist-esm/canvas/thread-state.mjs.map +7 -0
  130. package/dist-esm/clustering/computeClusterTable.mjs +63 -0
  131. package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
  132. package/dist-esm/clustering/mst.mjs +97 -0
  133. package/dist-esm/clustering/mst.mjs.map +7 -0
  134. package/dist-esm/clustering/replay.mjs +255 -0
  135. package/dist-esm/clustering/replay.mjs.map +7 -0
  136. package/dist-esm/clustering/runtime.mjs +114 -0
  137. package/dist-esm/clustering/runtime.mjs.map +7 -0
  138. package/dist-esm/clustering/schedule.mjs +150 -0
  139. package/dist-esm/clustering/schedule.mjs.map +7 -0
  140. package/dist-esm/clustering/types.mjs +1 -0
  141. package/dist-esm/clustering/types.mjs.map +7 -0
  142. package/dist-esm/index.d.mts +868 -0
  143. package/dist-esm/index.mjs +139 -0
  144. package/dist-esm/index.mjs.map +7 -0
  145. package/dist-esm/ui/avatar.mjs +23 -0
  146. package/dist-esm/ui/avatar.mjs.map +7 -0
  147. package/dist-esm/ui/byline.mjs +15 -0
  148. package/dist-esm/ui/byline.mjs.map +7 -0
  149. package/dist-esm/ui/comment-card.mjs +17 -0
  150. package/dist-esm/ui/comment-card.mjs.map +7 -0
  151. package/dist-esm/ui/comment-composer.mjs +116 -0
  152. package/dist-esm/ui/comment-composer.mjs.map +7 -0
  153. package/dist-esm/ui/comment-extensions.mjs +17 -0
  154. package/dist-esm/ui/comment-extensions.mjs.map +7 -0
  155. package/dist-esm/ui/comment-mention.mjs +22 -0
  156. package/dist-esm/ui/comment-mention.mjs.map +7 -0
  157. package/dist-esm/ui/comment-pin.mjs +9 -0
  158. package/dist-esm/ui/comment-pin.mjs.map +7 -0
  159. package/dist-esm/ui/comment-text.mjs +9 -0
  160. package/dist-esm/ui/comment-text.mjs.map +7 -0
  161. package/dist-esm/ui/comment-thread.mjs +25 -0
  162. package/dist-esm/ui/comment-thread.mjs.map +7 -0
  163. package/dist-esm/ui/comments-list.mjs +86 -0
  164. package/dist-esm/ui/comments-list.mjs.map +7 -0
  165. package/dist-esm/ui/count-badge.mjs +8 -0
  166. package/dist-esm/ui/count-badge.mjs.map +7 -0
  167. package/dist-esm/ui/empty-state.mjs +11 -0
  168. package/dist-esm/ui/empty-state.mjs.map +7 -0
  169. package/dist-esm/ui/format-time.mjs +26 -0
  170. package/dist-esm/ui/format-time.mjs.map +7 -0
  171. package/dist-esm/ui/mention-list.mjs +50 -0
  172. package/dist-esm/ui/mention-list.mjs.map +7 -0
  173. package/dist-esm/ui/mention-suggestion.mjs +186 -0
  174. package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
  175. package/dist-esm/ui/mention.mjs +11 -0
  176. package/dist-esm/ui/mention.mjs.map +7 -0
  177. package/dist-esm/ui/reaction.mjs +11 -0
  178. package/dist-esm/ui/reaction.mjs.map +7 -0
  179. package/dist-esm/ui/reactions.mjs +14 -0
  180. package/dist-esm/ui/reactions.mjs.map +7 -0
  181. package/dist-esm/ui/render-markdown.mjs +45 -0
  182. package/dist-esm/ui/render-markdown.mjs.map +7 -0
  183. package/dist-esm/ui/send-button.mjs +8 -0
  184. package/dist-esm/ui/send-button.mjs.map +7 -0
  185. package/package.json +71 -0
  186. package/src/canvas/canvas.css +148 -0
  187. package/src/canvas/cluster-input.test.ts +223 -0
  188. package/src/canvas/cluster-input.ts +29 -0
  189. package/src/canvas/comment-body.tsx +21 -0
  190. package/src/canvas/comment-render.test.ts +90 -0
  191. package/src/canvas/comment-render.ts +92 -0
  192. package/src/canvas/comment-store.ts +55 -0
  193. package/src/canvas/comment-tool.tsx +200 -0
  194. package/src/canvas/comments-filter-menu.tsx +104 -0
  195. package/src/canvas/comments-menu-item.tsx +24 -0
  196. package/src/canvas/comments-overflow-menu.tsx +62 -0
  197. package/src/canvas/comments-overlay.tsx +1460 -0
  198. package/src/canvas/comments-sidebar.tsx +184 -0
  199. package/src/canvas/hooks.ts +28 -0
  200. package/src/canvas/license.ts +15 -0
  201. package/src/canvas/options.test.ts +127 -0
  202. package/src/canvas/options.ts +111 -0
  203. package/src/canvas/region-options.ts +57 -0
  204. package/src/canvas/rich-text.ts +15 -0
  205. package/src/canvas/sidebar-filters.ts +25 -0
  206. package/src/canvas/state.ts +102 -0
  207. package/src/canvas/thread-state.ts +91 -0
  208. package/src/clustering/computeClusterTable.test.ts +299 -0
  209. package/src/clustering/computeClusterTable.ts +82 -0
  210. package/src/clustering/locality.test.ts +189 -0
  211. package/src/clustering/mst.test.ts +383 -0
  212. package/src/clustering/mst.ts +134 -0
  213. package/src/clustering/replay.test.ts +547 -0
  214. package/src/clustering/replay.ts +326 -0
  215. package/src/clustering/runtime.test.ts +596 -0
  216. package/src/clustering/runtime.ts +153 -0
  217. package/src/clustering/schedule.test.ts +514 -0
  218. package/src/clustering/schedule.ts +184 -0
  219. package/src/clustering/types.ts +102 -0
  220. package/src/index.ts +96 -0
  221. package/src/ui/avatar.tsx +31 -0
  222. package/src/ui/byline.tsx +23 -0
  223. package/src/ui/comment-card.tsx +32 -0
  224. package/src/ui/comment-composer.tsx +179 -0
  225. package/src/ui/comment-extensions.ts +25 -0
  226. package/src/ui/comment-mention.ts +47 -0
  227. package/src/ui/comment-pin.tsx +21 -0
  228. package/src/ui/comment-text.tsx +12 -0
  229. package/src/ui/comment-thread.tsx +56 -0
  230. package/src/ui/comments-list.tsx +139 -0
  231. package/src/ui/comments.css +678 -0
  232. package/src/ui/count-badge.tsx +9 -0
  233. package/src/ui/empty-state.tsx +16 -0
  234. package/src/ui/format-time.ts +29 -0
  235. package/src/ui/mention-list.tsx +106 -0
  236. package/src/ui/mention-suggestion.test.ts +18 -0
  237. package/src/ui/mention-suggestion.tsx +285 -0
  238. package/src/ui/mention.tsx +9 -0
  239. package/src/ui/reaction.tsx +16 -0
  240. package/src/ui/reactions.tsx +15 -0
  241. package/src/ui/render-markdown.tsx +72 -0
  242. package/src/ui/send-button.tsx +15 -0
@@ -0,0 +1,184 @@
1
+ import { ReactNode, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import {
4
+ TLComment,
5
+ TLCommentId,
6
+ useContainer,
7
+ useEditor,
8
+ usePassThroughMouseOverEvents,
9
+ useTranslation,
10
+ useValue,
11
+ } from 'tldraw'
12
+ import { CommentListItemProps, CommentsList } from '../ui/comments-list'
13
+ import { UNKNOWN_AUTHOR } from './comment-render'
14
+ import { CommentsFilterMenu } from './comments-filter-menu'
15
+ import { CommentsOverflowMenu } from './comments-overflow-menu'
16
+ import { useComments, useCommentThreads } from './hooks'
17
+ import { useCommentingEnabled } from './license'
18
+ import { useCommentingOptions } from './options'
19
+ import { richTextToPlaintext } from './rich-text'
20
+ import { openThreadId, sidebarFilters } from './state'
21
+ import { focusThread } from './thread-state'
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[]
36
+ /** Header above the list. */
37
+ header?: ReactNode
38
+ /** Shown when the page has no threads. */
39
+ 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
+ }
43
+
44
+ /**
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.
48
+ * @public @react
49
+ */
50
+ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
51
+ const {
52
+ resolveName,
53
+ currentUserId,
54
+ isCommentUnread,
55
+ tools,
56
+ header,
57
+ empty,
58
+ impreciseShapeAnchor,
59
+ } = props
60
+ const editor = useEditor()
61
+ const options = useCommentingOptions()
62
+ const sidebarTools = tools ?? ['comment']
63
+ const container = useContainer()
64
+ const commentingEnabled = useCommentingEnabled()
65
+ const msg = useTranslation()
66
+ const threads = useCommentThreads(editor)
67
+ const comments = useComments(editor)
68
+ const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
69
+ const activeTool = useValue('tool id', () => editor.getCurrentToolId(), [editor])
70
+ const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
71
+ const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
72
+ const pageNames = useValue(
73
+ 'page names',
74
+ () => new Map(editor.getPages().map((page) => [page.id, page.name])),
75
+ [editor]
76
+ )
77
+
78
+ if (!commentingEnabled || !sidebarTools.includes(activeTool)) return null
79
+
80
+ // Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
81
+ const byThread = new Map<string, TLComment[]>()
82
+ for (const comment of comments) {
83
+ const list = byThread.get(comment.threadId) ?? []
84
+ list.push(comment)
85
+ byThread.set(comment.threadId, list)
86
+ }
87
+
88
+ // Page scoping is treated as scoping, not a filter: an empty page reads "no comments yet",
89
+ // while a list emptied by the toggles below reads "nothing matches your filters".
90
+ const pageThreads = threads.filter(
91
+ (thread) => !filters.onlyCurrentPage || thread.pageId === currentPageId
92
+ )
93
+
94
+ const items: CommentListItemProps[] = pageThreads
95
+ .filter((thread) => filters.showResolved || thread.resolved == null)
96
+ // "Only mine" is ignored without a known user — otherwise a persisted onlyMine=true would
97
+ // empty the list for a signed-out viewer, with the (hidden) toggle giving no way to clear it.
98
+ .filter(
99
+ (thread) =>
100
+ !filters.onlyMine || currentUserId === undefined || thread.createdBy === currentUserId
101
+ )
102
+ // "Only unread" is likewise ignored without a read-status source.
103
+ .filter(
104
+ (thread) =>
105
+ !filters.onlyUnread ||
106
+ isCommentUnread === undefined ||
107
+ (byThread.get(thread.id) ?? []).some((c) => isCommentUnread(c.id))
108
+ )
109
+ .map((thread) => {
110
+ const threadComments = byThread.get(thread.id) ?? []
111
+ const first = threadComments[0]
112
+ let preview: ReactNode = ''
113
+ // The `ThreadPreview` component slot overrides the built-in plaintext default.
114
+ const ThreadPreview = options.components.ThreadPreview
115
+ if (first) {
116
+ preview = ThreadPreview ? (
117
+ <ThreadPreview comment={first} />
118
+ ) : (
119
+ richTextToPlaintext(first.body, resolveName)
120
+ )
121
+ }
122
+ 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,
131
+ }
132
+ })
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
+ })
138
+
139
+ const focus = (id: string) => {
140
+ 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)
144
+ }
145
+
146
+ return (
147
+ <SidebarPanel container={container}>
148
+ <CommentsList
149
+ items={items}
150
+ header={header ?? msg('comments.title')}
151
+ headerAction={
152
+ <div className="cmt-list__header-actions">
153
+ <CommentsFilterMenu
154
+ canFilterByAuthor={currentUserId !== undefined}
155
+ canFilterByUnread={isCommentUnread !== undefined}
156
+ />
157
+ <CommentsOverflowMenu />
158
+ </div>
159
+ }
160
+ empty={
161
+ items.length === 0 && pageThreads.length > 0
162
+ ? msg('comments.empty-filtered')
163
+ : (empty ?? msg('comments.empty'))
164
+ }
165
+ resolvedLabel={msg('comments.resolved')}
166
+ onSelect={focus}
167
+ />
168
+ </SidebarPanel>
169
+ )
170
+ }
171
+
172
+ /** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
173
+ * wheel-transparent panels — a wheel over it doesn't pan the canvas. Hover still passes through so
174
+ * shapes beneath it stay interactive. */
175
+ function SidebarPanel({ container, children }: { container: HTMLElement; children: ReactNode }) {
176
+ const ref = useRef<HTMLDivElement>(null)
177
+ usePassThroughMouseOverEvents(ref)
178
+ return createPortal(
179
+ <div ref={ref} className="cmt-canvas-sidebar">
180
+ {children}
181
+ </div>,
182
+ container
183
+ )
184
+ }
@@ -0,0 +1,28 @@
1
+ import { Editor, TLComment, TLCommentThread, TLCommentThreadId, useValue } from 'tldraw'
2
+ import { getComments, getCommentThreads } from './comment-store'
3
+
4
+ /** All comment threads in the store, reactively. @public */
5
+ export function useCommentThreads(editor: Editor): TLCommentThread[] {
6
+ return useValue('comment threads', () => getCommentThreads(editor), [editor])
7
+ }
8
+
9
+ /** A thread's comments, oldest first, reactively. @public */
10
+ export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[] {
11
+ return useValue(
12
+ 'thread comments',
13
+ () =>
14
+ getComments(editor)
15
+ .filter((c) => c.threadId === threadId)
16
+ .sort((a, b) => a.createdAt - b.createdAt),
17
+ [editor, threadId]
18
+ )
19
+ }
20
+
21
+ /** Every comment in the store, oldest first, reactively. Group by `threadId` for per-thread lists. @public */
22
+ export function useComments(editor: Editor): TLComment[] {
23
+ return useValue(
24
+ 'all comments',
25
+ () => getComments(editor).sort((a, b) => a.createdAt - b.createdAt),
26
+ [editor]
27
+ )
28
+ }
@@ -0,0 +1,15 @@
1
+ import { useLicenseContext, useLicenseFeatureFlag } from 'tldraw'
2
+
3
+ /**
4
+ * Whether commenting is licensed for this editor. Enabled in development; in production it requires
5
+ * a tldraw license that includes the commenting feature (or the collaboration umbrella that grants
6
+ * it). Reactive: re-reads when license validation resolves, and returns `false` while validation is
7
+ * pending, so gated UI stays hidden until the license is confirmed.
8
+ *
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.
11
+ * @public
12
+ */
13
+ export function useCommentingEnabled(): boolean {
14
+ return useLicenseFeatureFlag(useLicenseContext(), 'commenting')
15
+ }
@@ -0,0 +1,127 @@
1
+ import type { Editor } from 'tldraw'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { CommentTool } from './comment-tool'
4
+ import { defaultCommentingOptions, getCommentingOptions, type CommentingOptions } from './options'
5
+ import { commitCommentMutation, openThreadId, pendingComment } from './state'
6
+
7
+ // The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
8
+ // instantiate a configured tool and read its merged options.
9
+ function optionsOf(Tool: typeof CommentTool): CommentingOptions {
10
+ return new Tool({} as Editor).options
11
+ }
12
+
13
+ describe('CommentTool.configure', () => {
14
+ it('returns default options when unconfigured', () => {
15
+ expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
16
+ })
17
+
18
+ it('merges overrides over the defaults', () => {
19
+ const Tool = CommentTool.configure({ history: 'record', enableClustering: false })
20
+ expect(optionsOf(Tool)).toEqual({
21
+ ...defaultCommentingOptions,
22
+ history: 'record',
23
+ enableClustering: false,
24
+ })
25
+ })
26
+
27
+ it('layers chained configure calls', () => {
28
+ const Tool = CommentTool.configure({ history: 'record' }).configure({ enableClustering: false })
29
+ expect(optionsOf(Tool)).toEqual({
30
+ ...defaultCommentingOptions,
31
+ history: 'record',
32
+ enableClustering: false,
33
+ })
34
+ })
35
+
36
+ it('layers component slots across chained configure calls', () => {
37
+ const CommentBody = () => null
38
+ const PinContent = () => null
39
+ const Tool = CommentTool.configure({ components: { CommentBody } }).configure({
40
+ components: { PinContent },
41
+ })
42
+ // The second call's slot is added without dropping the first call's slot.
43
+ expect(optionsOf(Tool).components).toEqual({ CommentBody, PinContent })
44
+ })
45
+
46
+ it('does not mutate the base tool or the defaults', () => {
47
+ CommentTool.configure({ history: 'record' })
48
+ expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
49
+ expect(defaultCommentingOptions.history).toBe('ignore')
50
+ })
51
+ })
52
+
53
+ // A minimal editor stub: getCommentingOptions reads the comment tool's `options` off
54
+ // getStateDescendant, and commitCommentMutation forwards to run().
55
+ function stubEditor(options: CommentingOptions) {
56
+ const runCalls: Array<{ history: unknown }> = []
57
+ const editor = {
58
+ getStateDescendant: () => ({ options }),
59
+ run: (fn: () => void, opts: { history: unknown }) => {
60
+ runCalls.push(opts)
61
+ fn()
62
+ return editor
63
+ },
64
+ } as unknown as Editor
65
+ return { editor, runCalls }
66
+ }
67
+
68
+ describe('getCommentingOptions', () => {
69
+ it('reads the tool options off the editor', () => {
70
+ const options = { ...defaultCommentingOptions, history: 'record' } as CommentingOptions
71
+ const { editor } = stubEditor(options)
72
+ expect(getCommentingOptions(editor)).toBe(options)
73
+ })
74
+
75
+ it('falls back to defaults when the comment tool is absent', () => {
76
+ const editor = { getStateDescendant: () => undefined } as unknown as Editor
77
+ expect(getCommentingOptions(editor)).toBe(defaultCommentingOptions)
78
+ })
79
+ })
80
+
81
+ describe('commitCommentMutation', () => {
82
+ it('uses options.history for a mutation and returns the callback result', () => {
83
+ const { editor, runCalls } = stubEditor({
84
+ ...defaultCommentingOptions,
85
+ history: 'record',
86
+ } as CommentingOptions)
87
+ const result = commitCommentMutation(editor, () => 42)
88
+ expect(result).toBe(42)
89
+ expect(runCalls).toEqual([{ history: 'record' }])
90
+ })
91
+
92
+ it('uses dragHistory for a drag, falling back to history when unset', () => {
93
+ const withDrag = stubEditor({
94
+ ...defaultCommentingOptions,
95
+ history: 'ignore',
96
+ dragHistory: 'record',
97
+ } as CommentingOptions)
98
+ commitCommentMutation(withDrag.editor, () => undefined, 'drag')
99
+ expect(withDrag.runCalls).toEqual([{ history: 'record' }])
100
+
101
+ const noDrag = stubEditor({
102
+ ...defaultCommentingOptions,
103
+ history: 'ignore',
104
+ dragHistory: undefined,
105
+ } as CommentingOptions)
106
+ commitCommentMutation(noDrag.editor, () => undefined, 'drag')
107
+ expect(noDrag.runCalls).toEqual([{ history: 'ignore' }])
108
+ })
109
+ })
110
+
111
+ describe('editor-scoped transient state', () => {
112
+ it('keeps open-thread state independent per editor (multi-editor guard)', () => {
113
+ const a = {} as Editor
114
+ const b = {} as Editor
115
+ openThreadId.set(a, 'thread:1')
116
+ expect(openThreadId.get(a)).toBe('thread:1')
117
+ expect(openThreadId.get(b)).toBe(null)
118
+ })
119
+
120
+ it('keeps pending-comment state independent per editor', () => {
121
+ const a = {} as Editor
122
+ const b = {} as Editor
123
+ pendingComment.set(a, { anchor: { type: 'page' }, point: { x: 0, y: 0 } })
124
+ expect(pendingComment.get(a)).not.toBe(null)
125
+ expect(pendingComment.get(b)).toBe(null)
126
+ })
127
+ })
@@ -0,0 +1,111 @@
1
+ import { useMemo, type ComponentType } from 'react'
2
+ import {
3
+ type Editor,
4
+ type TLComment,
5
+ type TLCommentThread,
6
+ type TLHistoryBatchOptions,
7
+ useEditor,
8
+ } from 'tldraw'
9
+
10
+ /**
11
+ * Component overrides for the batteries-included comments layer. Each slot replaces a built-in
12
+ * piece; leave a slot unset to keep its default.
13
+ *
14
+ * @public
15
+ */
16
+ export interface CommentingComponents {
17
+ /** A comment's body. Replaces the default rich-text `<CommentBody>`. */
18
+ CommentBody?: ComponentType<{ comment: TLComment }>
19
+ /** A pin's inner content. Replaces the author-initial default. */
20
+ PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
21
+ /** A sidebar row's preview. Replaces the plaintext default. */
22
+ ThreadPreview?: ComponentType<{ comment: TLComment }>
23
+ }
24
+
25
+ /**
26
+ * Configuration for the commenting layer. Static config only — pass it once via
27
+ * `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
28
+ * (`currentUserId`, `resolveName`, read-status callbacks) stay as props on `<CanvasComments>`.
29
+ *
30
+ * For defaults, see {@link defaultCommentingOptions}.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <Tldraw tools={[CommentTool.configure({ history: 'ignore', enableClustering: false })]} />
35
+ * ```
36
+ *
37
+ * @public
38
+ */
39
+ export interface CommentingOptions {
40
+ // ── History / undo ───────────────────────────────────────────────────────────────────────
41
+ /**
42
+ * How comment mutations (post, reply, edit, resolve, delete) interact with the editor undo
43
+ * stack. Defaults to `'ignore'` — comments are deliberately not undoable (see `TLComment`).
44
+ * `'record'` is a multiplayer footgun: undoing a delete resurrects a thread a collaborator
45
+ * already removed, and undoing a resolve/edit reverts their newer state. Safe only single-player
46
+ * or on a non-synced local comment store.
47
+ */
48
+ readonly history: TLHistoryBatchOptions['history']
49
+ /**
50
+ * History mode for the pin drag-to-move re-anchor specifically. Unlike posts/edits this is a
51
+ * spatial edit that may reasonably be undoable alongside a shape move. Defaults to `history`.
52
+ */
53
+ readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
54
+
55
+ // ── Feature toggles ──────────────────────────────────────────────────────────────────────
56
+ /** Fold nearby pins into count badges as the camera zooms out. */
57
+ readonly enableClustering: boolean
58
+
59
+ // ── Anchoring ────────────────────────────────────────────────────────────────────────────
60
+ /** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
61
+ readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
62
+
63
+ // ── Clustering tuning ─────────────────────────────────────────────────────────────────────
64
+ /** Screen-pixel margin by which the viewport is inflated when culling cluster badges. */
65
+ readonly clusterCullMargin: number
66
+ /** How far past a cluster's split zoom to land when expanding it (1.05 = 5% overshoot). */
67
+ readonly clusterSplitZoomFactor: number
68
+
69
+ // ── Components ────────────────────────────────────────────────────────────────────────────
70
+ /** Component overrides. See {@link CommentingComponents}. */
71
+ readonly components: CommentingComponents
72
+ }
73
+
74
+ /**
75
+ * The default {@link CommentingOptions}. Override via `CommentTool.configure({ ... })`.
76
+ *
77
+ * @public
78
+ */
79
+ export const defaultCommentingOptions = {
80
+ history: 'ignore',
81
+ dragHistory: undefined,
82
+ enableClustering: true,
83
+ impreciseShapeAnchor: { x: 1, y: 0 },
84
+ clusterCullMargin: 120,
85
+ clusterSplitZoomFactor: 1.05,
86
+ components: {},
87
+ } as const satisfies CommentingOptions
88
+
89
+ /**
90
+ * The merged {@link CommentingOptions} for an editor, read off the registered comment tool (which
91
+ * carries them via `CommentTool.configure`). Falls back to {@link defaultCommentingOptions} when
92
+ * the comment tool isn't registered. Usable from anywhere with an `Editor` — including the tool's
93
+ * own state, which has no React context.
94
+ *
95
+ * @public
96
+ */
97
+ export function getCommentingOptions(editor: Editor): CommentingOptions {
98
+ const tool = editor.getStateDescendant('comment') as { options?: CommentingOptions } | undefined
99
+ return tool?.options ?? defaultCommentingOptions
100
+ }
101
+
102
+ /**
103
+ * React hook for {@link getCommentingOptions}. Options are fixed per editor (set at tool
104
+ * registration), so this doesn't need to be reactive.
105
+ *
106
+ * @public
107
+ */
108
+ export function useCommentingOptions(): CommentingOptions {
109
+ const editor = useEditor()
110
+ return useMemo(() => getCommentingOptions(editor), [editor])
111
+ }
@@ -0,0 +1,57 @@
1
+ import { atom, Atom, Editor, VecLike } from 'tldraw'
2
+
3
+ /**
4
+ * The configurable dimensions of region comments — a design surface for prototyping the interaction
5
+ * before settling it. Region is **off by default**, so a consumer that leaves it unset keeps plain
6
+ * click-only point/shape commenting. Set it per editor through `<CanvasComments regionOptions>`.
7
+ * @public
8
+ */
9
+ export interface RegionCommentOptions {
10
+ /** Whether dragging the comment tool out creates a region anchor. Off → click-only. */
11
+ enabled: boolean
12
+ /** Which corner the pin and composer sit on, as a normalized 0–1 offset. Default bottom-right. */
13
+ pinCorner: VecLike
14
+ /** When the dashed box and its handles reveal: while the pointer is within the region, while the
15
+ * pin is hovered, or only while the thread is open. */
16
+ reveal: 'pointer' | 'pin-hover' | 'open'
17
+ /** How a region is moved: dragging its pin, dragging its body, or either. */
18
+ move: 'pin' | 'body' | 'both'
19
+ /** The resize affordance: corner handles, edge handles, or none. */
20
+ resize: 'corners' | 'edges' | 'none'
21
+ }
22
+
23
+ /** The out-of-the-box region config: disabled, and — when a consumer enables it — the current
24
+ * bottom-right / pointer-reveal / pin-move / corner-resize behaviour.
25
+ * @public */
26
+ export const DEFAULT_REGION_COMMENT_OPTIONS: RegionCommentOptions = {
27
+ enabled: false,
28
+ pinCorner: { x: 1, y: 1 },
29
+ reveal: 'pointer',
30
+ move: 'pin',
31
+ resize: 'corners',
32
+ }
33
+
34
+ // Per-editor region config. The overlay publishes its merged `regionOptions` here so the comment
35
+ // tool — which has no props of its own — can read the same per-instance config at interaction time.
36
+ // Held in an atom so reactive readers (e.g. `anchorPagePoint`, called inside `useValue`) recompute
37
+ // when the config changes; a plain map read wouldn't, leaving the pin at the stale default corner.
38
+ const byEditor = new WeakMap<Editor, Atom<RegionCommentOptions>>()
39
+
40
+ function getEditorAtom(editor: Editor): Atom<RegionCommentOptions> {
41
+ let a = byEditor.get(editor)
42
+ if (!a) {
43
+ a = atom('regionCommentOptions', DEFAULT_REGION_COMMENT_OPTIONS)
44
+ byEditor.set(editor, a)
45
+ }
46
+ return a
47
+ }
48
+
49
+ /** Publish an editor's region config (called by the overlay from its `regionOptions` prop). */
50
+ export function setRegionCommentOptions(editor: Editor, options: RegionCommentOptions): void {
51
+ getEditorAtom(editor).set(options)
52
+ }
53
+
54
+ /** The editor's region config, or the disabled default when none was set. */
55
+ export function getRegionCommentOptions(editor: Editor): RegionCommentOptions {
56
+ return getEditorAtom(editor).get()
57
+ }
@@ -0,0 +1,15 @@
1
+ import { TLRichText } from 'tldraw'
2
+ import { renderCommentPlaintext } from './comment-render'
3
+
4
+ /**
5
+ * Flatten a rich-text comment body to plaintext through the limited comment extension set,
6
+ * separating paragraphs and list items with newlines. A convenience for consumers rendering bodies
7
+ * as plain text (e.g. the sidebar preview); richer rendering can read the `TLRichText` directly.
8
+ * @public
9
+ */
10
+ export function richTextToPlaintext(
11
+ body: TLRichText,
12
+ resolveName?: (id: string) => string | undefined
13
+ ): string {
14
+ return renderCommentPlaintext(body, resolveName)
15
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which threads the comments sidebar shows. Held as an editor-scoped signal (the `sidebarFilters`
3
+ * `EditorAtom` in `./state`) rather than component state so it survives the sidebar
4
+ * unmounting when the comment tool deactivates — a user's "hide resolved" choice shouldn't reset
5
+ * every time they leave the tool.
6
+ * @public
7
+ */
8
+ export interface SidebarFilters {
9
+ /** Include resolved threads. */
10
+ showResolved: boolean
11
+ /** Only threads the current user started. Ignored when there's no current user. */
12
+ onlyMine: boolean
13
+ /** Only threads with unread comments. Ignored when the host provides no read status. */
14
+ onlyUnread: boolean
15
+ /** Only threads on the current page. Off = every page's threads, each labelled. */
16
+ onlyCurrentPage: boolean
17
+ }
18
+
19
+ /** @public */
20
+ export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
21
+ showResolved: true,
22
+ onlyMine: false,
23
+ onlyUnread: false,
24
+ onlyCurrentPage: true,
25
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ EditorAtom,
3
+ type BoxModel,
4
+ type Editor,
5
+ type TLHistoryBatchOptions,
6
+ useEditor,
7
+ useValue,
8
+ } from 'tldraw'
9
+ import type { PendingComment } from './comment-tool'
10
+ import { getCommentingOptions } from './options'
11
+ import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
12
+
13
+ /**
14
+ * Transient commenting UI state, scoped per editor via {@link EditorAtom}. Editor-scoping (rather
15
+ * than module-global atoms) keeps two editors on one page — or an editor and a second instance —
16
+ * from sharing open-thread, visibility, and filter state. Reachable from both React (`useEditor()`)
17
+ * and the comment tool (`this.editor`).
18
+ */
19
+
20
+ /** The id of the one open thread (only one popover is open at a time), or null when all closed.
21
+ * @public */
22
+ export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
23
+
24
+ /** The comment currently being placed (composer open, not yet posted), or null.
25
+ * @public */
26
+ export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
27
+
28
+ /** The region rectangle being dragged out right now (page coords), or null when not dragging. The
29
+ * comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
30
+ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
31
+
32
+ /**
33
+ * Whether comment pins are hidden on the canvas. Governs the on-canvas layer (pins + open popover)
34
+ * only — the sidebar is unaffected.
35
+ * @public
36
+ */
37
+ export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
38
+
39
+ /** Which threads the comments sidebar shows.
40
+ * @public */
41
+ export const sidebarFilters = new EditorAtom<SidebarFilters>(
42
+ 'sidebarFilters',
43
+ () => DEFAULT_SIDEBAR_FILTERS
44
+ )
45
+
46
+ /** Toggle comment-pin visibility for an editor.
47
+ * @public */
48
+ export function toggleCommentsHidden(editor: Editor): void {
49
+ commentsHidden.update(editor, (hidden) => !hidden)
50
+ }
51
+
52
+ /** React hook for the open thread id.
53
+ * @public */
54
+ export function useOpenThreadId(): string | null {
55
+ const editor = useEditor()
56
+ return useValue('open thread id', () => openThreadId.get(editor), [editor])
57
+ }
58
+
59
+ /** React hook for the pending (being-placed) comment.
60
+ * @public */
61
+ export function usePendingComment(): PendingComment | null {
62
+ const editor = useEditor()
63
+ return useValue('pending comment', () => pendingComment.get(editor), [editor])
64
+ }
65
+
66
+ /** React hook for whether comment pins are hidden.
67
+ * @public */
68
+ export function useCommentsHidden(): boolean {
69
+ const editor = useEditor()
70
+ return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
71
+ }
72
+
73
+ /** React hook for the current sidebar filters.
74
+ * @public */
75
+ export function useSidebarFilters(): SidebarFilters {
76
+ const editor = useEditor()
77
+ return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
78
+ }
79
+
80
+ /**
81
+ * Commit a comment mutation with the configured undo/redo behavior. All comment writes go through
82
+ * here so the {@link CommentingOptions.history} option (and {@link CommentingOptions.dragHistory}
83
+ * for pin re-anchors) governs whether they land on the undo stack. Defaults to `'ignore'`.
84
+ * @public
85
+ */
86
+ export function commitCommentMutation<T>(
87
+ editor: Editor,
88
+ fn: () => T,
89
+ kind: 'mutation' | 'drag' = 'mutation'
90
+ ): T {
91
+ const options = getCommentingOptions(editor)
92
+ const history: TLHistoryBatchOptions['history'] =
93
+ kind === 'drag' ? (options.dragHistory ?? options.history) : options.history
94
+ let result: T
95
+ editor.run(
96
+ () => {
97
+ result = fn()
98
+ },
99
+ { history }
100
+ )
101
+ return result!
102
+ }