canopycms 0.10.0 → 0.11.1

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.
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
- import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_NAVIGATE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
4
+ import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_NAVIGATE, CANOPY_PREVIEW_READY, CANOPY_PREVIEW_REVEAL, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
5
5
  /**
6
6
  * How long after the iframe's `load` the preview has to send its ready message before the frame
7
7
  * says live updates are off. Ready can also arrive before `load`, which waits for images too.
@@ -51,7 +51,7 @@ const sendDraftUpdate = (iframe, message, targetOrigin) => {
51
51
  * runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
52
52
  * to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
53
53
  */
54
- export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy = false, }) => {
54
+ export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, reveal, busy = false, }) => {
55
55
  const iframeRef = useRef(null);
56
56
  // Pin the preview origin from the src prop: outbound messages target it (never '*'),
57
57
  // and inbound messages must come from it. An iframe that navigates cross-origin
@@ -166,6 +166,19 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
166
166
  postHighlight();
167
167
  // eslint-disable-next-line react-hooks/exhaustive-deps -- postHighlight is tracked via ref
168
168
  }, [highlightEnabled]);
169
+ // Each frame, a retry's or a new page's, gets the latest reveal once it connects.
170
+ const revealSent = useRef(undefined);
171
+ useEffect(() => {
172
+ const contentWindow = iframeRef.current?.contentWindow;
173
+ if (!reveal || handshake !== 'ready' || !contentWindow || isOpaqueOrigin(previewOrigin))
174
+ return;
175
+ const frame = `${retries}:${src}`;
176
+ if (revealSent.current?.frame === frame && revealSent.current.id === reveal.id)
177
+ return;
178
+ revealSent.current = { frame, id: reveal.id };
179
+ const msg = { type: CANOPY_PREVIEW_REVEAL, path: reveal.path };
180
+ contentWindow.postMessage(msg, previewOrigin);
181
+ }, [reveal, handshake, previewOrigin, retries, src]);
169
182
  // When the preview page's React effects have run and its message listener is ready,
170
183
  // it sends CANOPY_PREVIEW_READY. We respond with the current data so the preview
171
184
  // receives the draft even if it wasn't ready when onLoad fired.
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { PreviewWidth } from './hooks/useEditorLayout.js';
3
- import { type PreviewMarks } from './PreviewFrame.js';
3
+ import { type PreviewMarks, type PreviewReveal } from './PreviewFrame.js';
4
4
  import type { InexactMark } from './preview-marks.js';
5
5
  import type { PaneArrangement } from './shell/PaneLayout.js';
6
6
  import { type PreviewMount } from './preview-path.js';
@@ -54,10 +54,15 @@ export interface PreviewPaneProps {
54
54
  onSiteChromeChange?: (siteChrome: boolean) => void;
55
55
  /** Called with the absolute URL of a link clicked in the preview; see `PreviewFrame`. */
56
56
  onNavigate?: (href: string) => void;
57
+ /** The field the form is on, for the preview to bring into view; see `usePreviewFollow`. */
58
+ reveal?: PreviewReveal;
59
+ /** With `onPreviewFollowsFormChange`, the toolbar shows the follow toggle. */
60
+ previewFollowsForm?: boolean;
61
+ onPreviewFollowsFormChange?: (follows: boolean) => void;
57
62
  }
58
63
  /**
59
64
  * The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
60
65
  * fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
61
66
  * down to fit a narrower pane.
62
67
  */
63
- export declare const PreviewPane: ({ src, path, pending, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, siteChrome, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
68
+ export declare const PreviewPane: ({ src, path, pending, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, siteChrome, onSiteChromeChange, previewFollowsForm, onPreviewFollowsFormChange, reveal, data, isLoading, onMarks, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
@@ -4,8 +4,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { ActionIcon, Box, Group, Select, Skeleton, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
5
  import { useClipboard, useElementSize, useWindowEvent } from '@mantine/hooks';
6
6
  import { IconCheck, IconExternalLink, IconRefresh } from '@tabler/icons-react';
7
- import { PreviewFrame } from './PreviewFrame.js';
8
- import { HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
7
+ import { PreviewFrame, } from './PreviewFrame.js';
8
+ import { FollowFormToggle, HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
9
9
  import { PANE_HEADER_HEIGHT } from './pane-header.js';
10
10
  import { displayPreviewPath, siteChromeSrc } from './preview-path.js';
11
11
  import { computePreviewViewport, formatPreviewWidthReadout, PREVIEW_DEVICE_WIDTHS, } from './preview-scale.js';
@@ -28,7 +28,7 @@ const OPEN_SAVED_LABEL = 'Open saved version in a new tab';
28
28
  * fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
29
29
  * down to fit a narrower pane.
30
30
  */
31
- export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }) => {
31
+ export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, previewFollowsForm = false, onPreviewFollowsFormChange, reveal, data, isLoading, onMarks, ...frameProps }) => {
32
32
  const { ref: viewportRef, width, height } = useElementSize();
33
33
  const rootRef = useRef(null);
34
34
  const maximizeRef = useRef(null);
@@ -160,13 +160,14 @@ export const PreviewPane = ({ src, path = src, pending = false, mount, previewWi
160
160
  if (isPreviewWidth(value))
161
161
  onPreviewWidthChange(value);
162
162
  }, allowDeselect: false, size: "xs", w: 96, style: { flexShrink: 0 }, styles: { input: { height: 24, minHeight: 24 } }, comboboxProps: { width: 170, position: 'bottom-end' }, renderOption: ({ option, checked }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1 }, children: option.label }), _jsx(Text, { component: "span", size: "xs", children: WIDTH_OPTIONS.find((o) => o.value === option.value)?.detail })] })) }), width > 0 && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "preview-width-readout", style: { flexShrink: 0, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, children: formatPreviewWidthReadout(viewport) })), (onHighlightToggle ||
163
+ onPreviewFollowsFormChange ||
163
164
  showChromeToggle ||
164
165
  (showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [showChromeToggle && onSiteChromeChange && (_jsx(SiteChromeToggle, { enabled: siteChrome, failed: chromeFallback === 'confirmed', onToggle: () => {
165
166
  setChromeFallback('off');
166
167
  onSiteChromeChange(!siteChrome);
167
- } })), onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: liveSrc, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
168
+ } })), onPreviewFollowsFormChange && (_jsx(FollowFormToggle, { enabled: previewFollowsForm, onToggle: () => onPreviewFollowsFormChange(!previewFollowsForm) })), onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: liveSrc, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
168
169
  // A held page gets no draft and its reports go nowhere: it belongs to another entry.
