@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/commenting.css +63 -71
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +252 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +0 -1
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +77 -870
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +134 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +323 -202
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +12 -0
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +115 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +236 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +0 -1
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +54 -878
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +126 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +323 -202
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +12 -0
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +95 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +5 -5
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.tsx +8 -12
  189. package/src/canvas/comments-filter-menu.tsx +9 -16
  190. package/src/canvas/comments-overflow-menu.tsx +8 -3
  191. package/src/canvas/comments-overlay.tsx +154 -1344
  192. package/src/canvas/comments-sidebar.tsx +40 -19
  193. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  194. package/src/canvas/context.ts +4 -3
  195. package/src/canvas/hooks.test.ts +94 -0
  196. package/src/canvas/hooks.ts +17 -3
  197. package/src/canvas/license.ts +1 -1
  198. package/src/canvas/mobile-placement.ts +115 -0
  199. package/src/canvas/options.test.ts +124 -2
  200. package/src/canvas/options.ts +192 -58
  201. package/src/canvas/pending-composer.tsx +157 -0
  202. package/src/canvas/pin-stacking.test.ts +111 -1
  203. package/src/canvas/pin-stacking.ts +46 -0
  204. package/src/canvas/region-box.tsx +124 -0
  205. package/src/canvas/state.ts +20 -28
  206. package/src/canvas/thread-pin.tsx +418 -0
  207. package/src/canvas/thread-preview.tsx +77 -87
  208. package/src/canvas/thread-stack.tsx +19 -29
  209. package/src/canvas/thread-state.test.ts +51 -0
  210. package/src/canvas/thread-state.ts +56 -23
  211. package/src/canvas/thread-view.test.ts +72 -0
  212. package/src/canvas/thread-view.tsx +231 -133
  213. package/src/clustering/computeClusterTable.ts +12 -3
  214. package/src/clustering/replay.test.ts +0 -7
  215. package/src/clustering/replay.ts +131 -32
  216. package/src/clustering/runtime.test.ts +50 -6
  217. package/src/clustering/runtime.ts +42 -39
  218. package/src/clustering/schedule.test.ts +0 -6
  219. package/src/clustering/screen-offsets.test.ts +171 -0
  220. package/src/clustering/types.ts +10 -0
  221. package/src/index.ts +15 -2
  222. package/src/ui/byline.tsx +16 -6
  223. package/src/ui/comment-composer.tsx +25 -69
  224. package/src/ui/comment-pin.tsx +3 -16
  225. package/src/ui/comments-list.tsx +40 -29
  226. package/src/ui/comments.css +58 -60
  227. package/src/ui/format-time.test.ts +69 -0
  228. package/src/ui/format-time.ts +20 -0
  229. package/src/ui/icons.tsx +116 -0
  230. package/src/ui/reaction-picker.tsx +9 -16
  231. package/src/ui/reaction.tsx +10 -6
  232. package/src/ui/send-button.tsx +3 -8
  233. package/src/ui/visual-viewport.test.ts +36 -0
  234. package/src/ui/visual-viewport.ts +27 -0
  235. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  236. package/dist-esm/ui/tooltip-button.mjs +0 -12
  237. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  238. package/src/ui/tooltip-button.tsx +0 -20
@@ -16,14 +16,11 @@ import { openThreadId } from './state'
16
16
  /**
17
17
  * Every write to a comment record, and the undo/redo policy governing them.
18
18
  *
19
- * This file layers bottom-up: {@link commitCommentMutation} resolves the history mode,
20
- * {@link putCommentRecords} and {@link removeCommentRecords} are the typed writes that run under
21
- * it, and the verbs below are those writes plus the one rule each carries — a timestamp to stamp, a
22
- * shape to put the `resolved` field in, or the soft-delete protocol. The built-in thread view calls
23
- * exactly these verbs, so a UI of your own behaves the same as the one in the box.
24
- *
25
- * Every verb takes the record it acts on, but treats it as the identity of what to change rather
26
- * than as the value to write back — see {@link readLatest}.
19
+ * The file layers bottom-up: {@link commitCommentMutation} resolves the history mode and hands its
20
+ * callback a writer, {@link putCommentRecords} and {@link removeCommentRecords} are the typed
21
+ * writes that run under it, and the verbs below are those writes plus the one rule each carries.
22
+ * Every verb takes the record it acts on as the identity of what to change, not the value to write
23
+ * back see {@link readLatest}.
27
24
  *
28
25
  * Posting carries no such rule, so it isn't a verb here: build the records with
29
26
  * `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.
@@ -42,6 +39,13 @@ import { openThreadId } from './state'
42
39
  */
