@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
@@ -9,6 +9,7 @@ import {
9
9
  useEditor,
10
10
  useValue,
11
11
  } from 'tldraw'
12
+ import { type CommentListItemRenderProps } from '../ui/comments-list'
12
13
  import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
13
14
  import { type ReactionTooltipProps } from '../ui/reaction'
14
15
 
@@ -25,6 +26,31 @@ export interface ShapeCommentPrecisionContext {
25
26
  readonly altKey: boolean
26
27
  }
27
28
 
29
+ /**
30
+ * A commenting write that belongs to someone in particular, and the record it targets — the
31
+ * argument to {@link CommentingOptions.canModifyComment}.
32
+ *
33
+ * Resolving, reopening, reacting, and moving a pin aren't here: none of them is anyone's in
34
+ * particular, so {@link CommentingOptions.canComment} is the only gate on them.
35
+ *
36
+ * @public
37
+ */
38
+ export type CommentModification =
39
+ | { readonly action: 'edit-comment'; readonly comment: TLComment }
40
+ | { readonly action: 'delete-comment'; readonly comment: TLComment }
41
+ | { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
42
+
43
+ /**
44
+ * The argument to {@link CommentingOptions.canModifyComment}: which write, against which record,
45
+ * and by whom.
46
+ *
47
+ * @public
48
+ */
49
+ export type CommentModificationContext = {
50
+ readonly editor: Editor
51
+ readonly currentUserId: string | null
52
+ } & CommentModification
53
+
28
54
  /**
29
55
  * Component overrides for the batteries-included comments layer. Each slot replaces a built-in
30
56
  * piece; leave a slot unset to keep its default.
@@ -38,32 +64,42 @@ export interface CommentingComponents {
38
64
  PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
39
65
  /** A sidebar row's preview. Replaces the plaintext default. */
40
66
  ThreadPreview?: ComponentType<{ comment: TLComment }>
67
+ /**
68
+ * A whole sidebar row. Replaces the default `<CommentListItem>`, which is exported — so a row
69
+ * that only adds an unread dot or a status chip can spread these props into it. Use
70
+ * `ThreadPreview` instead when only the preview text is changing.
71
+ */
72
+ ThreadRow?: ComponentType<CommentListItemRenderProps & { thread: TLCommentThread }>
73
+ /**
74
+ * Extra controls in an open thread's header, added ahead of the built-in resolve and dismiss
75
+ * buttons rather than replacing them. "Copy link" is already built in whenever the host supplies
76
+ * `getThreadHref`.
77
+ */
78
+ ThreadActions?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
41
79
  /**
42
80
  * A reaction's visual, given its token. The default renders the token string for the OS emoji
43
- * font to draw (so the token is the emoji glyph). Override this to render your own palette —
44
- * return an `<img>` for custom emoji, an SVG, or anything. The token is whatever your picker
45
- * emits and is what gets stored/synced; this only controls how it's drawn.
81
+ * font. Override to draw a custom palette an `<img>`, an SVG, anything. The token is what gets
82
+ * stored and synced; this only controls how it's drawn.
46
83
  */
47
84
  ReactionContent?: ComponentType<{ token: string }>
48
85
  /**
49
- * What the add-reaction button opens: the thing that produces a reaction token. Replaces the
50
- * default `<EmojiPicker>` grid. Pairs with `ReactionContent` (which draws whatever tokens this
51
- * emits) and with `isAllowedReaction` (which has to accept them).
86
+ * What the add-reaction button opens. Replaces the default `<EmojiPicker>` grid. Pairs with
87
+ * `ReactionContent` (which draws the tokens this emits) and `isAllowedReaction` (which must
88
+ * accept them).
52
89
  */
53
90
  ReactionPalette?: ComponentType<EmojiPickerProps>
54
91
  /**
55
- * The hover affordance naming who reacted with an emoji. It receives the reactors and the pill
56
- * (as `children`) and returns the whole thing — so it owns the tooltip, its box, size, shape, and
57
- * position. Replaces the default (`DefaultReactionTooltip`). For a simple wording change, translate
58
- * the `comments.reacted-*` strings instead; reach for this to change the structure — a different
59
- * box, avatars, a banner anywhere on screen.
92
+ * The hover affordance naming who reacted with an emoji. Receives the reactors and the pill (as
93
+ * `children`) and owns the whole thing — box, size, shape, position. For a wording change,
94
+ * translate the `comments.reacted-*` strings instead.
60
95
  */
61
96
  ReactionTooltip?: ComponentType<ReactionTooltipProps>
62
- /** Shown where a composer would sit when the viewer can't compose (see
63
- * {@link CommentingOptions.canComment} a signed-out viewer, a viewer role, a host that
64
- * turns commenting off). `context` says which surface is rendering it: the bottom of an open
65
- * thread popover (`'thread'`) or the placement popover the comment tool opens (`'pending'`).
66
- * Unset, those surfaces render nothing. */
97
+ /**
98
+ * Shown where a composer would sit when the viewer can't compose (see
99
+ * {@link CommentingOptions.canComment}). `context` is the surface rendering it: an open thread
100
+ * popover (`'thread'`) or the comment tool's placement popover (`'pending'`). Unset, those
101
+ * surfaces render nothing.
102
+ */
67
103
  ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
68
104
  }
