@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
@@ -1,9 +1,10 @@
1
- import type { Editor, TLCommentAnchor, TLCommentThread } from 'tldraw'
1
+ import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
2
2
  import { describe, expect, it } from 'vitest'
3
3
  // This import is red until step 6's filter module is implemented — that is
4
4
  // intentional. Implement `cluster-input.ts` per CLUSTERING-STEPS.md step 6
5
5
  // until this suite passes, without modifying this file.
6
6
  import { collectClusterLeaves } from './cluster-input'
7
+ import { defaultCommentingOptions, type CommentingOptions } from './options'
7
8
 
8
9
  const CURRENT_PAGE = 'page:one'
9
10
  const OTHER_PAGE = 'page:two'
@@ -27,19 +28,31 @@ function thread(
27
28
  }
28
29
 
29
30
  /**
30
- * Stub editor: the filter's only editor dependencies are the current page id
31
- * and shape page bounds (via anchorPagePoint). `shapes` maps shape id → bounds
32
- * for shapes that exist; anything else resolves to undefined (deleted shape).
31
+ * Stub editor: the filter's editor dependencies are the current page id, shape
32
+ * geometry and page transform, and the commenting options (via anchorPagePoint,
33
+ * read off the registered comment tool). `shapes` maps shape id → page bounds for
34
+ * unrotated shapes that exist, modeled as local geometry sized by the box plus a
35
+ * translate-only page transform; anything else resolves to undefined (deleted
36
+ * shape). `options` stands in for `CommentTool.configure({ ... })`; omit it for a
37
+ * editor with no comment tool registered, which falls back to the defaults.
33
38
  */
34
39
  function stubEditor(
35
- shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
40
+ shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {},
41
+ options?: Partial<CommentingOptions>
36
42
  ): Editor {
43
+ const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
37
44
  return {
38
45
  getCurrentPageId: () => CURRENT_PAGE,
39
- getShapePageBounds: (id: string) => {
40
- const bounds = shapes[id]
41
- if (!bounds) return undefined
42
- return { ...bounds, w: bounds.maxX - bounds.minX, h: bounds.maxY - bounds.minY }
46
+ getStateDescendant: () =>
47
+ options ? { options: { ...defaultCommentingOptions, ...options } } : undefined,
48
+ getShape: (id: string) => (shapes[id] ? { id } : undefined),
49
+ getShapeGeometry: (shape: string | { id: string }) => {
50
+ const bounds = shapes[shapeId(shape)]
51
+ return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
52
+ },
53
+ getShapePageTransform: (shape: string | { id: string }) => {
54
+ const bounds = shapes[shapeId(shape)]
55
+ return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
43
56
  },
44
57
  } as unknown as Editor
45
58
  }
@@ -67,28 +80,24 @@ describe('collectClusterLeaves anchor resolution', () => {
67
80
  expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
68
81
  })
69
82
 
70
- it('maps shape and text-range anchors to the shape bounds top-right corner', () => {
83
+ it('maps shape anchors to the shape bounds top-right corner', () => {
71
84
  const editor = stubEditor({
72
85
  'shape:a': { minX: 0, minY: 5, maxX: 100, maxY: 50 },
73
86
  })
74
87
  const leaves = collectClusterLeaves(
75
88
  editor,
76
- [
77
- thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false }),
78
- thread('t2', { type: 'text-range', shapeId: 'shape:a' as any, from: 0, to: 3 }),
79
- ],
89
+ [thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false })],
80
90
  null
81
91
  )
82
- expect(leaves).toEqual([
83
- { id: 't1', point: { x: 100, y: 5 } },
84
- { id: 't2', point: { x: 100, y: 5 } },
85
- ])
92
+ expect(leaves).toEqual([{ id: 't1', point: { x: 100, y: 5 } }])
86
93
  })
87
94
 