43
40
  export type CommentMutationKind = 'delete' | 'drag' | 'mutation'
44
41
 
42
+ interface CommentMutationWriter {
43
+ put(records: TLCommentRecord[]): void
44
+ remove(ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]): void
45
+ }
46
+
47
+ const activeCommentMutations = new WeakMap<Editor, { history: TLHistoryBatchOptions['history'] }>()
48
+
45
49
  /** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */
46
50
  function historyModeFor(
47
51
  options: CommentingOptions,
@@ -61,71 +65,92 @@ function historyModeFor(
61
65
  * Commit a comment mutation with the configured undo/redo behavior, so the
62
66
  * {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to
63
67
  * `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.
68
+ *
69
+ * `editor.run`'s history option isn't additive — a nested run overwrites the enclosing mode — so
70
+ * constituent records go through the callback's writer instead of opening a commit of their own,
71
+ * which would quietly make a `drag` write non-undoable.
72
+ *
73
+ * Commits that nest are only a problem when they resolve to different modes, and then neither is
74
+ * the right one to keep, so it throws. Matching modes have to nest: a store side effect runs inside
75
+ * the write that triggered it, with no "after the commit" to defer to. (A `store.listen` handler
76
+ * normally flushes on a later frame, so its writes open a commit of their own, but a synchronous
77
+ * flush — as under test — lands it inside too.)
64
78
  * @internal
65
79
  */
66
80
  export function commitCommentMutation<T>(
67
81
  editor: Editor,
68
- fn: () => T,
82
+ fn: (writer: CommentMutationWriter) => T,
69
83
  kind: CommentMutationKind = 'mutation'
70
84
  ): T {
71
85
  const history = historyModeFor(getCommentingOptions(editor), kind)
72
- let result: T
73
- editor.run(
74
- () => {
75
- result = fn()
76
- },
77
- { history }
78
- )
79
- return result!
80
- }
81
-
82
- /**
83
- * Write records without opening a commit, for call sites that already sit inside one.
84
- *
85
- * `editor.run`'s history option isn't additive — a nested run overwrites the enclosing mode for its
86
- * own scope — so a {@link putCommentRecords} call inside a commit would discard the mode its caller
87
- * chose. A pin drag committed as `drag` under `dragHistory: 'record'` would land back on
88
- * `history: 'ignore'` and quietly stop being undoable. Inside a commit, write with this.
89
- *
90
- * @internal
91
- */
92
- export function putRecordsInCommit(editor: Editor, records: TLCommentRecord[]): void {
93
- editor.store.put(records as unknown as TLRecord[])
94
- }
86
+ const enclosing = activeCommentMutations.get(editor)
87
+ if (enclosing && enclosing.history !== history) {
88
+ throw new Error(
89
+ `A comment mutation that records history as '${history}' can't run inside one recording it as '${enclosing.history}': one of the two modes would be silently discarded. Use the provided writer for constituent records, or run this operation after the enclosing one has committed.`
90
+ )
91
+ }
95
92
 
96
- /** {@link putRecordsInCommit}'s counterpart for removals. @internal */
97
- export function removeRecordsInCommit(
98
- editor: Editor,
99
- ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
100
- ): void {
101
- editor.store.remove(ids as unknown as TLRecord['id'][])
93
+ activeCommentMutations.set(editor, { history })
94
+ try {
95
+ let result: T
96
+ editor.run(
97
+ () => {
98
+ let isWriterActive = true
99
+ const assertWriterActive = () => {
100
+ if (!isWriterActive) {
101
+ throw new Error(
102
+ 'A comment mutation writer cannot be used after its commit has finished.'
103
+ )
104
+ }
105
+ }
106
+ try {
107
+ result = fn({
108
+ put: (records) => {
109
+ assertWriterActive()
110
+ editor.store.put(records as unknown as TLRecord[])
111
+ },
112
+ remove: (ids) => {
113
+ assertWriterActive()
114
+ editor.store.remove(ids as unknown as TLRecord['id'][])
115
+ },
116
+ })
117
+ } finally {
118
+ isWriterActive = false
119
+ }
120
+ },
121
+ { history }
122
+ )
123
+ return result!
124
+ } finally {
125
+ if (enclosing) {
126
+ activeCommentMutations.set(editor, enclosing)
127
+ } else {
128
+ activeCommentMutations.delete(editor)
129
+ }
130
+ }
102
131
  }
103
132
 
104
133
  /**
105
- * Write comment records to the store, under the configured
106
- * {@link CommentingOptions.history} behavior so a record you write lands on the undo stack (or
107
- * doesn't) exactly like one the built-in UI writes. Defaults to `'ignore'`.
134
+ * Write comment records to the store, under the configured {@link CommentingOptions.history}
135
+ * behavior. Defaults to `'ignore'`.
108
136
  *
109
- * Use it to seed or import threads, and to save an edit. To delete, prefer
110
- * {@link deleteComment} and {@link deleteThread} over {@link removeCommentRecords}: comments are
111
- * soft-deleted, and a synced server rejects the hard delete.
137
+ * Use it to seed or import threads, and to save an edit. To delete, prefer {@link deleteComment}
138
+ * and {@link deleteThread}: comments are soft-deleted, and a synced server rejects the hard delete.
112
139
  *
113
140
  * @public
114
141
  */
115
142
  export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
116
- commitCommentMutation(editor, () => putRecordsInCommit(editor, records))
143
+ commitCommentMutation(editor, ({ put }) => put(records))
117
144
  }
