@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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Editor, react, TLCommentThread, useValue } from 'tldraw'
|
|
3
|
+
import { computeClusterTable } from '../clustering/computeClusterTable'
|
|
4
|
+
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
|
5
|
+
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
|
6
|
+
import { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'
|
|
7
|
+
import {
|
|
8
|
+
type ClusterInput,
|
|
9
|
+
clusterInputEqual,
|
|
10
|
+
clusterInputIdsEqual,
|
|
11
|
+
collectClusterLeaves,
|
|
12
|
+
} from './cluster-input'
|
|
13
|
+
import { type CommentingOptions } from './options'
|
|
14
|
+
import { openThreadId } from './state'
|
|
15
|
+
import { anchorPagePoint, commentCenterScreenOffset } from './thread-state'
|
|
16
|
+
|
|
17
|
+
/** Duration of the click-a-badge zoom-to-split animation. */
|
|
18
|
+
export const CLUSTER_EXPAND_ZOOM_MS = 450
|
|
19
|
+
/** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
|
|
20
|
+
* lands clear of the threshold it just crossed rather than flickering on it. */
|
|
21
|
+
const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
|
|
22
|
+
|
|
23
|
+
const EMPTY_SET: ReadonlySet<string> = new Set()
|
|
24
|
+
const MOVED_LEAF_EPSILON = 1e-6
|
|
25
|
+
|
|
26
|
+
/** Default select-tool states that move shapes continuously, one store write per pointermove. A
|
|
27
|
+
* custom tool in their place just never matches, falling back to a rebuild per frame. */
|
|
28
|
+
const SHAPE_DRAG_STATE_PATHS = [
|
|
29
|
+
'select.translating',
|
|
30
|
+
'select.resizing',
|
|
31
|
+
'select.rotating',
|
|
32
|
+
'select.dragging_handle',
|
|
33
|
+
'select.crop.cropping',
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the
|
|
37
|
+
* gesture starts or settles. */
|
|
38
|
+
function isShapeDragInProgress(editor: Editor): boolean {
|
|
39
|
+
return editor.isInAny(...SHAPE_DRAG_STATE_PATHS)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The clustering table for the current scene, plus the runtime walking its merge events. */
|
|
43
|
+
export interface ClusterModel {
|
|
44
|
+
runtime: ClusterRuntime
|
|
45
|
+
table: ClusterTable
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ClusterZoomBounds {
|
|
49
|
+
minZoom: number
|
|
50
|
+
maxZoom: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ClusterModelState {
|
|
54
|
+
/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */
|
|
55
|
+
model: ClusterModel
|
|
56
|
+
zoomBounds: ClusterZoomBounds
|
|
57
|
+
/** The displayed nodes, each tagged with its cross-fade phase. */
|
|
58
|
+
fadeNodes: ClusterFadeNode[]
|
|
59
|
+
/** Threads in the input that the displayed partition doesn't show — render them as plain pins. */
|
|
60
|
+
orphanThreads: TLCommentThread[]
|
|
61
|
+
/** Threads held out of clustering because their anchor moved while folded into a badge. */
|
|
62
|
+
heldThreads: TLCommentThread[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The clustering state machine behind the comments layer.
|
|
67
|
+
*
|
|
68
|
+
* The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is
|
|
69
|
+
* computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only
|
|
70
|
+
* changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
|
|
71
|
+
* (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in
|
|
72
|
+
* place and nothing else on the canvas moves.
|
|
73
|
+
*
|
|
74
|
+
* Threads the displayed partition can't represent are returned separately (`orphanThreads`,
|
|
75
|
+
* `heldThreads`) for the layer to draw as ordinary pins.
|
|
76
|
+
*/
|
|
77
|
+
export function useClusterModel(
|
|
78
|
+
editor: Editor,
|
|
79
|
+
threads: readonly TLCommentThread[],
|
|
80
|
+
openId: string | null
|
|
81
|
+
): ClusterModelState {
|
|
82
|
+
// Threads held out of clustering because their anchor moved while folded inside a badge
|
|
83
|
+
// (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
|
|
84
|
+
// as live pins riding their anchor and rejoin clustering on the next zoom-out.
|
|
85
|
+
const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
|
|
86
|
+
const adoptOnRebuild = useRef(false)
|
|
87
|
+
// This input's identity keys the O(N²) table rebuild below, so it's gated on value equality: a
|
|
88
|
+
// reply, a reaction, a resolve — anything that touches comment records without moving a pin —
|
|
89
|
+
// returns the previous input and rebuilds nothing.
|
|
90
|
+
//
|
|
91
|
+
// Mid-drag the gate widens to ignore positions too, and that costs something real: it freezes
|
|
92
|
+
// `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin
|
|
93
|
+
// folded into a badge stops popping out to ride its anchor and corrects on release instead. The
|
|
94
|
+
// per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.
|
|
95
|
+
// An added or deleted thread changes the id set, so it still rebuilds promptly.
|
|
96
|
+
const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
|
|
97
|
+
const clusterInput = useValue(
|
|
98
|
+
'comment cluster leaves',
|
|
99
|
+
() => {
|
|
100
|
+
const next = collectClusterLeaves(
|
|
101
|
+
editor,
|
|
102
|
+
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
|
103
|
+
openThreadId.get(editor)
|
|
104
|
+
)
|
|
105
|
+
const prev = clusterInputRef.current
|
|
106
|
+
if (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev
|
|
107
|
+
if (clusterInputEqual(prev, next)) return prev
|
|
108
|
+
clusterInputRef.current = next
|
|
109
|
+
return next
|
|
110
|
+
},
|
|
111
|
+
[editor, threads, heldThreadIds]
|
|
112
|
+
)
|
|
113
|
+
const clusterZoomBounds = useValue(
|
|
114
|
+
'comment cluster zoom bounds',
|
|
115
|
+
() => getClusterZoomBounds(editor),
|
|
116
|
+
[editor]
|
|
117
|
+
)
|
|
118
|
+
const latestModel = useMemo(() => {
|
|
119
|
+
const table = computeClusterTable(
|
|
120
|
+
clusterInput.leaves,
|
|
121
|
+
clusterZoomBounds,
|
|
122
|
+
clusterInput.screenOffsets
|
|
123
|
+
)
|
|
124
|
+
const runtime = createClusterRuntime(table)
|
|
125
|
+
runtime.seed(editor.getZoomLevel())
|
|
126
|
+
return { runtime, table }
|
|
127
|
+
}, [clusterInput, clusterZoomBounds, editor])
|
|
128
|
+
const [renderedModel, setRenderedModel] = useState(latestModel)
|
|
129
|
+
let clusterModel = renderedModel
|
|
130
|
+
// A page switch replaces the whole scene: hard-reset rather than detach the world.
|
|
131
|
+
const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
|
|
132
|
+
const pageRef = useRef(pageId)
|
|
133
|
+
if (pageRef.current !== pageId) {
|
|
134
|
+
pageRef.current = pageId
|
|
135
|
+
adoptOnRebuild.current = false
|
|
136
|
+
latestModel.runtime.seed(editor.getZoomLevel())
|
|
137
|
+
if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
|
|
138
|
+
setRenderedModel(latestModel)
|
|
139
|
+
clusterModel = latestModel
|
|
140
|
+
}
|
|
141
|
+
// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust
|
|
142
|
+
// it once that pairing is visible here, or an unrelated re-render can land in the gap.
|
|
143
|
+
const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
|
|
144
|
+
if (renderedModel !== latestModel && rejoinPending) {
|
|
145
|
+
adoptOnRebuild.current = false
|
|
146
|
+
// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
|
|
147
|
+
// nothing changes state because of the swap alone. Idempotent, so safe during render.
|
|
148
|
+
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
|
149
|
+
setRenderedModel(latestModel)
|
|
150
|
+
clusterModel = latestModel
|
|
151
|
+
} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
|
|
152
|
+
// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
|
|
153
|
+
// survive to force-adopt a later, unrelated rebuild.
|
|
154
|
+
adoptOnRebuild.current = false
|
|
155
|
+
}
|
|
156
|
+
// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
|
|
157
|
+
// is baked into the model), so when its live position drifts from the baked one, hold it out.
|
|
158
|
+
// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
|
|
159
|
+
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
|
160
|
+
if (newlyMovedIds.length > 0) {
|
|
161
|
+
const next = new Set(heldThreadIds)
|
|
162
|
+
for (const id of newlyMovedIds) next.add(id)
|
|
163
|
+
setHeldThreadIds(next)
|
|
164
|
+
}
|
|
165
|
+
// Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
|
|
166
|
+
// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
|
|
167
|
+
// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
|
|
168
|
+
if (clusterModel !== latestModel) {
|
|
169
|
+
const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
170
|
+
const removedLeafIds: string[] = []
|
|
171
|
+
for (const leaf of clusterModel.table.leaves) {
|
|
172
|
+
if (!latestLeafIds.has(leaf.id)) {
|
|
173
|
+
removedLeafIds.push(leaf.id)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Batched: one patch rebuild and one version bump for the whole set.
|
|
177
|
+
if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)
|
|
178
|
+
}
|
|
179
|
+
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
|
180
|
+
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
|
181
|
+
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (heldThreadIds.size === 0) return
|
|
184
|
+
let lastZoom = editor.getZoomLevel()
|
|
185
|
+
return react('rejoin moved comment pins on zoom out', () => {
|
|
186
|
+
const zoom = editor.getZoomLevel()
|
|
187
|
+
const prevZoom = lastZoom
|
|
188
|
+
lastZoom = zoom
|
|
189
|
+
if (zoom >= prevZoom) return
|
|
190
|
+
adoptOnRebuild.current = true
|
|
191
|
+
setHeldThreadIds(EMPTY_SET)
|
|
192
|
+
})
|
|
193
|
+
}, [heldThreadIds, editor])
|
|
194
|
+
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
|
195
|
+
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
|
196
|
+
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (clusterModel === latestModel) return
|
|
199
|
+
let lastZoom = editor.getZoomLevel()
|
|
200
|
+
return react('adopt pending cluster model on zoom out', () => {
|
|
201
|
+
const zoom = editor.getZoomLevel()
|
|
202
|
+
const prevZoom = lastZoom
|
|
203
|
+
lastZoom = zoom
|
|
204
|
+
if (zoom >= prevZoom) return
|
|
205
|
+
latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
|
|
206
|
+
setRenderedModel(latestModel)
|
|
207
|
+
})
|
|
208
|
+
}, [clusterModel, latestModel, editor])
|
|
209
|
+
// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone
|
|
210
|
+
// deletions) render as plain pins until the next zoom-out folds them in. Judged against the
|
|
211
|
+
// displayed partition, so a detached-then-restored leaf reappears.
|
|
212
|
+
const partitionVersion = clusterModel.runtime.version
|
|
213
|
+
const orphanThreads = useMemo(() => {
|
|
214
|
+
if (clusterModel === latestModel) return []
|
|
215
|
+
const displayed = new Set<string>()
|
|
216
|
+
for (const node of clusterModel.runtime.getVisible().values()) {
|
|
217
|
+
for (const member of node.members) displayed.add(member)
|
|
218
|
+
}
|
|
219
|
+
const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
220
|
+
return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
|
|
221
|
+
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
222
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
223
|
+
}, [clusterModel, latestModel, threads, partitionVersion])
|
|
224
|
+
const heldThreads = useMemo(
|
|
225
|
+
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
|
226
|
+
[threads, heldThreadIds, openId]
|
|
227
|
+
)
|
|
228
|
+
// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster
|
|
229
|
+
// changes rather than every camera frame. The memo below re-reads the version inline because
|
|
230
|
+
// render-time detaches bump it after the subscription's computed already evaluated.
|
|
231
|
+
useValue(
|
|
232
|
+
'comment cluster version',
|
|
233
|
+
() => {
|
|
234
|
+
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
|
235
|
+
return clusterModel.runtime.version
|
|
236
|
+
},
|
|
237
|
+
[clusterModel, editor]
|
|
238
|
+
)
|
|
239
|
+
const visibleNodes = useMemo(() => {
|
|
240
|
+
return Array.from(clusterModel.runtime.getVisible().values())
|
|
241
|
+
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
242
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
243
|
+
}, [clusterModel, partitionVersion])
|
|
244
|
+
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
model: clusterModel,
|
|
248
|
+
zoomBounds: clusterZoomBounds,
|
|
249
|
+
fadeNodes,
|
|
250
|
+
orphanThreads,
|
|
251
|
+
heldThreads,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Leaves folded inside a badge whose live anchor no longer matches the position the rendered
|
|
257
|
+
* model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
|
|
258
|
+
* stay deferred; a badge can't follow a member, so these must pop out of clustering.
|
|
259
|
+
*/
|
|
260
|
+
function findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {
|
|
261
|
+
if (rendered.table === latest.table) return []
|
|
262
|
+
const visible = rendered.runtime.getVisible()
|
|
263
|
+
const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
|
|
264
|
+
const moved: string[] = []
|
|
265
|
+
for (const leaf of rendered.table.leaves) {
|
|
266
|
+
if (visible.has(leaf.id)) continue
|
|
267
|
+
const current = latestById.get(leaf.id)
|
|
268
|
+
if (!current) continue
|
|
269
|
+
if (
|
|
270
|
+
Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
|
|
271
|
+
Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
|
|
272
|
+
) {
|
|
273
|
+
moved.push(leaf.id)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return moved
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getClusterZoomBounds(editor: Editor): ClusterZoomBounds {
|
|
280
|
+
const cameraOptions = editor.getCameraOptions()
|
|
281
|
+
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
|
282
|
+
const zoomSteps = cameraOptions.zoomSteps
|
|
283
|
+
return {
|
|
284
|
+
minZoom: zoomSteps[0] * baseZoom,
|
|
285
|
+
maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split
|
|
291
|
+
* that unfolds it from its badge (or just centre on it when it isn't clustered).
|
|
292
|
+
*/
|
|
293
|
+
export function revealThreadPin(
|
|
294
|
+
editor: Editor,
|
|
295
|
+
thread: TLCommentThread,
|
|
296
|
+
table: ClusterTable,
|
|
297
|
+
zoomBounds: ClusterZoomBounds,
|
|
298
|
+
options: CommentingOptions,
|
|
299
|
+
duration = 200
|
|
300
|
+
) {
|
|
301
|
+
if (thread.pageId !== editor.getCurrentPageId()) {
|
|
302
|
+
editor.setCurrentPage(thread.pageId as any)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const point = anchorPagePoint(editor, thread.anchor)
|
|
306
|
+
if (!point) return
|
|
307
|
+
|
|
308
|
+
// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
|
|
309
|
+
// badge never exists) and just center on the pin.
|
|
310
|
+
if (options.enableClustering) {
|
|
311
|
+
const parentEvent = findDirectParentEvent(table, thread.id)
|
|
312
|
+
if (
|
|
313
|
+
parentEvent &&
|
|
314
|
+
Number.isFinite(parentEvent.zSplit) &&
|
|
315
|
+
parentEvent.zSplit <= zoomBounds.maxZoom
|
|
316
|
+
) {
|
|
317
|
+
const zoom = clamp(
|
|
318
|
+
parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
319
|
+
zoomBounds.minZoom,
|
|
320
|
+
zoomBounds.maxZoom
|
|
321
|
+
)
|
|
322
|
+
centerOnPointAtZoom(editor, point, zoom, duration)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
|
328
|
+
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The
|
|
333
|
+
* event that created a visible cluster is the event that splits it, and has the smallest zSplit of
|
|
334
|
+
* everything applied inside it — so its zSplit is exactly the first split within those comments.
|
|
335
|
+
* The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.
|
|
336
|
+
*/
|
|
337
|
+
export function zoomToClusterSplit(
|
|
338
|
+
editor: Editor,
|
|
339
|
+
table: ClusterTable,
|
|
340
|
+
zoomBounds: ClusterZoomBounds,
|
|
341
|
+
node: ClusterNode
|
|
342
|
+
) {
|
|
343
|
+
const event = table.events.find((e) => e.result.id === node.id)
|
|
344
|
+
if (!event || !Number.isFinite(event.zSplit)) return
|
|
345
|
+
const zoom = clamp(
|
|
346
|
+
event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
347
|
+
zoomBounds.minZoom,
|
|
348
|
+
zoomBounds.maxZoom
|
|
349
|
+
)
|
|
350
|
+
centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
|
354
|
+
return table.events.find((event) => event.children.some((child) => child.id === threadId))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function centerOnPointAtZoom(
|
|
358
|
+
editor: Editor,
|
|
359
|
+
point: { x: number; y: number },
|
|
360
|
+
zoom: number,
|
|
361
|
+
duration = 200
|
|
362
|
+
) {
|
|
363
|
+
const viewport = editor.getViewportScreenBounds()
|
|
364
|
+
// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
|
|
365
|
+
const offset = commentCenterScreenOffset(editor)
|
|
366
|
+
editor.setCamera(
|
|
367
|
+
{
|
|
368
|
+
x: (viewport.w / 2 - offset) / zoom - point.x,
|
|
369
|
+
y: viewport.h / (2 * zoom) - point.y,
|
|
370
|
+
z: zoom,
|
|
371
|
+
},
|
|
372
|
+
{ animation: { duration } }
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function clamp(value: number, min: number, max: number): number {
|
|
377
|
+
return Math.max(min, Math.min(max, value))
|
|
378
|
+
}
|
|
@@ -17,5 +17,5 @@ export interface CommentBodyProps {
|
|
|
17
17
|
*/
|
|
18
18
|
export function CommentBody({ richText, resolveName }: CommentBodyProps) {
|
|
19
19
|
const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
|
|
20
|
-
return <div className="cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
|
|
20
|
+
return <div className="tlui-cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
|
|
21
21
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* eslint-disable tldraw/no-direct-storage */
|
|
2
|
+
import type { TLRichText } from 'tldraw'
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
clearCommentDraft,
|
|
6
|
+
getCommentDraft,
|
|
7
|
+
NEW_COMMENT_DRAFT,
|
|
8
|
+
replyDraftSlot,
|
|
9
|
+
saveCommentDraft,
|
|
10
|
+
} from './comment-drafts'
|
|
11
|
+
|
|
12
|
+
function richText(text: string): TLRichText {
|
|
13
|
+
return {
|
|
14
|
+
type: 'doc',
|
|
15
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
16
|
+
} as unknown as TLRichText
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as TLRichText
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
localStorage.clear()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('comment drafts', () => {
|
|
26
|
+
it('round-trips a draft through its slot', () => {
|
|
27
|
+
saveCommentDraft(NEW_COMMENT_DRAFT, richText('hello'))
|
|
28
|
+
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toEqual(richText('hello'))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('keeps reply drafts apart by thread id', () => {
|
|
32
|
+
saveCommentDraft(replyDraftSlot('comment-thread:a'), richText('for a'))
|
|
33
|
+
saveCommentDraft(replyDraftSlot('comment-thread:b'), richText('for b'))
|
|
34
|
+
expect(getCommentDraft(replyDraftSlot('comment-thread:a'))).toEqual(richText('for a'))
|
|
35
|
+
expect(getCommentDraft(replyDraftSlot('comment-thread:b'))).toEqual(richText('for b'))
|
|
36
|
+
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('saving empty content clears the slot instead', () => {
|
|
40
|
+
saveCommentDraft(NEW_COMMENT_DRAFT, richText('typed then deleted'))
|
|
41
|
+
saveCommentDraft(NEW_COMMENT_DRAFT, EMPTY)
|
|
42
|
+
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('clears a slot explicitly', () => {
|
|
46
|
+
saveCommentDraft(NEW_COMMENT_DRAFT, richText('posted'))
|
|
47
|
+
clearCommentDraft(NEW_COMMENT_DRAFT)
|
|
48
|
+
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns undefined for a corrupt stored value', () => {
|
|
52
|
+
localStorage.setItem('tldraw-comment-draft:new', '{not json')
|
|
53
|
+
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { deleteFromLocalStorage, getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
|
|
2
|
+
import type { TLRichText } from 'tldraw'
|
|
3
|
+
import { isCommentEmpty } from '../ui/comment-extensions'
|
|
4
|
+
|
|
5
|
+
const DRAFT_KEY_PREFIX = 'tldraw-comment-draft'
|
|
6
|
+
|
|
7
|
+
/** The single slot for an unsent new-comment (placement composer) draft. The anchor is re-picked
|
|
8
|
+
* by clicking when the composer reopens, so only the text is worth keeping — and without a
|
|
9
|
+
* universal document id in the SDK, one slot is the honest scope. */
|
|
10
|
+
export const NEW_COMMENT_DRAFT = 'new'
|
|
11
|
+
|
|
12
|
+
/** The draft slot for an unsent reply on a thread. Thread record ids are globally unique and
|
|
13
|
+
* stable across reloads, so replies never need a document key. @public */
|
|
14
|
+
export function replyDraftSlot(threadId: string): string {
|
|
15
|
+
return `reply:${threadId}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read a saved unsent draft, or undefined when there is none (or it fails to parse). @public */
|
|
19
|
+
export function getCommentDraft(slot: string): TLRichText | undefined {
|
|
20
|
+
const raw = getFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
|
|
21
|
+
if (!raw) return undefined
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw) as TLRichText
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Save an unsent draft so click-away and reload don't lose it — the flip side of not warning
|
|
31
|
+
* before discard. Saving empty content clears the slot instead, so deleting your text and
|
|
32
|
+
* closing doesn't resurrect an empty draft later.
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
export function saveCommentDraft(slot: string, value: TLRichText) {
|
|
36
|
+
if (isCommentEmpty(value)) {
|
|
37
|
+
clearCommentDraft(slot)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
setInLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`, JSON.stringify(value))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remove a saved draft (after posting, or when its content was emptied). @public */
|
|
44
|
+
export function clearCommentDraft(slot: string) {
|
|
45
|
+
deleteFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
|
|
46
|
+
}
|