88
95
  it('places imprecise shape leaves at a custom impreciseShapeAnchor, matching pin rendering', () => {
89
- const editor = stubEditor({
90
- 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 },
91
- })
96
+ // bottom-center instead of the default top-right
97
+ const editor = stubEditor(
98
+ { 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } },
99
+ { impreciseShapeAnchor: { x: 0.5, y: 1 } }
100
+ )
92
101
  const threads = [
93
102
  thread('imprecise', {
94
103
  type: 'shape',
@@ -105,8 +114,7 @@ describe('collectClusterLeaves anchor resolution', () => {
105
114
  isPrecise: true,
106
115
  }),
107
116
  ]
108
- // bottom-center instead of the default top-right
109
- const leaves = collectClusterLeaves(editor, threads, null, { x: 0.5, y: 1 })
117
+ const leaves = collectClusterLeaves(editor, threads, null)
110
118
  expect(leaves).toEqual([
111
119
  { id: 'imprecise', point: { x: 50, y: 50 } },
112
120
  { id: 'precise', point: { x: 50, y: 25 } },
@@ -2,12 +2,11 @@ import type { Editor, TLCommentThread } from 'tldraw'
2
2
  import type { LeafInput } from '../clustering/types'
3
3
  import { anchorPagePoint } from './thread-state'
4
4
 
5
- /** @public */
5
+ /** The cluster leaves for the threads pinned on the current page. @internal */
6
6
  export function collectClusterLeaves(
7
7
  editor: Editor,
8
8
  threads: readonly TLCommentThread[],
9
- openThreadId: string | null,
10
- impreciseShapeAnchor?: { x: number; y: number }
9
+ openThreadId: string | null
11
10
  ): LeafInput[] {
12
11
  const pageId = editor.getCurrentPageId()
13
12
  const leaves: LeafInput[] = []
@@ -16,7 +15,7 @@ export function collectClusterLeaves(
16
15
  if (thread.id === openThreadId) continue
17
16
  if (thread.pageId !== pageId) continue
18
17
 
19
- const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
18
+ const point = anchorPagePoint(editor, thread.anchor)
20
19
  if (!point) continue
21
20
 
22
21
  leaves.push({
@@ -17,5 +17,5 @@ export interface CommentBodyProps {
17
17
  */
18
18
  export function CommentBody({ richText, resolveName }: CommentBodyProps) {
19
19
  const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
20
- return <div className="cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
20
+ return <div className="tlui-cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
21
21
  }
@@ -0,0 +1,55 @@
1
+ /* eslint-disable tldraw/no-direct-storage */
2
+ import type { TLRichText } from 'tldraw'
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import {
5
+ clearCommentDraft,
6
+ getCommentDraft,
7
+ NEW_COMMENT_DRAFT,
8
+ replyDraftSlot,
9
+ saveCommentDraft,
10
+ } from './comment-drafts'
11
+
12
+ function richText(text: string): TLRichText {
13
+ return {
14
+ type: 'doc',
15
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
16
+ } as unknown as TLRichText
17
+ }
18
+
19
+ const EMPTY = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as TLRichText
20
+
21
+ beforeEach(() => {
22
+ localStorage.clear()
23
+ })
24
+
25
+ describe('comment drafts', () => {
26
+ it('round-trips a draft through its slot', () => {
27
+ saveCommentDraft(NEW_COMMENT_DRAFT, richText('hello'))
28
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toEqual(richText('hello'))
29
+ })
30
+
31
+ it('keeps reply drafts apart by thread id', () => {
32
+ saveCommentDraft(replyDraftSlot('comment-thread:a'), richText('for a'))
33
+ saveCommentDraft(replyDraftSlot('comment-thread:b'), richText('for b'))
34
+ expect(getCommentDraft(replyDraftSlot('comment-thread:a'))).toEqual(richText('for a'))
35
+ expect(getCommentDraft(replyDraftSlot('comment-thread:b'))).toEqual(richText('for b'))
36
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
37
+ })
38
+
39
+ it('saving empty content clears the slot instead', () => {
40
+ saveCommentDraft(NEW_COMMENT_DRAFT, richText('typed then deleted'))
41
+ saveCommentDraft(NEW_COMMENT_DRAFT, EMPTY)
42
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
43
+ })
44
+
45
+ it('clears a slot explicitly', () => {
46
+ saveCommentDraft(NEW_COMMENT_DRAFT, richText('posted'))
47
+ clearCommentDraft(NEW_COMMENT_DRAFT)
48
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
49
+ })
50
+
51
+ it('returns undefined for a corrupt stored value', () => {
52
+ localStorage.setItem('tldraw-comment-draft:new', '{not json')
53
+ expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
54
+ })
55
+ })
@@ -0,0 +1,46 @@
1
+ import { deleteFromLocalStorage, getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
2
+ import type { TLRichText } from 'tldraw'
3
+ import { isCommentEmpty } from '../ui/comment-extensions'
4
+
5
+ const DRAFT_KEY_PREFIX = 'tldraw-comment-draft'
6
+
7
+ /** The single slot for an unsent new-comment (placement composer) draft. The anchor is re-picked
8
+ * by clicking when the composer reopens, so only the text is worth keeping — and without a
9
+ * universal document id in the SDK, one slot is the honest scope. */
10
+ export const NEW_COMMENT_DRAFT = 'new'
11
+
12
+ /** The draft slot for an unsent reply on a thread. Thread record ids are globally unique and
13
+ * stable across reloads, so replies never need a document key. @public */
14
+ export function replyDraftSlot(threadId: string): string {
15
+ return `reply:${threadId}`
16
+ }
17
+
18
+ /** Read a saved unsent draft, or undefined when there is none (or it fails to parse). @public */
19
+ export function getCommentDraft(slot: string): TLRichText | undefined {
20
+ const raw = getFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
21
+ if (!raw) return undefined
22
+ try {
23
+ return JSON.parse(raw) as TLRichText
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Save an unsent draft so click-away and reload don't lose it — the flip side of not warning
31
+ * before discard. Saving empty content clears the slot instead, so deleting your text and
32
+ * closing doesn't resurrect an empty draft later.
33
+ * @public
34
+ */
35
+ export function saveCommentDraft(slot: string, value: TLRichText) {
36
+ if (isCommentEmpty(value)) {
37
+ clearCommentDraft(slot)
38
+ return
39
+ }
40
+ setInLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`, JSON.stringify(value))
41
+ }
42
+
43
+ /** Remove a saved draft (after posting, or when its content was emptied). @public */
44
+ export function clearCommentDraft(slot: string) {
45
+ deleteFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
46
+ }
@@ -0,0 +1,410 @@
1
+ import {
2
+ commentSchemaRecords,
3
+ createCommentThread,
4
+ createShapeId,
5
+ createTLSchema,
6
+ createTLStore,
7
+ defaultBindingUtils,
8
+ defaultShapeUtils,
9
+ defaultTools,
10
+ Editor,
11
+ TLComment,
12
+ TLCommentThread,
13
+ toRichText,
14
+ createComment as tlCreateComment,
15
+ } from 'tldraw'
16
+ import { afterEach, describe, expect, it } from 'vitest'
17
+ import {
18
+ commitCommentMutation,
19
+ deleteComment,
20
+ deleteThread,
21
+ editComment,
22
+ putCommentRecords,
23
+ putRecordsInCommit,
24
+ removeCommentRecords,
25
+ reopenThread,
26
+ resolveThread,
27
+ } from './comment-mutations'
28
+ import {
29
+ getCommentRecord,
30
+ getComments,
31
+ getCommentThreads,
32
+ getLiveComments,
33
+ getLiveCommentThreads,
34
+ } from './comment-store'
35
+ import { CommentTool } from './comment-tool'
36
+ import { openThreadId } from './state'
37
+
38
+ /**
39
+ * These need a real editor: what's under test is how each write interacts with the store and the
40
+ * undo stack, which is exactly what the history option governs.
41
+ */
42
+
43
+ let editors: Editor[] = []
44
+
45
+ afterEach(() => {
46
+ for (const editor of editors) editor.dispose()
47
+ editors = []
48
+ })
49
+
50
+ function makeEditor(tool?: ReturnType<typeof CommentTool.configure>): Editor {
51
+ const editor = new Editor({
52
+ store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
53
+ shapeUtils: defaultShapeUtils,
54
+ bindingUtils: defaultBindingUtils,
55
+ tools: tool ? [...defaultTools, tool] : defaultTools,
56
+ getContainer: () => document.body,
57
+ })
58
+ editors.push(editor)
59
+ return editor
60
+ }
61
+
62
+ function makeThread(editor: Editor, createdBy = 'me') {
63
+ const thread = createCommentThread({
64
+ pageId: editor.getCurrentPageId(),
65
+ anchor: { type: 'point', x: 0, y: 0 },
66
+ createdBy,
67
+ })
68
+ const comment = tlCreateComment({
69
+ threadId: thread.id,
70
+ pageId: thread.pageId,
71
+ authorId: createdBy,
72
+ body: toRichText('hello'),
73
+ })
74
+ putCommentRecords(editor, [thread, comment])
75
+ return { thread, comment }
76
+ }
77
+
78
+ function addComment(editor: Editor, thread: TLCommentThread, authorId = 'me') {
79
+ const comment = tlCreateComment({
80
+ threadId: thread.id,
81
+ pageId: thread.pageId,
82
+ authorId,
83
+ body: toRichText('reply'),
84
+ })
85
+ putCommentRecords(editor, [comment])
86
+ return comment
87
+ }
88
+
89
+ function readComment(editor: Editor, comment: TLComment) {
90
+ return getCommentRecord(editor, comment.id) as TLComment | undefined
91
+ }
92
+
93
+ function readThread(editor: Editor, thread: TLCommentThread) {
94
+ return getCommentRecord(editor, thread.id) as TLCommentThread | undefined
95
+ }
96
+
97
+ describe('editComment', () => {
98
+ it('replaces the body and stamps editedAt', () => {
99
+ const editor = makeEditor()
100
+ const { comment } = makeThread(editor)
101
+ expect(comment.editedAt).toBe(null)
102
+
103
+ editComment(editor, comment, toRichText('changed'))
104
+
105
+ const updated = readComment(editor, comment)!
106
+ expect(updated.body).toEqual(toRichText('changed'))
107
+ expect(typeof updated.editedAt).toBe('number')
108
+ })
109
+
110
+ // The caller's record is a snapshot. Writing it back would revert every field it doesn't mean
111
+ // to touch — here `meta`, set after the caller took its copy.
112
+ it('edits the version in the store, not the copy it was handed', () => {
113
+ const editor = makeEditor()
114
+ const { comment } = makeThread(editor)
115
+ putCommentRecords(editor, [{ ...comment, meta: { pinned: true } }])
116
+
117
+ editComment(editor, comment, toRichText('changed'))
118
+
119
+ expect(readComment(editor, comment)).toMatchObject({
120
+ body: toRichText('changed'),
121
+ meta: { pinned: true },
122
+ })
123
+ })
124
+
125
+ // `put` is an upsert, so writing a snapshot back would re-create the record — with
126
+ // `isDeleted: false` — after someone else's delete had already taken it away.
127
+ it('does not resurrect a comment that has been removed since', () => {
128
+ const editor = makeEditor()
129
+ const { comment } = makeThread(editor)
130
+ removeCommentRecords(editor, [comment.id])
131
+
132
+ editComment(editor, comment, toRichText('changed'))
133
+
134
+ expect(readComment(editor, comment)).toBeUndefined()
135
+ })
136
+ })
137
+
138
+ describe('resolveThread and reopenThread', () => {
139
+ it('stamps who resolved the thread and when', () => {
140
+ const editor = makeEditor()
141
+ const { thread } = makeThread(editor)
142
+
143
+ resolveThread(editor, thread, 'ada')
144
+
145
+ const resolved = readThread(editor, thread)!.resolved!
146
+ expect(resolved.by).toBe('ada')
147
+ expect(typeof resolved.at).toBe('number')
148
+ })
149
+
150
+ it('clears the resolution when reopened', () => {
151
+ const editor = makeEditor()
152
+ const { thread } = makeThread(editor)
153
+ resolveThread(editor, thread, 'ada')
154
+
155
+ reopenThread(editor, readThread(editor, thread)!)
156
+
157
+ expect(readThread(editor, thread)!.resolved).toBe(null)
158
+ })
159
+
160
+ // A thread's anchor moves without anyone touching the thread — a pin drag, or a pinned shape's
161
+ // delete converting it to a point. Resolving from a copy taken before that must not undo it.
162
+ it('resolves the version in the store, leaving an anchor moved since where it is', () => {
163
+ const editor = makeEditor()
164
+ const { thread } = makeThread(editor)
165
+ putCommentRecords(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }])
166
+
167
+ resolveThread(editor, thread, 'ada')
168
+
169
+ const updated = readThread(editor, thread)!
170
+ expect(updated.anchor).toEqual({ type: 'point', x: 50, y: 50 })
171
+ expect(updated.resolved!.by).toBe('ada')
172
+ })
173
+
174
+ it('reopens the version in the store, leaving an anchor moved since where it is', () => {
175
+ const editor = makeEditor()
176
+ const { thread } = makeThread(editor)
177
+ resolveThread(editor, thread, 'ada')
178
+ putCommentRecords(editor, [
179
+ { ...readThread(editor, thread)!, anchor: { type: 'point', x: 50, y: 50 } },
180
+ ])
181
+
182
+ reopenThread(editor, thread)
183
+
184
+ const updated = readThread(editor, thread)!
185
+ expect(updated.anchor).toEqual({ type: 'point', x: 50, y: 50 })
186
+ expect(updated.resolved).toBe(null)
187
+ })
188
+
189
+ it('does nothing to a thread that has been removed since', () => {
190
+ const editor = makeEditor()
191
+ const { thread } = makeThread(editor)
192
+ removeCommentRecords(editor, [thread.id])
193
+
194
+ resolveThread(editor, thread, 'ada')
195
+ reopenThread(editor, thread)
196
+
197
+ expect(readThread(editor, thread)).toBeUndefined()
198
+ })
199
+ })
200
+
201
+ describe('deleteComment', () => {
202
+ it('soft-deletes: the record stays put with its flag set, for the server to prune', () => {
203
+ const editor = makeEditor()
204
+ const { comment } = makeThread(editor)
205
+
206
+ deleteComment(editor, comment)
207
+
208
+ expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
209
+ expect(getComments(editor)).toHaveLength(1)
210
+ expect(getLiveComments(editor)).toHaveLength(0)
211
+ })
212
+
213
+ it('closes the thread when its last comment goes', () => {
214
+ const editor = makeEditor()
215
+ const { thread, comment } = makeThread(editor)
216
+ openThreadId.set(editor, thread.id)
217
+
218
+ deleteComment(editor, comment)
219
+
220
+ expect(openThreadId.get(editor)).toBe(null)
221
+ })
222
+
223
+ it('leaves the thread open while it still has comments', () => {
224
+ const editor = makeEditor()
225
+ const { thread, comment } = makeThread(editor)
226
+ addComment(editor, thread)
227
+ openThreadId.set(editor, thread.id)
228
+
229
+ deleteComment(editor, comment)
230
+
231
+ expect(openThreadId.get(editor)).toBe(thread.id)
232
+ })
233
+
234
+ // Deleting the same comment twice — a double activation, a handler holding a copy taken before
235
+ // the first delete — shouldn't read as "the last comment went" while a sibling is still live.
236
+ it('leaves the thread open when the comment was already deleted', () => {
237
+ const editor = makeEditor()
238
+ const { thread, comment } = makeThread(editor)
239
+ addComment(editor, thread)
240
+ deleteComment(editor, comment)
241
+ openThreadId.set(editor, thread.id)
242
+
243
+ deleteComment(editor, comment)
244
+
245
+ expect(openThreadId.get(editor)).toBe(thread.id)
246
+ })
247
+
248
+ it('does not resurrect a comment that has been removed since', () => {
249
+ const editor = makeEditor()
250
+ const { comment } = makeThread(editor)
251
+ removeCommentRecords(editor, [comment.id])
252
+
253
+ deleteComment(editor, comment)
254
+
255
+ expect(readComment(editor, comment)).toBeUndefined()
256
+ })
257
+
258
+ it('leaves a different open thread alone', () => {
259
+ const editor = makeEditor()
260
+ const { comment } = makeThread(editor)
261
+ const other = makeThread(editor)
262
+ openThreadId.set(editor, other.thread.id)
263
+
264
+ deleteComment(editor, comment)
265
+
266
+ expect(openThreadId.get(editor)).toBe(other.thread.id)
267
+ })
268
+ })
269
+
270
+ describe('deleteThread', () => {
271
+ it('soft-deletes the thread record', () => {
272
+ const editor = makeEditor()
273
+ const { thread } = makeThread(editor)
274
+
275
+ deleteThread(editor, thread)
276
+
277
+ expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
278
+ expect(getCommentThreads(editor)).toHaveLength(1)
279
+ expect(getLiveCommentThreads(editor)).toHaveLength(0)
280
+ })
281
+
282
+ it('closes the thread when it is the open one', () => {
283
+ const editor = makeEditor()
284
+ const { thread } = makeThread(editor)
285
+ openThreadId.set(editor, thread.id)
286
+
287
+ deleteThread(editor, thread)
288
+
289
+ expect(openThreadId.get(editor)).toBe(null)
290
+ })
291
+
292
+ it('flags the version in the store, leaving an anchor moved since where it is', () => {
293
+ const editor = makeEditor()
294
+ const { thread } = makeThread(editor)
295
+ putCommentRecords(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }])
296
+
297
+ deleteThread(editor, thread)
298
+
299
+ expect(readThread(editor, thread)).toMatchObject({
300
+ isDeleted: true,
301
+ anchor: { type: 'point', x: 50, y: 50 },
302
+ })
303
+ })
304
+
305
+ it('does not resurrect a thread that has been removed since', () => {
306
+ const editor = makeEditor()
307
+ const { thread } = makeThread(editor)
308
+ removeCommentRecords(editor, [thread.id])
309
+
310
+ deleteThread(editor, thread)
311
+
312
+ expect(readThread(editor, thread)).toBeUndefined()
313
+ })
314
+ })
315
+
316
+ describe('history', () => {
317
+ it('does not put comment writes on the undo stack by default', () => {
318
+ const editor = makeEditor()
319
+ editor.markHistoryStoppingPoint()
320
+ const { comment } = makeThread(editor)
321
+
322
+ editor.undo()
323
+
324
+ expect(readComment(editor, comment)).toBeDefined()
325
+ })
326
+
327
+ it('puts them on the undo stack when the history option asks for it', () => {
328
+ const editor = makeEditor(CommentTool.configure({ history: 'record' }))
329
+ editor.markHistoryStoppingPoint()
330
+ const { comment } = makeThread(editor)
331
+
332
+ editor.undo()
333
+
334
+ expect(readComment(editor, comment)).toBeUndefined()
335
+ })
336
+
337
+ // The flag is write-once server-side, so an undo clearing it would be vetoed and rebased —
338
+ // leaving the UI showing a comment the server has already pruned. The shape gives the undo
339
+ // something recorded to rewind, so what's under test is that the delete isn't on the stack
340
+ // rather than that the stack is empty.
341
+ it('never makes a delete undoable, even with history: record', () => {
342
+ const editor = makeEditor(CommentTool.configure({ history: 'record' }))
343
+ const { comment } = makeThread(editor)
344
+ editor.markHistoryStoppingPoint()
345
+ const shapeId = createShapeId()
346
+ editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
347
+
348
+ deleteComment(editor, comment)
349
+ editor.undo()
350
+
351
+ expect(editor.getShape(shapeId)).toBeUndefined()
352
+ expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
353
+ })
354
+
355
+ it('never makes a thread delete undoable, even with history: record', () => {
356
+ const editor = makeEditor(CommentTool.configure({ history: 'record' }))
357
+ const { thread } = makeThread(editor)
358
+ editor.markHistoryStoppingPoint()
359
+ const shapeId = createShapeId()
360
+ editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
361
+
362
+ deleteThread(editor, thread)
363
+ editor.undo()
364
+
365
+ expect(editor.getShape(shapeId)).toBeUndefined()
366
+ expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
367
+ })
368
+
369
+ // A host can want pin drags undoable while posts and edits aren't. The drag owns its commit and
370
+ // writes inside it, which is what keeps `dragHistory` in charge of the mode.
371
+ it('lets dragHistory govern a drag on its own', () => {
372
+ const editor = makeEditor(CommentTool.configure({ history: 'ignore', dragHistory: 'record' }))
373
+ const { thread } = makeThread(editor)
374
+ editor.markHistoryStoppingPoint()
375
+
376
+ commitCommentMutation(
377
+ editor,
378
+ () => putRecordsInCommit(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }]),
379
+ 'drag'
380
+ )
381
+ editor.undo()
382
+
383
+ expect(readThread(editor, thread)!.anchor).toEqual({ type: 'point', x: 0, y: 0 })
384
+ })
385
+ })
386
+
387
+ describe('getLiveComments and getLiveCommentThreads', () => {
388
+ it('hides a thread whose comments have all been deleted', () => {
389
+ const editor = makeEditor()
390
+ const { thread, comment } = makeThread(editor)
391
+
392
+ deleteComment(editor, comment)
393
+
394
+ // The thread record itself is untouched — the deleter may not be its creator — but with no
395
+ // live comment left it has no surface.
396
+ expect(readThread(editor, thread)!.isDeleted).toBe(false)
397
+ expect(getLiveCommentThreads(editor)).toHaveLength(0)
398
+ })
399
+
400
+ it('keeps a thread that still has a live comment', () => {
401
+ const editor = makeEditor()
402
+ const { thread, comment } = makeThread(editor)
403
+ addComment(editor, thread)
404
+
405
+ deleteComment(editor, comment)
406
+
407
+ expect(getLiveComments(editor)).toHaveLength(1)
408
+ expect(getLiveCommentThreads(editor)).toEqual([readThread(editor, thread)])
409
+ })
410
+ })