118
145
 
119
146
  /**
120
147
  * Remove comment records from the store by id, under the configured
121
148
  * {@link CommentingOptions.history} behavior.
122
149
  *
123
- * This is a hard delete, which is rarely what you want for a comment or a thread: the built-in UI
124
- * soft-deletes them ({@link deleteComment}, {@link deleteThread}) so the server can prune the
125
- * records including reactions, which belong to whoever left them rather than to the deleter. A
126
- * server that enforces per-record permissions will veto a hard delete outright. Reach for this on
127
- * a local, unsynced comment store, or to drop a reaction (which is a hard delete — see
128
- * {@link toggleCommentReaction}).
150
+ * This is a hard delete, which is rarely what you want: the built-in UI soft-deletes
151
+ * ({@link deleteComment}, {@link deleteThread}) so the server can prune the records, and a server
152
+ * enforcing per-record permissions vetoes a hard delete outright. Reach for this on a local,
153
+ * unsynced store, or to drop a reaction (see {@link toggleCommentReaction}).
129
154
  *
130
155
  * @public
131
156
  */
@@ -133,24 +158,19 @@ export function removeCommentRecords(
133
158
  editor: Editor,
134
159
  ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
135
160
  ): void {
136
- commitCommentMutation(editor, () => removeRecordsInCommit(editor, ids))
161
+ commitCommentMutation(editor, ({ remove }) => remove(ids))
137
162
  }
138
163
 
139
164
  /**
140
165
  * The record as the store currently holds it, or `undefined` if it isn't there any more.
141
166
  *
142
- * A verb is handed a record, but that record is a snapshot of whenever its caller got hold of one,
143
- * and a comment record moves underneath it. A thread's `anchor` and `pageId` are rewritten without
144
- * anyone touching the thread: deleting a pinned shape converts the anchor to a point, reparenting
145
- * one rehomes the thread to another page, a pin drag re-anchors it. Writing the caller's snapshot
146
- * back would put those fields as they were and sync the revert out to everyone.
147
- *
148
- * `put` is also an upsert, so a record a remote delete has already removed would come back — with
149
- * `isDeleted: false` on an edit or a resolve. That's the multiplayer surprise the schema warns
150
- * about (see `TLComment`), reached by a route the history option doesn't cover.
167
+ * A verb is handed a record, but that record is a snapshot and comment records move underneath it:
168
+ * deleting a pinned shape converts the anchor to a point, reparenting rehomes the thread, a drag
169
+ * re-anchors it. Writing the caller's snapshot back would revert those fields for everyone. `put`
170
+ * is also an upsert, so a record a remote delete already removed would come back.
151
171
  *
152
172
  * So a verb reads what it's changing rather than trusting what it was given, and a record that's
153
- * gone is a no-op: whatever the change was, there's nothing left for it to apply to.
173
+ * gone is a no-op.
154
174
  */
