@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
package/src/canvas/options.ts
CHANGED
|
@@ -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
|
|
44
|
-
*
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
//
|
|
122
|
+
// History / undo
|
|
87
123
|
/**
|
|
88
|
-
* How comment mutations
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
//
|
|
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}
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
122
|
-
*
|
|
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
|
-
//
|
|
159
|
+
// Permissions
|
|
129
160
|
/**
|
|
130
|
-
* Whether the viewer may participate in commenting: composing
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
//
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
//
|
|
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).
|
|
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 —
|
|
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
|
|
219
|
-
|
|
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
|
+
})
|