@tldraw/commenting 5.3.0-internal.c5c7f1d817d0 → 5.3.0-next.2fa9c61a8de6

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 (268) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1040 -230
  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-drafts.js +54 -0
  8. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  9. package/dist-cjs/canvas/comment-mutations.js +123 -0
  10. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  11. package/dist-cjs/canvas/comment-reactions.js +163 -0
  12. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  13. package/dist-cjs/canvas/comment-render.js +5 -3
  14. package/dist-cjs/canvas/comment-render.js.map +2 -2
  15. package/dist-cjs/canvas/comment-store.js +19 -9
  16. package/dist-cjs/canvas/comment-store.js.map +2 -2
  17. package/dist-cjs/canvas/comment-tool.js +64 -14
  18. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  19. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  20. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  21. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  22. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overlay.js +377 -437
  24. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  25. package/dist-cjs/canvas/comments-sidebar.js +34 -31
  26. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  27. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  28. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  29. package/dist-cjs/canvas/context.js +17 -0
  30. package/dist-cjs/canvas/context.js.map +7 -0
  31. package/dist-cjs/canvas/hooks.js +22 -3
  32. package/dist-cjs/canvas/hooks.js.map +2 -2
  33. package/dist-cjs/canvas/options.js +19 -2
  34. package/dist-cjs/canvas/options.js.map +2 -2
  35. package/dist-cjs/canvas/pin-stacking.js +56 -0
  36. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  37. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  38. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  39. package/dist-cjs/canvas/state.js +28 -14
  40. package/dist-cjs/canvas/state.js.map +2 -2
  41. package/dist-cjs/canvas/thread-preview.js +185 -0
  42. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  43. package/dist-cjs/canvas/thread-stack.js +199 -0
  44. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  45. package/dist-cjs/canvas/thread-state.js +67 -21
  46. package/dist-cjs/canvas/thread-state.js.map +2 -2
  47. package/dist-cjs/canvas/thread-view.js +397 -0
  48. package/dist-cjs/canvas/thread-view.js.map +7 -0
  49. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  50. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  51. package/dist-cjs/clustering/runtime.js +9 -9
  52. package/dist-cjs/clustering/runtime.js.map +2 -2
  53. package/dist-cjs/clustering/types.js.map +1 -1
  54. package/dist-cjs/index.d.ts +763 -353
  55. package/dist-cjs/index.js +46 -28
  56. package/dist-cjs/index.js.map +2 -2
  57. package/dist-cjs/ui/byline.js +3 -1
  58. package/dist-cjs/ui/byline.js.map +2 -2
  59. package/dist-cjs/ui/comment-card.js +12 -5
  60. package/dist-cjs/ui/comment-card.js.map +2 -2
  61. package/dist-cjs/ui/comment-composer.js +112 -17
  62. package/dist-cjs/ui/comment-composer.js.map +2 -2
  63. package/dist-cjs/ui/comment-pin.js +20 -2
  64. package/dist-cjs/ui/comment-pin.js.map +2 -2
  65. package/dist-cjs/ui/comment-thread.js +4 -2
  66. package/dist-cjs/ui/comment-thread.js.map +2 -2
  67. package/dist-cjs/ui/comments-list.js +22 -10
  68. package/dist-cjs/ui/comments-list.js.map +2 -2
  69. package/dist-cjs/ui/count-badge.js +3 -2
  70. package/dist-cjs/ui/count-badge.js.map +2 -2
  71. package/dist-cjs/ui/emoji-picker.js +54 -0
  72. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  73. package/dist-cjs/ui/format-time.js +4 -1
  74. package/dist-cjs/ui/format-time.js.map +2 -2
  75. package/dist-cjs/ui/reaction-picker.js +73 -0
  76. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  77. package/dist-cjs/ui/reaction.js +50 -4
  78. package/dist-cjs/ui/reaction.js.map +2 -2
  79. package/dist-cjs/ui/reactions.js +20 -15
  80. package/dist-cjs/ui/reactions.js.map +2 -2
  81. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  82. package/dist-cjs/ui/reply-count.js.map +7 -0
  83. package/dist-cjs/ui/send-button.js +20 -1
  84. package/dist-cjs/ui/send-button.js.map +2 -2
  85. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  86. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  87. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  89. package/dist-esm/canvas/cluster-input.mjs +2 -2
  90. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  91. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  92. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  93. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  94. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  95. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  96. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  97. package/dist-esm/canvas/comment-render.mjs +5 -3
  98. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  99. package/dist-esm/canvas/comment-store.mjs +19 -9
  100. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  101. package/dist-esm/canvas/comment-tool.mjs +67 -17
  102. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  103. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  104. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  105. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  106. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  107. package/dist-esm/canvas/comments-overlay.mjs +398 -442
  108. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  109. package/dist-esm/canvas/comments-sidebar.mjs +37 -34
  110. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  112. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  113. package/dist-esm/canvas/context.mjs +1 -0
  114. package/dist-esm/canvas/context.mjs.map +7 -0
  115. package/dist-esm/canvas/hooks.mjs +28 -5
  116. package/dist-esm/canvas/hooks.mjs.map +2 -2
  117. package/dist-esm/canvas/options.mjs +21 -3
  118. package/dist-esm/canvas/options.mjs.map +2 -2
  119. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  120. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  121. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  122. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  123. package/dist-esm/canvas/state.mjs +29 -19
  124. package/dist-esm/canvas/state.mjs.map +2 -2
  125. package/dist-esm/canvas/thread-preview.mjs +170 -0
  126. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  127. package/dist-esm/canvas/thread-stack.mjs +190 -0
  128. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  129. package/dist-esm/canvas/thread-state.mjs +71 -22
  130. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-view.mjs +402 -0
  132. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  133. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  134. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  135. package/dist-esm/clustering/runtime.mjs +9 -9
  136. package/dist-esm/clustering/runtime.mjs.map +2 -2
  137. package/dist-esm/index.d.mts +763 -353
  138. package/dist-esm/index.mjs +87 -41
  139. package/dist-esm/index.mjs.map +2 -2
  140. package/dist-esm/ui/byline.mjs +3 -1
  141. package/dist-esm/ui/byline.mjs.map +2 -2
  142. package/dist-esm/ui/comment-card.mjs +12 -5
  143. package/dist-esm/ui/comment-card.mjs.map +2 -2
  144. package/dist-esm/ui/comment-composer.mjs +122 -16
  145. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  146. package/dist-esm/ui/comment-pin.mjs +20 -2
  147. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  148. package/dist-esm/ui/comment-thread.mjs +4 -2
  149. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  150. package/dist-esm/ui/comments-list.mjs +39 -25
  151. package/dist-esm/ui/comments-list.mjs.map +2 -2
  152. package/dist-esm/ui/count-badge.mjs +3 -2
  153. package/dist-esm/ui/count-badge.mjs.map +2 -2
  154. package/dist-esm/ui/emoji-picker.mjs +34 -0
  155. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  156. package/dist-esm/ui/format-time.mjs +4 -1
  157. package/dist-esm/ui/format-time.mjs.map +2 -2
  158. package/dist-esm/ui/reaction-picker.mjs +60 -0
  159. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  160. package/dist-esm/ui/reaction.mjs +51 -5
  161. package/dist-esm/ui/reaction.mjs.map +2 -2
  162. package/dist-esm/ui/reactions.mjs +21 -16
  163. package/dist-esm/ui/reactions.mjs.map +2 -2
  164. package/dist-esm/ui/reply-count.mjs +9 -0
  165. package/dist-esm/ui/reply-count.mjs.map +7 -0
  166. package/dist-esm/ui/send-button.mjs +20 -1
  167. package/dist-esm/ui/send-button.mjs.map +2 -2
  168. package/dist-esm/ui/tooltip-button.mjs +12 -0
  169. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  170. package/package.json +6 -6
  171. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  172. package/src/canvas/anchor-lifecycle.ts +205 -0
  173. package/src/canvas/canvas.css +439 -24
  174. package/src/canvas/cluster-input.test.ts +31 -23
  175. package/src/canvas/cluster-input.ts +3 -4
  176. package/src/canvas/comment-drafts.test.ts +55 -0
  177. package/src/canvas/comment-drafts.ts +46 -0
  178. package/src/canvas/comment-mutations.test.ts +410 -0
  179. package/src/canvas/comment-mutations.ts +288 -0
  180. package/src/canvas/comment-reactions.test.ts +132 -0
  181. package/src/canvas/comment-reactions.tsx +245 -0
  182. package/src/canvas/comment-render.ts +9 -5
  183. package/src/canvas/comment-store.ts +67 -27
  184. package/src/canvas/comment-tool.test.ts +162 -0
  185. package/src/canvas/comment-tool.tsx +105 -23
  186. package/src/canvas/comments-filter-menu.tsx +23 -32
  187. package/src/canvas/comments-overflow-menu.tsx +12 -19
  188. package/src/canvas/comments-overlay.tsx +602 -562
  189. package/src/canvas/comments-sidebar.test.ts +45 -0
  190. package/src/canvas/comments-sidebar.tsx +75 -57
  191. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  192. package/src/canvas/context.ts +47 -0
  193. package/src/canvas/hooks.ts +52 -11
  194. package/src/canvas/options.test.ts +52 -2
  195. package/src/canvas/options.ts +133 -9
  196. package/src/canvas/pin-stacking.test.ts +114 -0
  197. package/src/canvas/pin-stacking.ts +57 -0
  198. package/src/canvas/sidebar-filters.ts +4 -3
  199. package/src/canvas/state.ts +92 -35
  200. package/src/canvas/thread-preview.test.ts +91 -0
  201. package/src/canvas/thread-preview.tsx +304 -0
  202. package/src/canvas/thread-stack.tsx +252 -0
  203. package/src/canvas/thread-state.test.ts +209 -0
  204. package/src/canvas/thread-state.ts +137 -36
  205. package/src/canvas/thread-view.tsx +516 -0
  206. package/src/clustering/computeClusterTable.test.ts +24 -23
  207. package/src/clustering/computeClusterTable.ts +5 -5
  208. package/src/clustering/runtime.test.ts +34 -0
  209. package/src/clustering/runtime.ts +20 -15
  210. package/src/clustering/types.ts +9 -9
  211. package/src/index.ts +76 -40
  212. package/src/ui/byline.tsx +4 -2
  213. package/src/ui/comment-card.tsx +17 -7
  214. package/src/ui/comment-composer.tsx +160 -13
  215. package/src/ui/comment-pin.tsx +22 -2
  216. package/src/ui/comment-thread.tsx +6 -1
  217. package/src/ui/comments-list.test.ts +31 -0
  218. package/src/ui/comments-list.tsx +32 -12
  219. package/src/ui/comments.css +489 -204
  220. package/src/ui/count-badge.tsx +9 -2
  221. package/src/ui/emoji-picker.test.ts +25 -0
  222. package/src/ui/emoji-picker.tsx +76 -0
  223. package/src/ui/format-time.ts +7 -2
  224. package/src/ui/reaction-picker.tsx +109 -0
  225. package/src/ui/reaction.tsx +116 -4
  226. package/src/ui/reactions.tsx +68 -13
  227. package/src/ui/reply-count.ts +16 -0
  228. package/src/ui/send-button.tsx +18 -3
  229. package/src/ui/tooltip-button.tsx +20 -0
  230. package/dist-cjs/canvas/region-options.js +0 -49
  231. package/dist-cjs/canvas/region-options.js.map +0 -7
  232. package/dist-cjs/ui/avatar.js +0 -51
  233. package/dist-cjs/ui/avatar.js.map +0 -7
  234. package/dist-cjs/ui/comment-mention.js +0 -42
  235. package/dist-cjs/ui/comment-mention.js.map +0 -7
  236. package/dist-cjs/ui/comment-text.js.map +0 -7
  237. package/dist-cjs/ui/mention-list.js +0 -70
  238. package/dist-cjs/ui/mention-list.js.map +0 -7
  239. package/dist-cjs/ui/mention-suggestion.js +0 -206
  240. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  241. package/dist-cjs/ui/mention.js.map +0 -7
  242. package/dist-cjs/ui/render-markdown.js +0 -63
  243. package/dist-cjs/ui/render-markdown.js.map +0 -7
  244. package/dist-esm/canvas/region-options.mjs +0 -29
  245. package/dist-esm/canvas/region-options.mjs.map +0 -7
  246. package/dist-esm/ui/avatar.mjs +0 -31
  247. package/dist-esm/ui/avatar.mjs.map +0 -7
  248. package/dist-esm/ui/comment-mention.mjs +0 -22
  249. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  250. package/dist-esm/ui/comment-text.mjs +0 -9
  251. package/dist-esm/ui/comment-text.mjs.map +0 -7
  252. package/dist-esm/ui/mention-list.mjs +0 -50
  253. package/dist-esm/ui/mention-list.mjs.map +0 -7
  254. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  255. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  256. package/dist-esm/ui/mention.mjs +0 -11
  257. package/dist-esm/ui/mention.mjs.map +0 -7
  258. package/dist-esm/ui/render-markdown.mjs +0 -45
  259. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  260. package/src/canvas/region-options.ts +0 -57
  261. package/src/ui/avatar.tsx +0 -38
  262. package/src/ui/comment-mention.ts +0 -47
  263. package/src/ui/comment-text.tsx +0 -12
  264. package/src/ui/mention-list.tsx +0 -106
  265. package/src/ui/mention-suggestion.test.ts +0 -18
  266. package/src/ui/mention-suggestion.tsx +0 -285
  267. package/src/ui/mention.tsx +0 -9
  268. package/src/ui/render-markdown.tsx +0 -72
@@ -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
+ })