169
- data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
170
+ data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, reveal: pending ? undefined : reveal, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
170
171
  // Retry goes with the frame it sits in; the toolbar's reload offers the same action.
171
172
  reload();
172
173
  reloadButtonRef.current?.focus();
@@ -8,6 +8,12 @@ export interface HighlightToggleProps {
8
8
  }
9
9
  /** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
10
10
  export declare function HighlightToggle({ enabled, onToggle, marksNothing }: HighlightToggleProps): import("react/jsx-runtime").JSX.Element;
11
+ export interface FollowFormToggleProps {
12
+ enabled: boolean;
13
+ onToggle: () => void;
14
+ }
15
+ /** Turns on and off the preview following the form to the field it is on. */
16
+ export declare function FollowFormToggle({ enabled, onToggle }: FollowFormToggleProps): import("react/jsx-runtime").JSX.Element;
11
17
  export interface SiteChromeToggleProps {
12
18
  enabled: boolean;
13
19
  onToggle: () => void;
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
4
  import { ActionIcon, Button, Group, Popover, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
- import { IconArrowsMaximize, IconArrowsMinimize, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
5
+ import { IconArrowsMaximize, IconArrowsMinimize, IconFocusCentered, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
6
6
  const HIGHLIGHT_LABEL = 'Highlight fields in the preview';
7
7
  const NOTHING_MARKED_NOTE = "This page marks no editable elements, so nothing can be highlighted or clicked to focus. The site's developer can mark them with fieldProps.";
8
8
  /** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
@@ -11,6 +11,11 @@ export function HighlightToggle({ enabled, onToggle, marksNothing = false }) {
11
11
  const showNote = enabled && marksNothing;
12
12
  return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: HIGHLIGHT_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": HIGHLIGHT_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-highlights-button", onClick: onToggle, children: _jsx(IconMarquee, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: NOTHING_MARKED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Nothing to highlight", _jsx(VisuallyHidden, { id: noteId, children: NOTHING_MARKED_NOTE })] }) }))] }));
13
13
  }
14
+ const FOLLOW_FORM_LABEL = "Scroll the preview to the field you're on";
15
+ /** Turns on and off the preview following the form to the field it is on. */
16
+ export function FollowFormToggle({ enabled, onToggle }) {
17
+ return (_jsx(Tooltip, { label: FOLLOW_FORM_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": FOLLOW_FORM_LABEL, "data-testid": "toggle-follow-form-button", onClick: onToggle, children: _jsx(IconFocusCentered, { size: 16 }) }) }));
18
+ }
14
19
  const SITE_CHROME_LABEL = 'Show site header and footer';
15
20
  const SITE_CHROME_FAILED_NOTE = "The site couldn't show its header and footer, so the preview shows pages without them. Turn this off and on, or reload the preview, to try again.";
16
21
  /** Shows and hides the site's header and footer; says so when the site couldn't show them. */
@@ -33,6 +33,8 @@ export interface CommentThreadsPopoverProps {
33
33
  * Shown before anyone types; the box stays usable, so a draft survives until it can be posted.
34
34
  */
35
35
  composeBlockedReason?: string;
36
+ /** Called each time the popover opens, by any route. */
37
+ onOpened?: () => void;
36
38
  testId: string;
37
39
  }
38
40
  export interface OpenRequest {
@@ -24,7 +24,7 @@ const openThreadsLabel = (count) => count === 1 ? '1 open thread' : `${count} op
24
24
  * with reply and Resolve, and a box for a new thread. Comments stay open on a read-only form,
25
25
  * since reviewers comment on submitted branches.
26
26
  */
27
- export const CommentThreadsPopover = ({ threads, subject, variant, revealed = false, anchors, openRequest, currentUserId, canResolve, onAddComment, onResolveThread, onGetUserMetadata, highlightThreadId, threadFieldLabel, composeBlockedReason, testId, }) => {
27
+ export const CommentThreadsPopover = ({ threads, subject, variant, revealed = false, anchors, openRequest, currentUserId, canResolve, onAddComment, onResolveThread, onGetUserMetadata, highlightThreadId, threadFieldLabel, composeBlockedReason, onOpened, testId, }) => {
28
28
  const [opened, setOpened] = useState(false);
29
29
  const [composing, setComposing] = useState(false);
30
30
  // Drafts live here, not in the dropdown, which unmounts whenever the popover closes.
@@ -139,6 +139,14 @@ export const CommentThreadsPopover = ({ threads, subject, variant, revealed = fa
139
139
  }, 20);
140
140
  return () => window.clearTimeout(timer);
141
141
  }, [opened]);
142
+ const onOpenedRef = useRef(onOpened);
143
+ useEffect(() => {
144
+ onOpenedRef.current = onOpened;
145
+ });
146
+ useEffect(() => {
147
+ if (opened)
148
+ onOpenedRef.current?.();
149
+ }, [opened]);
142
150
  useEffect(() => {
143
151
  if (!flashThreadId)
144
152
  return;
@@ -6,6 +6,7 @@ import { CommentThreadsPopover } from './CommentThreadsPopover.js';
6
6
  import { FieldCommentContext } from './field-comment-context.js';
7
7
  import { useFormComments } from './form-comments.js';
8
8
  import { whenRevealed } from '../field-target.js';
9
+ import { usePreviewFollowRequest } from '../hooks/usePreviewFollow.js';
9
10
  const FIELD_SCOPE_ATTR = 'data-field-comments';
10
11
  /**
11
12
  * Gives a form field its comment control. The control shows while the pointer or focus is on
@@ -23,6 +24,7 @@ export const FieldWrapper = ({ children, canopyPath, entryPath, threads, autoFoc
23
24
  const highlightHere = !!highlightThreadId && threads.some((t) => t.id === highlightThreadId);
24
25
  const [openRequest, setOpenRequest] = useState(undefined);
25
26
  const formComments = useFormComments();
27
+ const followInPreview = usePreviewFollowRequest();
26
28
  useEffect(() => formComments?.register(canopyPath, {
27
29
  subject: fieldLabel,
28
30
  blockedReason: commentBlockedReason,
@@ -51,7 +53,7 @@ export const FieldWrapper = ({ children, canopyPath, entryPath, threads, autoFoc
51
53
  const ownsTarget = (target) => target instanceof Element &&
52
54
  wrapperRef.current !== null &&
53
55
  target.closest(`[${FIELD_SCOPE_ATTR}]`) === wrapperRef.current;
54
- const control = (_jsx(CommentThreadsPopover, { threads: threads, subject: fieldLabel, variant: "field", revealed: pointerOn || focusOn, anchors: anchors, openRequest: openRequest, currentUserId: currentUserId, canResolve: canResolve, onAddComment: (text, threadId) => onAddComment(text, 'field', entryPath, canopyPath, threadId), onResolveThread: onResolveThread, onGetUserMetadata: onGetUserMetadata, highlightThreadId: highlightThreadId, composeBlockedReason: commentBlockedReason, testId: `field-comment-${canopyPath}` }, resetKey));
56
+ const control = (_jsx(CommentThreadsPopover, { threads: threads, subject: fieldLabel, variant: "field", revealed: pointerOn || focusOn, anchors: anchors, openRequest: openRequest, currentUserId: currentUserId, canResolve: canResolve, onAddComment: (text, threadId) => onAddComment(text, 'field', entryPath, canopyPath, threadId), onResolveThread: onResolveThread, onGetUserMetadata: onGetUserMetadata, highlightThreadId: highlightThreadId, composeBlockedReason: commentBlockedReason, onOpened: () => followInPreview?.(canopyPath), testId: `field-comment-${canopyPath}` }, resetKey));
55
57
  return (_jsxs(Box, { ref: wrapperRef, "data-canopy-field": canopyPath, [FIELD_SCOPE_ATTR]: '', pos: "relative", style: { width: '100%' }, onMouseOver: (event) => setPointerOn(ownsTarget(event.target)), onMouseLeave: () => setPointerOn(false), onFocus: (event) => {
56
58
  const owned = ownsTarget(event.target);
57
59
  setFocusOn(owned);
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Button, Group, Stack, Textarea } from '@mantine/core';
4
+ import { Button, Group, Stack, Textarea, Tooltip } from '@mantine/core';
5
5
  import { moveFocusIfUnmoved } from './move-focus-if-unmoved.js';
6
6
  import { CommentWriteAlert, toCommentWriteFailure, } from './CommentWriteAlert.js';
7
+ const RESOLVE_BLOCKED_REASON = "Only the person who started this thread or a reviewer can resolve it. Reply to let them know it's done.";
7
8
  /** Refocuses `box` when a submit settles: the busy button disabled itself and dropped focus. */
8
9
  export const useRefocusAfterSubmit = (submitting, box) => {
9
10
  const wasSubmitting = useRef(false);
@@ -90,6 +91,8 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
90
91
  }
91
92
  };
92
93
  const mayResolve = canResolve || thread.authorId === currentUserId;
93
- const resolveButton = mayResolve && (_jsx(Button, { variant: "light", color: "green", onClick: handleResolve, loading: resolving, disabled: replying, "data-testid": "resolve-thread-button", children: hasDraft ? 'Reply and resolve' : 'Resolve' }));
94
+ // Shown disabled with its reason rather than hidden: the same user may resolve threads they start.
95
+ // `data-disabled` without native `disabled` keeps it focusable, so the Tooltip can open on focus.
96
+ const resolveButton = mayResolve ? (_jsx(Button, { variant: "light", color: "green", onClick: handleResolve, loading: resolving, disabled: replying, "data-testid": "resolve-thread-button", children: hasDraft ? 'Reply and resolve' : 'Resolve' })) : (_jsx(Tooltip, { label: RESOLVE_BLOCKED_REASON, multiline: true, maw: 280, events: { hover: true, focus: true, touch: true }, children: _jsx(Button, { variant: "light", color: "green", "data-disabled": true, "aria-disabled": true, onClick: (e) => e.preventDefault(), "data-testid": "resolve-thread-button", children: "Resolve" }) }));
94
97
  return (_jsxs(Stack, { gap: "xs", children: [error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), expanded ? (_jsxs(_Fragment, { children: [_jsx(Textarea, { ref: boxRef, placeholder: "Write a reply\u2026", "aria-label": "Reply to this thread", value: replyText, onChange: (e) => setReplyText(e.target.value), minRows: 2, disabled: replying || resolving }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { onClick: handleReply, loading: replying, disabled: !replyText.trim() || resolving, "data-testid": "submit-reply-button", children: "Reply" }), _jsx(Button, { variant: "subtle", color: "gray", onClick: collapse, disabled: replying, children: "Cancel" }), resolveButton] })] })) : (_jsxs(Group, { gap: "xs", children: [_jsx(Button, { ref: replyButtonRef, variant: "light", onClick: expand, disabled: resolving, "data-testid": "reply-thread-button", children: "Reply\u2026" }), resolveButton] }))] }));
95
98
  };
@@ -8,6 +8,8 @@ interface EditorLayoutPrefs {
8
8
  /** Unset until the user picks one, so it follows `defaultLayout`; JSON drops it while unset. */
9
9
  layout?: PaneArrangement;
10
10
  highlightEnabled: boolean;
11
+ /** The preview scrolls to the field the form is on; see `usePreviewFollow`. */
12
+ previewFollowsForm: boolean;
11
13
  sideSplitPercent: number;
12
14
  stackedSplitPercent: number;
13
15
  previewWidth: PreviewWidth;
@@ -42,6 +44,8 @@ export interface UseEditorLayoutReturn {
42
44
  setLayout: (layout: PaneArrangement) => void;
43
45
  highlightEnabled: boolean;
44
46
  setHighlightEnabled: (enabled: boolean) => void;
47
+ previewFollowsForm: boolean;
48
+ setPreviewFollowsForm: (follows: boolean) => void;
45
49
  sideSplitPercent: number;
46
50
  stackedSplitPercent: number;
47
51
  /** Stores the clamped primary-pane size for one layout; a non-finite percent is ignored. */
@@ -12,6 +12,7 @@ export const CONTENT_PANEL_WIDTH_MAX = 400;
12
12
  */
13
13
  export const DEFAULT_EDITOR_LAYOUT_PREFS = {
14
14
  highlightEnabled: false,
15
+ previewFollowsForm: true,
15
16
  sideSplitPercent: DEFAULT_SIDE_SPLIT_PERCENT,
16
17
  stackedSplitPercent: DEFAULT_STACKED_SPLIT_PERCENT,
17
18
  previewWidth: 'fit',
@@ -48,6 +49,7 @@ export function parseEditorLayoutPrefs(raw) {
48
49
  return {
49
50
  layout: PANE_LAYOUTS.find((layout) => layout === parsed.layout),
50
51
  highlightEnabled: boolOr(parsed.highlightEnabled, defaults.highlightEnabled),
52
+ previewFollowsForm: boolOr(parsed.previewFollowsForm, defaults.previewFollowsForm),
51
53
  sideSplitPercent: numberOr(parsed.sideSplitPercent, defaults.sideSplitPercent, clampSplitPercent),
52
54
  stackedSplitPercent: numberOr(parsed.stackedSplitPercent, defaults.stackedSplitPercent, clampSplitPercent),
53
55
  previewWidth: oneOf(PREVIEW_WIDTHS, parsed.previewWidth, defaults.previewWidth),
@@ -73,6 +75,7 @@ export function useEditorLayout(options) {
73
75
  const loaded = useMounted();
74
76
  const setLayout = useCallback((layout) => setPrefs((prev) => ({ ...prev, layout })), [setPrefs]);
75
77
  const setHighlightEnabled = useCallback((highlightEnabled) => setPrefs((prev) => ({ ...prev, highlightEnabled })), [setPrefs]);
78
+ const setPreviewFollowsForm = useCallback((previewFollowsForm) => setPrefs((prev) => ({ ...prev, previewFollowsForm })), [setPrefs]);
76
79
  const setSplitPercent = useCallback((layout, percent) => {
77
80
  if (!Number.isFinite(percent))
78
81
  return;
@@ -96,6 +99,8 @@ export function useEditorLayout(options) {
96
99
  setLayout,
97
100
  highlightEnabled: prefs.highlightEnabled,
98
101
  setHighlightEnabled,
102
+ previewFollowsForm: prefs.previewFollowsForm,
103
+ setPreviewFollowsForm,
99
104
  sideSplitPercent: prefs.sideSplitPercent,
100
105
  stackedSplitPercent: prefs.stackedSplitPercent,
101
106
  setSplitPercent,
@@ -0,0 +1,26 @@
1
+ import type { PreviewReveal } from '../PreviewFrame.js';
2
+ /**
3
+ * How long the form rests on one field before the preview follows, so tabbing through doesn't.
4
+ * @internal Exported for tests.
5
+ */
6
+ export declare const PREVIEW_FOLLOW_DELAY_MS = 250;
7
+ /** Has the preview follow the form to a field; null while it doesn't follow. */
8
+ export declare const PreviewFollowContext: import("react").Context<((canopyPath: string) => void) | null>;
9
+ export declare const usePreviewFollowRequest: () => ((canopyPath: string) => void) | null;
10
+ export interface UsePreviewFollowReturn {
11
+ /** The latest field to bring into view in the preview, for `PreviewFrame`'s `reveal`. */
12
+ reveal: PreviewReveal | undefined;
13
+ /** Asks for a field, as a comment opening on it does; undefined while the preview doesn't follow. */
14
+ follow: ((canopyPath: string) => void) | undefined;
15
+ }
16
+ /**
17
+ * The preview follows the form: focus moving to another form field, or `follow` for one, asks the
18
+ * preview to bring that field's element into view, once the form has rested on it for
19
+ * `PREVIEW_FOLLOW_DELAY_MS`. A reveal belongs to one page, which the branch names too, so a change
20
+ * of either drops it.
21
+ */
22
+ export declare function usePreviewFollow({ enabled, branch, entryId, }: {
23
+ enabled: boolean;
24
+ branch: string;
25
+ entryId: string | undefined;
26
+ }): UsePreviewFollowReturn;
@@ -0,0 +1,54 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
2
+ import { normalizeCanopyPath } from '../canopy-path.js';
3
+ import { FORM_PANE_ID } from '../shell/PaneLayout.js';
4
+ /**
5
+ * How long the form rests on one field before the preview follows, so tabbing through doesn't.
6
+ * @internal Exported for tests.
7
+ */
8
+ export const PREVIEW_FOLLOW_DELAY_MS = 250;
9
+ /** Has the preview follow the form to a field; null while it doesn't follow. */
10
+ export const PreviewFollowContext = createContext(null);
11
+ export const usePreviewFollowRequest = () => useContext(PreviewFollowContext);
12
+ /**
13
+ * The preview follows the form: focus moving to another form field, or `follow` for one, asks the
14
+ * preview to bring that field's element into view, once the form has rested on it for
15
+ * `PREVIEW_FOLLOW_DELAY_MS`. A reveal belongs to one page, which the branch names too, so a change
16
+ * of either drops it.
17
+ */
18
+ export function usePreviewFollow({ enabled, branch, entryId, }) {
19
+ const [reveal, setReveal] = useState(undefined);
20
+ const timer = useRef(undefined);
21
+ const lastId = useRef(0);
22
+ const lastPath = useRef(undefined);
23
+ const follow = useCallback((canopyPath) => {
24
+ lastPath.current = normalizeCanopyPath(canopyPath);
25
+ window.clearTimeout(timer.current);
26
+ timer.current = window.setTimeout(() => {
27
+ lastId.current += 1;
28
+ setReveal({ path: normalizeCanopyPath(canopyPath), id: lastId.current });
29
+ }, PREVIEW_FOLLOW_DELAY_MS);
30
+ }, []);
31
+ useEffect(() => {
32
+ window.clearTimeout(timer.current);
33
+ lastPath.current = undefined;
34
+ setReveal(undefined);
35
+ }, [branch, entryId, enabled]);
36
+ useEffect(() => {
37
+ if (!enabled)
38
+ return;
39
+ const onFocusIn = (event) => {
40
+ const target = event.target;
41
+ if (!(target instanceof Element) || !target.closest(`#${FORM_PANE_ID}`))
42
+ return;
43
+ const path = target.closest('[data-canopy-field]')?.getAttribute('data-canopy-field');
44
+ // Focus coming back to the field, after a popover or the window, leaves the preview as the
45
+ // user scrolled it; a comment opening still asks, through `follow`.
46
+ if (path && normalizeCanopyPath(path) !== lastPath.current)
47
+ follow(path);
48
+ };
49
+ document.addEventListener('focusin', onFocusIn);
50
+ return () => document.removeEventListener('focusin', onFocusIn);
51
+ }, [enabled, follow]);
52
+ useEffect(() => () => window.clearTimeout(timer.current), []);
53
+ return { reveal, follow: enabled ? follow : undefined };
54
+ }
@@ -6,6 +6,7 @@ export declare const CANOPY_PREVIEW_READY = "canopycms:preview:ready";
6
6
  export declare const CANOPY_PREVIEW_ERROR = "canopycms:preview:error";
7
7
  export declare const CANOPY_PREVIEW_MARKS = "canopycms:preview:marks";
8
8
  export declare const CANOPY_PREVIEW_NAVIGATE = "canopycms:preview:navigate";
9
+ export declare const CANOPY_PREVIEW_REVEAL = "canopycms:preview:reveal";
9
10
  /**
10
11
  * The shape of the preview's `isLoading` for data of type `T`: a `boolean` at each reference
11
12
  * position, `true` while the editor is still resolving that reference, under the same keys and
@@ -85,6 +86,15 @@ export interface PreviewMarksMessage {
85
86
  count: number;
86
87
  paths?: string[];
87
88
  }
89
+ /**
90
+ * Editor → preview: bring the element marked for this field into view, else the marks inside it,
91
+ * else its nearest marked ancestor's (`findRevealTargets`). A bridge older than the message
92
+ * ignores it.
93
+ */
94
+ export interface PreviewRevealMessage {
95
+ type: typeof CANOPY_PREVIEW_REVEAL;
96
+ path: string;
97
+ }
88
98
  /** Preview → editor: a plain click on a link to another page of this origin, left unfollowed. */
89
99
  export interface PreviewNavigateMessage {
90
100
  type: typeof CANOPY_PREVIEW_NAVIGATE;
@@ -146,6 +156,21 @@ export declare const usePreviewAssetBaseGate: (assetBase: unknown) => boolean;
146
156
  export declare const usePreviewHighlight: (opts?: {
147
157
  editorOrigin?: string;
148
158
  }) => boolean;
159
+ /**
160
+ * The rendered elements marked for `path`, else those inside it in page order, such as a list
161
+ * marked item by item, else those for its nearest marked ancestor: a list item, then its list or
162
+ * block. Compares values, never builds a selector: the path is untrusted.
163
+ * @internal Exported for tests.
164
+ */
165
+ export declare const findRevealTargets: (path: string, root?: ParentNode) => HTMLElement[];
166
+ /**
167
+ * Hook for preview pages to bring the element the editor's form is on into view and mark it
168
+ * briefly, whether or not highlighting is on. It leaves focus where it is, and an element already
169
+ * as much in view as it can be where it is.
170
+ */
171
+ export declare const usePreviewReveal: (opts?: {
172
+ editorOrigin?: string;
173
+ }) => void;
149
174
  /**
150
175
  * Hook for preview pages to emit focus messages when elements with data-canopy-path are clicked.
151
176
  * A click that follows a link, ours or the browser's, goes to another page, not to a field.
@@ -5,6 +5,7 @@
5
5
  * (`pnpm lint:bundle`); the editor's side is `PreviewFrame.tsx`.
6
6
  */
7
7
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
8
+ import { formatCanopyPath, normalizeCanopyPath, parseCanopyPath } from './canopy-path.js';
8
9
  import { createFieldProps } from './field-props.js';
9
10
  import { setPreviewAssetBase } from './preview-asset-base.js';
10
11
  import { isSamePreviewPath } from './preview-path.js';
@@ -16,6 +17,7 @@ export const CANOPY_PREVIEW_READY = 'canopycms:preview:ready';
16
17
  export const CANOPY_PREVIEW_ERROR = 'canopycms:preview:error';
17
18
  export const CANOPY_PREVIEW_MARKS = 'canopycms:preview:marks';
18
19
  export const CANOPY_PREVIEW_NAVIGATE = 'canopycms:preview:navigate';
20
+ export const CANOPY_PREVIEW_REVEAL = 'canopycms:preview:reveal';
19
21
  /**
20
22
  * Resolve a (possibly relative) URL to an origin for postMessage targeting.
21
23
  * Falls back to the page's own origin, so same-origin editor/preview setups
@@ -80,6 +82,7 @@ export const useCanopyPreview = (opts) => {
80
82
  });
81
83
  const highlightEnabled = usePreviewHighlight(bridgeOpts);
82
84
  usePreviewFocusEmitter(resolvedPath, bridgeOpts);
85
+ usePreviewReveal(bridgeOpts);
83
86
  usePreviewLinkNavigation({ editorOrigin, highlighting: highlightEnabled });
84
87
  const fieldProps = createFieldProps();
85
88
  /**
@@ -273,6 +276,178 @@ export const usePreviewHighlight = (opts) => {
273
276
  }, [enabled, editorOrigin]);
274
277
  return enabled;
275
278
  };
279
+ const REVEALED_ATTR = 'data-canopy-revealed';
280
+ /** How long a revealed element stays marked. */
281
+ const REVEAL_MARK_MS = 1500;
282
+ /** A field just opened may render after its reveal arrives, so a miss is tried once more. */
283
+ const REVEAL_RETRY_MS = 300;
284
+ /** How long the page's scrolling must pause before a smooth scroll counts as arrived. */
285
+ const REVEAL_SCROLL_SETTLE_MS = 150;
286
+ /** The longest a smooth scroll is waited for before the mark's time starts anyway. */
287
+ const REVEAL_SCROLL_WAIT_MS = 3000;
288
+ /**
289
+ * The rendered elements marked for `path`, else those inside it in page order, such as a list
290
+ * marked item by item, else those for its nearest marked ancestor: a list item, then its list or
291
+ * block. Compares values, never builds a selector: the path is untrusted.
292
+ * @internal Exported for tests.
293
+ */
294
+ export const findRevealTargets = (path, root = document) => {
295
+ const byPath = new Map();
296
+ for (const element of root.querySelectorAll('[data-canopy-path]')) {
297
+ if (element.getClientRects().length === 0)
298
+ continue;
299
+ const key = normalizeCanopyPath(element.getAttribute('data-canopy-path') ?? '');
300
+ byPath.set(key, [...(byPath.get(key) ?? []), element]);
301
+ }
302
+ const segments = parseCanopyPath(path);
303
+ const field = formatCanopyPath(segments);
304
+ if (!field)
305
+ return [];
306
+ const exact = byPath.get(field);
307
+ if (exact)
308
+ return exact;
309
+ const inside = [...byPath]
310
+ .filter(([key]) => key.startsWith(`${field}.`) || key.startsWith(`${field}[`))
311
+ .flatMap(([, elements]) => elements)
312
+ .sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
313
+ if (inside.length > 0)
314
+ return inside;
315
+ for (let length = segments.length - 1; length > 0; length--) {
316
+ const found = byPath.get(formatCanopyPath(segments.slice(0, length)));
317
+ if (found)
318
+ return found;
319
+ }
320
+ return [];
321
+ };
322
+ const CLIPPING = new Set(['hidden', 'clip', 'auto', 'scroll']);
323
+ /** Where `element` can show: the window, clipped by each ancestor whose overflow clips. */
324
+ const shownRect = (element) => {
325
+ const shown = { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };
326
+ for (let at = element.parentElement; at && at !== document.body; at = at.parentElement) {
327
+ const style = getComputedStyle(at);
328
+ const overflow = [style.overflowX, style.overflowY, ...style.overflow.split(' ')];
329
+ if (!overflow.some((value) => CLIPPING.has(value)))
330
+ continue;
331
+ const clip = at.getBoundingClientRect();
332
+ shown.top = Math.max(shown.top, clip.top);
333
+ shown.bottom = Math.min(shown.bottom, clip.bottom);
334
+ shown.left = Math.max(shown.left, clip.left);
335
+ shown.right = Math.min(shown.right, clip.right);
336
+ }
337
+ return shown;
338
+ };
339
+ /** As much of `element` shows as can: all of it, or all the room there is for a larger one. */
340
+ const isInView = (element) => {
341
+ const rect = element.getBoundingClientRect();
342
+ const room = shownRect(element);
343
+ // A clipping box off the page has no room at all, so nothing in it shows.
344
+ if (room.bottom <= room.top || room.right <= room.left)
345
+ return false;
346
+ const tall = Math.min(rect.bottom, room.bottom) - Math.max(rect.top, room.top);
347
+ const wide = Math.min(rect.right, room.right) - Math.max(rect.left, room.left);
348
+ return (tall >= Math.min(rect.height, room.bottom - room.top) - 1 &&
349
+ wide >= Math.min(rect.width, room.right - room.left) - 1);
350
+ };
351
+ /**
352
+ * Hook for preview pages to bring the element the editor's form is on into view and mark it
353
+ * briefly, whether or not highlighting is on. It leaves focus where it is, and an element already
354
+ * as much in view as it can be where it is.
355
+ */
356
+ export const usePreviewReveal = (opts) => {
357
+ const editorOrigin = opts?.editorOrigin;
358
+ useEffect(() => {
359
+ if (window.parent === window)
360
+ return;
361
+ const styleId = 'canopycms-preview-reveal-style';
362
+ if (!document.getElementById(styleId)) {
363
+ const styleEl = document.createElement('style');
364
+ styleEl.id = styleId;
365
+ // Two attributes, so it outranks the highlight outline.
366
+ styleEl.textContent = `[data-canopy-path][${REVEALED_ATTR}] { outline: 3px solid #4338ca; outline-offset: 3px; }`;
367
+ document.head.appendChild(styleEl);
368
+ }
369
+ let marked;
370
+ let unmarkTimer;
371
+ let retryTimer;
372
+ let stopWaiting;
373
+ const unmark = () => {
374
+ stopWaiting?.();
375
+ clearTimeout(unmarkTimer);
376
+ marked?.removeAttribute(REVEALED_ATTR);
377
+ marked = undefined;
378
+ };
379
+ // The mark's time starts once the scrolling that holds `target` pauses, so a slow smooth
380
+ // scroll can't use it up; another scroller on the page, such as a carousel, doesn't count.
381
+ const unmarkAfterScroll = (target, smooth) => {
382
+ if (!smooth) {
383
+ unmarkTimer = setTimeout(unmark, REVEAL_MARK_MS);
384
+ return;
385
+ }
386
+ const arrived = () => {
387
+ stopWaiting?.();
388
+ unmarkTimer = setTimeout(unmark, REVEAL_MARK_MS);
389
+ };
390
+ let settle = setTimeout(arrived, REVEAL_SCROLL_SETTLE_MS);
391
+ const cap = setTimeout(arrived, REVEAL_SCROLL_WAIT_MS);
392
+ const onScroll = (event) => {
393
+ const from = event.target;
394
+ if (from !== document && !(from instanceof Node && from.contains(target)))
395
+ return;
396
+ clearTimeout(settle);
397
+ settle = setTimeout(arrived, REVEAL_SCROLL_SETTLE_MS);
398
+ };
399
+ document.addEventListener('scroll', onScroll, true);
400
+ stopWaiting = () => {
401
+ clearTimeout(settle);
402
+ clearTimeout(cap);
403
+ document.removeEventListener('scroll', onScroll, true);
404
+ stopWaiting = undefined;
405
+ };
406
+ };
407
+ const reveal = (path) => {
408
+ const targets = findRevealTargets(path);
409
+ if (targets.length === 0)
410
+ return false;
411
+ // Mid-scroll, a mark passed on the way only looks in view.
412
+ if (!stopWaiting && targets.some(isInView))
413
+ return true;
414
+ const target = targets[0];
415
+ const smooth = !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
416
+ target.scrollIntoView({
417
+ // One taller than the page shows its top.
418
+ block: target.getBoundingClientRect().height > window.innerHeight ? 'start' : 'center',
419
+ inline: 'nearest',
420
+ // `instant`, not `auto`, which a page's `scroll-behavior: smooth` animates.
421
+ behavior: smooth ? 'smooth' : 'instant',
422
+ });
423
+ unmark();
424
+ // An attribute and a stylesheet only: the page's own styles are never written to.
425
+ marked = target;
426
+ target.setAttribute(REVEALED_ATTR, '');
427
+ unmarkAfterScroll(target, smooth);
428
+ return true;
429
+ };
430
+ const handler = (event) => {
431
+ if (!isTrustedEditorMessage(event, editorOrigin))
432
+ return;
433
+ const msg = event.data;
434
+ if (msg?.type !== CANOPY_PREVIEW_REVEAL)
435
+ return;
436
+ const path = msg.path;
437
+ if (typeof path !== 'string' || path.length > MARK_REPORT_LIMITS.pathLength)
438
+ return;
439
+ clearTimeout(retryTimer);
440
+ if (!reveal(path))
441
+ retryTimer = setTimeout(() => reveal(path), REVEAL_RETRY_MS);
442
+ };
443
+ window.addEventListener('message', handler);
444
+ return () => {
445
+ window.removeEventListener('message', handler);
446
+ clearTimeout(retryTimer);
447
+ unmark();
448
+ };
449
+ }, [editorOrigin]);
450
+ };
276
451
  const isPlainClick = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
277
452
  const linkClick = (event) => {
278
453
  // The composed path reaches a link inside a shadow root, which the retargeted `target` hides.
package/dist/preview.d.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  * CSS, `@mantine/*` and editor UI (`pnpm lint:bundle`): a page that imports it renders in the
4
4
  * adopter's own styles inside the editor's preview, and ships no editor code to the public.
5
5
  */
6
- export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, } from './editor/preview-bridge.js';
7
- export type { DraftUpdateMessage, HighlightMessage, PreviewErrorMessage, PreviewFocusMessage, PreviewLoadingState, } from './editor/preview-bridge.js';
6
+ export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, CANOPY_PREVIEW_REVEAL, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, usePreviewReveal, } from './editor/preview-bridge.js';
7
+ export type { DraftUpdateMessage, HighlightMessage, PreviewErrorMessage, PreviewFocusMessage, PreviewLoadingState, PreviewRevealMessage, } from './editor/preview-bridge.js';
8
8
  export type { CanopyPathSegment } from './editor/canopy-path.js';
package/dist/preview.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * CSS, `@mantine/*` and editor UI (`pnpm lint:bundle`): a page that imports it renders in the
5
5
  * adopter's own styles inside the editor's preview, and ships no editor code to the public.
6
6
  */
7
- export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, } from './editor/preview-bridge.js';
7
+ export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, CANOPY_PREVIEW_REVEAL, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, usePreviewReveal, } from './editor/preview-bridge.js';