@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.
- package/commenting.css +63 -71
- package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
- package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
- package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
- package/dist-cjs/canvas/canvas-events.js.map +7 -0
- package/dist-cjs/canvas/cluster-badge.js +121 -0
- package/dist-cjs/canvas/cluster-badge.js.map +7 -0
- package/dist-cjs/canvas/cluster-fade.js +85 -0
- package/dist-cjs/canvas/cluster-fade.js.map +7 -0
- package/dist-cjs/canvas/cluster-input.js +51 -1
- package/dist-cjs/canvas/cluster-input.js.map +2 -2
- package/dist-cjs/canvas/cluster-model.js +252 -0
- package/dist-cjs/canvas/cluster-model.js.map +7 -0
- package/dist-cjs/canvas/comment-mutations.js +57 -28
- package/dist-cjs/canvas/comment-mutations.js.map +2 -2
- package/dist-cjs/canvas/comment-reactions.js +4 -7
- package/dist-cjs/canvas/comment-reactions.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js +4 -3
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +0 -1
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +11 -13
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +77 -870
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +16 -11
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
- package/dist-cjs/canvas/context.js.map +1 -1
- package/dist-cjs/canvas/hooks.js +8 -2
- package/dist-cjs/canvas/hooks.js.map +2 -2
- package/dist-cjs/canvas/license.js.map +1 -1
- package/dist-cjs/canvas/mobile-placement.js +91 -0
- package/dist-cjs/canvas/mobile-placement.js.map +7 -0
- package/dist-cjs/canvas/options.js +39 -1
- package/dist-cjs/canvas/options.js.map +2 -2
- package/dist-cjs/canvas/pending-composer.js +134 -0
- package/dist-cjs/canvas/pending-composer.js.map +7 -0
- package/dist-cjs/canvas/pin-stacking.js +26 -1
- package/dist-cjs/canvas/pin-stacking.js.map +2 -2
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.js.map +7 -0
- package/dist-cjs/canvas/state.js.map +2 -2
- package/dist-cjs/canvas/thread-pin.js +304 -0
- package/dist-cjs/canvas/thread-pin.js.map +7 -0
- package/dist-cjs/canvas/thread-preview.js +41 -49
- package/dist-cjs/canvas/thread-preview.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +7 -7
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +15 -0
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +120 -67
- package/dist-cjs/canvas/thread-view.js.map +3 -3
- package/dist-cjs/clustering/computeClusterTable.js +2 -2
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/replay.js +89 -22
- package/dist-cjs/clustering/replay.js.map +3 -3
- package/dist-cjs/clustering/runtime.js +19 -12
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/clustering/types.js.map +1 -1
- package/dist-cjs/index.d.ts +323 -202
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +5 -3
- package/dist-cjs/ui/byline.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +2 -18
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +2 -15
- package/dist-cjs/ui/comment-pin.js.map +2 -2
- package/dist-cjs/ui/comments-list.js +11 -24
- package/dist-cjs/ui/comments-list.js.map +2 -2
- package/dist-cjs/ui/format-time.js +12 -0
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/icons.js +115 -0
- package/dist-cjs/ui/icons.js.map +7 -0
- package/dist-cjs/ui/reaction-picker.js +11 -13
- package/dist-cjs/ui/reaction-picker.js.map +2 -2
- package/dist-cjs/ui/reaction.js +3 -4
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/send-button.js +2 -9
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/visual-viewport.js +32 -0
- package/dist-cjs/ui/visual-viewport.js.map +7 -0
- package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
- package/dist-esm/canvas/canvas-events.mjs +15 -0
- package/dist-esm/canvas/canvas-events.mjs.map +7 -0
- package/dist-esm/canvas/cluster-badge.mjs +106 -0
- package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
- package/dist-esm/canvas/cluster-fade.mjs +65 -0
- package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +52 -2
- package/dist-esm/canvas/cluster-input.mjs.map +2 -2
- package/dist-esm/canvas/cluster-model.mjs +236 -0
- package/dist-esm/canvas/cluster-model.mjs.map +7 -0
- package/dist-esm/canvas/comment-mutations.mjs +57 -28
- package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
- package/dist-esm/canvas/comment-reactions.mjs +5 -12
- package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs +5 -4
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +0 -1
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +54 -878
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +18 -14
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
- package/dist-esm/canvas/hooks.mjs +9 -2
- package/dist-esm/canvas/hooks.mjs.map +2 -2
- package/dist-esm/canvas/license.mjs.map +1 -1
- package/dist-esm/canvas/mobile-placement.mjs +71 -0
- package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +39 -1
- package/dist-esm/canvas/options.mjs.map +2 -2
- package/dist-esm/canvas/pending-composer.mjs +126 -0
- package/dist-esm/canvas/pending-composer.mjs.map +7 -0
- package/dist-esm/canvas/pin-stacking.mjs +26 -1
- package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs.map +2 -2
- package/dist-esm/canvas/thread-pin.mjs +310 -0
- package/dist-esm/canvas/thread-pin.mjs.map +7 -0
- package/dist-esm/canvas/thread-preview.mjs +42 -49
- package/dist-esm/canvas/thread-preview.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +8 -9
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +15 -0
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +131 -72
- package/dist-esm/canvas/thread-view.mjs.map +3 -3
- package/dist-esm/clustering/computeClusterTable.mjs +2 -2
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/replay.mjs +89 -22
- package/dist-esm/clustering/replay.mjs.map +3 -3
- package/dist-esm/clustering/runtime.mjs +19 -12
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +323 -202
- package/dist-esm/index.mjs +16 -3
- 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-composer.mjs +2 -18
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +2 -15
- package/dist-esm/ui/comment-pin.mjs.map +2 -2
- package/dist-esm/ui/comments-list.mjs +10 -23
- package/dist-esm/ui/comments-list.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +12 -0
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/icons.mjs +95 -0
- package/dist-esm/ui/icons.mjs.map +7 -0
- package/dist-esm/ui/reaction-picker.mjs +12 -13
- package/dist-esm/ui/reaction-picker.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +3 -4
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/send-button.mjs +2 -9
- package/dist-esm/ui/send-button.mjs.map +2 -2
- package/dist-esm/ui/visual-viewport.mjs +12 -0
- package/dist-esm/ui/visual-viewport.mjs.map +7 -0
- package/package.json +5 -5
- package/src/canvas/anchor-lifecycle.test.ts +2 -2
- package/src/canvas/anchor-lifecycle.ts +25 -39
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +5 -11
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +148 -14
- package/src/canvas/cluster-input.ts +92 -5
- package/src/canvas/cluster-model.ts +378 -0
- package/src/canvas/comment-mutations.test.ts +46 -4
- package/src/canvas/comment-mutations.ts +115 -104
- package/src/canvas/comment-reactions.test.ts +22 -0
- package/src/canvas/comment-reactions.tsx +33 -32
- package/src/canvas/comment-render.ts +8 -8
- package/src/canvas/comment-store.ts +12 -17
- package/src/canvas/comment-tool.tsx +8 -12
- package/src/canvas/comments-filter-menu.tsx +9 -16
- package/src/canvas/comments-overflow-menu.tsx +8 -3
- package/src/canvas/comments-overlay.tsx +154 -1344
- package/src/canvas/comments-sidebar.tsx +40 -19
- package/src/canvas/comments-visibility-toggle.tsx +6 -30
- package/src/canvas/context.ts +4 -3
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +17 -3
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +124 -2
- package/src/canvas/options.ts +192 -58
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +111 -1
- package/src/canvas/pin-stacking.ts +46 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/state.ts +20 -28
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.tsx +77 -87
- package/src/canvas/thread-stack.tsx +19 -29
- package/src/canvas/thread-state.test.ts +51 -0
- package/src/canvas/thread-state.ts +56 -23
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +231 -133
- package/src/clustering/computeClusterTable.ts +12 -3
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +50 -6
- package/src/clustering/runtime.ts +42 -39
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +10 -0
- package/src/index.ts +15 -2
- package/src/ui/byline.tsx +16 -6
- package/src/ui/comment-composer.tsx +25 -69
- package/src/ui/comment-pin.tsx +3 -16
- package/src/ui/comments-list.tsx +40 -29
- package/src/ui/comments.css +58 -60
- package/src/ui/format-time.test.ts +69 -0
- package/src/ui/format-time.ts +20 -0
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +9 -16
- package/src/ui/reaction.tsx +10 -6
- package/src/ui/send-button.tsx +3 -8
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -0
- package/dist-cjs/ui/tooltip-button.js.map +0 -7
- package/dist-esm/ui/tooltip-button.mjs +0 -12
- package/dist-esm/ui/tooltip-button.mjs.map +0 -7
- package/src/ui/tooltip-button.tsx +0 -20
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import { ReactNode, useCallback
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
1
|
+
import { ReactNode, useCallback } from 'react'
|
|
3
2
|
import {
|
|
3
|
+
EditorPortal,
|
|
4
4
|
TLComment,
|
|
5
|
-
|
|
5
|
+
TLCommentThread,
|
|
6
6
|
useEditor,
|
|
7
|
-
usePassThroughMouseOverEvents,
|
|
8
7
|
useTranslation,
|
|
9
8
|
useValue,
|
|
10
9
|
} from 'tldraw'
|
|
11
|
-
import { CommentListItemProps, CommentsList } from '../ui/comments-list'
|
|
10
|
+
import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
|
|
12
11
|
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
13
12
|
import { CommentsFilterMenu } from './comments-filter-menu'
|
|
14
13
|
import { CommentsVisibilityToggle } from './comments-visibility-toggle'
|
|
@@ -51,7 +50,6 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
51
50
|
const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
|
52
51
|
const editor = useEditor()
|
|
53
52
|
const options = useCommentingOptions()
|
|
54
|
-
const container = useContainer()
|
|
55
53
|
const commentingEnabled = useCommentingEnabled()
|
|
56
54
|
const msg = useTranslation()
|
|
57
55
|
const threads = useCommentThreads(editor)
|
|
@@ -139,8 +137,28 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
139
137
|
if (thread) focusThread(editor, thread)
|
|
140
138
|
}
|
|
141
139
|
|
|
140
|
+
// The `ThreadRow` component slot overrides the built-in row. It gets the thread record alongside
|
|
141
|
+
// the summarized row, so host state on `thread.meta` is in reach without a second lookup. Indexed
|
|
142
|
+
// once rather than scanned per row — this runs for every row on every render of the panel.
|
|
143
|
+
const ThreadRow = options.components.ThreadRow
|
|
144
|
+
const renderItem = ThreadRow
|
|
145
|
+
? (() => {
|
|
146
|
+
// Keyed by plain string: a row's id comes back as one, not as a branded thread id.
|
|
147
|
+
const threadsById = new Map<string, TLCommentThread>(
|
|
148
|
+
threads.map((thread) => [thread.id, thread])
|
|
149
|
+
)
|
|
150
|
+
return (rowProps: CommentListItemRenderProps) => {
|
|
151
|
+
const thread = threadsById.get(rowProps.id)
|
|
152
|
+
// Can't happen — the items are built from these threads — but a row with no thread has
|
|
153
|
+
// nothing to render, and the slot's contract says the record is there.
|
|
154
|
+
if (!thread) return null
|
|
155
|
+
return <ThreadRow {...rowProps} thread={thread} />
|
|
156
|
+
}
|
|
157
|
+
})()
|
|
158
|
+
: undefined
|
|
159
|
+
|
|
142
160
|
return (
|
|
143
|
-
<SidebarPanel
|
|
161
|
+
<SidebarPanel>
|
|
144
162
|
<CommentsList
|
|
145
163
|
items={items}
|
|
146
164
|
header={header ?? msg('comments.title')}
|
|
@@ -160,13 +178,14 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
160
178
|
}
|
|
161
179
|
resolvedLabel={msg('comments.resolved')}
|
|
162
180
|
onSelect={focus}
|
|
181
|
+
renderItem={renderItem}
|
|
163
182
|
/>
|
|
164
183
|
</SidebarPanel>
|
|
165
184
|
)
|
|
166
185
|
}
|
|
167
186
|
|
|
168
|
-
/** A list row paired with the sort key that isn't part of what the row displays. */
|
|
169
|
-
interface SidebarRow {
|
|
187
|
+
/** A list row paired with the sort key that isn't part of what the row displays. @public */
|
|
188
|
+
export interface SidebarRow {
|
|
170
189
|
item: CommentListItemProps
|
|
171
190
|
/** When the thread's most recent comment was posted — what the list orders by. */
|
|
172
191
|
lastActivity: number
|
|
@@ -177,6 +196,10 @@ interface SidebarRow {
|
|
|
177
196
|
* Recency is the thread's *latest* comment, not its first, so a thread someone just replied to rises
|
|
178
197
|
* to the top instead of staying wherever it was started. (The row still shows the thread's opening
|
|
179
198
|
* comment and its date — that's what identifies the thread; only the ordering follows the replies.)
|
|
199
|
+
*
|
|
200
|
+
* Exported so a hand-built list can match the sidebar's ordering instead of re-deriving it.
|
|
201
|
+
*
|
|
202
|
+
* @public
|
|
180
203
|
*/
|
|
181
204
|
export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[] {
|
|
182
205
|
return [...rows].sort(
|
|
@@ -188,15 +211,13 @@ export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRo
|
|
|
188
211
|
}
|
|
189
212
|
|
|
190
213
|
/** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
|
|
191
|
-
* wheel-transparent panels — a wheel over it doesn't pan the canvas.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
</div>,
|
|
200
|
-
container
|
|
214
|
+
* wheel-transparent panels — a wheel over it doesn't pan the canvas. */
|
|
215
|
+
function SidebarPanel({ children }: { children: ReactNode }) {
|
|
216
|
+
return (
|
|
217
|
+
<EditorPortal>
|
|
218
|
+
<div className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
|
|
219
|
+
{children}
|
|
220
|
+
</div>
|
|
221
|
+
</EditorPortal>
|
|
201
222
|
)
|
|
202
223
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEditor, useTranslation, useValue } from 'tldraw'
|
|
2
|
-
import {
|
|
1
|
+
import { TldrawUiButton, useEditor, useTranslation, useValue } from 'tldraw'
|
|
2
|
+
import { EyeClosedIcon, EyeOpenIcon } from '../ui/icons'
|
|
3
3
|
import { commentsHidden, toggleCommentsHidden } from './state'
|
|
4
4
|
|
|
5
5
|
/** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
|
|
@@ -12,38 +12,14 @@ export function CommentsVisibilityToggle() {
|
|
|
12
12
|
const label = hidden ? msg('comments.show') : msg('comments.hide')
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
|
-
<
|
|
15
|
+
<TldrawUiButton
|
|
16
|
+
type="icon"
|
|
16
17
|
tooltip={label}
|
|
18
|
+
title={label}
|
|
17
19
|
className="tlui-cmt-header-btn"
|
|
18
20
|
onClick={() => toggleCommentsHidden(editor)}
|
|
19
21
|
>
|
|
20
22
|
{hidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
|
|
21
|
-
</
|
|
22
|
-
)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function EyeOpenIcon() {
|
|
26
|
-
return (
|
|
27
|
-
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
|
28
|
-
<path
|
|
29
|
-
d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.5C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
|
|
30
|
-
fill="currentColor"
|
|
31
|
-
fillRule="evenodd"
|
|
32
|
-
clipRule="evenodd"
|
|
33
|
-
/>
|
|
34
|
-
</svg>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function EyeClosedIcon() {
|
|
39
|
-
return (
|
|
40
|
-
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
|
41
|
-
<path
|
|
42
|
-
d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 11 7.50001 11C6.99965 11 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.39889 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 10 7.50001 10C10.3078 10 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
|
|
43
|
-
fill="currentColor"
|
|
44
|
-
fillRule="evenodd"
|
|
45
|
-
clipRule="evenodd"
|
|
46
|
-
/>
|
|
47
|
-
</svg>
|
|
23
|
+
</TldrawUiButton>
|
|
48
24
|
)
|
|
49
25
|
}
|
package/src/canvas/context.ts
CHANGED
|
@@ -30,10 +30,11 @@ export interface CommentingContext {
|
|
|
30
30
|
/** Whether a comment is unread for the current user (return true for unread). */
|
|
31
31
|
isCommentUnread?(commentId: TLCommentId): boolean
|
|
32
32
|
/**
|
|
33
|
-
* Called
|
|
34
|
-
* record
|
|
33
|
+
* Called with every unread comment shown to the user in an open thread popover, batched per
|
|
34
|
+
* report, so hosts can record read receipts without a write per comment. Needs
|
|
35
|
+
* `isCommentUnread` to know what's unread.
|
|
35
36
|
*/
|
|
36
|
-
|
|
37
|
+
onCommentsRead?(commentIds: TLCommentId[]): void
|
|
37
38
|
/** Resolve the members matching an `@`-query in the composers (sync or async). */
|
|
38
39
|
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
|
39
40
|
/** Override a mention-picker row's content. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commentSchemaRecords,
|
|
3
|
+
createComment,
|
|
4
|
+
createCommentThread,
|
|
5
|
+
createTLSchema,
|
|
6
|
+
createTLStore,
|
|
7
|
+
defaultBindingUtils,
|
|
8
|
+
defaultShapeUtils,
|
|
9
|
+
defaultTools,
|
|
10
|
+
Editor,
|
|
11
|
+
toRichText,
|
|
12
|
+
} from 'tldraw'
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
|
+
import { putCommentRecords } from './comment-mutations'
|
|
15
|
+
import { getCommentsByThread } from './hooks'
|
|
16
|
+
|
|
17
|
+
let editors: Editor[] = []
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const editor of editors) editor.dispose()
|
|
21
|
+
editors = []
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function makeEditor(): Editor {
|
|
25
|
+
const editor = new Editor({
|
|
26
|
+
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
|
27
|
+
shapeUtils: defaultShapeUtils,
|
|
28
|
+
bindingUtils: defaultBindingUtils,
|
|
29
|
+
tools: defaultTools,
|
|
30
|
+
getContainer: () => document.body,
|
|
31
|
+
})
|
|
32
|
+
editors.push(editor)
|
|
33
|
+
return editor
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeThreadWithComment(editor: Editor, text: string) {
|
|
37
|
+
const pageId = editor.getCurrentPageId()
|
|
38
|
+
const thread = createCommentThread({
|
|
39
|
+
pageId,
|
|
40
|
+
anchor: { type: 'point', x: 0, y: 0 },
|
|
41
|
+
createdBy: 'user1',
|
|
42
|
+
})
|
|
43
|
+
const comment = createComment({
|
|
44
|
+
threadId: thread.id,
|
|
45
|
+
pageId,
|
|
46
|
+
authorId: 'user1',
|
|
47
|
+
body: toRichText(text),
|
|
48
|
+
})
|
|
49
|
+
return { thread, comment }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('getCommentsByThread', () => {
|
|
53
|
+
it('groups live comments by thread, oldest first', () => {
|
|
54
|
+
const editor = makeEditor()
|
|
55
|
+
const { thread, comment } = makeThreadWithComment(editor, 'first')
|
|
56
|
+
const reply = createComment({
|
|
57
|
+
threadId: thread.id,
|
|
58
|
+
pageId: editor.getCurrentPageId(),
|
|
59
|
+
authorId: 'user2',
|
|
60
|
+
body: toRichText('second'),
|
|
61
|
+
now: comment.createdAt + 1,
|
|
62
|
+
})
|
|
63
|
+
// Inserted newest-first to prove the grouping sorts by createdAt rather than store order.
|
|
64
|
+
putCommentRecords(editor, [thread, reply, comment])
|
|
65
|
+
expect(getCommentsByThread(editor).get().get(thread.id)).toEqual([comment, reply])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("reuses a thread's array when another thread's comments change", () => {
|
|
69
|
+
const editor = makeEditor()
|
|
70
|
+
const a = makeThreadWithComment(editor, 'thread a')
|
|
71
|
+
const b = makeThreadWithComment(editor, 'thread b')
|
|
72
|
+
putCommentRecords(editor, [a.thread, a.comment, b.thread, b.comment])
|
|
73
|
+
|
|
74
|
+
const byThread = getCommentsByThread(editor)
|
|
75
|
+
const before = byThread.get()
|
|
76
|
+
|
|
77
|
+
const replyToB = createComment({
|
|
78
|
+
threadId: b.thread.id,
|
|
79
|
+
pageId: editor.getCurrentPageId(),
|
|
80
|
+
authorId: 'user2',
|
|
81
|
+
body: toRichText('reply'),
|
|
82
|
+
now: b.comment.createdAt + 1,
|
|
83
|
+
})
|
|
84
|
+
putCommentRecords(editor, [replyToB])
|
|
85
|
+
const after = byThread.get()
|
|
86
|
+
|
|
87
|
+
expect(after).not.toBe(before)
|
|
88
|
+
// The unchanged thread keeps its exact array, so its subscribers don't re-render...
|
|
89
|
+
expect(after.get(a.thread.id)).toBe(before.get(a.thread.id))
|
|
90
|
+
// ...while the changed thread gets a fresh one with the reply in place.
|
|
91
|
+
expect(after.get(b.thread.id)).not.toBe(before.get(b.thread.id))
|
|
92
|
+
expect(after.get(b.thread.id)).toEqual([b.comment, replyToB])
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/canvas/hooks.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
computed,
|
|
3
3
|
Computed,
|
|
4
4
|
Editor,
|
|
5
|
+
isUninitialized,
|
|
5
6
|
TLComment,
|
|
6
7
|
TLCommentThread,
|
|
7
8
|
TLCommentThreadId,
|
|
@@ -29,17 +30,30 @@ export function useCommentThreads(editor: Editor): TLCommentThread[] {
|
|
|
29
30
|
*/
|
|
30
31
|
const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
/** Exported for tests; use {@link useThreadComments} to consume. @internal */
|
|
34
|
+
export function getCommentsByThread(editor: Editor) {
|
|
33
35
|
return commentsByThread.get(editor, () =>
|
|
34
|
-
computed('comments by thread', () => {
|
|
36
|
+
computed('comments by thread', (prev) => {
|
|
35
37
|
const byThread = new Map<TLCommentThreadId, TLComment[]>()
|
|
36
38
|
for (const comment of getLiveComments(editor)) {
|
|
37
39
|
const existing = byThread.get(comment.threadId)
|
|
38
40
|
if (existing) existing.push(comment)
|
|
39
41
|
else byThread.set(comment.threadId, [comment])
|
|
40
42
|
}
|
|
41
|
-
for (const comments of byThread
|
|
43
|
+
for (const [threadId, comments] of byThread) {
|
|
42
44
|
comments.sort((a, b) => a.createdAt - b.createdAt)
|
|
45
|
+
// Reuse the previous per-thread array when that thread's comment list is unchanged
|
|
46
|
+
// (record identities are stable while unchanged), so `useThreadComments` subscribers
|
|
47
|
+
// on other threads don't re-render when one thread gains a reply.
|
|
48
|
+
if (isUninitialized(prev)) continue
|
|
49
|
+
const prevComments = prev.get(threadId)
|
|
50
|
+
if (
|
|
51
|
+
prevComments &&
|
|
52
|
+
prevComments.length === comments.length &&
|
|
53
|
+
comments.every((comment, i) => prevComments[i] === comment)
|
|
54
|
+
) {
|
|
55
|
+
byThread.set(threadId, prevComments)
|
|
56
|
+
}
|
|
43
57
|
}
|
|
44
58
|
return byThread
|
|
45
59
|
})
|
package/src/canvas/license.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { useLicenseContext, useLicenseFeatureFlag } from 'tldraw'
|
|
|
7
7
|
* pending, so gated UI stays hidden until the license is confirmed.
|
|
8
8
|
*
|
|
9
9
|
* The built-in commenting components (`CanvasComments`, `CanvasCommentsSidebar`, and the comment
|
|
10
|
-
* tool's
|
|
10
|
+
* tool's Quick Action) gate on this. Use it to gate any custom commenting UI the same way.
|
|
11
11
|
* @public
|
|
12
12
|
*/
|
|
13
13
|
export function useCommentingEnabled(): boolean {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { PORTRAIT_BREAKPOINT, useBreakpoint, useContainer } from 'tldraw'
|
|
3
|
+
import { getVisibleViewport } from '../ui/visual-viewport'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mobile mode for the commenting surfaces — the same breakpoint gate as tldraw's mobile toolbar and
|
|
7
|
+
* style panel (which also honors `forceMobile` on `<Tldraw>`). The commenting layer can mount
|
|
8
|
+
* without tldraw's default UI (`hideUi`, custom UI), where the breakpoint provider is absent and
|
|
9
|
+
* `useBreakpoint` throws; fall back to desktop there, matching the pre-mobile rendering those hosts
|
|
10
|
+
* always had.
|
|
11
|
+
*/
|
|
12
|
+
export function useIsMobileCommenting(): boolean {
|
|
13
|
+
let breakpoint: number
|
|
14
|
+
try {
|
|
15
|
+
// The call is unconditional — the try only guards the provider's absence, not hook order.
|
|
16
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
17
|
+
breakpoint = useBreakpoint()
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VIEWPORT_MARGIN = 8
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Keep a canvas-anchored panel — the pending composer or an open thread popover — within the
|
|
28
|
+
* visible viewport on mobile. The software keyboard shrinks the *visual* viewport while leaving the
|
|
29
|
+
* layout viewport (and CSS `dvh`) untouched, so a panel placed at a fixed offset from its pin can
|
|
30
|
+
* end up behind the keyboard. On mobile this clamps the panel's top-left into the visible box so it
|
|
31
|
+
* stays on-screen above the keyboard. Desktop is untouched: when `enabled` is false the base point
|
|
32
|
+
* is returned unchanged, tracking the camera exactly as before.
|
|
33
|
+
*
|
|
34
|
+
* `base` is the panel's desired top-left in container-relative viewport coordinates (what the call
|
|
35
|
+
* site would otherwise write straight into `left`/`top`).
|
|
36
|
+
*/
|
|
37
|
+
export function useMobilePlacement(
|
|
38
|
+
ref: RefObject<HTMLElement | null>,
|
|
39
|
+
base: { x: number; y: number },
|
|
40
|
+
enabled: boolean
|
|
41
|
+
): { left: number; top: number } {
|
|
42
|
+
const container = useContainer()
|
|
43
|
+
// Destructured so the effects below depend on the two numbers rather than the object, which the
|
|
44
|
+
// call sites build fresh on every render.
|
|
45
|
+
const { x: baseX, y: baseY } = base
|
|
46
|
+
const [placed, setPlaced] = useState<{ left: number; top: number }>(() => ({
|
|
47
|
+
left: baseX,
|
|
48
|
+
top: baseY,
|
|
49
|
+
}))
|
|
50
|
+
// The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
|
|
51
|
+
// across those frames, so the observers below are set up once per panel rather than being torn
|
|
52
|
+
// down and rebuilt each frame.
|
|
53
|
+
const baseRef = useRef({ x: baseX, y: baseY })
|
|
54
|
+
|
|
55
|
+
const update = useCallback(() => {
|
|
56
|
+
if (!enabled) return
|
|
57
|
+
const el = ref.current
|
|
58
|
+
if (!el) return
|
|
59
|
+
const win = container.ownerDocument.defaultView ?? window
|
|
60
|
+
const { x, y } = baseRef.current
|
|
61
|
+
|
|
62
|
+
// Panel coordinates are container-relative; the visual viewport is window-relative.
|
|
63
|
+
const cRect = container.getBoundingClientRect()
|
|
64
|
+
const vp = getVisibleViewport(win)
|
|
65
|
+
const top = vp.top - cRect.top + VIEWPORT_MARGIN
|
|
66
|
+
const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
|
|
67
|
+
const left = vp.left - cRect.left + VIEWPORT_MARGIN
|
|
68
|
+
const right = vp.right - cRect.left - VIEWPORT_MARGIN
|
|
69
|
+
|
|
70
|
+
const w = el.offsetWidth
|
|
71
|
+
const h = el.offsetHeight
|
|
72
|
+
|
|
73
|
+
const nextLeft = Math.max(left, Math.min(x, right - w))
|
|
74
|
+
// The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
|
|
75
|
+
// bottom would be hidden) but must never push it down below its natural spot. `y` is the
|
|
76
|
+
// floor: any spurious rise in `top` (e.g. iOS scrolling the page to reveal a focused input)
|
|
77
|
+
// is capped here, so a panel that already clears the keyboard doesn't move at all.
|
|
78
|
+
const nextTop = Math.min(y, Math.max(top, bottom - h))
|
|
79
|
+
setPlaced((prev) =>
|
|
80
|
+
prev.left === nextLeft && prev.top === nextTop ? prev : { left: nextLeft, top: nextTop }
|
|
81
|
+
)
|
|
82
|
+
}, [container, ref, enabled])
|
|
83
|
+
|
|
84
|
+
// Re-place as the camera moves the panel's base point.
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
baseRef.current = { x: baseX, y: baseY }
|
|
87
|
+
update()
|
|
88
|
+
}, [baseX, baseY, update])
|
|
89
|
+
|
|
90
|
+
// Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
|
|
91
|
+
// pinch-zoom), or when the window resizes.
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
if (!enabled) return
|
|
94
|
+
const el = ref.current
|
|
95
|
+
if (!el) return
|
|
96
|
+
const win = container.ownerDocument.defaultView ?? window
|
|
97
|
+
const ro = new ResizeObserver(update)
|
|
98
|
+
ro.observe(el)
|
|
99
|
+
const vv = win.visualViewport
|
|
100
|
+
vv?.addEventListener('resize', update)
|
|
101
|
+
vv?.addEventListener('scroll', update)
|
|
102
|
+
win.addEventListener('resize', update)
|
|
103
|
+
return () => {
|
|
104
|
+
ro.disconnect()
|
|
105
|
+
vv?.removeEventListener('resize', update)
|
|
106
|
+
vv?.removeEventListener('scroll', update)
|
|
107
|
+
win.removeEventListener('resize', update)
|
|
108
|
+
}
|
|
109
|
+
}, [container, ref, enabled, update])
|
|
110
|
+
|
|
111
|
+
// Desktop keeps its fixed placement, recomputed each render so it tracks the pin as the camera
|
|
112
|
+
// moves.
|
|
113
|
+
if (!enabled) return { left: baseX, top: baseY }
|
|
114
|
+
return placed
|
|
115
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { createComment, createCommentThread, toRichText, type Editor, type TLPageId } from 'tldraw'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { commitCommentMutation } from './comment-mutations'
|
|
4
4
|
import { CommentTool } from './comment-tool'
|
|
5
5
|
import {
|
|
6
|
+
defaultCanModifyComment,
|
|
6
7
|
defaultCommentingOptions,
|
|
7
8
|
getCanComment,
|
|
9
|
+
getCanModifyComment,
|
|
8
10
|
getCommentingOptions,
|
|
9
11
|
type CommentingOptions,
|
|
12
|
+
type CommentModificationContext,
|
|
10
13
|
} from './options'
|
|
11
14
|
import { openThreadId, pendingComment } from './state'
|
|
12
15
|
|
|
@@ -126,6 +129,125 @@ describe('getCanComment', () => {
|
|
|
126
129
|
const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
|
|
127
130
|
expect(getCanComment(editor, null)).toBe(true)
|
|
128
131
|
})
|
|
132
|
+
|
|
133
|
+
// This is read during render, so a throwing host rule would take the comments layer with it.
|
|
134
|
+
it('denies rather than throws when the callback throws', () => {
|
|
135
|
+
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
136
|
+
const { editor } = stubEditor({
|
|
137
|
+
...defaultCommentingOptions,
|
|
138
|
+
canComment: () => {
|
|
139
|
+
throw new Error('lookup failed')
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
expect(getCanComment(editor, 'alice')).toBe(false)
|
|
143
|
+
expect(onError).toHaveBeenCalled()
|
|
144
|
+
onError.mockRestore()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Plain records for the permission checks — the factories are pure, so no store is involved.
|
|
149
|
+
const PAGE_ID = 'page:test' as TLPageId
|
|
150
|
+
|
|
151
|
+
function makeThread(createdBy: string) {
|
|
152
|
+
return createCommentThread({
|
|
153
|
+
pageId: PAGE_ID,
|
|
154
|
+
anchor: { type: 'point', x: 0, y: 0 },
|
|
155
|
+
createdBy,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function makeComment(authorId: string) {
|
|
160
|
+
return createComment({
|
|
161
|
+
threadId: makeThread(authorId).id,
|
|
162
|
+
pageId: PAGE_ID,
|
|
163
|
+
authorId,
|
|
164
|
+
body: toRichText('hello'),
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe('getCanModifyComment', () => {
|
|
169
|
+
it('defaults to the record owner: the comment author edits and deletes', () => {
|
|
170
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
171
|
+
const comment = makeComment('alice')
|
|
172
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
|
173
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'delete-comment', comment })).toBe(true)
|
|
174
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'edit-comment', comment })).toBe(false)
|
|
175
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('defaults to the thread creator for a thread delete', () => {
|
|
179
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
180
|
+
const thread = makeThread('alice')
|
|
181
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'delete-thread', thread })).toBe(true)
|
|
182
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'delete-thread', thread })).toBe(false)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('withholds everything from a viewer with no identity', () => {
|
|
186
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
187
|
+
const comment = makeComment('alice')
|
|
188
|
+
const thread = makeThread('alice')
|
|
189
|
+
expect(getCanModifyComment(editor, null, { action: 'edit-comment', comment })).toBe(false)
|
|
190
|
+
expect(getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })).toBe(
|
|
191
|
+
false
|
|
192
|
+
)
|
|
193
|
+
expect(getCanModifyComment(editor, null, { action: 'delete-thread', thread })).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('passes the editor, the viewer, and the targeted write to the callback', () => {
|
|
197
|
+
const calls: CommentModificationContext[] = []
|
|
198
|
+
const { editor } = stubEditor({
|
|
199
|
+
...defaultCommentingOptions,
|
|
200
|
+
canModifyComment: (ctx) => {
|
|
201
|
+
calls.push(ctx)
|
|
202
|
+
return true
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
const comment = makeComment('alice')
|
|
206
|
+
getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })
|
|
207
|
+
// An undefined viewer normalizes to null, as it does for `canComment`.
|
|
208
|
+
expect(calls).toEqual([{ editor, currentUserId: null, action: 'delete-comment', comment }])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("lets a callback widen the default: a moderator deleting anyone's comment", () => {
|
|
212
|
+
const { editor } = stubEditor({
|
|
213
|
+
...defaultCommentingOptions,
|
|
214
|
+
canModifyComment: (ctx) =>
|
|
215
|
+
(ctx.action !== 'edit-comment' && ctx.currentUserId === 'mod') ||
|
|
216
|
+
defaultCanModifyComment(ctx),
|
|
217
|
+
})
|
|
218
|
+
const comment = makeComment('alice')
|
|
219
|
+
const thread = makeThread('alice')
|
|
220
|
+
expect(getCanModifyComment(editor, 'mod', { action: 'delete-comment', comment })).toBe(true)
|
|
221
|
+
expect(getCanModifyComment(editor, 'mod', { action: 'delete-thread', thread })).toBe(true)
|
|
222
|
+
// Widening deletion doesn't hand the moderator anyone else's edit affordance.
|
|
223
|
+
expect(getCanModifyComment(editor, 'mod', { action: 'edit-comment', comment })).toBe(false)
|
|
224
|
+
// The author keeps their own affordances.
|
|
225
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
|
226
|
+
// Everyone else still gets nothing.
|
|
227
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('lets a callback narrow the default', () => {
|
|
231
|
+
const { editor } = stubEditor({ ...defaultCommentingOptions, canModifyComment: () => false })
|
|
232
|
+
const comment = makeComment('alice')
|
|
233
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// A throw withholds the affordance rather than the whole layer, and never offers a write a
|
|
237
|
+
// server enforcing the same rule would reject anyway.
|
|
238
|
+
it('denies rather than throws when the callback throws', () => {
|
|
239
|
+
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
240
|
+
const { editor } = stubEditor({
|
|
241
|
+
...defaultCommentingOptions,
|
|
242
|
+
canModifyComment: () => {
|
|
243
|
+
throw new Error('lookup failed')
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
const comment = makeComment('alice')
|
|
247
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
|
248
|
+
expect(onError).toHaveBeenCalled()
|
|
249
|
+
onError.mockRestore()
|
|
250
|
+
})
|
|
129
251
|
})
|
|
130
252
|
|
|
131
253
|
describe('commitCommentMutation', () => {
|