@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
package/src/canvas/options.ts
CHANGED
|
@@ -4,8 +4,26 @@ import {
|
|
|
4
4
|
type TLComment,
|
|
5
5
|
type TLCommentThread,
|
|
6
6
|
type TLHistoryBatchOptions,
|
|
7
|
+
type TLShapeId,
|
|
8
|
+
type VecLike,
|
|
7
9
|
useEditor,
|
|
10
|
+
useValue,
|
|
8
11
|
} from 'tldraw'
|
|
12
|
+
import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
|
|
13
|
+
import { type ReactionTooltipProps } from '../ui/reaction'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The gesture that's creating a shape anchor, passed to
|
|
17
|
+
* {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
|
|
18
|
+
* whether Alt was held.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export interface ShapeCommentPrecisionContext {
|
|
23
|
+
readonly shapeId: TLShapeId
|
|
24
|
+
readonly point: VecLike
|
|
25
|
+
readonly altKey: boolean
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
/**
|
|
11
29
|
* Component overrides for the batteries-included comments layer. Each slot replaces a built-in
|
|
@@ -20,12 +38,40 @@ export interface CommentingComponents {
|
|
|
20
38
|
PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
|
21
39
|
/** A sidebar row's preview. Replaces the plaintext default. */
|
|
22
40
|
ThreadPreview?: ComponentType<{ comment: TLComment }>
|
|
41
|
+
/**
|
|
42
|
+
* A reaction's visual, given its token. The default renders the token string for the OS emoji
|
|
43
|
+
* font to draw (so the token is the emoji glyph). Override this to render your own palette —
|
|
44
|
+
* return an `<img>` for custom emoji, an SVG, or anything. The token is whatever your picker
|
|
45
|
+
* emits and is what gets stored/synced; this only controls how it's drawn.
|
|
46
|
+
*/
|
|
47
|
+
ReactionContent?: ComponentType<{ token: string }>
|
|
48
|
+
/**
|
|
49
|
+
* What the add-reaction button opens: the thing that produces a reaction token. Replaces the
|
|
50
|
+
* default `<EmojiPicker>` grid. Pairs with `ReactionContent` (which draws whatever tokens this
|
|
51
|
+
* emits) and with `isAllowedReaction` (which has to accept them).
|
|
52
|
+
*/
|
|
53
|
+
ReactionPalette?: ComponentType<EmojiPickerProps>
|
|
54
|
+
/**
|
|
55
|
+
* The hover affordance naming who reacted with an emoji. It receives the reactors and the pill
|
|
56
|
+
* (as `children`) and returns the whole thing — so it owns the tooltip, its box, size, shape, and
|
|
57
|
+
* position. Replaces the default (`DefaultReactionTooltip`). For a simple wording change, translate
|
|
58
|
+
* the `comments.reacted-*` strings instead; reach for this to change the structure — a different
|
|
59
|
+
* box, avatars, a banner anywhere on screen.
|
|
60
|
+
*/
|
|
61
|
+
ReactionTooltip?: ComponentType<ReactionTooltipProps>
|
|
62
|
+
/** Shown where a composer would sit when the viewer can't compose (see
|
|
63
|
+
* {@link CommentingOptions.canComment} — a signed-out viewer, a viewer role, a host that
|
|
64
|
+
* turns commenting off). `context` says which surface is rendering it: the bottom of an open
|
|
65
|
+
* thread popover (`'thread'`) or the placement popover the comment tool opens (`'pending'`).
|
|
66
|
+
* Unset, those surfaces render nothing. */
|
|
67
|
+
ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
|
|
23
68
|
}
|
|
24
69
|
|
|
25
70
|
/**
|
|
26
71
|
* Configuration for the commenting layer. Static config only — pass it once via
|
|
27
72
|
* `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
|
|
28
|
-
* (`currentUserId`,
|
|
73
|
+
* (`currentUserId`, author resolution, read-status callbacks) are the `CommentingContext`, passed as
|
|
74
|
+
* props to each commenting surface.
|
|
29
75
|
*
|
|
30
76
|
* For defaults, see {@link defaultCommentingOptions}.
|
|
31
77
|
*
|
|
@@ -55,16 +101,60 @@ export interface CommentingOptions {
|
|
|
55
101
|
// ── Feature toggles ──────────────────────────────────────────────────────────────────────
|
|
56
102
|
/** Fold nearby pins into count badges as the camera zooms out. */
|
|
57
103
|
readonly enableClustering: boolean
|
|
104
|
+
/**
|
|
105
|
+
* Whether a user may hold several emoji reactions on one comment. `true` (the default) is the
|
|
106
|
+
* Slack model: each emoji toggles independently. `false` is single-select: picking a new emoji
|
|
107
|
+
* replaces the user's existing reaction. Note this is enforced client-side; the server accepts
|
|
108
|
+
* per-emoji records either way.
|
|
109
|
+
*/
|
|
110
|
+
readonly allowMultipleReactions: boolean
|
|
111
|
+
/**
|
|
112
|
+
* Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji} against
|
|
113
|
+
* the built-in emoji palette, which is what keeps a scripted client from writing junk `emoji`
|
|
114
|
+
* values the picker would never offer. Override it alongside a custom `ReactionPalette` so the
|
|
115
|
+
* tokens that palette emits get through. Removals aren't checked — a reaction carrying an
|
|
116
|
+
* off-palette token must still be clearable.
|
|
117
|
+
*/
|
|
118
|
+
isAllowedReaction(token: string): boolean
|
|
119
|
+
/**
|
|
120
|
+
* Whether dragging the comment tool out creates a region anchor — a comment attached to a
|
|
121
|
+
* rectangular area of the page, drawn as a dashed box with the thread's pin on the corner the
|
|
122
|
+
* drag released on. Off by default: comments attach to points and shapes only, and a drag just
|
|
123
|
+
* trails the composer. A region reveals its box while the pointer is inside it, moves by its
|
|
124
|
+
* pin, and resizes from its corners.
|
|
125
|
+
*/
|
|
126
|
+
readonly enableRegions: boolean
|
|
127
|
+
|
|
128
|
+
// ── Permissions ──────────────────────────────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Whether the viewer may participate in commenting: composing new threads and replies, editing
|
|
131
|
+
* and deleting comments, resolving threads, and moving pins or regions. Composers render when
|
|
132
|
+
* it returns true; when it returns false, the {@link CommentingComponents.ComposerFallback}
|
|
133
|
+
* slot renders in their place (or nothing, if that slot is unset) and the action affordances
|
|
134
|
+
* are hidden. Unset, participation is allowed exactly when `currentUserId` is set.
|
|
135
|
+
*
|
|
136
|
+
* Called during render via {@link useCanComment}, so reactive reads (signals) are tracked.
|
|
137
|
+
* The comment tool itself stays registered and selectable — hosts that want its toolbar button
|
|
138
|
+
* to do something else (e.g. open a sign-in dialog) can override the tool item's `onSelect`.
|
|
139
|
+
* Note posting still requires a `currentUserId` to author the records, so a callback that
|
|
140
|
+
* returns true for a signed-out viewer yields a composer whose send button stays disabled.
|
|
141
|
+
*/
|
|
142
|
+
readonly canComment:
|
|
143
|
+
| ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
|
|
144
|
+
| undefined
|
|
58
145
|
|
|
59
146
|
// ── Anchoring ────────────────────────────────────────────────────────────────────────────
|
|
60
147
|
/** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
|
|
61
148
|
readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Whether a comment landing on a shape anchors precisely — pinned to the exact clicked spot
|
|
151
|
+
* within the shape — or imprecisely — pinned to the shape as a whole, rendered at
|
|
152
|
+
* `impreciseShapeAnchor`. Called wherever a shape anchor is created (placing with the comment
|
|
153
|
+
* tool, dropping a dragged pin onto a shape). Always precise by default. Return `false` for
|
|
154
|
+
* shape-level anchoring, or decide from the context — the Alt key's state, or the shape itself,
|
|
155
|
+
* e.g. precise only on notes. Governs new placements only; existing anchors render as stored.
|
|
156
|
+
*/
|
|
157
|
+
shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
|
|
68
158
|
|
|
69
159
|
// ── Components ────────────────────────────────────────────────────────────────────────────
|
|
70
160
|
/** Component overrides. See {@link CommentingComponents}. */
|
|
@@ -80,9 +170,12 @@ export const defaultCommentingOptions = {
|
|
|
80
170
|
history: 'ignore',
|
|
81
171
|
dragHistory: undefined,
|
|
82
172
|
enableClustering: true,
|
|
173
|
+
allowMultipleReactions: true,
|
|
174
|
+
isAllowedReaction: isAllowedReactionEmoji,
|
|
175
|
+
enableRegions: false,
|
|
176
|
+
canComment: undefined,
|
|
83
177
|
impreciseShapeAnchor: { x: 1, y: 0 },
|
|
84
|
-
|
|
85
|
-
clusterSplitZoomFactor: 1.05,
|
|
178
|
+
shouldBePrecise: () => true,
|
|
86
179
|
components: {},
|
|
87
180
|
} as const satisfies CommentingOptions
|
|
88
181
|
|
|
@@ -109,3 +202,34 @@ export function useCommentingOptions(): CommentingOptions {
|
|
|
109
202
|
const editor = useEditor()
|
|
110
203
|
return useMemo(() => getCommentingOptions(editor), [editor])
|
|
111
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
|
|
208
|
+
* (defaulting to `currentUserId != null` when unset). Where this is false, composers give way to
|
|
209
|
+
* the {@link CommentingComponents.ComposerFallback} slot and action affordances are hidden.
|
|
210
|
+
*
|
|
211
|
+
* This is a plain, untracked read — a `canComment` callback that reads signals is not observed.
|
|
212
|
+
* In React, use {@link useCanComment} instead.
|
|
213
|
+
*
|
|
214
|
+
* @public
|
|
215
|
+
*/
|
|
216
|
+
export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
|
|
217
|
+
const { canComment } = getCommentingOptions(editor)
|
|
218
|
+
return canComment
|
|
219
|
+
? canComment({ editor, currentUserId: currentUserId ?? null })
|
|
220
|
+
: currentUserId != null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reactive React hook for {@link getCanComment}: a `canComment` callback that reads signals
|
|
225
|
+
* re-evaluates when they change.
|
|
226
|
+
*
|
|
227
|
+
* @public
|
|
228
|
+
*/
|
|
229
|
+
export function useCanComment(currentUserId: string | null | undefined): boolean {
|
|
230
|
+
const editor = useEditor()
|
|
231
|
+
return useValue('can comment', () => getCanComment(editor, currentUserId), [
|
|
232
|
+
editor,
|
|
233
|
+
currentUserId,
|
|
234
|
+
])
|
|
235
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { computePinStacks } from './pin-stacking'
|
|
4
|
+
|
|
5
|
+
const CURRENT_PAGE = 'page:one'
|
|
6
|
+
const OTHER_PAGE = 'page:two'
|
|
7
|
+
|
|
8
|
+
function thread(
|
|
9
|
+
id: string,
|
|
10
|
+
anchor: TLCommentAnchor,
|
|
11
|
+
opts: { pageId?: string; createdAt?: number } = {}
|
|
12
|
+
): TLCommentThread {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
typeName: 'comment-thread',
|
|
16
|
+
pageId: opts.pageId ?? CURRENT_PAGE,
|
|
17
|
+
anchor,
|
|
18
|
+
createdBy: 'user:1',
|
|
19
|
+
createdAt: opts.createdAt ?? 0,
|
|
20
|
+
resolved: null,
|
|
21
|
+
meta: {},
|
|
22
|
+
} as unknown as TLCommentThread
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Unrotated shapes: local geometry sized by the box, placed by a translate-only page transform.
|
|
27
|
+
* No comment tool registered, so the commenting options `anchorPagePoint` reads are the defaults.
|
|
28
|
+
*/
|
|
29
|
+
function stubEditor(
|
|
30
|
+
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
|
|
31
|
+
): Editor {
|
|
32
|
+
const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
|
|
33
|
+
return {
|
|
34
|
+
getCurrentPageId: () => CURRENT_PAGE,
|
|
35
|
+
getStateDescendant: () => undefined,
|
|
36
|
+
getShape: (id: string) => (shapes[id] ? { id } : undefined),
|
|
37
|
+
getShapeGeometry: (shape: string | { id: string }) => {
|
|
38
|
+
const bounds = shapes[shapeId(shape)]
|
|
39
|
+
return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
|
|
40
|
+
},
|
|
41
|
+
getShapePageTransform: (shape: string | { id: string }) => {
|
|
42
|
+
const bounds = shapes[shapeId(shape)]
|
|
43
|
+
return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
|
|
44
|
+
},
|
|
45
|
+
} as unknown as Editor
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SHAPE = { 'shape:a': { minX: 0, minY: 0, maxX: 200, maxY: 100 } }
|
|
49
|
+
|
|
50
|
+
function impreciseAnchor(shapeId: string): TLCommentAnchor {
|
|
51
|
+
return { type: 'shape', shapeId, x: 0.2, y: 0.9, isPrecise: false } as TLCommentAnchor
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('computePinStacks', () => {
|
|
55
|
+
it('groups coincident imprecise pins on one shape, oldest first', () => {
|
|
56
|
+
const stacks = computePinStacks(stubEditor(SHAPE), [
|
|
57
|
+
thread('t2', impreciseAnchor('shape:a'), { createdAt: 20 }),
|
|
58
|
+
thread('t1', impreciseAnchor('shape:a'), { createdAt: 10 }),
|
|
59
|
+
])
|
|
60
|
+
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
|
61
|
+
expect(stacks.get('t2')).toEqual(['t1', 't2'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('leaves separated pins ungrouped', () => {
|
|
65
|
+
const stacks = computePinStacks(stubEditor(), [
|
|
66
|
+
thread('t1', { type: 'point', x: 0, y: 0 }),
|
|
67
|
+
thread('t2', { type: 'point', x: 50, y: 0 }),
|
|
68
|
+
])
|
|
69
|
+
expect(stacks.size).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('groups coincident point anchors', () => {
|
|
73
|
+
const stacks = computePinStacks(stubEditor(), [
|
|
74
|
+
thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
|
75
|
+
thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 2 }),
|
|
76
|
+
thread('t3', { type: 'point', x: 5, y: 5 }, { createdAt: 3 }),
|
|
77
|
+
])
|
|
78
|
+
expect(stacks.get('t2')).toEqual(['t1', 't2', 't3'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('breaks creation-time ties by id so ordering is deterministic', () => {
|
|
82
|
+
const stacks = computePinStacks(stubEditor(), [
|
|
83
|
+
thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
|
84
|
+
thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
|
85
|
+
])
|
|
86
|
+
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('ignores threads on other pages and unresolvable anchors', () => {
|
|
90
|
+
const stacks = computePinStacks(stubEditor(SHAPE), [
|
|
91
|
+
thread('t1', impreciseAnchor('shape:a')),
|
|
92
|
+
thread('t2', impreciseAnchor('shape:a'), { pageId: OTHER_PAGE }),
|
|
93
|
+
thread('t3', impreciseAnchor('shape:gone')),
|
|
94
|
+
])
|
|
95
|
+
expect(stacks.size).toBe(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not group a precise pin sitting away from a coincident pair', () => {
|
|
99
|
+
const precise: TLCommentAnchor = {
|
|
100
|
+
type: 'shape',
|
|
101
|
+
shapeId: 'shape:a',
|
|
102
|
+
x: 0.5,
|
|
103
|
+
y: 0.5,
|
|
104
|
+
isPrecise: true,
|
|
105
|
+
} as TLCommentAnchor
|
|
106
|
+
const stacks = computePinStacks(stubEditor(SHAPE), [
|
|
107
|
+
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
|
108
|
+
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
|
109
|
+
thread('t3', precise),
|
|
110
|
+
])
|
|
111
|
+
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
|
112
|
+
expect(stacks.has('t3')).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Editor, TLCommentThread } from 'tldraw'
|
|
2
|
+
import { anchorPagePoint } from './thread-state'
|
|
3
|
+
|
|
4
|
+
/** Two anchors within this page-space distance (per axis) share a stack. Identical imprecise
|
|
5
|
+
* anchors on one shape resolve to the same point exactly; the tolerance only absorbs float noise. */
|
|
6
|
+
const PIN_STACK_QUANTUM = 0.1
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The key a page point stacks under. Threads sharing a key are coincident. Stable identity for a
|
|
10
|
+
* stack itself: it survives losing any member (the survivors keep the same key), unlike keying by
|
|
11
|
+
* a particular thread id, so open-stack state stays put when the stack's oldest thread is deleted.
|
|
12
|
+
*/
|
|
13
|
+
export function pinStackKey(point: { x: number; y: number }): string {
|
|
14
|
+
return `${Math.round(point.x / PIN_STACK_QUANTUM)}:${Math.round(point.y / PIN_STACK_QUANTUM)}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Group threads whose pins land on the same page point — coincident pins (typically several
|
|
19
|
+
* imprecise comments on one shape) that zooming can never separate. The overlay renders each
|
|
20
|
+
* group as a single count-badge pin that opens the threads as a list. Every member id maps to
|
|
21
|
+
* its group's ordered member ids (oldest first); threads without an entry pin individually.
|
|
22
|
+
*
|
|
23
|
+
* Keyed by page-space anchor point, not screen position, so the result only changes when threads
|
|
24
|
+
* or their anchors move — never on camera moves.
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
export function computePinStacks(
|
|
28
|
+
editor: Editor,
|
|
29
|
+
threads: readonly TLCommentThread[]
|
|
30
|
+
): Map<string, readonly string[]> {
|
|
31
|
+
const pageId = editor.getCurrentPageId()
|
|
32
|
+
const groups = new Map<string, TLCommentThread[]>()
|
|
33
|
+
|
|
34
|
+
for (const thread of threads) {
|
|
35
|
+
if (thread.pageId !== pageId) continue
|
|
36
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
37
|
+
if (!point) continue
|
|
38
|
+
const key = pinStackKey(point)
|
|
39
|
+
const group = groups.get(key)
|
|
40
|
+
if (group) {
|
|
41
|
+
group.push(thread)
|
|
42
|
+
} else {
|
|
43
|
+
groups.set(key, [thread])
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stacks = new Map<string, readonly string[]>()
|
|
48
|
+
for (const group of groups.values()) {
|
|
49
|
+
if (group.length < 2) continue
|
|
50
|
+
group.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
|
|
51
|
+
const ids = group.map((thread) => thread.id)
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
stacks.set(id, ids)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return stacks
|
|
57
|
+
}
|
|
@@ -16,10 +16,11 @@ export interface SidebarFilters {
|
|
|
16
16
|
onlyCurrentPage: boolean
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** The out-of-the-box view: every page's threads, resolved ones hidden until asked for.
|
|
20
|
+
* @public */
|
|
20
21
|
export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
|
|
21
|
-
showResolved:
|
|
22
|
+
showResolved: false,
|
|
22
23
|
onlyMine: false,
|
|
23
24
|
onlyUnread: false,
|
|
24
|
-
onlyCurrentPage:
|
|
25
|
+
onlyCurrentPage: false,
|
|
25
26
|
}
|
package/src/canvas/state.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
EditorAtom,
|
|
3
|
-
type BoxModel,
|
|
4
|
-
type Editor,
|
|
5
|
-
type TLHistoryBatchOptions,
|
|
6
|
-
useEditor,
|
|
7
|
-
useValue,
|
|
8
|
-
} from 'tldraw'
|
|
1
|
+
import { EditorAtom, type BoxModel, type Editor, useEditor, useValue } from 'tldraw'
|
|
9
2
|
import type { PendingComment } from './comment-tool'
|
|
10
|
-
import { getCommentingOptions } from './options'
|
|
11
3
|
import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
|
|
12
4
|
|
|
13
5
|
/**
|
|
@@ -21,10 +13,77 @@ import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
|
|
|
21
13
|
* @public */
|
|
22
14
|
export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
|
|
23
15
|
|
|
16
|
+
/** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread
|
|
17
|
+
* id), or null. Editor state rather than component state: the stack pin remounts when its owning
|
|
18
|
+
* render path changes (e.g. a member thread opens), and the open list must survive that.
|
|
19
|
+
* @internal */
|
|
20
|
+
export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
|
|
21
|
+
|
|
24
22
|
/** The comment currently being placed (composer open, not yet posted), or null.
|
|
25
|
-
* @
|
|
23
|
+
* @internal */
|
|
26
24
|
export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
|
|
27
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A pending request to reveal a thread: a thread or comment id to open and bring into view, or
|
|
28
|
+
* null when none is pending. Written by {@link revealThread}; served and cleared by
|
|
29
|
+
* `CanvasComments`, which owns the wait for the records to sync in and the cluster-aware reveal.
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it
|
|
36
|
+
* to jump to a thread from outside the canvas — a notification, a deep link, your own list.
|
|
37
|
+
*
|
|
38
|
+
* The request is served by `CanvasComments`, so it works before the records have arrived: the
|
|
39
|
+
* layer waits for them to sync in, switches pages if it needs to, unhides pins, zooms in far
|
|
40
|
+
* enough to split the thread out of any cluster it's folded into, and then opens it. That also
|
|
41
|
+
* means nothing happens if `CanvasComments` isn't mounted.
|
|
42
|
+
*
|
|
43
|
+
* To open a thread you already hold and skip the wait, see {@link focusThread}.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* revealThread(editor, new URLSearchParams(location.search).get('comment')!)
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
export function revealThread(editor: Editor, threadOrCommentId: string): void {
|
|
53
|
+
revealThreadRequest.set(editor, threadOrCommentId)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
|
|
58
|
+
* yet, or null when there's nothing outstanding. A request also clears when `CanvasComments`
|
|
59
|
+
* unmounts, since nothing is left to serve it.
|
|
60
|
+
*
|
|
61
|
+
* This is a plain, untracked read. In React, use {@link useRevealThreadPending} — but reach for
|
|
62
|
+
* this one inside a timer or callback that needs the value as of *now* rather than as of the
|
|
63
|
+
* render it closed over.
|
|
64
|
+
*
|
|
65
|
+
* @public
|
|
66
|
+
*/
|
|
67
|
+
export function getRevealThreadPending(editor: Editor): string | null {
|
|
68
|
+
return revealThreadRequest.get(editor)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reactive React hook for {@link getRevealThreadPending}.
|
|
73
|
+
*
|
|
74
|
+
* Use it to notice a reveal that never lands — most often a deep link to a comment that has since
|
|
75
|
+
* been deleted. Give it a grace period before you act: a request also sits here while its records
|
|
76
|
+
* are still syncing in, which is the normal case on a cold load. Re-check with
|
|
77
|
+
* {@link getRevealThreadPending} when the grace period elapses, since the request can clear inside
|
|
78
|
+
* it without this hook's value having caught up yet.
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
export function useRevealThreadPending(): string | null {
|
|
83
|
+
const editor = useEditor()
|
|
84
|
+
return useValue('pending reveal thread', () => getRevealThreadPending(editor), [editor])
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
/** The region rectangle being dragged out right now (page coords), or null when not dragging. The
|
|
29
88
|
* comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
|
|
30
89
|
export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
|
|
@@ -36,6 +95,15 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
|
|
|
36
95
|
*/
|
|
37
96
|
export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
|
|
38
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether the comments sidebar (the thread list) is open. Driven by an explicit control — a button
|
|
100
|
+
* next to Share on dotcom — rather than by which tool is active, so browsing threads is separate
|
|
101
|
+
* from placing them. The comment tool additionally closes it on enter, keeping placement
|
|
102
|
+
* canvas-focused.
|
|
103
|
+
* @public
|
|
104
|
+
*/
|
|
105
|
+
export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
|
|
106
|
+
|
|
39
107
|
/** Which threads the comments sidebar shows.
|
|
40
108
|
* @public */
|
|
41
109
|
export const sidebarFilters = new EditorAtom<SidebarFilters>(
|
|
@@ -49,6 +117,12 @@ export function toggleCommentsHidden(editor: Editor): void {
|
|
|
49
117
|
commentsHidden.update(editor, (hidden) => !hidden)
|
|
50
118
|
}
|
|
51
119
|
|
|
120
|
+
/** Open or close the comments sidebar for an editor.
|
|
121
|
+
* @public */
|
|
122
|
+
export function toggleCommentsSidebar(editor: Editor): void {
|
|
123
|
+
commentsSidebarOpen.update(editor, (open) => !open)
|
|
124
|
+
}
|
|
125
|
+
|
|
52
126
|
/** React hook for the open thread id.
|
|
53
127
|
* @public */
|
|
54
128
|
export function useOpenThreadId(): string | null {
|
|
@@ -57,7 +131,7 @@ export function useOpenThreadId(): string | null {
|
|
|
57
131
|
}
|
|
58
132
|
|
|
59
133
|
/** React hook for the pending (being-placed) comment.
|
|
60
|
-
* @
|
|
134
|
+
* @internal */
|
|
61
135
|
export function usePendingComment(): PendingComment | null {
|
|
62
136
|
const editor = useEditor()
|
|
63
137
|
return useValue('pending comment', () => pendingComment.get(editor), [editor])
|
|
@@ -70,33 +144,16 @@ export function useCommentsHidden(): boolean {
|
|
|
70
144
|
return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
71
145
|
}
|
|
72
146
|
|
|
147
|
+
/** React hook for whether the comments sidebar is open.
|
|
148
|
+
* @public */
|
|
149
|
+
export function useCommentsSidebarOpen(): boolean {
|
|
150
|
+
const editor = useEditor()
|
|
151
|
+
return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
|
152
|
+
}
|
|
153
|
+
|
|
73
154
|
/** React hook for the current sidebar filters.
|
|
74
155
|
* @public */
|
|
75
156
|
export function useSidebarFilters(): SidebarFilters {
|
|
76
157
|
const editor = useEditor()
|
|
77
158
|
return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
|
78
159
|
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Commit a comment mutation with the configured undo/redo behavior. All comment writes go through
|
|
82
|
-
* here so the {@link CommentingOptions.history} option (and {@link CommentingOptions.dragHistory}
|
|
83
|
-
* for pin re-anchors) governs whether they land on the undo stack. Defaults to `'ignore'`.
|
|
84
|
-
* @public
|
|
85
|
-
*/
|
|
86
|
-
export function commitCommentMutation<T>(
|
|
87
|
-
editor: Editor,
|
|
88
|
-
fn: () => T,
|
|
89
|
-
kind: 'mutation' | 'drag' = 'mutation'
|
|
90
|
-
): T {
|
|
91
|
-
const options = getCommentingOptions(editor)
|
|
92
|
-
const history: TLHistoryBatchOptions['history'] =
|
|
93
|
-
kind === 'drag' ? (options.dragHistory ?? options.history) : options.history
|
|
94
|
-
let result: T
|
|
95
|
-
editor.run(
|
|
96
|
-
() => {
|
|
97
|
-
result = fn()
|
|
98
|
-
},
|
|
99
|
-
{ history }
|
|
100
|
-
)
|
|
101
|
-
return result!
|
|
102
|
-
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { TLComment, TLCommentThread } from 'tldraw'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { selectPreviewCards, sortThreadsForPreview } from './thread-preview'
|
|
4
|
+
|
|
5
|
+
function thread(id: string, createdAt: number): TLCommentThread {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
typeName: 'comment-thread',
|
|
9
|
+
pageId: 'page:one',
|
|
10
|
+
anchor: { type: 'point', x: 0, y: 0 },
|
|
11
|
+
createdBy: 'user:1',
|
|
12
|
+
createdAt,
|
|
13
|
+
resolved: null,
|
|
14
|
+
meta: {},
|
|
15
|
+
} as unknown as TLCommentThread
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function comment(threadId: string): TLComment {
|
|
19
|
+
return { id: `comment:${threadId}`, typeName: 'comment', threadId } as unknown as TLComment
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stands in for the store lookup: only the named threads have their opening comment yet. */
|
|
23
|
+
function arrived(...threadIds: string[]) {
|
|
24
|
+
return (t: TLCommentThread) => (threadIds.includes(t.id) ? comment(t.id) : undefined)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('sortThreadsForPreview', () => {
|
|
28
|
+
it('orders threads oldest first', () => {
|
|
29
|
+
const sorted = sortThreadsForPreview([thread('t2', 20), thread('t1', 10), thread('t3', 30)])
|
|
30
|
+
expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('breaks creation-time ties by id, matching the stack ordering', () => {
|
|
34
|
+
const sorted = sortThreadsForPreview([thread('t3', 5), thread('t1', 5), thread('t2', 5)])
|
|
35
|
+
expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// A cluster hands over `node.members`, which the clustering table sorts by id — the preview has
|
|
39
|
+
// to re-sort rather than inherit that order, or a cluster would read newest-first at random.
|
|
40
|
+
it('reorders an id-sorted cluster membership by creation time', () => {
|
|
41
|
+
const sorted = sortThreadsForPreview([thread('t1', 300), thread('t2', 200), thread('t3', 100)])
|
|
42
|
+
expect(sorted.map((t) => t.id)).toEqual(['t3', 't2', 't1'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not mutate the input', () => {
|
|
46
|
+
const input = [thread('t2', 20), thread('t1', 10)]
|
|
47
|
+
sortThreadsForPreview(input)
|
|
48
|
+
expect(input.map((t) => t.id)).toEqual(['t2', 't1'])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('selectPreviewCards', () => {
|
|
53
|
+
it('pairs each thread with its opening comment', () => {
|
|
54
|
+
const threads = [thread('t1', 10), thread('t2', 20)]
|
|
55
|
+
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2'))
|
|
56
|
+
expect(cards.map((c) => [c.thread.id, c.first.id])).toEqual([
|
|
57
|
+
['t1', 'comment:t1'],
|
|
58
|
+
['t2', 'comment:t2'],
|
|
59
|
+
])
|
|
60
|
+
expect(overflow).toBe(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// A thread record can sync ahead of its first comment. It has nothing to show, and rendering it
|
|
64
|
+
// anyway would paint a blank card — or, for a single-pin preview, a blank panel.
|
|
65
|
+
it('drops threads whose opening comment has not arrived', () => {
|
|
66
|
+
const threads = [thread('t1', 10), thread('t2', 20), thread('t3', 30)]
|
|
67
|
+
const { cards } = selectPreviewCards(threads, arrived('t1', 't3'))
|
|
68
|
+
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't3'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('reports nothing to show when no comment has arrived', () => {
|
|
72
|
+
const { cards, overflow } = selectPreviewCards([thread('t1', 10)], arrived())
|
|
73
|
+
expect(cards).toEqual([])
|
|
74
|
+
expect(overflow).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('caps the cards and counts the rest as overflow', () => {
|
|
78
|
+
const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
|
|
79
|
+
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3', 't4'), 2)
|
|
80
|
+
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
|
|
81
|
+
expect(overflow).toBe(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// "+N more" should promise N readable threads, so unarrived ones can't pad the count.
|
|
85
|
+
it('excludes unarrived threads from the overflow tally', () => {
|
|
86
|
+
const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
|
|
87
|
+
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3'), 2)
|
|
88
|
+
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
|
|
89
|
+
expect(overflow).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
})
|