@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,115 @@
|
|
|
1
|
+
import { RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { PORTRAIT_BREAKPOINT, useBreakpoint, useContainer } from 'tldraw'
|
|
3
|
+
import { getVisibleViewport } from '../ui/visual-viewport'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mobile mode for the commenting surfaces — the same breakpoint gate as tldraw's mobile toolbar and
|
|
7
|
+
* style panel (which also honors `forceMobile` on `<Tldraw>`). The commenting layer can mount
|
|
8
|
+
* without tldraw's default UI (`hideUi`, custom UI), where the breakpoint provider is absent and
|
|
9
|
+
* `useBreakpoint` throws; fall back to desktop there, matching the pre-mobile rendering those hosts
|
|
10
|
+
* always had.
|
|
11
|
+
*/
|
|
12
|
+
export function useIsMobileCommenting(): boolean {
|
|
13
|
+
let breakpoint: number
|
|
14
|
+
try {
|
|
15
|
+
// The call is unconditional — the try only guards the provider's absence, not hook order.
|
|
16
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
17
|
+
breakpoint = useBreakpoint()
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VIEWPORT_MARGIN = 8
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Keep a canvas-anchored panel — the pending composer or an open thread popover — within the
|
|
28
|
+
* visible viewport on mobile. The software keyboard shrinks the *visual* viewport while leaving the
|
|
29
|
+
* layout viewport (and CSS `dvh`) untouched, so a panel placed at a fixed offset from its pin can
|
|
30
|
+
* end up behind the keyboard. On mobile this clamps the panel's top-left into the visible box so it
|
|
31
|
+
* stays on-screen above the keyboard. Desktop is untouched: when `enabled` is false the base point
|
|
32
|
+
* is returned unchanged, tracking the camera exactly as before.
|
|
33
|
+
*
|
|
34
|
+
* `base` is the panel's desired top-left in container-relative viewport coordinates (what the call
|
|
35
|
+
* site would otherwise write straight into `left`/`top`).
|
|
36
|
+
*/
|
|
37
|
+
export function useMobilePlacement(
|
|
38
|
+
ref: RefObject<HTMLElement | null>,
|
|
39
|
+
base: { x: number; y: number },
|
|
40
|
+
enabled: boolean
|
|
41
|
+
): { left: number; top: number } {
|
|
42
|
+
const container = useContainer()
|
|
43
|
+
// Destructured so the effects below depend on the two numbers rather than the object, which the
|
|
44
|
+
// call sites build fresh on every render.
|
|
45
|
+
const { x: baseX, y: baseY } = base
|
|
46
|
+
const [placed, setPlaced] = useState<{ left: number; top: number }>(() => ({
|
|
47
|
+
left: baseX,
|
|
48
|
+
top: baseY,
|
|
49
|
+
}))
|
|
50
|
+
// The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
|
|
51
|
+
// across those frames, so the observers below are set up once per panel rather than being torn
|
|
52
|
+
// down and rebuilt each frame.
|
|
53
|
+
const baseRef = useRef({ x: baseX, y: baseY })
|
|
54
|
+
|
|
55
|
+
const update = useCallback(() => {
|
|
56
|
+
if (!enabled) return
|
|
57
|
+
const el = ref.current
|
|
58
|
+
if (!el) return
|
|
59
|
+
const win = container.ownerDocument.defaultView ?? window
|
|
60
|
+
const { x, y } = baseRef.current
|
|
61
|
+
|
|
62
|
+
// Panel coordinates are container-relative; the visual viewport is window-relative.
|
|
63
|
+
const cRect = container.getBoundingClientRect()
|
|
64
|
+
const vp = getVisibleViewport(win)
|
|
65
|
+
const top = vp.top - cRect.top + VIEWPORT_MARGIN
|
|
66
|
+
const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
|
|
67
|
+
const left = vp.left - cRect.left + VIEWPORT_MARGIN
|
|
68
|
+
const right = vp.right - cRect.left - VIEWPORT_MARGIN
|
|
69
|
+
|
|
70
|
+
const w = el.offsetWidth
|
|
71
|
+
const h = el.offsetHeight
|
|
72
|
+
|
|
73
|
+
const nextLeft = Math.max(left, Math.min(x, right - w))
|
|
74
|
+
// The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
|
|
75
|
+
// bottom would be hidden) but must never push it down below its natural spot. `y` is the
|
|
76
|
+
// floor: any spurious rise in `top` (e.g. iOS scrolling the page to reveal a focused input)
|
|
77
|
+
// is capped here, so a panel that already clears the keyboard doesn't move at all.
|
|
78
|
+
const nextTop = Math.min(y, Math.max(top, bottom - h))
|
|
79
|
+
setPlaced((prev) =>
|
|
80
|
+
prev.left === nextLeft && prev.top === nextTop ? prev : { left: nextLeft, top: nextTop }
|
|
81
|
+
)
|
|
82
|
+
}, [container, ref, enabled])
|
|
83
|
+
|
|
84
|
+
// Re-place as the camera moves the panel's base point.
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
baseRef.current = { x: baseX, y: baseY }
|
|
87
|
+
update()
|
|
88
|
+
}, [baseX, baseY, update])
|
|
89
|
+
|
|
90
|
+
// Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
|
|
91
|
+
// pinch-zoom), or when the window resizes.
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
if (!enabled) return
|
|
94
|
+
const el = ref.current
|
|
95
|
+
if (!el) return
|
|
96
|
+
const win = container.ownerDocument.defaultView ?? window
|
|
97
|
+
const ro = new ResizeObserver(update)
|
|
98
|
+
ro.observe(el)
|
|
99
|
+
const vv = win.visualViewport
|
|
100
|
+
vv?.addEventListener('resize', update)
|
|
101
|
+
vv?.addEventListener('scroll', update)
|
|
102
|
+
win.addEventListener('resize', update)
|
|
103
|
+
return () => {
|
|
104
|
+
ro.disconnect()
|
|
105
|
+
vv?.removeEventListener('resize', update)
|
|
106
|
+
vv?.removeEventListener('scroll', update)
|
|
107
|
+
win.removeEventListener('resize', update)
|
|
108
|
+
}
|
|
109
|
+
}, [container, ref, enabled, update])
|
|
110
|
+
|
|
111
|
+
// Desktop keeps its fixed placement, recomputed each render so it tracks the pin as the camera
|
|
112
|
+
// moves.
|
|
113
|
+
if (!enabled) return { left: baseX, top: baseY }
|
|
114
|
+
return placed
|
|
115
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { createComment, createCommentThread, toRichText, type Editor, type TLPageId } from 'tldraw'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { commitCommentMutation } from './comment-mutations'
|
|
3
4
|
import { CommentTool } from './comment-tool'
|
|
4
|
-
import {
|
|
5
|
-
|
|
5
|
+
import {
|
|
6
|
+
defaultCanModifyComment,
|
|
7
|
+
defaultCommentingOptions,
|
|
8
|
+
getCanComment,
|
|
9
|
+
getCanModifyComment,
|
|
10
|
+
getCommentingOptions,
|
|
11
|
+
type CommentingOptions,
|
|
12
|
+
type CommentModificationContext,
|
|
13
|
+
} from './options'
|
|
14
|
+
import { openThreadId, pendingComment } from './state'
|
|
6
15
|
|
|
7
16
|
// The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
|
|
8
17
|
// instantiate a configured tool and read its merged options.
|
|
@@ -78,6 +87,169 @@ describe('getCommentingOptions', () => {
|
|
|
78
87
|
})
|
|
79
88
|
})
|
|
80
89
|
|
|
90
|
+
describe('getCanComment', () => {
|
|
91
|
+
it('defaults to requiring a current user', () => {
|
|
92
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
93
|
+
expect(getCanComment(editor, 'alice')).toBe(true)
|
|
94
|
+
expect(getCanComment(editor, null)).toBe(false)
|
|
95
|
+
expect(getCanComment(editor, undefined)).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('normalizes an undefined viewer to null for the callback', () => {
|
|
99
|
+
const calls: Array<string | null> = []
|
|
100
|
+
const { editor } = stubEditor({
|
|
101
|
+
...defaultCommentingOptions,
|
|
102
|
+
canComment: (ctx) => {
|
|
103
|
+
calls.push(ctx.currentUserId)
|
|
104
|
+
return false
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
getCanComment(editor, undefined)
|
|
108
|
+
expect(calls).toEqual([null])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('delegates to the canComment callback, passing the editor and viewer', () => {
|
|
112
|
+
const calls: Array<{ editor: Editor; currentUserId: string | null }> = []
|
|
113
|
+
const { editor } = stubEditor({
|
|
114
|
+
...defaultCommentingOptions,
|
|
115
|
+
canComment: (ctx) => {
|
|
116
|
+
calls.push(ctx)
|
|
117
|
+
return ctx.currentUserId === 'alice'
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
expect(getCanComment(editor, 'alice')).toBe(true)
|
|
121
|
+
expect(getCanComment(editor, 'bob')).toBe(false)
|
|
122
|
+
expect(calls).toEqual([
|
|
123
|
+
{ editor, currentUserId: 'alice' },
|
|
124
|
+
{ editor, currentUserId: 'bob' },
|
|
125
|
+
])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('lets the callback fully replace the signed-in default', () => {
|
|
129
|
+
const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
|
|
130
|
+
expect(getCanComment(editor, null)).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// This is read during render, so a throwing host rule would take the comments layer with it.
|
|
134
|
+
it('denies rather than throws when the callback throws', () => {
|
|
135
|
+
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
136
|
+
const { editor } = stubEditor({
|
|
137
|
+
...defaultCommentingOptions,
|
|
138
|
+
canComment: () => {
|
|
139
|
+
throw new Error('lookup failed')
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
expect(getCanComment(editor, 'alice')).toBe(false)
|
|
143
|
+
expect(onError).toHaveBeenCalled()
|
|
144
|
+
onError.mockRestore()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Plain records for the permission checks — the factories are pure, so no store is involved.
|
|
149
|
+
const PAGE_ID = 'page:test' as TLPageId
|
|
150
|
+
|
|
151
|
+
function makeThread(createdBy: string) {
|
|
152
|
+
return createCommentThread({
|
|
153
|
+
pageId: PAGE_ID,
|
|
154
|
+
anchor: { type: 'point', x: 0, y: 0 },
|
|
155
|
+
createdBy,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function makeComment(authorId: string) {
|
|
160
|
+
return createComment({
|
|
161
|
+
threadId: makeThread(authorId).id,
|
|
162
|
+
pageId: PAGE_ID,
|
|
163
|
+
authorId,
|
|
164
|
+
body: toRichText('hello'),
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe('getCanModifyComment', () => {
|
|
169
|
+
it('defaults to the record owner: the comment author edits and deletes', () => {
|
|
170
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
171
|
+
const comment = makeComment('alice')
|
|
172
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
|
173
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'delete-comment', comment })).toBe(true)
|
|
174
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'edit-comment', comment })).toBe(false)
|
|
175
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('defaults to the thread creator for a thread delete', () => {
|
|
179
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
180
|
+
const thread = makeThread('alice')
|
|
181
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'delete-thread', thread })).toBe(true)
|
|
182
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'delete-thread', thread })).toBe(false)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('withholds everything from a viewer with no identity', () => {
|
|
186
|
+
const { editor } = stubEditor(defaultCommentingOptions)
|
|
187
|
+
const comment = makeComment('alice')
|
|
188
|
+
const thread = makeThread('alice')
|
|
189
|
+
expect(getCanModifyComment(editor, null, { action: 'edit-comment', comment })).toBe(false)
|
|
190
|
+
expect(getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })).toBe(
|
|
191
|
+
false
|
|
192
|
+
)
|
|
193
|
+
expect(getCanModifyComment(editor, null, { action: 'delete-thread', thread })).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('passes the editor, the viewer, and the targeted write to the callback', () => {
|
|
197
|
+
const calls: CommentModificationContext[] = []
|
|
198
|
+
const { editor } = stubEditor({
|
|
199
|
+
...defaultCommentingOptions,
|
|
200
|
+
canModifyComment: (ctx) => {
|
|
201
|
+
calls.push(ctx)
|
|
202
|
+
return true
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
const comment = makeComment('alice')
|
|
206
|
+
getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })
|
|
207
|
+
// An undefined viewer normalizes to null, as it does for `canComment`.
|
|
208
|
+
expect(calls).toEqual([{ editor, currentUserId: null, action: 'delete-comment', comment }])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("lets a callback widen the default: a moderator deleting anyone's comment", () => {
|
|
212
|
+
const { editor } = stubEditor({
|
|
213
|
+
...defaultCommentingOptions,
|
|
214
|
+
canModifyComment: (ctx) =>
|
|
215
|
+
(ctx.action !== 'edit-comment' && ctx.currentUserId === 'mod') ||
|
|
216
|
+
defaultCanModifyComment(ctx),
|
|
217
|
+
})
|
|
218
|
+
const comment = makeComment('alice')
|
|
219
|
+
const thread = makeThread('alice')
|
|
220
|
+
expect(getCanModifyComment(editor, 'mod', { action: 'delete-comment', comment })).toBe(true)
|
|
221
|
+
expect(getCanModifyComment(editor, 'mod', { action: 'delete-thread', thread })).toBe(true)
|
|
222
|
+
// Widening deletion doesn't hand the moderator anyone else's edit affordance.
|
|
223
|
+
expect(getCanModifyComment(editor, 'mod', { action: 'edit-comment', comment })).toBe(false)
|
|
224
|
+
// The author keeps their own affordances.
|
|
225
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
|
226
|
+
// Everyone else still gets nothing.
|
|
227
|
+
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('lets a callback narrow the default', () => {
|
|
231
|
+
const { editor } = stubEditor({ ...defaultCommentingOptions, canModifyComment: () => false })
|
|
232
|
+
const comment = makeComment('alice')
|
|
233
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// A throw withholds the affordance rather than the whole layer, and never offers a write a
|
|
237
|
+
// server enforcing the same rule would reject anyway.
|
|
238
|
+
it('denies rather than throws when the callback throws', () => {
|
|
239
|
+
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
240
|
+
const { editor } = stubEditor({
|
|
241
|
+
...defaultCommentingOptions,
|
|
242
|
+
canModifyComment: () => {
|
|
243
|
+
throw new Error('lookup failed')
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
const comment = makeComment('alice')
|
|
247
|
+
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
|
248
|
+
expect(onError).toHaveBeenCalled()
|
|
249
|
+
onError.mockRestore()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
81
253
|
describe('commitCommentMutation', () => {
|
|
82
254
|
it('uses options.history for a mutation and returns the callback result', () => {
|
|
83
255
|
const { editor, runCalls } = stubEditor({
|
package/src/canvas/options.ts
CHANGED
|
@@ -4,8 +4,52 @@ import {
|
|
|
4
4
|
type TLComment,
|
|
5
5
|
type TLCommentThread,
|
|
6
6
|
type TLHistoryBatchOptions,
|
|
7
|
+
type TLShapeId,
|
|
8
|
+
type VecLike,
|
|
7
9
|
useEditor,
|
|
10
|
+
useValue,
|
|
8
11
|
} from 'tldraw'
|
|
12
|
+
import { type CommentListItemRenderProps } from '../ui/comments-list'
|
|
13
|
+
import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
|
|
14
|
+
import { type ReactionTooltipProps } from '../ui/reaction'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The gesture that's creating a shape anchor, passed to
|
|
18
|
+
* {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
|
|
19
|
+
* whether Alt was held.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export interface ShapeCommentPrecisionContext {
|
|
24
|
+
readonly shapeId: TLShapeId
|
|
25
|
+
readonly point: VecLike
|
|
26
|
+
readonly altKey: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A commenting write that belongs to someone in particular, and the record it targets — the
|
|
31
|
+
* argument to {@link CommentingOptions.canModifyComment}.
|
|
32
|
+
*
|
|
33
|
+
* Resolving, reopening, reacting, and moving a pin aren't here: none of them is anyone's in
|
|
34
|
+
* particular, so {@link CommentingOptions.canComment} is the only gate on them.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export type CommentModification =
|
|
39
|
+
| { readonly action: 'edit-comment'; readonly comment: TLComment }
|
|
40
|
+
| { readonly action: 'delete-comment'; readonly comment: TLComment }
|
|
41
|
+
| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The argument to {@link CommentingOptions.canModifyComment}: which write, against which record,
|
|
45
|
+
* and by whom.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
export type CommentModificationContext = {
|
|
50
|
+
readonly editor: Editor
|
|
51
|
+
readonly currentUserId: string | null
|
|
52
|
+
} & CommentModification
|
|
9
53
|
|
|
10
54
|
/**
|
|
11
55
|
* Component overrides for the batteries-included comments layer. Each slot replaces a built-in
|
|
@@ -20,12 +64,50 @@ export interface CommentingComponents {
|
|
|
20
64
|
PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
|
21
65
|
/** A sidebar row's preview. Replaces the plaintext default. */
|
|
22
66
|
ThreadPreview?: ComponentType<{ comment: TLComment }>
|
|
67
|
+
/**
|
|
68
|
+
* A whole sidebar row. Replaces the default `<CommentListItem>`, which is exported — so a row
|
|
69
|
+
* that only adds an unread dot or a status chip can spread these props into it. Use
|
|
70
|
+
* `ThreadPreview` instead when only the preview text is changing.
|
|
71
|
+
*/
|
|
72
|
+
ThreadRow?: ComponentType<CommentListItemRenderProps & { thread: TLCommentThread }>
|
|
73
|
+
/**
|
|
74
|
+
* Extra controls in an open thread's header, added ahead of the built-in resolve and dismiss
|
|
75
|
+
* buttons rather than replacing them. "Copy link" is already built in whenever the host supplies
|
|
76
|
+
* `getThreadHref`.
|
|
77
|
+
*/
|
|
78
|
+
ThreadActions?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
|
79
|
+
/**
|
|
80
|
+
* A reaction's visual, given its token. The default renders the token string for the OS emoji
|
|
81
|
+
* font. Override to draw a custom palette — an `<img>`, an SVG, anything. The token is what gets
|
|
82
|
+
* stored and synced; this only controls how it's drawn.
|
|
83
|
+
*/
|
|
84
|
+
ReactionContent?: ComponentType<{ token: string }>
|
|
85
|
+
/**
|
|
86
|
+
* What the add-reaction button opens. Replaces the default `<EmojiPicker>` grid. Pairs with
|
|
87
|
+
* `ReactionContent` (which draws the tokens this emits) and `isAllowedReaction` (which must
|
|
88
|
+
* accept them).
|
|
89
|
+
*/
|
|
90
|
+
ReactionPalette?: ComponentType<EmojiPickerProps>
|
|
91
|
+
/**
|
|
92
|
+
* The hover affordance naming who reacted with an emoji. Receives the reactors and the pill (as
|
|
93
|
+
* `children`) and owns the whole thing — box, size, shape, position. For a wording change,
|
|
94
|
+
* translate the `comments.reacted-*` strings instead.
|
|
95
|
+
*/
|
|
96
|
+
ReactionTooltip?: ComponentType<ReactionTooltipProps>
|
|
97
|
+
/**
|
|
98
|
+
* Shown where a composer would sit when the viewer can't compose (see
|
|
99
|
+
* {@link CommentingOptions.canComment}). `context` is the surface rendering it: an open thread
|
|
100
|
+
* popover (`'thread'`) or the comment tool's placement popover (`'pending'`). Unset, those
|
|
101
|
+
* surfaces render nothing.
|
|
102
|
+
*/
|
|
103
|
+
ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
|
|
23
104
|
}
|
|
24
105
|
|
|
25
106
|
/**
|
|
26
107
|
* Configuration for the commenting layer. Static config only — pass it once via
|
|
27
108
|
* `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
|
|
28
|
-
* (`currentUserId`,
|
|
109
|
+
* (`currentUserId`, author resolution, read-status callbacks) are the `CommentingContext`, passed as
|
|
110
|
+
* props to each commenting surface.
|
|
29
111
|
*
|
|
30
112
|
* For defaults, see {@link defaultCommentingOptions}.
|
|
31
113
|
*
|
|
@@ -37,13 +119,11 @@ export interface CommentingComponents {
|
|
|
37
119
|
* @public
|
|
38
120
|
*/
|
|
39
121
|
export interface CommentingOptions {
|
|
40
|
-
//
|
|
122
|
+
// History / undo
|
|
41
123
|
/**
|
|
42
|
-
* How comment mutations
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* already removed, and undoing a resolve/edit reverts their newer state. Safe only single-player
|
|
46
|
-
* or on a non-synced local comment store.
|
|
124
|
+
* How comment mutations interact with the editor undo stack. Defaults to `'ignore'` — comments
|
|
125
|
+
* are deliberately not undoable (see `TLComment`). `'record'` is a multiplayer footgun: undoing
|
|
126
|
+
* a delete resurrects a thread a collaborator already removed. Safe only single-player.
|
|
47
127
|
*/
|
|
48
128
|
readonly history: TLHistoryBatchOptions['history']
|
|
49
129
|
/**
|
|
@@ -52,21 +132,83 @@ export interface CommentingOptions {
|
|
|
52
132
|
*/
|
|
53
133
|
readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
|
|
54
134
|
|
|
55
|
-
//
|
|
135
|
+
// Feature toggles
|
|
56
136
|
/** Fold nearby pins into count badges as the camera zooms out. */
|
|
57
137
|
readonly enableClustering: boolean
|
|
138
|
+
/**
|
|
139
|
+
* Whether a user may hold several emoji reactions on one comment. `true` (the default) is the
|
|
140
|
+
* Slack model: each emoji toggles independently. `false` is single-select: picking a new emoji
|
|
141
|
+
* replaces the user's existing reaction. Note this is enforced client-side; the server accepts
|
|
142
|
+
* per-emoji records either way.
|
|
143
|
+
*/
|
|
144
|
+
readonly allowMultipleReactions: boolean
|
|
145
|
+
/**
|
|
146
|
+
* Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji}, which
|
|
147
|
+
* keeps a scripted client from writing junk values the picker would never offer. Override
|
|
148
|
+
* alongside a custom `ReactionPalette`. Removals aren't checked — an off-palette reaction must
|
|
149
|
+
* still be clearable.
|
|
150
|
+
*/
|
|
151
|
+
isAllowedReaction(token: string): boolean
|
|
152
|
+
/**
|
|
153
|
+
* Whether dragging the comment tool out creates a region anchor — a comment attached to a
|
|
154
|
+
* rectangular area, drawn as a dashed box with the pin on the corner the drag released on. Off
|
|
155
|
+
* by default, where comments attach to points and shapes only and a drag trails the composer.
|
|
156
|
+
*/
|
|
157
|
+
readonly enableRegions: boolean
|
|
158
|
+
|
|
159
|
+
// Permissions
|
|
160
|
+
/**
|
|
161
|
+
* Whether the viewer may participate in commenting: composing, editing, deleting, resolving, and
|
|
162
|
+
* moving pins. When false, {@link CommentingComponents.ComposerFallback} renders in the
|
|
163
|
+
* composer's place and action affordances are hidden. Unset, participation is allowed exactly
|
|
164
|
+
* when `currentUserId` is set.
|
|
165
|
+
*
|
|
166
|
+
* Called during render via {@link useCanComment}, so signal reads are tracked. Posting still
|
|
167
|
+
* needs a `currentUserId`, so returning true for a signed-out viewer yields a composer whose
|
|
168
|
+
* send button stays disabled. A callback that throws is logged and read as false, rather than
|
|
169
|
+
* taking the comments layer down with it.
|
|
170
|
+
*/
|
|
171
|
+
readonly canComment:
|
|
172
|
+
| ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
|
|
173
|
+
| undefined
|
|
174
|
+
/**
|
|
175
|
+
* Whether the viewer may make a particular write against a particular record: editing or
|
|
176
|
+
* deleting a comment, or deleting a thread. Unset, each is its record's owner's to make
|
|
177
|
+
* ({@link defaultCanModifyComment}) — you edit and delete your own comments, and delete threads
|
|
178
|
+
* you started. Override it to widen that (a workspace admin or moderator who may remove
|
|
179
|
+
* anyone's comment) or to narrow it (no edits after an hour). Where it returns false the
|
|
180
|
+
* affordance isn't rendered.
|
|
181
|
+
*
|
|
182
|
+
* Checked after {@link CommentingOptions.canComment}, which gates commenting as a whole: a
|
|
183
|
+
* viewer who may not participate gets no action affordances at all, whatever this returns.
|
|
184
|
+
*
|
|
185
|
+
* Called during render via {@link useCanModifyComment}, so reactive reads (signals) are tracked.
|
|
186
|
+
* A callback that throws is logged and read as false: an affordance is withheld rather than the
|
|
187
|
+
* comments layer lost, and a denial is what a server enforcing the same rule would have said.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```tsx
|
|
191
|
+
* CommentTool.configure({
|
|
192
|
+
* canModifyComment: (ctx) =>
|
|
193
|
+
* // Moderators may delete anything; everything else stays the owner's to do.
|
|
194
|
+
* (ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
|
|
195
|
+
* defaultCanModifyComment(ctx),
|
|
196
|
+
* })
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
readonly canModifyComment: ((ctx: CommentModificationContext) => boolean) | undefined
|
|
58
200
|
|
|
59
|
-
//
|
|
201
|
+
// Anchoring
|
|
60
202
|
/** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
|
|
61
203
|
readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
|
|
204
|
+
/**
|
|
205
|
+
* Whether a comment landing on a shape pins to the exact clicked spot, or to the shape as a
|
|
206
|
+
* whole (rendered at `impreciseShapeAnchor`). Always precise by default; return `false`, or
|
|
207
|
+
* decide from the context. Governs new placements only — existing anchors render as stored.
|
|
208
|
+
*/
|
|
209
|
+
shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
|
|
62
210
|
|
|
63
|
-
//
|
|
64
|
-
/** Screen-pixel margin by which the viewport is inflated when culling cluster badges. */
|
|
65
|
-
readonly clusterCullMargin: number
|
|
66
|
-
/** How far past a cluster's split zoom to land when expanding it (1.05 = 5% overshoot). */
|
|
67
|
-
readonly clusterSplitZoomFactor: number
|
|
68
|
-
|
|
69
|
-
// ── Components ────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// Components
|
|
70
212
|
/** Component overrides. See {@link CommentingComponents}. */
|
|
71
213
|
readonly components: CommentingComponents
|
|
72
214
|
}
|
|
@@ -80,9 +222,13 @@ export const defaultCommentingOptions = {
|
|
|
80
222
|
history: 'ignore',
|
|
81
223
|
dragHistory: undefined,
|
|
82
224
|
enableClustering: true,
|
|
225
|
+
allowMultipleReactions: true,
|
|
226
|
+
isAllowedReaction: isAllowedReactionEmoji,
|
|
227
|
+
enableRegions: false,
|
|
228
|
+
canComment: undefined,
|
|
229
|
+
canModifyComment: undefined,
|
|
83
230
|
impreciseShapeAnchor: { x: 1, y: 0 },
|
|
84
|
-
|
|
85
|
-
clusterSplitZoomFactor: 1.05,
|
|
231
|
+
shouldBePrecise: () => true,
|
|
86
232
|
components: {},
|
|
87
233
|
} as const satisfies CommentingOptions
|
|
88
234
|
|
|
@@ -109,3 +255,115 @@ export function useCommentingOptions(): CommentingOptions {
|
|
|
109
255
|
const editor = useEditor()
|
|
110
256
|
return useMemo(() => getCommentingOptions(editor), [editor])
|
|
111
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Ask a host's permission callback, denying the write if it throws.
|
|
261
|
+
*
|
|
262
|
+
* These are called during render, so an exception in one would take the comments layer down with
|
|
263
|
+
* the answer. Denying costs an affordance, which is what a `false` would have cost anyway, and it
|
|
264
|
+
* can't offer a write a server enforcing the same rule would then reject.
|
|
265
|
+
*/
|
|
266
|
+
function permits(option: string, check: () => boolean): boolean {
|
|
267
|
+
try {
|
|
268
|
+
return check()
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(`[tldraw] \`${option}\` threw, so the write is denied:`, error)
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
|
|
277
|
+
* (defaulting to `currentUserId != null` when unset). A callback that throws denies.
|
|
278
|
+
*
|
|
279
|
+
* This is a plain, untracked read — in React, use {@link useCanComment} instead.
|
|
280
|
+
*
|
|
281
|
+
* @public
|
|
282
|
+
*/
|
|
283
|
+
export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
|
|
284
|
+
const { canComment } = getCommentingOptions(editor)
|
|
285
|
+
if (!canComment) return currentUserId != null
|
|
286
|
+
return permits('canComment', () => canComment({ editor, currentUserId: currentUserId ?? null }))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Reactive React hook for {@link getCanComment}: a `canComment` callback that reads signals
|
|
291
|
+
* re-evaluates when they change.
|
|
292
|
+
*
|
|
293
|
+
* @public
|
|
294
|
+
*/
|
|
295
|
+
export function useCanComment(currentUserId: string | null | undefined): boolean {
|
|
296
|
+
const editor = useEditor()
|
|
297
|
+
return useValue('can comment', () => getCanComment(editor, currentUserId), [
|
|
298
|
+
editor,
|
|
299
|
+
currentUserId,
|
|
300
|
+
])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The default {@link CommentingOptions.canModifyComment}: a write is its record's owner's to make —
|
|
305
|
+
* a comment's author edits and deletes it, a thread's creator deletes the thread — and a viewer
|
|
306
|
+
* with no identity may make none of them.
|
|
307
|
+
*
|
|
308
|
+
* Exported so a callback can widen the default rather than restate it:
|
|
309
|
+
* `(ctx) => isModerator(ctx.currentUserId) || defaultCanModifyComment(ctx)`.
|
|
310
|
+
*
|
|
311
|
+
* @public
|
|
312
|
+
*/
|
|
313
|
+
export function defaultCanModifyComment(ctx: CommentModificationContext): boolean {
|
|
314
|
+
const { currentUserId } = ctx
|
|
315
|
+
if (!currentUserId) return false
|
|
316
|
+
const owner = ctx.action === 'delete-thread' ? ctx.thread.createdBy : ctx.comment.authorId
|
|
317
|
+
return owner === currentUserId
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether the viewer may make a given write against a given record, per
|
|
322
|
+
* {@link CommentingOptions.canModifyComment} (defaulting to {@link defaultCanModifyComment} when
|
|
323
|
+
* unset). Where this is false the affordance isn't rendered.
|
|
324
|
+
*
|
|
325
|
+
* This is the per-record rule alone: the built-in UI additionally requires
|
|
326
|
+
* {@link CommentingOptions.canComment}, since a viewer who may not participate gets no action
|
|
327
|
+
* affordances at all.
|
|
328
|
+
*
|
|
329
|
+
* A plain, untracked read — a `canModifyComment` callback that reads signals is not observed. In
|
|
330
|
+
* React, use {@link useCanModifyComment} instead.
|
|
331
|
+
*
|
|
332
|
+
* @public
|
|
333
|
+
*/
|
|
334
|
+
export function getCanModifyComment(
|
|
335
|
+
editor: Editor,
|
|
336
|
+
currentUserId: string | null | undefined,
|
|
337
|
+
modification: CommentModification
|
|
338
|
+
): boolean {
|
|
339
|
+
const { canModifyComment } = getCommentingOptions(editor)
|
|
340
|
+
const ctx: CommentModificationContext = {
|
|
341
|
+
editor,
|
|
342
|
+
currentUserId: currentUserId ?? null,
|
|
343
|
+
...modification,
|
|
344
|
+
}
|
|
345
|
+
if (!canModifyComment) return defaultCanModifyComment(ctx)
|
|
346
|
+
return permits('canModifyComment', () => canModifyComment(ctx))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Reactive React hook for {@link getCanModifyComment}: a `canModifyComment` callback that reads
|
|
351
|
+
* signals re-evaluates when they change.
|
|
352
|
+
*
|
|
353
|
+
* @public
|
|
354
|
+
*/
|
|
355
|
+
export function useCanModifyComment(
|
|
356
|
+
currentUserId: string | null | undefined,
|
|
357
|
+
modification: CommentModification
|
|
358
|
+
): boolean {
|
|
359
|
+
const editor = useEditor()
|
|
360
|
+
// Comment records are immutable, so the record itself is what changes when the thing being
|
|
361
|
+
// checked changes — `modification` is a fresh object on every render and can't be a dep.
|
|
362
|
+
const record =
|
|
363
|
+
modification.action === 'delete-thread' ? modification.thread : modification.comment
|
|
364
|
+
return useValue(
|
|
365
|
+
'can modify comment',
|
|
366
|
+
() => getCanModifyComment(editor, currentUserId, modification),
|
|
367
|
+
[editor, currentUserId, modification.action, record]
|
|
368
|
+
)
|
|
369
|
+
}
|