@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/commenting.css +1139 -329
- package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
- package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
- package/dist-cjs/canvas/cluster-input.js +2 -2
- package/dist-cjs/canvas/cluster-input.js.map +2 -2
- package/dist-cjs/canvas/comment-body.js +1 -1
- package/dist-cjs/canvas/comment-body.js.map +2 -2
- package/dist-cjs/canvas/comment-drafts.js +54 -0
- package/dist-cjs/canvas/comment-drafts.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +123 -0
- package/dist-cjs/canvas/comment-mutations.js.map +7 -0
- package/dist-cjs/canvas/comment-reactions.js +163 -0
- package/dist-cjs/canvas/comment-reactions.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +5 -3
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js +19 -9
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +64 -14
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +20 -38
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +429 -474
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +35 -32
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
- package/dist-cjs/canvas/context.js +17 -0
- package/dist-cjs/canvas/context.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +22 -3
- package/dist-cjs/canvas/hooks.js.map +2 -2
- package/dist-cjs/canvas/options.js +19 -2
- package/dist-cjs/canvas/options.js.map +2 -2
- package/dist-cjs/canvas/pin-stacking.js +56 -0
- package/dist-cjs/canvas/pin-stacking.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +2 -2
- package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
- package/dist-cjs/canvas/state.js +28 -14
- package/dist-cjs/canvas/state.js.map +2 -2
- package/dist-cjs/canvas/thread-preview.js +185 -0
- package/dist-cjs/canvas/thread-preview.js.map +7 -0
- package/dist-cjs/canvas/thread-stack.js +199 -0
- package/dist-cjs/canvas/thread-stack.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +67 -21
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +397 -0
- package/dist-cjs/canvas/thread-view.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +4 -4
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/runtime.js +119 -7
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +763 -333
- package/dist-cjs/index.js +46 -28
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +6 -4
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-card.js +15 -8
- package/dist-cjs/ui/comment-card.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +137 -36
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +24 -2
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comment-thread.js +9 -7
- package/dist-cjs/ui/comment-thread.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +30 -18
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/count-badge.js +3 -2
- package/dist-cjs/ui/count-badge.js.map +2 -2
- package/dist-cjs/ui/emoji-picker.js +54 -0
- package/dist-cjs/ui/emoji-picker.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +3 -3
- package/dist-cjs/ui/empty-state.js.map +2 -2
- package/dist-cjs/ui/format-time.js +4 -1
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/reaction-picker.js +73 -0
- package/dist-cjs/ui/reaction-picker.js.map +7 -0
- package/dist-cjs/ui/reaction.js +59 -6
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/reactions.js +20 -7
- package/dist-cjs/ui/reactions.js.map +2 -2
- package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
- package/dist-cjs/ui/reply-count.js.map +7 -0
- package/dist-cjs/ui/send-button.js +20 -1
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
- package/dist-cjs/ui/tooltip-button.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +2 -2
- package/dist-esm/canvas/cluster-input.mjs.map +2 -2
- package/dist-esm/canvas/comment-body.mjs +1 -1
- package/dist-esm/canvas/comment-body.mjs.map +2 -2
- package/dist-esm/canvas/comment-drafts.mjs +34 -0
- package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +103 -0
- package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
- package/dist-esm/canvas/comment-reactions.mjs +152 -0
- package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +5 -3
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs +19 -9
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +67 -17
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +450 -479
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +38 -35
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
- package/dist-esm/canvas/context.mjs +1 -0
- package/dist-esm/canvas/context.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +28 -5
- package/dist-esm/canvas/hooks.mjs.map +2 -2
- package/dist-esm/canvas/options.mjs +21 -3
- package/dist-esm/canvas/options.mjs.map +2 -2
- package/dist-esm/canvas/pin-stacking.mjs +36 -0
- package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +2 -2
- package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
- package/dist-esm/canvas/state.mjs +29 -19
- package/dist-esm/canvas/state.mjs.map +2 -2
- package/dist-esm/canvas/thread-preview.mjs +170 -0
- package/dist-esm/canvas/thread-preview.mjs.map +7 -0
- package/dist-esm/canvas/thread-stack.mjs +190 -0
- package/dist-esm/canvas/thread-stack.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +71 -22
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +402 -0
- package/dist-esm/canvas/thread-view.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +4 -4
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/runtime.mjs +119 -7
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +763 -333
- package/dist-esm/index.mjs +87 -41
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +6 -4
- package/dist-esm/ui/byline.mjs.map +2 -2
- package/dist-esm/ui/comment-card.mjs +15 -8
- package/dist-esm/ui/comment-card.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +148 -36
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +24 -2
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comment-thread.mjs +9 -7
- package/dist-esm/ui/comment-thread.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +43 -29
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/count-badge.mjs +3 -2
- package/dist-esm/ui/count-badge.mjs.map +2 -2
- package/dist-esm/ui/emoji-picker.mjs +34 -0
- package/dist-esm/ui/emoji-picker.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +3 -3
- package/dist-esm/ui/empty-state.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +4 -1
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/reaction-picker.mjs +60 -0
- package/dist-esm/ui/reaction-picker.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +60 -7
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/reactions.mjs +21 -8
- package/dist-esm/ui/reactions.mjs.map +2 -2
- package/dist-esm/ui/reply-count.mjs +9 -0
- package/dist-esm/ui/reply-count.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +20 -1
- package/dist-esm/ui/send-button.mjs.map +2 -2
- package/dist-esm/ui/tooltip-button.mjs +12 -0
- package/dist-esm/ui/tooltip-button.mjs.map +7 -0
- package/package.json +6 -6
- package/src/canvas/anchor-lifecycle.test.ts +290 -0
- package/src/canvas/anchor-lifecycle.ts +205 -0
- package/src/canvas/canvas.css +461 -46
- package/src/canvas/cluster-input.test.ts +31 -23
- package/src/canvas/cluster-input.ts +3 -4
- package/src/canvas/comment-body.tsx +1 -1
- package/src/canvas/comment-drafts.test.ts +55 -0
- package/src/canvas/comment-drafts.ts +46 -0
- package/src/canvas/comment-mutations.test.ts +410 -0
- package/src/canvas/comment-mutations.ts +288 -0
- package/src/canvas/comment-reactions.test.ts +132 -0
- package/src/canvas/comment-reactions.tsx +245 -0
- package/src/canvas/comment-render.test.ts +1 -1
- package/src/canvas/comment-render.ts +9 -5
- package/src/canvas/comment-store.ts +67 -27
- package/src/canvas/comment-tool.test.ts +162 -0
- package/src/canvas/comment-tool.tsx +105 -23
- package/src/canvas/comments-filter-menu.tsx +23 -32
- package/src/canvas/comments-overflow-menu.tsx +14 -21
- package/src/canvas/comments-overlay.tsx +695 -622
- package/src/canvas/comments-sidebar.test.ts +45 -0
- package/src/canvas/comments-sidebar.tsx +76 -58
- package/src/canvas/comments-visibility-toggle.tsx +49 -0
- package/src/canvas/context.ts +47 -0
- package/src/canvas/hooks.ts +52 -11
- package/src/canvas/options.test.ts +52 -2
- package/src/canvas/options.ts +133 -9
- package/src/canvas/pin-stacking.test.ts +114 -0
- package/src/canvas/pin-stacking.ts +57 -0
- package/src/canvas/sidebar-filters.ts +4 -3
- package/src/canvas/state.ts +92 -35
- package/src/canvas/thread-preview.test.ts +91 -0
- package/src/canvas/thread-preview.tsx +304 -0
- package/src/canvas/thread-stack.tsx +252 -0
- package/src/canvas/thread-state.test.ts +209 -0
- package/src/canvas/thread-state.ts +137 -36
- package/src/canvas/thread-view.tsx +516 -0
- package/src/clustering/computeClusterTable.test.ts +24 -23
- package/src/clustering/computeClusterTable.ts +5 -5
- package/src/clustering/runtime.test.ts +217 -7
- package/src/clustering/runtime.ts +171 -17
- package/src/clustering/types.ts +9 -9
- package/src/index.ts +76 -40
- package/src/ui/byline.tsx +7 -5
- package/src/ui/comment-card.tsx +20 -10
- package/src/ui/comment-composer.tsx +206 -40
- package/src/ui/comment-pin.tsx +26 -2
- package/src/ui/comment-thread.tsx +13 -6
- package/src/ui/comments-list.test.ts +31 -0
- package/src/ui/comments-list.tsx +45 -21
- package/src/ui/comments.css +566 -281
- package/src/ui/count-badge.tsx +9 -2
- package/src/ui/emoji-picker.test.ts +25 -0
- package/src/ui/emoji-picker.tsx +76 -0
- package/src/ui/empty-state.tsx +3 -3
- package/src/ui/format-time.ts +7 -2
- package/src/ui/reaction-picker.tsx +109 -0
- package/src/ui/reaction.tsx +121 -6
- package/src/ui/reactions.tsx +69 -10
- package/src/ui/reply-count.ts +16 -0
- package/src/ui/send-button.tsx +18 -3
- package/src/ui/tooltip-button.tsx +20 -0
- package/dist-cjs/canvas/region-options.js +0 -49
- package/dist-cjs/canvas/region-options.js.map +0 -7
- package/dist-cjs/ui/avatar.js +0 -43
- package/dist-cjs/ui/avatar.js.map +0 -7
- package/dist-cjs/ui/comment-mention.js +0 -42
- package/dist-cjs/ui/comment-mention.js.map +0 -7
- package/dist-cjs/ui/comment-text.js.map +0 -7
- package/dist-cjs/ui/mention-list.js +0 -70
- package/dist-cjs/ui/mention-list.js.map +0 -7
- package/dist-cjs/ui/mention-suggestion.js +0 -206
- package/dist-cjs/ui/mention-suggestion.js.map +0 -7
- package/dist-cjs/ui/mention.js.map +0 -7
- package/dist-cjs/ui/render-markdown.js +0 -63
- package/dist-cjs/ui/render-markdown.js.map +0 -7
- package/dist-esm/canvas/region-options.mjs +0 -29
- package/dist-esm/canvas/region-options.mjs.map +0 -7
- package/dist-esm/ui/avatar.mjs +0 -23
- package/dist-esm/ui/avatar.mjs.map +0 -7
- package/dist-esm/ui/comment-mention.mjs +0 -22
- package/dist-esm/ui/comment-mention.mjs.map +0 -7
- package/dist-esm/ui/comment-text.mjs +0 -9
- package/dist-esm/ui/comment-text.mjs.map +0 -7
- package/dist-esm/ui/mention-list.mjs +0 -50
- package/dist-esm/ui/mention-list.mjs.map +0 -7
- package/dist-esm/ui/mention-suggestion.mjs +0 -186
- package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
- package/dist-esm/ui/mention.mjs +0 -11
- package/dist-esm/ui/mention.mjs.map +0 -7
- package/dist-esm/ui/render-markdown.mjs +0 -45
- package/dist-esm/ui/render-markdown.mjs.map +0 -7
- package/src/canvas/region-options.ts +0 -57
- package/src/ui/avatar.tsx +0 -31
- package/src/ui/comment-mention.ts +0 -47
- package/src/ui/comment-text.tsx +0 -12
- package/src/ui/mention-list.tsx +0 -106
- package/src/ui/mention-suggestion.test.ts +0 -18
- package/src/ui/mention-suggestion.tsx +0 -285
- package/src/ui/mention.tsx +0 -9
- package/src/ui/render-markdown.tsx +0 -72
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Editor,
|
|
3
|
+
TLComment,
|
|
4
|
+
TLCommentId,
|
|
5
|
+
TLCommentReactionId,
|
|
6
|
+
TLCommentThread,
|
|
7
|
+
TLCommentThreadId,
|
|
8
|
+
TLHistoryBatchOptions,
|
|
9
|
+
TLRecord,
|
|
10
|
+
TLRichText,
|
|
11
|
+
} from 'tldraw'
|
|
12
|
+
import { getCommentRecord, getLiveComments, type TLCommentRecord } from './comment-store'
|
|
13
|
+
import { getCommentingOptions, type CommentingOptions } from './options'
|
|
14
|
+
import { openThreadId } from './state'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every write to a comment record, and the undo/redo policy governing them.
|
|
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}.
|
|
27
|
+
*
|
|
28
|
+
* Posting carries no such rule, so it isn't a verb here: build the records with
|
|
29
|
+
* `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which history policy a comment write follows:
|
|
34
|
+
*
|
|
35
|
+
* - `mutation` — {@link CommentingOptions.history}: posts, replies, edits, resolves.
|
|
36
|
+
* - `drag` — {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region
|
|
37
|
+
* re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.
|
|
38
|
+
* - `delete` — always `'ignore'`, whatever the options say. A soft-delete flag is write-once
|
|
39
|
+
* server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export type CommentMutationKind = 'delete' | 'drag' | 'mutation'
|
|
44
|
+
|
|
45
|
+
/** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */
|
|
46
|
+
function historyModeFor(
|
|
47
|
+
options: CommentingOptions,
|
|
48
|
+
kind: CommentMutationKind
|
|
49
|
+
): TLHistoryBatchOptions['history'] {
|
|
50
|
+
switch (kind) {
|
|
51
|
+
case 'delete':
|
|
52
|
+
return 'ignore'
|
|
53
|
+
case 'drag':
|
|
54
|
+
return options.dragHistory ?? options.history
|
|
55
|
+
case 'mutation':
|
|
56
|
+
return options.history
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Commit a comment mutation with the configured undo/redo behavior, so the
|
|
62
|
+
* {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to
|
|
63
|
+
* `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
export function commitCommentMutation<T>(
|
|
67
|
+
editor: Editor,
|
|
68
|
+
fn: () => T,
|
|
69
|
+
kind: CommentMutationKind = 'mutation'
|
|
70
|
+
): T {
|
|
71
|
+
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
|
+
}
|
|
95
|
+
|
|
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'][])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
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'`.
|
|
108
|
+
*
|
|
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.
|
|
112
|
+
*
|
|
113
|
+
* @public
|
|
114
|
+
*/
|
|
115
|
+
export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
|
|
116
|
+
commitCommentMutation(editor, () => putRecordsInCommit(editor, records))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Remove comment records from the store by id, under the configured
|
|
121
|
+
* {@link CommentingOptions.history} behavior.
|
|
122
|
+
*
|
|
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}).
|
|
129
|
+
*
|
|
130
|
+
* @public
|
|
131
|
+
*/
|
|
132
|
+
export function removeCommentRecords(
|
|
133
|
+
editor: Editor,
|
|
134
|
+
ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
|
|
135
|
+
): void {
|
|
136
|
+
commitCommentMutation(editor, () => removeRecordsInCommit(editor, ids))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The record as the store currently holds it, or `undefined` if it isn't there any more.
|
|
141
|
+
*
|
|
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.
|
|
151
|
+
*
|
|
152
|
+
* 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.
|
|
154
|
+
*/
|
|
155
|
+
function readLatest<T extends TLComment | TLCommentThread>(
|
|
156
|
+
editor: Editor,
|
|
157
|
+
record: T
|
|
158
|
+
): T | undefined {
|
|
159
|
+
const current = getCommentRecord(editor, record.id)
|
|
160
|
+
// Record ids carry their type, so a matching `typeName` means a record of exactly T.
|
|
161
|
+
return current?.typeName === record.typeName ? (current as T) : undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
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.
|
|
170
|
+
*
|
|
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.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* editComment(editor, comment, toRichText('Actually, make it dashed'))
|
|
178
|
+
* ```
|
|
179
|
+
*
|
|
180
|
+
* @public
|
|
181
|
+
*/
|
|
182
|
+
export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {
|
|
183
|
+
commitCommentMutation(editor, () => {
|
|
184
|
+
const current = readLatest(editor, comment)
|
|
185
|
+
if (!current) return
|
|
186
|
+
putRecordsInCommit(editor, [{ ...current, body, editedAt: Date.now() }])
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a
|
|
192
|
+
* checked one) and are hidden from the sidebar until its "show resolved" filter is on.
|
|
193
|
+
*
|
|
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.
|
|
196
|
+
*
|
|
197
|
+
* @public
|
|
198
|
+
*/
|
|
199
|
+
export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {
|
|
200
|
+
commitCommentMutation(editor, () => {
|
|
201
|
+
const current = readLatest(editor, thread)
|
|
202
|
+
if (!current) return
|
|
203
|
+
putRecordsInCommit(editor, [{ ...current, resolved: { at: Date.now(), by: userId } }])
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and
|
|
209
|
+
* on one that's gone. Like {@link resolveThread}, it touches only the resolution.
|
|
210
|
+
*
|
|
211
|
+
* @public
|
|
212
|
+
*/
|
|
213
|
+
export function reopenThread(editor: Editor, thread: TLCommentThread): void {
|
|
214
|
+
commitCommentMutation(editor, () => {
|
|
215
|
+
const current = readLatest(editor, thread)
|
|
216
|
+
if (!current) return
|
|
217
|
+
putRecordsInCommit(editor, [{ ...current, resolved: null }])
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Delete a comment.
|
|
223
|
+
*
|
|
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.
|
|
229
|
+
*
|
|
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.
|
|
233
|
+
*
|
|
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.
|
|
237
|
+
*
|
|
238
|
+
* A comment that's already deleted, or already pruned, is a no-op.
|
|
239
|
+
*
|
|
240
|
+
* @public
|
|
241
|
+
*/
|
|
242
|
+
export function deleteComment(editor: Editor, comment: TLComment): void {
|
|
243
|
+
commitCommentMutation(
|
|
244
|
+
editor,
|
|
245
|
+
() => {
|
|
246
|
+
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.
|
|
251
|
+
if (!current || current.isDeleted) return
|
|
252
|
+
const isLastInThread =
|
|
253
|
+
getLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1
|
|
254
|
+
if (isLastInThread && openThreadId.get(editor) === current.threadId) {
|
|
255
|
+
openThreadId.set(editor, null)
|
|
256
|
+
}
|
|
257
|
+
putRecordsInCommit(editor, [{ ...current, isDeleted: true }])
|
|
258
|
+
},
|
|
259
|
+
'delete'
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Delete a thread and, with it, the whole conversation.
|
|
265
|
+
*
|
|
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.
|
|
272
|
+
*
|
|
273
|
+
* @public
|
|
274
|
+
*/
|
|
275
|
+
export function deleteThread(editor: Editor, thread: TLCommentThread): void {
|
|
276
|
+
commitCommentMutation(
|
|
277
|
+
editor,
|
|
278
|
+
() => {
|
|
279
|
+
const current = readLatest(editor, thread)
|
|
280
|
+
if (!current) return
|
|
281
|
+
if (openThreadId.get(editor) === current.id) {
|
|
282
|
+
openThreadId.set(editor, null)
|
|
283
|
+
}
|
|
284
|
+
putRecordsInCommit(editor, [{ ...current, isDeleted: true }])
|
|
285
|
+
},
|
|
286
|
+
'delete'
|
|
287
|
+
)
|
|
288
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { TLComment, TLCommentId, TLCommentReaction, createCommentReactionId } from 'tldraw'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { summarizeReactions } from './comment-reactions'
|
|
4
|
+
|
|
5
|
+
const COMMENT_A = 'comment:a' as TLCommentId
|
|
6
|
+
const COMMENT_B = 'comment:b' as TLCommentId
|
|
7
|
+
|
|
8
|
+
function reaction(userId: string, emoji: string, createdAt: number): TLCommentReaction {
|
|
9
|
+
return {
|
|
10
|
+
id: createCommentReactionId(COMMENT_A, userId, emoji),
|
|
11
|
+
typeName: 'comment-reaction',
|
|
12
|
+
commentId: COMMENT_A,
|
|
13
|
+
threadId: 'comment-thread:t' as TLCommentReaction['threadId'],
|
|
14
|
+
pageId: 'page:page' as TLCommentReaction['pageId'],
|
|
15
|
+
userId,
|
|
16
|
+
emoji,
|
|
17
|
+
createdAt,
|
|
18
|
+
meta: {},
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('summarizeReactions', () => {
|
|
23
|
+
it('returns nothing when there are no reactions', () => {
|
|
24
|
+
expect(summarizeReactions([], 'user1')).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('counts each emoji, marks the current user’s active, and lists reactors', () => {
|
|
28
|
+
expect(
|
|
29
|
+
summarizeReactions(
|
|
30
|
+
[reaction('user1', '👍', 100), reaction('user2', '👍', 200), reaction('user3', '🎉', 300)],
|
|
31
|
+
'user1'
|
|
32
|
+
)
|
|
33
|
+
).toEqual([
|
|
34
|
+
{
|
|
35
|
+
emoji: '👍',
|
|
36
|
+
count: 2,
|
|
37
|
+
active: true,
|
|
38
|
+
// no resolveName here, so names fall back to the generic 'Someone'
|
|
39
|
+
reactors: [
|
|
40
|
+
{ name: 'Someone', you: true },
|
|
41
|
+
{ name: 'Someone', you: false },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{ emoji: '🎉', count: 1, active: false, reactors: [{ name: 'Someone', you: false }] },
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// the row shouldn't reshuffle as later reactions land, so groups sort by first use
|
|
49
|
+
it('orders emoji by when each was first used', () => {
|
|
50
|
+
expect(
|
|
51
|
+
summarizeReactions(
|
|
52
|
+
[reaction('user1', '🎉', 300), reaction('user2', '👍', 100), reaction('user3', '👍', 200)],
|
|
53
|
+
undefined
|
|
54
|
+
).map((r) => r.emoji)
|
|
55
|
+
).toEqual(['👍', '🎉'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('marks nothing active when there is no current user', () => {
|
|
59
|
+
expect(summarizeReactions([reaction('user1', '👍', 100)], undefined)[0].active).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('names reactors via resolveName, falling back to a generic name', () => {
|
|
63
|
+
const resolveName = (id: string) => (id === 'user1' ? 'Ada' : undefined)
|
|
64
|
+
expect(
|
|
65
|
+
summarizeReactions(
|
|
66
|
+
[reaction('user1', '👍', 100), reaction('user2', '👍', 200)],
|
|
67
|
+
'user1',
|
|
68
|
+
resolveName
|
|
69
|
+
)[0].reactors
|
|
70
|
+
).toEqual([
|
|
71
|
+
{ name: 'Ada', you: true },
|
|
72
|
+
// user2 can't be resolved — a generic name, never the raw id
|
|
73
|
+
{ name: 'Someone', you: false },
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// reactors keep the order they're passed in (the caller sorts by reaction time), so the hover
|
|
78
|
+
// list is stable
|
|
79
|
+
it('lists reactors in input order', () => {
|
|
80
|
+
expect(
|
|
81
|
+
summarizeReactions(
|
|
82
|
+
[reaction('user2', '👍', 100), reaction('user1', '👍', 200)],
|
|
83
|
+
undefined,
|
|
84
|
+
(id) => id.toUpperCase()
|
|
85
|
+
)[0].reactors.map((r) => r.name)
|
|
86
|
+
).toEqual(['USER2', 'USER1'])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('createCommentReactionId', () => {
|
|
91
|
+
// the id is what makes reaction identity structural: re-picking the same emoji addresses the
|
|
92
|
+
// same record (toggle off), while a different emoji is its own record (independent)
|
|
93
|
+
it('is stable for the same comment, user, and emoji', () => {
|
|
94
|
+
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).toBe(
|
|
95
|
+
createCommentReactionId(COMMENT_A, 'user1', '👍')
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('differs per user, per comment, and per emoji', () => {
|
|
100
|
+
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
|
101
|
+
createCommentReactionId(COMMENT_A, 'user2', '👍')
|
|
102
|
+
)
|
|
103
|
+
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
|
104
|
+
createCommentReactionId(COMMENT_B, 'user1', '👍')
|
|
105
|
+
)
|
|
106
|
+
// same user + comment, different emoji → a distinct record (this is what enables multi-react)
|
|
107
|
+
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
|
108
|
+
createCommentReactionId(COMMENT_A, 'user1', '🎉')
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('is a comment-reaction id, not a comment id', () => {
|
|
113
|
+
const id: string = createCommentReactionId(COMMENT_A, 'user1', '👍')
|
|
114
|
+
expect(id.startsWith('comment-reaction:')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('reaction records', () => {
|
|
119
|
+
// a comment's reactions are found by commentId, so a reaction to another comment must not
|
|
120
|
+
// leak into this one's tally
|
|
121
|
+
it('are scoped to one comment by commentId', () => {
|
|
122
|
+
const forOtherComment: TLCommentReaction = {
|
|
123
|
+
...reaction('user9', '💩', 400),
|
|
124
|
+
commentId: COMMENT_B,
|
|
125
|
+
}
|
|
126
|
+
const all = [reaction('user1', '👍', 100), forOtherComment]
|
|
127
|
+
const mine = all.filter((r) => r.commentId === (COMMENT_A as TLComment['id']))
|
|
128
|
+
expect(summarizeReactions(mine, 'user1')).toEqual([
|
|
129
|
+
{ emoji: '👍', count: 1, active: true, reactors: [{ name: 'Someone', you: true }] },
|
|
130
|
+
])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Editor,
|
|
4
|
+
TLComment,
|
|
5
|
+
TLCommentReaction,
|
|
6
|
+
createCommentReaction,
|
|
7
|
+
createCommentReactionId,
|
|
8
|
+
useEditor,
|
|
9
|
+
useValue,
|
|
10
|
+
} from 'tldraw'
|
|
11
|
+
import { RenderReaction } from '../ui/reaction'
|
|
12
|
+
import { ReactionPicker } from '../ui/reaction-picker'
|
|
13
|
+
import { Reactions, ReactionSummary } from '../ui/reactions'
|
|
14
|
+
import {
|
|
15
|
+
commitCommentMutation,
|
|
16
|
+
putRecordsInCommit,
|
|
17
|
+
removeRecordsInCommit,
|
|
18
|
+
} from './comment-mutations'
|
|
19
|
+
import { UNKNOWN_AUTHOR } from './comment-render'
|
|
20
|
+
import { getCommentReactions } from './comment-store'
|
|
21
|
+
import { getCommentingOptions, useCommentingOptions } from './options'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One comment's reactions, oldest first, reactively.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
export function useCommentReactions(
|
|
29
|
+
editor: Editor,
|
|
30
|
+
commentId: TLComment['id']
|
|
31
|
+
): TLCommentReaction[] {
|
|
32
|
+
return useValue(
|
|
33
|
+
'comment reactions',
|
|
34
|
+
() =>
|
|
35
|
+
getCommentReactions(editor)
|
|
36
|
+
.filter((reaction) => reaction.commentId === commentId)
|
|
37
|
+
.sort((a, b) => a.createdAt - b.createdAt),
|
|
38
|
+
[editor, commentId]
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
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.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
export function summarizeReactions(
|
|
52
|
+
reactions: TLCommentReaction[],
|
|
53
|
+
currentUserId?: string | null,
|
|
54
|
+
resolveName?: (userId: string) => string | undefined
|
|
55
|
+
): ReactionSummary[] {
|
|
56
|
+
const groups = new Map<
|
|
57
|
+
string,
|
|
58
|
+
{ count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] }
|
|
59
|
+
>()
|
|
60
|
+
for (const reaction of reactions) {
|
|
61
|
+
const mine = currentUserId != null && reaction.userId === currentUserId
|
|
62
|
+
// Fall back to a generic name, never the raw user id, when the id can't be resolved.
|
|
63
|
+
const reactor = { name: resolveName?.(reaction.userId) ?? UNKNOWN_AUTHOR, you: mine }
|
|
64
|
+
const group = groups.get(reaction.emoji)
|
|
65
|
+
if (group) {
|
|
66
|
+
group.count++
|
|
67
|
+
group.active ||= mine
|
|
68
|
+
group.firstAt = Math.min(group.firstAt, reaction.createdAt)
|
|
69
|
+
group.reactors.push(reactor)
|
|
70
|
+
} else {
|
|
71
|
+
groups.set(reaction.emoji, {
|
|
72
|
+
count: 1,
|
|
73
|
+
active: mine,
|
|
74
|
+
firstAt: reaction.createdAt,
|
|
75
|
+
reactors: [reactor],
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...groups]
|
|
80
|
+
.sort(([, a], [, b]) => a.firstAt - b.firstAt)
|
|
81
|
+
.map(([emoji, group]) => ({
|
|
82
|
+
emoji,
|
|
83
|
+
count: group.count,
|
|
84
|
+
active: group.active,
|
|
85
|
+
reactors: group.reactors,
|
|
86
|
+
}))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Toggle one user's reaction with a given emoji on a comment.
|
|
91
|
+
*
|
|
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:
|
|
95
|
+
*
|
|
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.
|
|
100
|
+
*
|
|
101
|
+
* @public
|
|
102
|
+
*/
|
|
103
|
+
export function toggleCommentReaction(
|
|
104
|
+
editor: Editor,
|
|
105
|
+
comment: TLComment,
|
|
106
|
+
userId: string,
|
|
107
|
+
emoji: string,
|
|
108
|
+
now = Date.now()
|
|
109
|
+
): void {
|
|
110
|
+
const { allowMultipleReactions, isAllowedReaction } = getCommentingOptions(editor)
|
|
111
|
+
const targetId = createCommentReactionId(comment.id, userId, emoji)
|
|
112
|
+
const mine = getCommentReactions(editor).filter(
|
|
113
|
+
(reaction) => reaction.commentId === comment.id && reaction.userId === userId
|
|
114
|
+
)
|
|
115
|
+
const removing = mine.some((reaction) => reaction.id === targetId)
|
|
116
|
+
// Only a token the configured palette allows may be added. Removals always go through — if a
|
|
117
|
+
// reaction somehow carries an off-palette token, the user must still be able to clear it.
|
|
118
|
+
if (!removing && !isAllowedReaction(emoji)) return
|
|
119
|
+
commitCommentMutation(editor, () => {
|
|
120
|
+
if (removing) {
|
|
121
|
+
removeRecordsInCommit(editor, [targetId])
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// Single-select: a new emoji replaces the user's existing reaction(s) on this comment.
|
|
125
|
+
if (!allowMultipleReactions && mine.length > 0) {
|
|
126
|
+
removeRecordsInCommit(
|
|
127
|
+
editor,
|
|
128
|
+
mine.map((reaction) => reaction.id)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
putRecordsInCommit(editor, [
|
|
132
|
+
createCommentReaction({
|
|
133
|
+
commentId: comment.id,
|
|
134
|
+
threadId: comment.threadId,
|
|
135
|
+
pageId: comment.pageId,
|
|
136
|
+
userId,
|
|
137
|
+
emoji,
|
|
138
|
+
now,
|
|
139
|
+
}),
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @public */
|
|
145
|
+
export interface CommentReactionsProps {
|
|
146
|
+
comment: TLComment
|
|
147
|
+
/** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the
|
|
148
|
+
* pills don't toggle. */
|
|
149
|
+
currentUserId?: string | null
|
|
150
|
+
/** Names a reactor id for the hover list. Ids it can't name fall back to the id. */
|
|
151
|
+
resolveName?(userId: string): string | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
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
|
+
*/
|
|
159
|
+
function useReactionRenderer(): RenderReaction | undefined {
|
|
160
|
+
const { components } = useCommentingOptions()
|
|
161
|
+
const ReactionContent = components.ReactionContent
|
|
162
|
+
return useMemo(
|
|
163
|
+
() => (ReactionContent ? (token: string) => <ReactionContent token={token} /> : undefined),
|
|
164
|
+
[ReactionContent]
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The tallied reaction row under one comment. Pair with `CommentReactionPicker`, which is what
|
|
170
|
+
* adds a reaction.
|
|
171
|
+
* @public @react
|
|
172
|
+
*/
|
|
173
|
+
export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps) {
|
|
174
|
+
const editor = useEditor()
|
|
175
|
+
const renderReaction = useReactionRenderer()
|
|
176
|
+
const { components } = useCommentingOptions()
|
|
177
|
+
const reactions = useCommentReactions(editor, comment.id)
|
|
178
|
+
const summaries = useMemo(
|
|
179
|
+
() => summarizeReactions(reactions, currentUserId, resolveName),
|
|
180
|
+
[reactions, currentUserId, resolveName]
|
|
181
|
+
)
|
|
182
|
+
// Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it
|
|
183
|
+
// doesn't compete with the menu the user is actually working in. Edit mode already hides the
|
|
184
|
+
// pills entirely (the card becomes a composer), so it needs no special case here.
|
|
185
|
+
const anyMenuOpen = useValue('any menu open', () => editor.menus.getOpenMenus().length > 0, [
|
|
186
|
+
editor,
|
|
187
|
+
])
|
|
188
|
+
return (
|
|
189
|
+
<Reactions
|
|
190
|
+
reactions={summaries}
|
|
191
|
+
canReact={currentUserId != null}
|
|
192
|
+
enableHoverList={!anyMenuOpen}
|
|
193
|
+
renderReaction={renderReaction}
|
|
194
|
+
ReactionTooltip={components.ReactionTooltip}
|
|
195
|
+
onToggle={(value) => {
|
|
196
|
+
if (currentUserId == null) return
|
|
197
|
+
toggleCommentReaction(editor, comment, currentUserId, value)
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @public */
|
|
204
|
+
export interface CommentReactionPickerProps {
|
|
205
|
+
comment: TLComment
|
|
206
|
+
/** The reacting user. Null/omitted renders nothing — there's nobody to react as. */
|
|
207
|
+
currentUserId?: string | null
|
|
208
|
+
/** The emoji the picker offers. Defaults to `DEFAULT_REACTION_EMOJI`. */
|
|
209
|
+
emoji?: string[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
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.
|
|
216
|
+
* @public @react
|
|
217
|
+
*/
|
|
218
|
+
export function CommentReactionPicker({
|
|
219
|
+
comment,
|
|
220
|
+
currentUserId,
|
|
221
|
+
emoji,
|
|
222
|
+
}: CommentReactionPickerProps) {
|
|
223
|
+
const editor = useEditor()
|
|
224
|
+
const { components } = useCommentingOptions()
|
|
225
|
+
const renderReaction = useReactionRenderer()
|
|
226
|
+
const reactions = useCommentReactions(editor, comment.id)
|
|
227
|
+
const selected = useMemo(
|
|
228
|
+
() =>
|
|
229
|
+
reactions
|
|
230
|
+
.filter((reaction) => currentUserId != null && reaction.userId === currentUserId)
|
|
231
|
+
.map((reaction) => reaction.emoji),
|
|
232
|
+
[reactions, currentUserId]
|
|
233
|
+
)
|
|
234
|
+
if (currentUserId == null) return null
|
|
235
|
+
return (
|
|
236
|
+
<ReactionPicker
|
|
237
|
+
emoji={emoji}
|
|
238
|
+
selected={selected}
|
|
239
|
+
renderReaction={renderReaction}
|
|
240
|
+
palette={components.ReactionPalette}
|
|
241
|
+
menuId={`comment-reactions-${comment.id}`}
|
|
242
|
+
onSelect={(value) => toggleCommentReaction(editor, comment, currentUserId, value)}
|
|
243
|
+
/>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
@@ -38,7 +38,7 @@ describe('renderCommentHtml', () => {
|
|
|
38
38
|
it('renders a mention as a pill and resolves its id to the current name', () => {
|
|
39
39
|
const body = doc(para(text('hey '), mention('u1', 'Ada')))
|
|
40
40
|
const html = renderCommentHtml(body, (id) => (id === 'u1' ? 'Ada Lovelace' : '?'))
|
|
41
|
-
expect(html).toContain('cmt-mention')
|
|
41
|
+
expect(html).toContain('tlui-cmt-mention')
|
|
42
42
|
// the live name from the resolver, not the label stored at insert time
|
|
43
43
|
expect(html).toContain('@Ada Lovelace')
|
|
44
44
|
expect(html).not.toContain('@Ada<')
|