69
105
 
@@ -83,13 +119,11 @@ export interface CommentingComponents {
83
119
  * @public
84
120
  */
85
121
  export interface CommentingOptions {
86
- // ── History / undo ───────────────────────────────────────────────────────────────────────
122
+ // History / undo
87
123
  /**
88
- * How comment mutations (post, reply, edit, resolve, delete) interact with the editor undo
89
- * stack. Defaults to `'ignore'` — comments are deliberately not undoable (see `TLComment`).
90
- * `'record'` is a multiplayer footgun: undoing a delete resurrects a thread a collaborator
91
- * already removed, and undoing a resolve/edit reverts their newer state. Safe only single-player
92
- * or on a non-synced local comment store.
124
+ * How comment mutations interact with the editor undo stack. Defaults to `'ignore'` — comments
125
+ * are deliberately not undoable (see `TLComment`). `'record'` is a multiplayer footgun: undoing
126
+ * a delete resurrects a thread a collaborator already removed. Safe only single-player.
93
127
  */
94
128
  readonly history: TLHistoryBatchOptions['history']
95
129
  /**
@@ -98,7 +132,7 @@ export interface CommentingOptions {
98
132
  */
99
133
  readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
100
134
 
101
- // ── Feature toggles ──────────────────────────────────────────────────────────────────────
135
+ // Feature toggles
102
136
  /** Fold nearby pins into count badges as the camera zooms out. */
103
137
  readonly enableClustering: boolean
104
138
  /**
@@ -109,54 +143,72 @@ export interface CommentingOptions {
109
143
  */
110
144
  readonly allowMultipleReactions: boolean
111
145
  /**
112
- * Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji} against
113
- * the built-in emoji palette, which is what keeps a scripted client from writing junk `emoji`
114
- * values the picker would never offer. Override it alongside a custom `ReactionPalette` so the
115
- * tokens that palette emits get through. Removals aren't checked — a reaction carrying an
116
- * off-palette token must still be clearable.
146
+ * Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji}, which
147
+ * keeps a scripted client from writing junk values the picker would never offer. Override
148
+ * alongside a custom `ReactionPalette`. Removals aren't checked an off-palette reaction must
149
+ * still be clearable.
117
150
  */
118
151
  isAllowedReaction(token: string): boolean
119
152
  /**
120
153
  * Whether dragging the comment tool out creates a region anchor — a comment attached to a
121
- * rectangular area of the page, drawn as a dashed box with the thread's pin on the corner the
122
- * drag released on. Off by default: comments attach to points and shapes only, and a drag just
123
- * trails the composer. A region reveals its box while the pointer is inside it, moves by its
124
- * pin, and resizes from its corners.
154
+ * rectangular area, drawn as a dashed box with the pin on the corner the drag released on. Off
155
+ * by default, where comments attach to points and shapes only and a drag trails the composer.
125
156
  */
126
157
  readonly enableRegions: boolean
127
158
 
128
- // ── Permissions ──────────────────────────────────────────────────────────────────────────
159
+ // Permissions
129
160
  /**
130
- * Whether the viewer may participate in commenting: composing new threads and replies, editing
131
- * and deleting comments, resolving threads, and moving pins or regions. Composers render when
132
- * it returns true; when it returns false, the {@link CommentingComponents.ComposerFallback}
133
- * slot renders in their place (or nothing, if that slot is unset) and the action affordances
134
- * are hidden. Unset, participation is allowed exactly when `currentUserId` is set.
161
+ * Whether the viewer may participate in commenting: composing, editing, deleting, resolving, and
162
+ * moving pins. When false, {@link CommentingComponents.ComposerFallback} renders in the
163
+ * composer's place and action affordances are hidden. Unset, participation is allowed exactly
164
+ * when `currentUserId` is set.
135
165
  *
136
- * Called during render via {@link useCanComment}, so reactive reads (signals) are tracked.
137
- * The comment tool itself stays registered and selectable hosts that want its toolbar button
138
- * to do something else (e.g. open a sign-in dialog) can override the tool item's `onSelect`.
139
- * Note posting still requires a `currentUserId` to author the records, so a callback that
140
- * returns true for a signed-out viewer yields a composer whose send button stays disabled.
166
+ * Called during render via {@link useCanComment}, so signal reads are tracked. Posting still
167
+ * needs a `currentUserId`, so returning true for a signed-out viewer yields a composer whose
168
+ * send button stays disabled. A callback that throws is logged and read as false, rather than
169
+ * taking the comments layer down with it.
141
170
  */
142
171
  readonly canComment:
143
172
  | ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
144
173
  | undefined
174
+ /**
175
+ * Whether the viewer may make a particular write against a particular record: editing or
176
+ * deleting a comment, or deleting a thread. Unset, each is its record's owner's to make
177
+ * ({@link defaultCanModifyComment}) — you edit and delete your own comments, and delete threads
178
+ * you started. Override it to widen that (a workspace admin or moderator who may remove
179
+ * anyone's comment) or to narrow it (no edits after an hour). Where it returns false the
180
+ * affordance isn't rendered.
181
+ *
182
+ * Checked after {@link CommentingOptions.canComment}, which gates commenting as a whole: a
183
+ * viewer who may not participate gets no action affordances at all, whatever this returns.
184
+ *
185
+ * Called during render via {@link useCanModifyComment}, so reactive reads (signals) are tracked.
186
+ * A callback that throws is logged and read as false: an affordance is withheld rather than the
187
+ * comments layer lost, and a denial is what a server enforcing the same rule would have said.
188
+ *
189
+ * @example
190
+ * ```tsx
191
+ * CommentTool.configure({
192
+ * canModifyComment: (ctx) =>
193
+ * // Moderators may delete anything; everything else stays the owner's to do.
194
+ * (ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
195
+ * defaultCanModifyComment(ctx),
196
+ * })
197
+ * ```
198
+ */
199
+ readonly canModifyComment: ((ctx: CommentModificationContext) => boolean) | undefined
145
200
 
146
- // ── Anchoring ────────────────────────────────────────────────────────────────────────────
201
+ // Anchoring
147
202
  /** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
148
203
  readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
149
204
  /**
150
- * Whether a comment landing on a shape anchors precisely pinned to the exact clicked spot
151
- * within the shape or imprecisely pinned to the shape as a whole, rendered at
152
- * `impreciseShapeAnchor`. Called wherever a shape anchor is created (placing with the comment
153
- * tool, dropping a dragged pin onto a shape). Always precise by default. Return `false` for
154
- * shape-level anchoring, or decide from the context — the Alt key's state, or the shape itself,
155
- * e.g. precise only on notes. Governs new placements only; existing anchors render as stored.
205
+ * Whether a comment landing on a shape pins to the exact clicked spot, or to the shape as a
206
+ * whole (rendered at `impreciseShapeAnchor`). Always precise by default; return `false`, or
207
+ * decide from the context. Governs new placements only existing anchors render as stored.
156
208
  */
157
209
  shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
158
210
 
159
- // ── Components ────────────────────────────────────────────────────────────────────────────
211
+ // Components
160
212
  /** Component overrides. See {@link CommentingComponents}. */
161
213
  readonly components: CommentingComponents
162
214
  }
@@ -174,6 +226,7 @@ export const defaultCommentingOptions = {
174
226
  isAllowedReaction: isAllowedReactionEmoji,
175
227
  enableRegions: false,
176
228
  canComment: undefined,
229
+ canModifyComment: undefined,
177
230
  impreciseShapeAnchor: { x: 1, y: 0 },
178
231
  shouldBePrecise: () => true,
179
232
  components: {},
@@ -203,21 +256,34 @@ export function useCommentingOptions(): CommentingOptions {
203
256
  return useMemo(() => getCommentingOptions(editor), [editor])
204
257
  }
205
258
 
259
+ /**
260
+ * Ask a host's permission callback, denying the write if it throws.
261
+ *
262
+ * These are called during render, so an exception in one would take the comments layer down with
263
+ * the answer. Denying costs an affordance, which is what a `false` would have cost anyway, and it
264
+ * can't offer a write a server enforcing the same rule would then reject.
265
+ */
266
+ function permits(option: string, check: () => boolean): boolean {
267
+ try {
268
+ return check()
269
+ } catch (error) {
270
+ console.error(`[tldraw] \`${option}\` threw, so the write is denied:`, error)
271
+ return false
272
+ }
273
+ }
274
+
206
275
  /**
207
276
  * Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
208
- * (defaulting to `currentUserId != null` when unset). Where this is false, composers give way to
209
- * the {@link CommentingComponents.ComposerFallback} slot and action affordances are hidden.
277
+ * (defaulting to `currentUserId != null` when unset). A callback that throws denies.
210
278
  *
211
- * This is a plain, untracked read — a `canComment` callback that reads signals is not observed.
212
- * In React, use {@link useCanComment} instead.
279
+ * This is a plain, untracked read — in React, use {@link useCanComment} instead.
213
280
  *
214
281
  * @public
215
282
  */
216
283
  export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
217
284
  const { canComment } = getCommentingOptions(editor)
218
- return canComment
219
- ? canComment({ editor, currentUserId: currentUserId ?? null })
220
- : currentUserId != null
285
+ if (!canComment) return currentUserId != null
286
+ return permits('canComment', () => canComment({ editor, currentUserId: currentUserId ?? null }))
221
287
  }
222
288
 
223
289
  /**
@@ -233,3 +299,71 @@ export function useCanComment(currentUserId: string | null | undefined): boolean
233
299
  currentUserId,
234
300
  ])
235
301
  }
302
+
303
+ /**
304
+ * The default {@link CommentingOptions.canModifyComment}: a write is its record's owner's to make —
305
+ * a comment's author edits and deletes it, a thread's creator deletes the thread — and a viewer
306
+ * with no identity may make none of them.
307
+ *
308
+ * Exported so a callback can widen the default rather than restate it:
309
+ * `(ctx) => isModerator(ctx.currentUserId) || defaultCanModifyComment(ctx)`.
310
+ *
311
+ * @public
312
+ */
313
+ export function defaultCanModifyComment(ctx: CommentModificationContext): boolean {
314
+ const { currentUserId } = ctx
315
+ if (!currentUserId) return false
316
+ const owner = ctx.action === 'delete-thread' ? ctx.thread.createdBy : ctx.comment.authorId
317
+ return owner === currentUserId
318
+ }
319
+
320
+ /**
321
+ * Whether the viewer may make a given write against a given record, per
322
+ * {@link CommentingOptions.canModifyComment} (defaulting to {@link defaultCanModifyComment} when
323
+ * unset). Where this is false the affordance isn't rendered.
324
+ *
325
+ * This is the per-record rule alone: the built-in UI additionally requires
326
+ * {@link CommentingOptions.canComment}, since a viewer who may not participate gets no action
327
+ * affordances at all.
328
+ *
329
+ * A plain, untracked read — a `canModifyComment` callback that reads signals is not observed. In
330
+ * React, use {@link useCanModifyComment} instead.
331
+ *
332
+ * @public
333
+ */
334
+ export function getCanModifyComment(
335
+ editor: Editor,
336
+ currentUserId: string | null | undefined,
337
+ modification: CommentModification
338
+ ): boolean {
339
+ const { canModifyComment } = getCommentingOptions(editor)
340
+ const ctx: CommentModificationContext = {
341
+ editor,
342
+ currentUserId: currentUserId ?? null,
343
+ ...modification,
344
+ }
345
+ if (!canModifyComment) return defaultCanModifyComment(ctx)
346
+ return permits('canModifyComment', () => canModifyComment(ctx))
347
+ }
348
+
349
+ /**
350
+ * Reactive React hook for {@link getCanModifyComment}: a `canModifyComment` callback that reads
351
+ * signals re-evaluates when they change.
352
+ *
353
+ * @public
354
+ */
355
+ export function useCanModifyComment(
356
+ currentUserId: string | null | undefined,
357
+ modification: CommentModification
358
+ ): boolean {
359
+ const editor = useEditor()
360
+ // Comment records are immutable, so the record itself is what changes when the thing being
361
+ // checked changes — `modification` is a fresh object on every render and can't be a dep.
362
+ const record =
363
+ modification.action === 'delete-thread' ? modification.thread : modification.comment
364
+ return useValue(
365
+ 'can modify comment',
366
+ () => getCanModifyComment(editor, currentUserId, modification),
367
+ [editor, currentUserId, modification.action, record]
368
+ )
369
+ }
@@ -0,0 +1,157 @@
1
+ import { Avatar, isMentionPickerOpen } from '@tldraw/mentions'
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import {
4
+ createComment,
5
+ createCommentThread,
6
+ Editor,
7
+ EditorPortal,
8
+ TLRichText,
9
+ usePassThroughWheelEvents,
10
+ useTranslation,
11
+ useValue,
12
+ } from 'tldraw'
13
+ import { CommentComposer } from '../ui/comment-composer'
14
+ import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
15
+ import { CommentPin } from '../ui/comment-pin'
16
+ import {
17
+ clearCommentDraft,
18
+ getCommentDraft,
19
+ NEW_COMMENT_DRAFT,
20
+ saveCommentDraft,
21
+ } from './comment-drafts'
22
+ import { commitCommentMutation } from './comment-mutations'
23
+ import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
24
+ import { PendingComment } from './comment-tool'
25
+ import { type CommentingContext } from './context'
26
+ import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
27
+ import { useCanComment, useCommentingOptions } from './options'
28
+ import { pendingComment } from './state'
29
+
30
+ const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
31
+
32
+ /**
33
+ * The composer for a thread that doesn't exist yet: the comment tool has placed a point (or
34
+ * region) and is waiting on a first comment. Submitting creates the thread and its comment
35
+ * together; clicking away keeps the draft for the next placement.
36
+ */
37
+ export function PendingComposer({
38
+ editor,
39
+ pending,
40
+ currentUserId,
41
+ resolveAuthor,
42
+ onPostComment,
43
+ getMentionSuggestions,
44
+ renderMentionSuggestion,
45
+ }: CommentingContext & { editor: Editor; pending: PendingComment }) {
46
+ const ComposerFallback = useCommentingOptions().components.ComposerFallback
47
+ const canComment = useCanComment(currentUserId)
48
+ const me = currentUserId ? resolveAuthor(currentUserId) : undefined
49
+ // The leading pin previews the pin this draft becomes: a white pin holding the author's avatar.
50
+ const draftAvatar = (
51
+ <CommentPin>
52
+ <Avatar author={me ?? UNKNOWN_COMMENT_AUTHOR} />
53
+ </CommentPin>
54
+ )
55
+ // Click-away keeps the draft (saved on every change) and the next placement composer
56
+ // restores it — the flip side of dismissing without a discard warning.
57
+ const [text, setText] = useState<TLRichText>(
58
+ () => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
59
+ )
60
+ const ref = useRef<HTMLDivElement>(null)
61
+ const msg = useTranslation()
62
+ // Over this floating panel, a scroll reaches the canvas (except where it scrolls itself).
63
+ usePassThroughWheelEvents(ref)
64
+
65
+ const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
66
+ editor,
67
+ pending.point,
68
+ ])
69
+ // On mobile the composer floats free of the pin so it can clear the software keyboard; desktop
70
+ // keeps it pinned to the point.
71
+ const isMobile = useIsMobileCommenting()
72
+ const placed = useMobilePlacement(ref, point, isMobile)
73
+
74
+ // Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
75
+ useEffect(() => {
76
+ const onPointerDown = (e: PointerEvent) => {
77
+ const el = ref.current
78
+ const target = e.target as HTMLElement | null
79
+ if (!el || !target) return
80
+ // A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
81
+ // not "outside" — keep the draft open so the pick can insert.
82
+ if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
83
+ pendingComment.set(editor, null)
84
+ }
85
+ document.addEventListener('pointerdown', onPointerDown, true)
86
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
87
+ }, [editor])
88
+
89
+ const submit = () => {
90
+ if (isCommentEmpty(text) || !currentUserId) return
91
+ const comment = commitCommentMutation(editor, ({ put }) => {
92
+ const pageId = editor.getCurrentPageId()
93
+ const thread = createCommentThread({
94
+ pageId,
95
+ anchor: pending.anchor,
96
+ createdBy: currentUserId,
97
+ })
98
+ const comment = createComment({
99
+ threadId: thread.id,
100
+ pageId,
101
+ authorId: currentUserId,
102
+ body: text,
103
+ })
104
+ put([thread, comment])
105
+ return comment
106
+ })
107
+ setText(EMPTY_COMMENT)
108
+ clearCommentDraft(NEW_COMMENT_DRAFT)
109
+ pendingComment.set(editor, null)
110
+ // The host's callback is its own operation, not part of the post's history scope. It runs
111
+ // last so a throwing host can't strand the composer holding a draft of a posted comment.
112
+ onPostComment?.(comment)
113
+ }
114
+
115
+ return (
116
+ <EditorPortal>
117
+ <div
118
+ ref={ref}
119
+ className={[
120
+ 'tlui-cmt-canvas-composer',
121
+ pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
122
+ !canComment && 'tlui-cmt-canvas-composer--fallback',
123
+ ]
124
+ .filter(Boolean)
125
+ .join(' ')}
126
+ style={{ left: placed.left, top: placed.top }}
127
+ onPointerDown={stop}
128
+ onContextMenu={stop}
129
+ onKeyDown={(e) => {
130
+ if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
131
+ }}
132
+ >
133
+ {canComment ? (
134
+ <CommentComposer
135
+ author={me ?? UNKNOWN_COMMENT_AUTHOR}
136
+ placeholder={msg('comments.add-placeholder')}
137
+ sendLabel={msg('comments.send')}
138
+ value={text}
139
+ onChange={(value) => {
140
+ setText(value)
141
+ saveCommentDraft(NEW_COMMENT_DRAFT, value)
142
+ }}
143
+ onSubmit={submit}
144
+ // No user, no author for the record — dead send button.
145
+ disabled={isCommentEmpty(text) || !currentUserId}
146
+ getMentionSuggestions={getMentionSuggestions}
147
+ renderMentionSuggestion={renderMentionSuggestion}
148
+ autoFocus
149
+ leading={draftAvatar}
150
+ />
151
+ ) : (
152
+ ComposerFallback && <ComposerFallback context="pending" />
153
+ )}
154
+ </div>
155
+ </EditorPortal>
156
+ )
157
+ }
@@ -1,6 +1,7 @@
1
1
  import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