155
175
  function readLatest<T extends TLComment | TLCommentThread>(
156
176
  editor: Editor,
@@ -162,15 +182,13 @@ function readLatest<T extends TLComment | TLCommentThread>(
162
182
  }
163
183
 
164
184
  /**
165
- * Replace a comment's body and stamp it as edited, which is what renders the "(edited)" marker on
166
- * its byline.
167
- *
168
- * Editing is the author's to do. The built-in UI only offers it on your own comments, and a server
169
- * that enforces per-record permissions rejects anyone else's edit.
185
+ * Replace a comment's body and stamp it as edited, which renders the "(edited)" marker on its
186
+ * byline. Editing is the author's to do by default ({@link CommentingOptions.canModifyComment}),
187
+ * and a server enforcing per-record permissions rejects anyone else's. Widening one end without the
188
+ * other leaves an edit that's offered and then rejected, so widen both.
170
189
  *
171
- * The `comment` you pass says which comment to edit; the body lands on the version the store
172
- * currently holds, so a copy you've held on to can't revert a change made since it, and can't
173
- * re-create a comment that's already been removed — editing one of those does nothing.
190
+ * The body lands on the version the store currently holds, so a stale copy can't revert a later
191
+ * change or re-create a removed comment editing one of those does nothing.
174
192
  *
175
193
  * @example
176
194
  * ```ts
@@ -180,10 +198,10 @@ function readLatest<T extends TLComment | TLCommentThread>(
180
198
  * @public
181
199
  */
182
200
  export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {
183
- commitCommentMutation(editor, () => {
201
+ commitCommentMutation(editor, ({ put }) => {
184
202
  const current = readLatest(editor, comment)
185
203
  if (!current) return
186
- putRecordsInCommit(editor, [{ ...current, body, editedAt: Date.now() }])
204
+ put([{ ...current, body, editedAt: Date.now() }])
187
205
  })
188
206
  }
189
207
 
@@ -191,16 +209,16 @@ export function editComment(editor: Editor, comment: TLComment, body: TLRichText
191
209
  * Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a
192
210
  * checked one) and are hidden from the sidebar until its "show resolved" filter is on.
193
211
  *
194
- * Only the resolution is written: the rest of the thread is read fresh, so resolving with a stale
195
- * copy in hand won't drag a pin back to where it used to be. A no-op on a thread that's gone.
212
+ * Only the resolution is written the rest of the thread is read fresh, so a stale copy can't drag
213
+ * a pin back. A no-op on a thread that's gone.
196
214
  *
197
215
  * @public
198
216
  */
199
217
  export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {
200
- commitCommentMutation(editor, () => {
218
+ commitCommentMutation(editor, ({ put }) => {
201
219
  const current = readLatest(editor, thread)
202
220
  if (!current) return
203
- putRecordsInCommit(editor, [{ ...current, resolved: { at: Date.now(), by: userId } }])
221
+ put([{ ...current, resolved: { at: Date.now(), by: userId } }])
204
222
  })
205
223
  }
206
224
 
@@ -211,50 +229,45 @@ export function resolveThread(editor: Editor, thread: TLCommentThread, userId: s
211
229
  * @public
212
230
  */
213
231
  export function reopenThread(editor: Editor, thread: TLCommentThread): void {
214
- commitCommentMutation(editor, () => {
232
+ commitCommentMutation(editor, ({ put }) => {
215
233
  const current = readLatest(editor, thread)
216
234
  if (!current) return
217
- putRecordsInCommit(editor, [{ ...current, resolved: null }])
235
+ put([{ ...current, resolved: null }])
218
236
  })
219
237
  }
220
238
 
221
239
  /**
222
240
  * Delete a comment.
223
241
  *
224
- * This is a soft delete: it sets the record's `isDeleted` flag rather than removing it, and the
225
- * server prunes the comment and its reactions once the flag is persisted. That way no client ever
226
- * removes records it doesn't own a reaction belongs to whoever left it and a server enforcing
227
- * per-record permissions has a write it can check rather than a deletion it can only refuse.
228
- * Deleting is the author's to do; the built-in UI only offers it on your own comments.
242
+ * This is a soft delete: it sets `isDeleted` rather than removing the record, and the server prunes
243
+ * the comment and its reactions once the flag is persisted so no client removes records it
244
+ * doesn't own, and a server enforcing per-record permissions has a write it can check.
229
245
  *
230
- * The write is never undoable, whatever {@link CommentingOptions.history} says: the flag is
231
- * write-once server-side, so an undo clearing it would be vetoed and rebased rather than bring the
232
- * comment back.
246
+ * Deleting is the author's to do by default; {@link CommentingOptions.canModifyComment} widens
247
+ * that, as does its counterpart on the server.
233
248
  *
234
- * Deleting a thread's last comment leaves the thread with no surface — an empty thread renders
235
- * nothing and closes it if it's open. The thread record is left for the server to prune, since
236
- * the deleter may not be its creator.
249
+ * Never undoable, whatever {@link CommentingOptions.history} says: the flag is write-once
250
+ * server-side, so an undo clearing it would be vetoed rather than bring the comment back.
237
251
  *
238
- * A comment that's already deleted, or already pruned, is a no-op.
252
+ * Deleting a thread's last comment closes it and leaves the thread record for the server to prune,
253
+ * since the deleter may not be its creator. An already-deleted comment is a no-op.
239
254
  *
240
255
  * @public
241
256
  */
242
257
  export function deleteComment(editor: Editor, comment: TLComment): void {
243
258
  commitCommentMutation(
244
259
  editor,
245
- () => {
260
+ ({ put }) => {
246
261
  const current = readLatest(editor, comment)
247
- // Deleting twice a double activation, a handler firing on a copy taken before the first
248
- // delete is nothing to do rather than something to redo. The check below counts the
249
- // comment being deleted among the live ones, so it only reads as "the last one" while
250
- // this delete is the one taking it away.
262
+ // Deleting twice is nothing to do rather than something to redo. The check below counts this
263
+ // comment among the live ones, so it only reads as "the last one" while this delete takes it away.
251
264
  if (!current || current.isDeleted) return
252
265
  const isLastInThread =
253
266
  getLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1
254
267
  if (isLastInThread && openThreadId.get(editor) === current.threadId) {
255
268
  openThreadId.set(editor, null)
256
269
  }
257
- putRecordsInCommit(editor, [{ ...current, isDeleted: true }])
270
+ put([{ ...current, isDeleted: true }])
258
271
  },
259
272
  'delete'
260
273
  )
@@ -263,25 +276,23 @@ export function deleteComment(editor: Editor, comment: TLComment): void {
263
276
  /**
264
277
  * Delete a thread and, with it, the whole conversation.
265
278
  *
266
- * A soft delete on the same model as {@link deleteComment}: the flag goes on the thread record and
267
- * the server prunes the thread, its comments, and their reactions once it's persisted. Deleting a
268
- * thread is its creator's to do a server enforcing per-record permissions vetoes anyone else
269
- * and the write is never undoable.
270
- *
271
- * Closes the thread if it's the open one. A thread that's already pruned is a no-op.
279
+ * A soft delete on the same model as {@link deleteComment}: the server prunes the thread, its
280
+ * comments, and their reactions once the flag is persisted. Deleting a thread is its creator's to
281
+ * do by default ({@link CommentingOptions.canModifyComment}), and the write is never undoable.
282
+ * Closes the thread if it's the open one; a pruned thread is a no-op.
272
283
  *
273
284
  * @public
274
285
  */
275
286
  export function deleteThread(editor: Editor, thread: TLCommentThread): void {
276
287
  commitCommentMutation(
277
288
  editor,
278
- () => {
289
+ ({ put }) => {
279
290
  const current = readLatest(editor, thread)
280
291
  if (!current) return
281
292
  if (openThreadId.get(editor) === current.id) {
282
293
  openThreadId.set(editor, null)
283
294
  }
284
- putRecordsInCommit(editor, [{ ...current, isDeleted: true }])
295
+ put([{ ...current, isDeleted: true }])
285
296
  },
286
297
  'delete'
287
298
  )
@@ -87,6 +87,28 @@ describe('summarizeReactions', () => {
87
87
  })
88
88
  })
89
89
 
90
+ describe('summarizeReactions input', () => {
91
+ // the notifications panel tallies rows synced from the app database, which carry only the
92
+ // fields the tally needs — full TLCommentReaction records must not be required
93
+ it('accepts minimal rows with only userId, emoji, and createdAt', () => {
94
+ const rows = [
95
+ { userId: 'user1', emoji: '👍', createdAt: 100 },
96
+ { userId: 'user2', emoji: '👍', createdAt: 200 },
97
+ ]
98
+ expect(summarizeReactions(rows, 'user1')).toEqual([
99
+ {
100
+ emoji: '👍',
101
+ count: 2,
102
+ active: true,
103
+ reactors: [
104
+ { name: 'Someone', you: true },
105
+ { name: 'Someone', you: false },
106
+ ],
107
+ },
108
+ ])
109
+ })
110
+ })
111
+
90
112
  describe('createCommentReactionId', () => {
91
113
  // the id is what makes reaction identity structural: re-picking the same emoji addresses the
92
114
  // same record (toggle off), while a different emoji is its own record (independent)
@@ -11,11 +11,7 @@ import {
11
11
  import { RenderReaction } from '../ui/reaction'
12
12
  import { ReactionPicker } from '../ui/reaction-picker'
13
13
  import { Reactions, ReactionSummary } from '../ui/reactions'
14
- import {
15
- commitCommentMutation,
16
- putRecordsInCommit,
17
- removeRecordsInCommit,
18
- } from './comment-mutations'
14
+ import { commitCommentMutation } from './comment-mutations'
19
15
  import { UNKNOWN_AUTHOR } from './comment-render'
20
16
  import { getCommentReactions } from './comment-store'
21
17
  import { getCommentingOptions, useCommentingOptions } from './options'
@@ -40,16 +36,28 @@ export function useCommentReactions(
40
36
  }
41
37
 
42
38
  /**
43
- * Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used
44
- * so the row stays stable as later reactions arrive. `active` marks the emoji the current user
45
- * reacted with (the pills render those highlighted), and `reactors` lists who reacted with it, in
46
- * reaction order, for the hover list. `resolveName` names each reactor; an id it can't name falls
47
- * back to the id itself.
39
+ * The reaction fields {@link summarizeReactions} needs a structural subset of
40
+ * {@link tldraw#TLCommentReaction}, so tallies can also be built from rows synced outside the
41
+ * editor store.
42
+ *
43
+ * @public
44
+ */
45
+ export interface ReactionSummaryInput {
46
+ userId: string
47
+ emoji: string
48
+ createdAt: number
49
+ }
50
+
51
+ /**
52
+ * Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used so
53
+ * the row stays stable as later reactions arrive. `active` marks the current user's emoji and
54
+ * `reactors` lists who reacted, in reaction order. `resolveName` names each reactor; an id it can't
55
+ * name falls back to a generic "Someone", never the raw user id.
48
56
  *
49
57
  * @public
50
58
  */
51
59
  export function summarizeReactions(
52
- reactions: TLCommentReaction[],
60
+ reactions: readonly ReactionSummaryInput[],
53
61
  currentUserId?: string | null,
54
62
  resolveName?: (userId: string) => string | undefined
55
63
  ): ReactionSummary[] {
@@ -89,14 +97,12 @@ export function summarizeReactions(
89
97
  /**
90
98
  * Toggle one user's reaction with a given emoji on a comment.
91
99
  *
92
- * Each reaction is its own record keyed by (comment, user, emoji), so this only ever touches that
93
- * one user's own records two people reacting at once never conflict. Behaviour depends on the
94
- * `allowMultipleReactions` option:
100
+ * Each reaction is its own record keyed by (comment, user, emoji), so this only touches that user's
101
+ * own records and two people reacting at once never conflict. Behaviour depends on
102
+ * `allowMultipleReactions`:
95
103
  *
96
- * - **multiple** (default): the emoji toggles independently add it if absent, remove it if
97
- * present, leaving the user's other reactions alone.
98
- * - **single**: picking a new emoji first removes the user's existing reaction(s) on the comment,
99
- * then adds this one (a replace); picking the one they already have removes it.
104
+ * - **multiple** (default): the emoji toggles independently, leaving other reactions alone.
105
+ * - **single**: a new emoji replaces the user's existing reaction; the same one removes it.
100
106
  *
101
107
  * @public
102
108
  */
@@ -116,19 +122,16 @@ export function toggleCommentReaction(
116
122
  // Only a token the configured palette allows may be added. Removals always go through — if a
117
123
  // reaction somehow carries an off-palette token, the user must still be able to clear it.
118
124
  if (!removing && !isAllowedReaction(emoji)) return
119
- commitCommentMutation(editor, () => {
125
+ commitCommentMutation(editor, ({ put, remove }) => {
120
126
  if (removing) {
121
- removeRecordsInCommit(editor, [targetId])
127
+ remove([targetId])
122
128
  return
123
129
  }
124
130
  // Single-select: a new emoji replaces the user's existing reaction(s) on this comment.
125
131
  if (!allowMultipleReactions && mine.length > 0) {
126
- removeRecordsInCommit(
127
- editor,
128
- mine.map((reaction) => reaction.id)
129
- )
132
+ remove(mine.map((reaction) => reaction.id))
130
133
  }
131
- putRecordsInCommit(editor, [
134
+ put([
132
135
  createCommentReaction({
133
136
  commentId: comment.id,
134
137
  threadId: comment.threadId,
@@ -147,14 +150,13 @@ export interface CommentReactionsProps {
147
150
  /** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the
148
151
  * pills don't toggle. */
149
152
  currentUserId?: string | null
150
- /** Names a reactor id for the hover list. Ids it can't name fall back to the id. */
153
+ /** Names a reactor id for the hover list. Ids it can't name fall back to a generic "Someone". */
151
154
  resolveName?(userId: string): string | undefined
152
155
  }
153
156
 
154
157
  /**
155
- * Adapts the `ReactionContent` component override (if any) into a `renderReaction` function for the
156
- * presentational reaction components. Returns undefined when no override is set, so they fall back
157
- * to the default (the token string, drawn by the OS emoji font).
158
+ * Adapts the `ReactionContent` override into a `renderReaction` function for the presentational
159
+ * components. Undefined when no override is set, so they fall back to drawing the token string.
158
160
  */
159
161
  function useReactionRenderer(): RenderReaction | undefined {
160
162
  const { components } = useCommentingOptions()
@@ -210,9 +212,8 @@ export interface CommentReactionPickerProps {
210
212
  }
211
213
 
212
214
  /**
213
- * The add-reaction button for one comment. Belongs with the comment card's hover actions (under
214
- * the ⋯ button) rather than in the reaction row, so opening it doesn't chase the row as reactions
215
- * are added.
215
+ * The add-reaction button for one comment. Belongs with the comment card's hover actions rather
216
+ * than in the reaction row, so opening it doesn't chase the row as reactions are added.
216
217
  * @public @react
217
218
  */
218
219
  export function CommentReactionPicker({
@@ -1,6 +1,6 @@
1
- import { generateHTML, generateText, JSONContent } from '@tiptap/core'
1
+ import { generateText, JSONContent } from '@tiptap/core'
2
2
  import { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'
3
- import { TLRichText } from 'tldraw'
3
+ import { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'
4
4
  import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
5
5
 
6
6
  /**
@@ -44,9 +44,9 @@ const htmlCache = new WeakMap<TLRichText, string>()
44
44
 
45
45
  /**
46
46
  * Render a comment body to HTML through the limited comment extension set (no headings), so a body
47
- * always renders with comment formatting regardless of the host editor's rich-text config. Mirrors
48
- * tldraw's `renderHtmlFromRichText`, including the empty-paragraph fix that keeps blank lines from
49
- * collapsing. `resolveName` maps a member id to its current name for any @mentions.
47
+ * always renders with comment formatting regardless of the host editor's rich-text config. Renders
48
+ * through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines
49
+ * from collapsing. `resolveName` maps a member id to its current name for any @mentions.
50
50
  */
51
51
  export function renderCommentHtml(
52
52
  richText: TLRichText,
@@ -60,9 +60,9 @@ export function renderCommentHtml(
60
60
  const extensions = mentions
61
61
  ? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
62
62
  : commentTipTapExtensions
63
- const html = generateHTML(demoteHeadings(richText as JSONContent), extensions).replaceAll(
64
- '<p dir="auto"></p>',
65
- '<p><br /></p>'
63
+ const html = renderHtmlFromRichTextWithExtensions(
64
+ demoteHeadings(richText as JSONContent) as TLRichText,
65
+ extensions
66
66
  )
67
67
  if (!mentions) htmlCache.set(richText, html)
68
68
  return html
@@ -3,15 +3,13 @@ import { Editor, TLComment, TLCommentReaction, TLCommentThread, TLRecord } from
3
3
  /**
4
4
  * Typed reads of comment records on the editor store.
5
5
  *
6
- * Comment threads and comments live on the editor's local store so the canvas can render them
7
- * reactively, but they are opt-in records that aren't part of the `TLRecord` union (they ride the
8
- * sync server's object-store lane on the wire see `TLCommentThread`). `editor.store` is therefore
9
- * statically typed `Store<TLRecord>` and doesn't know about them, so every access has to reinterpret
10
- * the type. These helpers own that reinterpretation — an `unknown` hop to exactly the type the store
11
- * expects, so the rest of each call stays checked — behind one boundary, and keep call sites typed.
6
+ * Comment records live on the editor's local store so the canvas can render them reactively, but
7
+ * they're opt-in and aren't part of the `TLRecord` union so `editor.store` is statically typed
8
+ * `Store<TLRecord>` and every access has to reinterpret the type. These helpers own that
9
+ * reinterpretation behind one boundary and keep call sites typed.
12
10
  *
13
- * Writes do the same reinterpretation, but they also answer to the undo/redo policy, so they live
14
- * with the rest of the write path in `comment-mutations.ts`.
11
+ * Writes do the same, but also answer to the undo/redo policy, so they live in
12
+ * `comment-mutations.ts`.
15
13
  */
16
14
 
17
15
  /**
@@ -36,9 +34,8 @@ export function getCommentRecord(editor: Editor, id: string): TLCommentRecord |
36
34
  }
37
35
 
38
36
  /**
39
- * Every comment thread in the store, **including soft-deleted ones** and ones left empty by their
40
- * last comment's delete records that are still present but awaiting the server's prune, and that
41
- * nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.
37
+ * Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the
38
+ * server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.
42
39
  *
43
40
  * Non-reactive; wrap in `useValue`, or use `useCommentThreads`, to react.
44
41
  * @public
@@ -62,8 +59,7 @@ export function getComments(editor: Editor): TLComment[] {
62
59
 
63
60
  /**
64
61
  * The comments that should render: not soft-deleted. A deleted record lingers in the store until
65
- * the server prunes it, so this not {@link getComments} — is what a count or a list of your own
66
- * should be built from.
62
+ * the server prunes it, so build counts and lists from this rather than {@link getComments}.
67
63
  *
68
64
  * Non-reactive; the reactive equivalent is `useComments` (which also sorts oldest first).
69
65
  * @public
@@ -73,10 +69,9 @@ export function getLiveComments(editor: Editor): TLComment[] {
73
69
  }
74
70
 
75
71
  /**
76
- * The comment threads that should render (pins, sidebar): live — not soft-deleted and still
77
- * holding at least one live comment. A soft-deleted thread or comment is awaiting the server's
78
- * prune, as is a thread emptied by its last comment's delete; until the prune lands, the emptied
79
- * thread record lingers with no surface.
72
+ * The comment threads that should render (pins, sidebar): not soft-deleted, and still holding at
73
+ * least one live comment. A thread emptied by its last comment's delete lingers with no surface
74
+ * until the server's prune lands.
80
75
  *
81
76
  * Non-reactive; the reactive equivalent is `useCommentThreads`.
82
77
  * @public