@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d
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 +1152 -350
- package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
- package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
- package/dist-cjs/{ui/avatar.js → canvas/canvas-events.js} +14 -22
- 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 +53 -3
- 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-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 +152 -0
- package/dist-cjs/canvas/comment-mutations.js.map +7 -0
- package/dist-cjs/canvas/comment-reactions.js +160 -0
- package/dist-cjs/canvas/comment-reactions.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +9 -6
- 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 +63 -14
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +17 -37
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +151 -989
- package/dist-cjs/canvas/comments-overlay.js.map +3 -3
- package/dist-cjs/canvas/comments-sidebar.js +49 -41
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +45 -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 +28 -3
- 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 +57 -2
- 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 +81 -0
- package/dist-cjs/canvas/pin-stacking.js.map +7 -0
- package/dist-cjs/canvas/region-box.js +113 -0
- package/dist-cjs/canvas/region-box.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-pin.js +304 -0
- package/dist-cjs/canvas/thread-pin.js.map +7 -0
- package/dist-cjs/canvas/thread-preview.js +177 -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 +82 -21
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +450 -0
- package/dist-cjs/canvas/thread-view.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +6 -6
- 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 +126 -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 +904 -353
- package/dist-cjs/index.js +52 -28
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/byline.js +10 -6
- 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 +124 -39
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/comment-pin.js +11 -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 +39 -40
- 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 +16 -1
- 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 +71 -0
- package/dist-cjs/ui/reaction-picker.js.map +7 -0
- package/dist-cjs/ui/reaction.js +58 -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 +13 -1
- package/dist-cjs/ui/send-button.js.map +2 -2
- package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
- package/dist-cjs/ui/visual-viewport.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/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 +54 -4
- 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-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 +132 -0
- package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
- package/dist-esm/canvas/comment-reactions.mjs +145 -0
- package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +10 -7
- 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 +66 -17
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +157 -1010
- package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
- package/dist-esm/canvas/comments-sidebar.mjs +53 -46
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -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 +35 -5
- 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 +59 -3
- 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 +61 -0
- package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
- package/dist-esm/canvas/region-box.mjs +93 -0
- package/dist-esm/canvas/region-box.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-pin.mjs +310 -0
- package/dist-esm/canvas/thread-pin.mjs.map +7 -0
- package/dist-esm/canvas/thread-preview.mjs +163 -0
- package/dist-esm/canvas/thread-preview.mjs.map +7 -0
- package/dist-esm/canvas/thread-stack.mjs +189 -0
- package/dist-esm/canvas/thread-stack.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +86 -22
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +461 -0
- package/dist-esm/canvas/thread-view.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +6 -6
- 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 +126 -7
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.d.mts +904 -353
- package/dist-esm/index.mjs +102 -43
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/byline.mjs +11 -7
- 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 +135 -39
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/comment-pin.mjs +11 -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 +51 -50
- 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 +16 -1
- 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 +59 -0
- package/dist-esm/ui/reaction-picker.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +59 -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 +13 -1
- 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 +6 -6
- package/src/canvas/anchor-lifecycle.test.ts +290 -0
- package/src/canvas/anchor-lifecycle.ts +191 -0
- package/src/canvas/canvas-events.ts +18 -0
- package/src/canvas/canvas.css +464 -55
- package/src/canvas/cluster-badge.tsx +133 -0
- package/src/canvas/cluster-fade.ts +102 -0
- package/src/canvas/cluster-input.test.ts +178 -36
- package/src/canvas/cluster-input.ts +95 -9
- package/src/canvas/cluster-model.ts +378 -0
- 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 +452 -0
- package/src/canvas/comment-mutations.ts +299 -0
- package/src/canvas/comment-reactions.test.ts +154 -0
- package/src/canvas/comment-reactions.tsx +246 -0
- package/src/canvas/comment-render.test.ts +1 -1
- package/src/canvas/comment-render.ts +17 -13
- package/src/canvas/comment-store.ts +68 -33
- package/src/canvas/comment-tool.test.ts +162 -0
- package/src/canvas/comment-tool.tsx +103 -25
- package/src/canvas/comments-filter-menu.tsx +20 -36
- package/src/canvas/comments-overflow-menu.tsx +17 -19
- package/src/canvas/comments-overlay.tsx +252 -1369
- package/src/canvas/comments-sidebar.test.ts +45 -0
- package/src/canvas/comments-sidebar.tsx +112 -73
- package/src/canvas/comments-visibility-toggle.tsx +25 -0
- package/src/canvas/context.ts +48 -0
- package/src/canvas/hooks.test.ts +94 -0
- package/src/canvas/hooks.ts +66 -11
- package/src/canvas/license.ts +1 -1
- package/src/canvas/mobile-placement.ts +115 -0
- package/src/canvas/options.test.ts +176 -4
- package/src/canvas/options.ts +276 -18
- package/src/canvas/pending-composer.tsx +157 -0
- package/src/canvas/pin-stacking.test.ts +224 -0
- package/src/canvas/pin-stacking.ts +103 -0
- package/src/canvas/region-box.tsx +124 -0
- package/src/canvas/sidebar-filters.ts +4 -3
- package/src/canvas/state.ts +88 -39
- package/src/canvas/thread-pin.tsx +418 -0
- package/src/canvas/thread-preview.test.ts +91 -0
- package/src/canvas/thread-preview.tsx +294 -0
- package/src/canvas/thread-stack.tsx +242 -0
- package/src/canvas/thread-state.test.ts +260 -0
- package/src/canvas/thread-state.ts +171 -37
- package/src/canvas/thread-view.test.ts +72 -0
- package/src/canvas/thread-view.tsx +614 -0
- package/src/clustering/computeClusterTable.test.ts +24 -23
- package/src/clustering/computeClusterTable.ts +17 -8
- package/src/clustering/replay.test.ts +0 -7
- package/src/clustering/replay.ts +131 -32
- package/src/clustering/runtime.test.ts +267 -13
- package/src/clustering/runtime.ts +176 -19
- package/src/clustering/schedule.test.ts +0 -6
- package/src/clustering/screen-offsets.test.ts +171 -0
- package/src/clustering/types.ts +19 -9
- package/src/index.ts +91 -42
- package/src/ui/byline.tsx +21 -9
- package/src/ui/comment-card.tsx +20 -10
- package/src/ui/comment-composer.tsx +178 -56
- package/src/ui/comment-pin.tsx +13 -2
- package/src/ui/comment-thread.tsx +13 -6
- package/src/ui/comments-list.test.ts +31 -0
- package/src/ui/comments-list.tsx +82 -47
- package/src/ui/comments.css +577 -294
- 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.test.ts +69 -0
- package/src/ui/format-time.ts +27 -2
- package/src/ui/icons.tsx +116 -0
- package/src/ui/reaction-picker.tsx +102 -0
- package/src/ui/reaction.tsx +125 -6
- package/src/ui/reactions.tsx +69 -10
- package/src/ui/reply-count.ts +16 -0
- package/src/ui/send-button.tsx +13 -3
- package/src/ui/visual-viewport.test.ts +36 -0
- package/src/ui/visual-viewport.ts +27 -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.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/state.ts
CHANGED
|
@@ -1,30 +1,83 @@
|
|
|
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
|
/**
|
|
14
|
-
* Transient commenting UI state, scoped per editor via {@link EditorAtom}
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* and the comment tool (`this.editor`).
|
|
6
|
+
* Transient commenting UI state, scoped per editor via {@link EditorAtom} so two editors on one
|
|
7
|
+
* page don't share open-thread, visibility, and filter state. Reachable from both React
|
|
8
|
+
* (`useEditor()`) and the comment tool (`this.editor`).
|
|
18
9
|
*/
|
|
19
10
|
|
|
20
11
|
/** The id of the one open thread (only one popover is open at a time), or null when all closed.
|
|
21
12
|
* @public */
|
|
22
13
|
export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
|
|
23
14
|
|
|
15
|
+
/** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread id),
|
|
16
|
+
* or null. Editor state rather than component state: the stack pin remounts when its owning render
|
|
17
|
+
* path changes, and the open list must survive that.
|
|
18
|
+
* @internal */
|
|
19
|
+
export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
|
|
20
|
+
|
|
24
21
|
/** The comment currently being placed (composer open, not yet posted), or null.
|
|
25
|
-
* @
|
|
22
|
+
* @internal */
|
|
26
23
|
export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
|
|
27
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A pending request to reveal a thread: a thread or comment id to open and bring into view, or
|
|
27
|
+
* null when none is pending. Written by {@link revealThread}; served and cleared by
|
|
28
|
+
* `CanvasComments`, which owns the wait for the records to sync in and the cluster-aware reveal.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it to
|
|
35
|
+
* jump to a thread from outside the canvas — a notification, a deep link, your own list.
|
|
36
|
+
*
|
|
37
|
+
* The request is served by `CanvasComments`, so it works before the records have arrived: the layer
|
|
38
|
+
* waits for them, switches pages, unhides pins, and zooms far enough to split the thread out of any
|
|
39
|
+
* cluster. That also means nothing happens if `CanvasComments` isn't mounted.
|
|
40
|
+
*
|
|
41
|
+
* To open a thread you already hold and skip the wait, see {@link focusThread}.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* revealThread(editor, new URLSearchParams(location.search).get('comment')!)
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export function revealThread(editor: Editor, threadOrCommentId: string): void {
|
|
51
|
+
revealThreadRequest.set(editor, threadOrCommentId)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
|
|
56
|
+
* yet, or null. A request also clears when `CanvasComments` unmounts.
|
|
57
|
+
*
|
|
58
|
+
* This is a plain, untracked read — in React, use {@link useRevealThreadPending}, unless you need
|
|
59
|
+
* the value as of *now* rather than as of the render you closed over.
|
|
60
|
+
*
|
|
61
|
+
* @public
|
|
62
|
+
*/
|
|
63
|
+
export function getRevealThreadPending(editor: Editor): string | null {
|
|
64
|
+
return revealThreadRequest.get(editor)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reactive React hook for {@link getRevealThreadPending}.
|
|
69
|
+
*
|
|
70
|
+
* Use it to notice a reveal that never lands — usually a deep link to a deleted comment. Give it a
|
|
71
|
+
* grace period first, since a request also sits here while its records sync in, and re-check with
|
|
72
|
+
* {@link getRevealThreadPending} when it elapses.
|
|
73
|
+
*
|
|
74
|
+
* @public
|
|
75
|
+
*/
|
|
76
|
+
export function useRevealThreadPending(): string | null {
|
|
77
|
+
const editor = useEditor()
|
|
78
|
+
return useValue('pending reveal thread', () => getRevealThreadPending(editor), [editor])
|
|
79
|
+
}
|
|
80
|
+
|
|
28
81
|
/** The region rectangle being dragged out right now (page coords), or null when not dragging. The
|
|
29
82
|
* comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
|
|
30
83
|
export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
|
|
@@ -36,6 +89,13 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
|
|
|
36
89
|
*/
|
|
37
90
|
export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
|
|
38
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Whether the comments sidebar (the thread list) is open. Driven by an explicit control rather than
|
|
94
|
+
* by which tool is active, so browsing threads is separate from placing them.
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
|
|
98
|
+
|
|
39
99
|
/** Which threads the comments sidebar shows.
|
|
40
100
|
* @public */
|
|
41
101
|
export const sidebarFilters = new EditorAtom<SidebarFilters>(
|
|
@@ -49,6 +109,12 @@ export function toggleCommentsHidden(editor: Editor): void {
|
|
|
49
109
|
commentsHidden.update(editor, (hidden) => !hidden)
|
|
50
110
|
}
|
|
51
111
|
|
|
112
|
+
/** Open or close the comments sidebar for an editor.
|
|
113
|
+
* @public */
|
|
114
|
+
export function toggleCommentsSidebar(editor: Editor): void {
|
|
115
|
+
commentsSidebarOpen.update(editor, (open) => !open)
|
|
116
|
+
}
|
|
117
|
+
|
|
52
118
|
/** React hook for the open thread id.
|
|
53
119
|
* @public */
|
|
54
120
|
export function useOpenThreadId(): string | null {
|
|
@@ -57,7 +123,7 @@ export function useOpenThreadId(): string | null {
|
|
|
57
123
|
}
|
|
58
124
|
|
|
59
125
|
/** React hook for the pending (being-placed) comment.
|
|
60
|
-
* @
|
|
126
|
+
* @internal */
|
|
61
127
|
export function usePendingComment(): PendingComment | null {
|
|
62
128
|
const editor = useEditor()
|
|
63
129
|
return useValue('pending comment', () => pendingComment.get(editor), [editor])
|
|
@@ -70,33 +136,16 @@ export function useCommentsHidden(): boolean {
|
|
|
70
136
|
return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
71
137
|
}
|
|
72
138
|
|
|
139
|
+
/** React hook for whether the comments sidebar is open.
|
|
140
|
+
* @public */
|
|
141
|
+
export function useCommentsSidebarOpen(): boolean {
|
|
142
|
+
const editor = useEditor()
|
|
143
|
+
return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
|
144
|
+
}
|
|
145
|
+
|
|
73
146
|
/** React hook for the current sidebar filters.
|
|
74
147
|
* @public */
|
|
75
148
|
export function useSidebarFilters(): SidebarFilters {
|
|
76
149
|
const editor = useEditor()
|
|
77
150
|
return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
|
78
151
|
}
|
|
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,418 @@
|
|
|
1
|
+
import { Avatar } from '@tldraw/mentions'
|
|
2
|
+
import {
|
|
3
|
+
memo,
|
|
4
|
+
type PointerEvent as ReactPointerEvent,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {
|
|
11
|
+
type BoxModel,
|
|
12
|
+
Editor,
|
|
13
|
+
TLCommentThread,
|
|
14
|
+
useContainer,
|
|
15
|
+
usePassThroughWheelEvents,
|
|
16
|
+
useTranslation,
|
|
17
|
+
useValue,
|
|
18
|
+
} from 'tldraw'
|
|
19
|
+
import { CommentPin } from '../ui/comment-pin'
|
|
20
|
+
import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
|
|
21
|
+
import { commitCommentMutation } from './comment-mutations'
|
|
22
|
+
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
23
|
+
import { type CommentingContext } from './context'
|
|
24
|
+
import { useThreadComments } from './hooks'
|
|
25
|
+
import { getCommentingOptions, useCanComment, useCommentingOptions } from './options'
|
|
26
|
+
import {
|
|
27
|
+
REGION_CORNERS,
|
|
28
|
+
REGION_HANDLE_MARGIN_PX,
|
|
29
|
+
RegionBox,
|
|
30
|
+
RegionResizeHandles,
|
|
31
|
+
} from './region-box'
|
|
32
|
+
import { openThreadId } from './state'
|
|
33
|
+
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
|
34
|
+
import {
|
|
35
|
+
anchorPagePoint,
|
|
36
|
+
commentTargetShapeAt,
|
|
37
|
+
impreciseShapePinInset,
|
|
38
|
+
isBoxInInflatedViewport,
|
|
39
|
+
isInInflatedViewport,
|
|
40
|
+
REGION_PIN_CORNER,
|
|
41
|
+
regionAnchorPinCorner,
|
|
42
|
+
regionPinPoint,
|
|
43
|
+
shapeAnchorAt,
|
|
44
|
+
} from './thread-state'
|
|
45
|
+
import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
|
46
|
+
|
|
47
|
+
/** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
|
|
48
|
+
* first comment then lands where the preview's sat. Re-measure if the header height or the preview
|
|
49
|
+
* panel padding changes. */
|
|
50
|
+
const THREAD_HEADER_BLOCK = 36
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A single thread's pin: the marker at its anchor, the thread popover when it's open, the hover
|
|
54
|
+
* preview, and — for a region anchor — the dashed box and its resize handles. Dragging the marker
|
|
55
|
+
* re-anchors the thread.
|
|
56
|
+
*
|
|
57
|
+
* Memoized so a pin skips re-rendering when the layer re-renders for unrelated reasons; camera
|
|
58
|
+
* tracking still works, since the pin subscribes to its own viewport position via signals.
|
|
59
|
+
*/
|
|
60
|
+
export const ThreadPin = memo(function ThreadPin({
|
|
61
|
+
editor,
|
|
62
|
+
thread,
|
|
63
|
+
...props
|
|
64
|
+
}: CommentingContext & {
|
|
65
|
+
editor: Editor
|
|
66
|
+
thread: TLCommentThread
|
|
67
|
+
}) {
|
|
68
|
+
const { resolveAuthor } = props
|
|
69
|
+
const options = useCommentingOptions()
|
|
70
|
+
const canComment = useCanComment(props.currentUserId)
|
|
71
|
+
const container = useContainer()
|
|
72
|
+
const msg = useTranslation()
|
|
73
|
+
const comments = useThreadComments(editor, thread.id)
|
|
74
|
+
// Only one thread's popover is open at a time — shared across pins via the atom.
|
|
75
|
+
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
|
76
|
+
editor,
|
|
77
|
+
thread.id,
|
|
78
|
+
])
|
|
79
|
+
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
|
80
|
+
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
|
81
|
+
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
|
82
|
+
// Hovering the marker previews the thread's opening comment, on the delay every marker uses.
|
|
83
|
+
const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
|
|
84
|
+
const previewThreads = useMemo(() => [thread], [thread])
|
|
85
|
+
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
|
86
|
+
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
|
87
|
+
// handle never loses the affordance — the box stays `pointer-events: none`.
|
|
88
|
+
const pointerInRegion = useValue(
|
|
89
|
+
'pointer in region',
|
|
90
|
+
() => {
|
|
91
|
+
if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
|
|
92
|
+
return false
|
|
93
|
+
const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
|
|
94
|
+
const p = editor.inputs.getCurrentPagePoint()
|
|
95
|
+
const a = thread.anchor
|
|
96
|
+
return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
|
|
97
|
+
},
|
|
98
|
+
[editor, thread.anchor, thread.pageId]
|
|
99
|
+
)
|
|
100
|
+
// A region's box and handles are revealed while open, mid-resize, or while the pointer is
|
|
101
|
+
// within the region.
|
|
102
|
+
const revealed = open || resizeBounds != null || pointerInRegion
|
|
103
|
+
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
|
104
|
+
// the default as the fallback for older records.
|
|
105
|
+
const pinCorner =
|
|
106
|
+
thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
|
|
107
|
+
// A region resizes from its corners — every corner but the pin's own, which the pin owns.
|
|
108
|
+
const resizeHandles = useMemo(
|
|
109
|
+
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
|
110
|
+
[pinCorner]
|
|
111
|
+
)
|
|
112
|
+
const dragRef = useRef<{
|
|
113
|
+
startX: number
|
|
114
|
+
startY: number
|
|
115
|
+
moved: boolean
|
|
116
|
+
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
|
117
|
+
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
|
118
|
+
offsetX: number
|
|
119
|
+
offsetY: number
|
|
120
|
+
} | null>(null)
|
|
121
|
+
const markerRef = useRef<HTMLButtonElement>(null)
|
|
122
|
+
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
|
123
|
+
// see the note on the layer.
|
|
124
|
+
usePassThroughWheelEvents(markerRef)
|
|
125
|
+
|
|
126
|
+
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
|
127
|
+
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
|
128
|
+
// clear the hint here or it stays on the shape indefinitely.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
return () => {
|
|
131
|
+
if (dragRef.current) editor.setHintingShapes([])
|
|
132
|
+
}
|
|
133
|
+
}, [editor])
|
|
134
|
+
|
|
135
|
+
// Clicking outside the open popover closes the thread. Capture phase + a class check rather than
|
|
136
|
+
// stopPropagation, since the popover portals elsewhere in the DOM. The marker is excluded so its own
|
|
137
|
+
// click-to-toggle handles it instead of this closing and the toggle reopening.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open) return
|
|
140
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
141
|
+
const target = e.target as HTMLElement | null
|
|
142
|
+
if (!target) return
|
|
143
|
+
if (target.closest('.tlui-cmt-canvas-popover')) return
|
|
144
|
+
const marker = markerRef.current
|
|
145
|
+
if (marker && marker.contains(target)) return
|
|
146
|
+
// A press on a region's resize handle edits this thread — don't dismiss it.
|
|
147
|
+
if (target.closest('.tlui-cmt-canvas-region-handle')) return
|
|
148
|
+
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
|
149
|
+
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
|
150
|
+
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
|
151
|
+
if (
|
|
152
|
+
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
openThreadId.set(editor, null)
|
|
156
|
+
}
|
|
157
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
158
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
159
|
+
}, [open, editor])
|
|
160
|
+
|
|
161
|
+
// The pin must not unmount mid-interaction: an open thread's popover hangs off it, and a drag
|
|
162
|
+
// or resize holds pointer capture on it — so those states are exempt from the viewport cull.
|
|
163
|
+
const exemptFromCull = open || dragPagePoint != null || resizeBounds != null
|
|
164
|
+
const point = useValue(
|
|
165
|
+
'pin point',
|
|
166
|
+
() => {
|
|
167
|
+
if (thread.pageId !== editor.getCurrentPageId()) return null
|
|
168
|
+
const pagePoint = anchorPagePoint(editor, thread.anchor)
|
|
169
|
+
if (!pagePoint) return null
|
|
170
|
+
const viewportPoint = editor.pageToViewport(pagePoint)
|
|
171
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
172
|
+
const point = inset
|
|
173
|
+
? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y }
|
|
174
|
+
: viewportPoint
|
|
175
|
+
// Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
|
|
176
|
+
// region thread stays mounted while any part of its box is on screen, since the box renders here too.
|
|
177
|
+
if (!exemptFromCull) {
|
|
178
|
+
const visible =
|
|
179
|
+
thread.anchor.type === 'region'
|
|
180
|
+
? isBoxInInflatedViewport(editor, thread.anchor)
|
|
181
|
+
: isInInflatedViewport(editor, point)
|
|
182
|
+
if (!visible) return null
|
|
183
|
+
}
|
|
184
|
+
return point
|
|
185
|
+
},
|
|
186
|
+
[editor, thread.anchor, thread.pageId, exemptFromCull]
|
|
187
|
+
)
|
|
188
|
+
if (!point) return null
|
|
189
|
+
|
|
190
|
+
const PinContent = options.components.PinContent
|
|
191
|
+
const threadAuthor = resolveAuthor(thread.createdBy)
|
|
192
|
+
const pinContent = PinContent ? (
|
|
193
|
+
<PinContent thread={thread} comments={comments} />
|
|
194
|
+
) : (
|
|
195
|
+
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
|
196
|
+
)
|
|
197
|
+
const pinLabel = msg(
|
|
198
|
+
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
|
199
|
+
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
|
200
|
+
|
|
201
|
+
// Drag the marker to move the thread: position is overridden locally while dragging, then re-anchored
|
|
202
|
+
// on drop. A region translates keeping its size; a barely-moved pointer is a click.
|
|
203
|
+
const isRegion = thread.anchor.type === 'region'
|
|
204
|
+
// The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
|
|
205
|
+
const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
206
|
+
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
|
207
|
+
// hand it to the canvas untouched.
|
|
208
|
+
if (isCanvasPanGesture(editor, e)) {
|
|
209
|
+
forwardPointerEventToCanvas(container, e)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
e.stopPropagation()
|
|
213
|
+
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
214
|
+
const anchorPage = anchorPagePoint(editor, thread.anchor)
|
|
215
|
+
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
|
216
|
+
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
|
217
|
+
const inset = impreciseShapePinInset(editor, thread.anchor)
|
|
218
|
+
if (anchorPage && inset) {
|
|
219
|
+
const zoom = editor.getZoomLevel()
|
|
220
|
+
anchorPage.x += inset.x / zoom
|
|
221
|
+
anchorPage.y += inset.y / zoom
|
|
222
|
+
}
|
|
223
|
+
dragRef.current = {
|
|
224
|
+
startX: e.clientX,
|
|
225
|
+
startY: e.clientY,
|
|
226
|
+
moved: false,
|
|
227
|
+
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
|
228
|
+
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
|
229
|
+
}
|
|
230
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
231
|
+
}
|
|
232
|
+
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
233
|
+
const drag = dragRef.current
|
|
234
|
+
if (!drag) return
|
|
235
|
+
// Moving a pin re-anchors the thread record — a commenting write. Without the permission the
|
|
236
|
+
// press stays a click (`moved` never sets, so release toggles the popover and never commits).
|
|
237
|
+
if (!canComment) return
|
|
238
|
+
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
|
239
|
+
drag.moved = true
|
|
240
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
241
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
242
|
+
setDragPagePoint(pagePoint)
|
|
243
|
+
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
|
244
|
+
// with. Regions translate rather than re-anchor, so they never hint.
|
|
245
|
+
if (!isRegion) {
|
|
246
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
247
|
+
editor.setHintingShapes(hit ? [hit.id] : [])
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
|
251
|
+
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
|
252
|
+
const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
253
|
+
const drag = dragRef.current
|
|
254
|
+
dragRef.current = null
|
|
255
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
256
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
257
|
+
}
|
|
258
|
+
if (!drag) return
|
|
259
|
+
setDragPagePoint(null)
|
|
260
|
+
editor.setHintingShapes([])
|
|
261
|
+
}
|
|
262
|
+
const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
263
|
+
const drag = dragRef.current
|
|
264
|
+
dragRef.current = null
|
|
265
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
266
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
267
|
+
}
|
|
268
|
+
if (!drag) return
|
|
269
|
+
editor.setHintingShapes([])
|
|
270
|
+
if (!drag.moved) {
|
|
271
|
+
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
|
275
|
+
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
|
276
|
+
setDragPagePoint(null)
|
|
277
|
+
let anchor: TLCommentThread['anchor']
|
|
278
|
+
if (thread.anchor.type === 'region') {
|
|
279
|
+
// Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
|
|
280
|
+
anchor = {
|
|
281
|
+
...thread.anchor,
|
|
282
|
+
x: pagePoint.x - pinCorner.x * thread.anchor.w,
|
|
283
|
+
y: pagePoint.y - pinCorner.y * thread.anchor.h,
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
const hit = commentTargetShapeAt(editor, pagePoint)
|
|
287
|
+
anchor = hit
|
|
288
|
+
? shapeAnchorAt(
|
|
289
|
+
editor,
|
|
290
|
+
hit.id,
|
|
291
|
+
pagePoint,
|
|
292
|
+
getCommentingOptions(editor).shouldBePrecise(editor, {
|
|
293
|
+
shapeId: hit.id,
|
|
294
|
+
point: pagePoint,
|
|
295
|
+
altKey: e.altKey,
|
|
296
|
+
})
|
|
297
|
+
)
|
|
298
|
+
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
|
299
|
+
}
|
|
300
|
+
commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
|
304
|
+
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
|
305
|
+
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
|
306
|
+
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
307
|
+
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
|
308
|
+
// The marker anchors bottom-left, so step half its 34px size left and down (screen px).
|
|
309
|
+
const renderPoint = isRegion
|
|
310
|
+
? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
|
|
311
|
+
: renderPointBase
|
|
312
|
+
|
|
313
|
+
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
|
314
|
+
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
|
315
|
+
const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
|
|
316
|
+
const movedRegion =
|
|
317
|
+
regionAnchor && dragPagePoint
|
|
318
|
+
? {
|
|
319
|
+
...regionAnchor,
|
|
320
|
+
x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
|
|
321
|
+
y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
|
|
322
|
+
}
|
|
323
|
+
: regionAnchor
|
|
324
|
+
const regionBoxBounds = resizeBounds ?? movedRegion
|
|
325
|
+
const commitResize = (bounds: BoxModel) => {
|
|
326
|
+
setResizeBounds(null)
|
|
327
|
+
if (!canComment) return
|
|
328
|
+
// Same commit path as a pin drag, so the configured `dragHistory` governs both — going
|
|
329
|
+
// straight to `editor.run` here would make region resizes silently ignore the option.
|
|
330
|
+
commitCommentMutation(
|
|
331
|
+
editor,
|
|
332
|
+
// Spread the existing anchor first so the region's pin corner survives a resize.
|
|
333
|
+
({ put }) => put([{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
|
334
|
+
'drag'
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<>
|
|
340
|
+
{regionBoxBounds && (dragPagePoint || revealed) && (
|
|
341
|
+
<RegionBox editor={editor} box={regionBoxBounds} />
|
|
342
|
+
)}
|
|
343
|
+
{regionBoxBounds && revealed && !dragPagePoint && canComment && (
|
|
344
|
+
<RegionResizeHandles
|
|
345
|
+
editor={editor}
|
|
346
|
+
box={regionBoxBounds}
|
|
347
|
+
handles={resizeHandles}
|
|
348
|
+
onPreview={setResizeBounds}
|
|
349
|
+
onCommit={commitResize}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
352
|
+
<div
|
|
353
|
+
className={[
|
|
354
|
+
'tlui-cmt-canvas-pin',
|
|
355
|
+
open && 'tlui-cmt-canvas-pin--open',
|
|
356
|
+
dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
|
|
357
|
+
]
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.join(' ')}
|
|
360
|
+
style={{ left: renderPoint.x, top: renderPoint.y }}
|
|
361
|
+
>
|
|
362
|
+
<button
|
|
363
|
+
ref={markerRef}
|
|
364
|
+
type="button"
|
|
365
|
+
className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
|
|
366
|
+
aria-label={pinLabel}
|
|
367
|
+
aria-expanded={open}
|
|
368
|
+
onPointerDown={startDrag}
|
|
369
|
+
onPointerMove={onDrag}
|
|
370
|
+
onPointerUp={endDrag}
|
|
371
|
+
onPointerCancel={cancelDrag}
|
|
372
|
+
// Pointer activation is already handled by endDrag (which distinguishes a click
|
|
373
|
+
// from a drag), so only take keyboard-synthesised clicks here — those report
|
|
374
|
+
// `detail === 0` — or the thread would toggle twice per mouse click.
|
|
375
|
+
onClick={(e) => {
|
|
376
|
+
if (e.detail !== 0) return
|
|
377
|
+
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
|
378
|
+
}}
|
|
379
|
+
onPointerEnter={previewHandlers.onPointerEnter}
|
|
380
|
+
onPointerLeave={previewHandlers.onPointerLeave}
|
|
381
|
+
// Focus stands in for hover, so tabbing to a marker gets the same preview.
|
|
382
|
+
onFocus={previewHandlers.onPointerEnter}
|
|
383
|
+
onBlur={previewHandlers.onPointerLeave}
|
|
384
|
+
>
|
|
385
|
+
<CommentPin resolved={thread.resolved != null} open={open}>
|
|
386
|
+
{pinContent}
|
|
387
|
+
</CommentPin>
|
|
388
|
+
</button>
|
|
389
|
+
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
|
390
|
+
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
|
391
|
+
{open && (
|
|
392
|
+
<ThreadPopover
|
|
393
|
+
base={{
|
|
394
|
+
x: renderPoint.x + POPOVER_OFFSET.thread.x,
|
|
395
|
+
y: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<ThreadView editor={editor} thread={thread} {...props} />
|
|
399
|
+
</ThreadPopover>
|
|
400
|
+
)}
|
|
401
|
+
{/* Not while dragging: the pin is being moved, not read, and a panel trailing the
|
|
402
|
+
cursor would obscure the drop target. */}
|
|
403
|
+
{previewShown && !dragPagePoint && (
|
|
404
|
+
<ThreadPreview
|
|
405
|
+
editor={editor}
|
|
406
|
+
threads={previewThreads}
|
|
407
|
+
variant="thread"
|
|
408
|
+
point={renderPoint}
|
|
409
|
+
onSelectThread={() => openThreadId.set(editor, thread.id)}
|
|
410
|
+
{...previewHandlers}
|
|
411
|
+
currentUserId={props.currentUserId}
|
|
412
|
+
resolveAuthor={resolveAuthor}
|
|
413
|
+
/>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
</>
|
|
417
|
+
)
|
|
418
|
+
})
|
|
@@ -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
|
+
})
|