2
2
  import { describe, expect, it } from 'vitest'
3
- import { computePinStacks } from './pin-stacking'
3
+ import { computePinStacks, isOpenStackKeyLive, pinStackKey, pinStacksEqual } from './pin-stacking'
4
+ import { anchorPagePoint } from './thread-state'
4
5
 
5
6
  const CURRENT_PAGE = 'page:one'
6
7
  const OTHER_PAGE = 'page:two'
@@ -46,11 +47,18 @@ function stubEditor(
46
47
  }
47
48
 
48
49
  const SHAPE = { 'shape:a': { minX: 0, minY: 0, maxX: 200, maxY: 100 } }
50
+ /** The same shape after someone moved it — same size, different page position. */
51
+ const SHAPE_MOVED = { 'shape:a': { minX: 500, minY: 300, maxX: 700, maxY: 400 } }
49
52
 
50
53
  function impreciseAnchor(shapeId: string): TLCommentAnchor {
51
54
  return { type: 'shape', shapeId, x: 0.2, y: 0.9, isPrecise: false } as TLCommentAnchor
52
55
  }
53
56
 
57
+ /** The stack key the pins on `shape:a` coincide at, for the given shape placement. */
58
+ function stackKeyOn(shapes: typeof SHAPE): string {
59
+ return pinStackKey(anchorPagePoint(stubEditor(shapes), impreciseAnchor('shape:a'))!)
60
+ }
61
+
54
62
  describe('computePinStacks', () => {
55
63
  it('groups coincident imprecise pins on one shape, oldest first', () => {
56
64
  const stacks = computePinStacks(stubEditor(SHAPE), [
@@ -112,3 +120,105 @@ describe('computePinStacks', () => {
112
120
  expect(stacks.has('t3')).toBe(false)
113
121
  })
114
122
  })
123
+
124
+ describe('pinStacksEqual', () => {
125
+ const pair = () =>
126
+ new Map<string, readonly string[]>([
127
+ ['t1', ['t1', 't2']],
128
+ ['t2', ['t1', 't2']],
129
+ ])
130
+
131
+ it('accepts equal groupings whatever the map or group identity', () => {
132
+ const a = pair()
133
+ expect(pinStacksEqual(a, a)).toBe(true)
134
+ expect(pinStacksEqual(a, pair())).toBe(true)
135
+ expect(pinStacksEqual(new Map(), new Map())).toBe(true)
136
+ })
137
+
138
+ it('rejects a changed member set, a changed group, and a changed order', () => {
139
+ // the stack broke up
140
+ expect(pinStacksEqual(pair(), new Map())).toBe(false)
141
+ // a third pin joined
142
+ expect(
143
+ pinStacksEqual(
144
+ pair(),
145
+ new Map<string, readonly string[]>([
146
+ ['t1', ['t1', 't2', 't3']],
147
+ ['t2', ['t1', 't2', 't3']],
148
+ ['t3', ['t1', 't2', 't3']],
149
+ ])
150
+ )
151
+ ).toBe(false)
152
+ // same size, different members
153
+ expect(
154
+ pinStacksEqual(
155
+ pair(),
156
+ new Map<string, readonly string[]>([
157
+ ['t1', ['t1', 't3']],
158
+ ['t3', ['t1', 't3']],
159
+ ])
160
+ )
161
+ ).toBe(false)
162
+ // same members, reordered within the group (the list renders in this order)
163
+ expect(
164
+ pinStacksEqual(
165
+ pair(),
166
+ new Map<string, readonly string[]>([
167
+ ['t1', ['t2', 't1']],
168
+ ['t2', ['t2', 't1']],
169
+ ])
170
+ )
171
+ ).toBe(false)
172
+ })
173
+
174
+ it('is blind to position — equal groupings can sit at different page points', () => {
175
+ // The overlay holds the map's identity while this is true, which is why nothing keyed on a
176
+ // stack's *point* may key off it. See isOpenStackKeyLive.
177
+ const threads = [
178
+ thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
179
+ thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
180
+ ]
181
+ const here = computePinStacks(stubEditor(SHAPE), threads)
182
+ const moved = computePinStacks(stubEditor(SHAPE_MOVED), threads)
183
+ expect(pinStacksEqual(here, moved)).toBe(true)
184
+ expect(stackKeyOn(SHAPE)).not.toBe(stackKeyOn(SHAPE_MOVED))
185
+ })
186
+ })
187
+
188
+ describe('isOpenStackKeyLive', () => {
189
+ const threads = [
190
+ thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
191
+ thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
192
+ ]
193
+ const byId = new Map(threads.map((t) => [t.id, t]))
194
+
195
+ it('is live while a stack still sits at the key', () => {
196
+ const editor = stubEditor(SHAPE)
197
+ expect(
198
+ isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), byId)
199
+ ).toBe(true)
200
+ })
201
+
202
+ it('goes stale when the stack moves — the case pinStacksEqual cannot see', () => {
203
+ // The anchor shape moved, so the point key changed while the membership didn't. Staying live
204
+ // here strands the old key in `openStackId`, killing every hover preview on the layer.
205
+ const editor = stubEditor(SHAPE_MOVED)
206
+ expect(
207
+ isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), byId)
208
+ ).toBe(false)
209
+ // and the key the stack moved *to* is live, so the caller clears rather than thrashing
210
+ expect(
211
+ isOpenStackKeyLive(editor, stackKeyOn(SHAPE_MOVED), computePinStacks(editor, threads), byId)
212
+ ).toBe(true)
213
+ })
214
+
215
+ it('goes stale once nothing stacks there, or the threads are gone', () => {
216
+ const editor = stubEditor(SHAPE)
217
+ // the stack collapsed to a single pin: no entries at all
218
+ expect(isOpenStackKeyLive(editor, stackKeyOn(SHAPE), new Map(), byId)).toBe(false)
219
+ // entries for threads that have left the input
220
+ expect(
221
+ isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), new Map())
222
+ ).toBe(false)
223
+ })
224
+ })