canopycms 0.9.0 → 0.11.0
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/CHANGELOG.md +74 -48
- package/dist/api/admin.d.ts +11 -0
- package/dist/api/admin.js +2 -0
- package/dist/api/archived-branches.d.ts +70 -0
- package/dist/api/archived-branches.js +146 -0
- package/dist/api/branch-merge.js +16 -1
- package/dist/api/branch-status.d.ts +11 -0
- package/dist/api/branch-status.js +2 -0
- package/dist/api/branch-withdraw.js +1 -1
- package/dist/api/branch.js +46 -108
- package/dist/api/client.d.ts +25 -21
- package/dist/api/client.js +46 -38
- package/dist/branch-archive.d.ts +19 -0
- package/dist/branch-archive.js +27 -0
- package/dist/branch-delete.d.ts +45 -0
- package/dist/branch-delete.js +97 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/branch-metadata.d.ts +6 -1
- package/dist/branch-metadata.js +15 -2
- package/dist/cli/cli.js +2 -0
- package/dist/cli/generate-ai-content.js +1 -0
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +49 -25
- package/dist/editor/PreviewFrame.d.ts +8 -1
- package/dist/editor/PreviewFrame.js +15 -2
- package/dist/editor/PreviewPane.d.ts +7 -2
- package/dist/editor/PreviewPane.js +6 -5
- package/dist/editor/PreviewToolbarTools.d.ts +6 -0
- package/dist/editor/PreviewToolbarTools.js +6 -1
- package/dist/editor/admin/SystemHealthPanel.js +61 -6
- package/dist/editor/admin/useSystemHealth.d.ts +3 -0
- package/dist/editor/admin/useSystemHealth.js +59 -0
- package/dist/editor/branch-status.js +9 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
- package/dist/editor/comments/CommentThreadsPopover.js +9 -1
- package/dist/editor/comments/FieldWrapper.js +3 -1
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/hooks/useBranchManager.d.ts +5 -0
- package/dist/editor/hooks/useBranchManager.js +55 -4
- package/dist/editor/hooks/useBranchesData.d.ts +2 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
- package/dist/editor/hooks/usePreviewFollow.js +54 -0
- package/dist/editor/preview-bridge.d.ts +25 -0
- package/dist/editor/preview-bridge.js +175 -0
- package/dist/preview.d.ts +2 -2
- package/dist/preview.js +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/archived-sweep.d.ts +23 -0
- package/dist/worker/archived-sweep.js +137 -0
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +7 -0
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +3 -0
- package/dist/worker/github-commits.d.ts +2 -2
- package/dist/worker/github-commits.js +5 -2
- package/dist/worker/github-gateway-wire.d.ts +2 -2
- package/dist/worker/rebase.js +6 -2
- package/dist/worker/task-runner.js +7 -4
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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);
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const EDITOR_ACTIONS: {
|
|
|
14
14
|
readonly requestChanges: "Request changes";
|
|
15
15
|
readonly switchBranch: "Switch branch";
|
|
16
16
|
readonly newBranch: "New branch";
|
|
17
|
+
readonly archiveBranch: "Archive branch";
|
|
17
18
|
readonly remove: "Remove";
|
|
18
19
|
readonly restore: "Restore";
|
|
19
20
|
readonly clear: "Clear";
|
|
@@ -34,5 +35,7 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
|
|
|
34
35
|
* says how to finish the submit, where it can be retried at all.
|
|
35
36
|
*/
|
|
36
37
|
export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
|
|
38
|
+
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
39
|
+
export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
|
|
37
40
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|
|
38
41
|
export declare const CLOSED_PULL_REQUEST_REASON = "The pull request was closed on GitHub. Use \"Withdraw from review\", then submit it again to open a new one.";
|
package/dist/editor/copy.js
CHANGED
|
@@ -14,6 +14,7 @@ export const EDITOR_ACTIONS = {
|
|
|
14
14
|
requestChanges: 'Request changes',
|
|
15
15
|
switchBranch: 'Switch branch',
|
|
16
16
|
newBranch: 'New branch',
|
|
17
|
+
archiveBranch: 'Archive branch',
|
|
17
18
|
remove: 'Remove',
|
|
18
19
|
restore: 'Restore',
|
|
19
20
|
clear: 'Clear',
|
|
@@ -36,5 +37,7 @@ export const rewrittenSubmitReason = (isAdmin) => isAdmin
|
|
|
36
37
|
* says how to finish the submit, where it can be retried at all.
|
|
37
38
|
*/
|
|
38
39
|
export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
|
|
40
|
+
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
41
|
+
export const DELETE_NEEDS_ADMIN_REASON = 'This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.';
|
|
39
42
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|
|
40
43
|
export const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Use "Withdraw from review", then submit it again to open a new one.';
|
|
@@ -28,8 +28,11 @@ export declare const CREATED_BRANCH_GRACE_MS = 120000;
|
|
|
28
28
|
interface BranchSummary {
|
|
29
29
|
name: string;
|
|
30
30
|
shortName?: string;
|
|
31
|
+
title?: string;
|
|
31
32
|
status: string;
|
|
32
33
|
createdBy?: string;
|
|
34
|
+
/** The creator's display name, when the listing carried it. */
|
|
35
|
+
ownerName?: string;
|
|
33
36
|
updatedAt?: string;
|
|
34
37
|
access: {
|
|
35
38
|
users: string[] | undefined;
|
|
@@ -126,6 +129,8 @@ export interface UseBranchManagerReturn {
|
|
|
126
129
|
/** Confirms first, taking an optional note; focus returns as for `handleApprove`. */
|
|
127
130
|
handleRequestChanges: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
|
|
128
131
|
handleDelete: (branchName: string) => Promise<void>;
|
|
132
|
+
/** Admin only: confirms first, then archives a branch whose pull request closed unmerged. */
|
|
133
|
+
handleArchive: (branchName: string) => Promise<void>;
|
|
129
134
|
handleReloadBranchData: () => Promise<void>;
|
|
130
135
|
loadBranches: () => Promise<void>;
|
|
131
136
|
}
|
|
@@ -201,7 +201,7 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
|
|
|
201
201
|
: undefined,
|
|
202
202
|
'Discard any unsaved or unmerged changes',
|
|
203
203
|
mayHoldUnpublished
|
|
204
|
-
? 'Remove the only copy of any changes
|
|
204
|
+
? 'Remove the only copy of any changes missing from GitHub, and the warning about them'
|
|
205
205
|
: undefined,
|
|
206
206
|
].filter((b) => b !== undefined);
|
|
207
207
|
openConfirm({
|
|
@@ -217,6 +217,16 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
|
|
|
217
217
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
218
218
|
});
|
|
219
219
|
};
|
|
220
|
+
/** Archiving is a one-way status change on a branch whose review is over. */
|
|
221
|
+
const showArchiveConfirmation = (branchName, inReview, onConfirm, onDismiss) => {
|
|
222
|
+
openConfirm({
|
|
223
|
+
title: EDITOR_ACTIONS.archiveBranch,
|
|
224
|
+
children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Archive “${branchName}”?\n\nIts pull request was closed without merging. This will:\n• Make the branch read-only and move it to “Archived”\n• Keep it marked as not merged\n\nAn archived branch can't go back to editing. This one stays until someone deletes it: automatic and bulk deletes only take merged branches.${inReview ? ` To keep working on it, use “${EDITOR_ACTIONS.withdrawFromReview}” instead.` : ''}` })),
|
|
225
|
+
labels: { confirm: EDITOR_ACTIONS.archiveBranch, cancel: 'Cancel' },
|
|
226
|
+
confirmProps: { color: 'orange', 'data-testid': 'confirm-archive-branch' },
|
|
227
|
+
...confirmModalHandlers(onConfirm, onDismiss),
|
|
228
|
+
});
|
|
229
|
+
};
|
|
220
230
|
/**
|
|
221
231
|
* How long a branch this session created stays overlaid on listings. Each warm
|
|
222
232
|
* server container caches the shared filesystem separately, so for its
|
|
@@ -381,8 +391,10 @@ export function useBranchManager(options) {
|
|
|
381
391
|
return {
|
|
382
392
|
name: b.name,
|
|
383
393
|
shortName: b.shortName,
|
|
394
|
+
title: b.title,
|
|
384
395
|
status: b.status,
|
|
385
396
|
createdBy: b.createdBy,
|
|
397
|
+
ownerName: branchesData?.ownerNames?.[b.createdBy],
|
|
386
398
|
updatedAt: b.updatedAt,
|
|
387
399
|
access: {
|
|
388
400
|
users: b.access.allowedUsers,
|
|
@@ -423,7 +435,7 @@ export function useBranchManager(options) {
|
|
|
423
435
|
submitBlocked: b.submitBlocked ?? true,
|
|
424
436
|
};
|
|
425
437
|
});
|
|
426
|
-
}, [branches, currentBranchName, options.comments]);
|
|
438
|
+
}, [branches, branchesData?.ownerNames, currentBranchName, options.comments]);
|
|
427
439
|
// Explicit reload: always issues a fresh, independent fetch (raw call, not
|
|
428
440
|
// SWR's `mutate()` revalidate path) so callers requesting a reload right
|
|
429
441
|
// after mount aren't coalesced against the still-in-flight automatic load
|
|
@@ -730,6 +742,45 @@ export function useBranchManager(options) {
|
|
|
730
742
|
}, () => resolve(), returnFocusTo);
|
|
731
743
|
}));
|
|
732
744
|
};
|
|
745
|
+
const handleArchive = (requested) => {
|
|
746
|
+
const branchNameToArchive = listedName(requested);
|
|
747
|
+
return singleFlight('archive', branchNameToArchive, () => new Promise((resolve, reject) => {
|
|
748
|
+
const status = latest.current.branches.find((b) => b.name === branchNameToArchive)?.status;
|
|
749
|
+
showArchiveConfirmation(shownName(branchNameToArchive), status === 'submitted' || status === 'approved', async () => {
|
|
750
|
+
options.setBusy(true);
|
|
751
|
+
try {
|
|
752
|
+
const result = await apiClient.workflow.archive({
|
|
753
|
+
branch: sendName(branchNameToArchive),
|
|
754
|
+
});
|
|
755
|
+
if (!result.ok)
|
|
756
|
+
throw actionError(result, 'Failed to archive branch');
|
|
757
|
+
notifications.show({ message: 'Branch archived', color: 'blue' });
|
|
758
|
+
forgetCreatedBranch(branchNameToArchive);
|
|
759
|
+
await loadBranches();
|
|
760
|
+
resolve();
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
if (err instanceof RefusedError && err.status === 409) {
|
|
764
|
+
// Nothing is lost, and the list says what changed.
|
|
765
|
+
notifications.show({
|
|
766
|
+
message: err.message,
|
|
767
|
+
color: 'yellow',
|
|
768
|
+
autoClose: false,
|
|
769
|
+
withCloseButton: true,
|
|
770
|
+
});
|
|
771
|
+
await reloadBranches();
|
|
772
|
+
resolve();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
await reportActionFailure(err, 'Failed to archive branch', EDITOR_ACTIONS.archiveBranch);
|
|
776
|
+
reject(err);
|
|
777
|
+
}
|
|
778
|
+
finally {
|
|
779
|
+
options.setBusy(false);
|
|
780
|
+
}
|
|
781
|
+
}, () => resolve());
|
|
782
|
+
}));
|
|
783
|
+
};
|
|
733
784
|
const handleApprove = (requested, returnFocusTo) => {
|
|
734
785
|
const branchNameToApprove = listedName(requested);
|
|
735
786
|
return singleFlight('approve', branchNameToApprove, () => new Promise((resolve, reject) => {
|
|
@@ -838,8 +889,7 @@ export function useBranchManager(options) {
|
|
|
838
889
|
const branchNameToDelete = listedName(requested);
|
|
839
890
|
const listedBefore = latest.current.branches;
|
|
840
891
|
return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
|
|
841
|
-
showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind
|
|
842
|
-
'unpublished-at-merge', async () => {
|
|
892
|
+
showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), ['unpublished-at-merge', 'rewritten-at-merge'].includes(listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind ?? ''), async () => {
|
|
843
893
|
options.setBusy(true);
|
|
844
894
|
try {
|
|
845
895
|
const result = await apiClient.branches.delete({
|
|
@@ -960,6 +1010,7 @@ export function useBranchManager(options) {
|
|
|
960
1010
|
handleApprove,
|
|
961
1011
|
handleRequestChanges,
|
|
962
1012
|
handleDelete,
|
|
1013
|
+
handleArchive,
|
|
963
1014
|
handleReloadBranchData,
|
|
964
1015
|
loadBranches,
|
|
965
1016
|
};
|
|
@@ -19,6 +19,8 @@ export declare const BRANCHES_KEY = "canopy:branches";
|
|
|
19
19
|
export interface BranchesData {
|
|
20
20
|
branches: BranchListItem[];
|
|
21
21
|
defaultBranch?: string;
|
|
22
|
+
/** Display names of the listed branches' creators, by user id. */
|
|
23
|
+
ownerNames?: Readonly<Record<string, string>>;
|
|
22
24
|
/**
|
|
23
25
|
* Client clock when this listing arrived. It makes listings fetched at
|
|
24
26
|
* different moments distinct `data` values even when their branches match,
|
|
@@ -33,10 +33,12 @@ export async function fetchBranches(apiClient, swr) {
|
|
|
33
33
|
if (!result.ok) {
|
|
34
34
|
throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
const users = result.data?.users ?? [];
|
|
37
|
+
await seedUserProfiles(swr, users);
|
|
37
38
|
return {
|
|
38
39
|
branches: result.data?.branches ?? [],
|
|
39
40
|
defaultBranch: result.data?.defaultBranch,
|
|
41
|
+
ownerNames: Object.fromEntries(users.map((user) => [user.id, user.name])),
|
|
40
42
|
receivedAt: Date.now(),
|
|
41
43
|
};
|
|
42
44
|
}
|
|
@@ -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';
|