canopycms 0.1.0-int.115 → 0.1.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 +267 -231
- package/README.md +0 -1
- package/dist/ai/references.js +3 -1
- package/dist/api/assets.js +2 -2
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch.js +1 -1
- package/dist/api/content.js +1 -1
- package/dist/api/entries-constants.d.ts +1 -1
- package/dist/api/entries-constants.js +1 -1
- package/dist/api/entries.d.ts +2 -2
- package/dist/api/permissions.d.ts +2 -2
- package/dist/api/types.d.ts +3 -2
- package/dist/cli/cli.js +16 -14
- package/dist/cli/generate-ai-content.js +7 -5
- package/dist/cli/init.js +4 -4
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/config/schemas/config.d.ts +0 -5
- package/dist/config/schemas/config.js +0 -1
- package/dist/config/types.d.ts +0 -1
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.js +1 -1
- package/dist/content-reader.js +1 -1
- package/dist/content-store.d.ts +1 -1
- package/dist/content-store.js +1 -1
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +5 -4
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.js +3 -6
- package/dist/editor/Editor.d.ts +0 -2
- package/dist/editor/Editor.js +329 -181
- package/dist/editor/EditorAuthGate.js +8 -3
- package/dist/editor/EntryNavigator.d.ts +10 -0
- package/dist/editor/EntryNavigator.js +51 -2
- package/dist/editor/FormHeader.js +1 -1
- package/dist/editor/PreviewFrame.d.ts +5 -1
- package/dist/editor/PreviewFrame.js +3 -2
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/comments/form-comments.js +2 -1
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/EditorHeader.d.ts +50 -108
- package/dist/editor/components/EditorHeader.js +117 -138
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
- package/dist/editor/components/QuickOpenPalette.js +3 -2
- package/dist/editor/components/RenameEntryModal.d.ts +3 -1
- package/dist/editor/components/RenameEntryModal.js +3 -2
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/editor-utils.d.ts +11 -6
- package/dist/editor/editor-utils.js +8 -17
- package/dist/editor/fields/MarkdownField.js +3 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
- package/dist/editor/hooks/useCommentSystem.js +3 -0
- package/dist/editor/hooks/useDraftManager.d.ts +24 -1
- package/dist/editor/hooks/useDraftManager.js +297 -16
- package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
- package/dist/editor/hooks/useEditorLayout.js +13 -25
- package/dist/editor/hooks/useEntryManager.d.ts +4 -0
- package/dist/editor/hooks/useEntryManager.js +30 -7
- package/dist/editor/media/MediaLibrary.d.ts +1 -1
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/theme.js +18 -3
- package/dist/git-manager.d.ts +1 -1
- package/dist/git-manager.js +2 -2
- package/dist/operating-mode/types.d.ts +1 -1
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/resolver.d.ts +1 -1
- package/dist/schema/resolver.js +1 -1
- package/dist/schema/schema-store.d.ts +1 -1
- package/dist/schema/schema-store.js +2 -2
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.js +1 -1
- package/dist/utils/content-write-lock.d.ts +1 -1
- package/dist/utils/content-write-lock.js +1 -1
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/git.d.ts +4 -0
- package/dist/utils/git.js +27 -5
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -1
- package/dist/worker/cms-worker.js +2 -2
- package/dist/worker/github-auth.d.ts +1 -1
- package/dist/worker/github-auth.js +1 -1
- package/dist/worker/github-gateway-wire.d.ts +2 -1
- package/dist/worker/github-gateway-wire.js +7 -3
- package/dist/worker/github-gateway.js +1 -1
- package/dist/worker/github-mirror.d.ts +2 -2
- package/dist/worker/github-mirror.js +2 -2
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/self-checks.d.ts +1 -1
- package/dist/worker/shared-repo-git.js +1 -1
- package/package.json +5 -4
- package/dist/editor/EditorPanes.d.ts +0 -20
- package/dist/editor/EditorPanes.js +0 -124
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -70
- package/dist/editor/components/EditorSidebar.js +0 -50
|
@@ -20,13 +20,14 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
20
20
|
* The server answers it before auth, so `whoami` meets it before any sign-in UI.
|
|
21
21
|
*/
|
|
22
22
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
|
23
|
-
import { Alert, Button, Center, Loader, Modal, Paper, Stack, Text, Title } from '@mantine/core';
|
|
23
|
+
import { Alert, Box, Button, Center, Loader, Modal, Paper, Stack, Text, Title } from '@mantine/core';
|
|
24
24
|
import { useSWRConfig } from 'swr';
|
|
25
25
|
import { getErrorMessage } from '../utils/error.js';
|
|
26
26
|
import { editorModeMismatchMessage } from '../operating-mode/editor-mode-check.js';
|
|
27
27
|
import { useApiClient, useOnEditorModeMismatch, useOnUnauthorized, } from './context/ApiClientContext.js';
|
|
28
28
|
import { EditorIdentityContext } from './context/EditorIdentityContext.js';
|
|
29
29
|
import { SWRProvider } from './context/SWRProvider.js';
|
|
30
|
+
import { EditorShell } from './shell/EditorShell.js';
|
|
30
31
|
import { CanopyCMSProvider } from './theme.js';
|
|
31
32
|
const initialGateState = {
|
|
32
33
|
phase: 'checking',
|
|
@@ -122,15 +123,19 @@ export function EditorAuthGate({ children, SignInComponent, themeOptions, editor
|
|
|
122
123
|
const modeMismatchNotice = state.modeMismatch && _jsx(ModeMismatchNotice, { editorMode: editorMode });
|
|
123
124
|
if (!state.user || !identity) {
|
|
124
125
|
if (modeMismatchNotice) {
|
|
125
|
-
return (_jsx(CanopyCMSProvider, { ...themeOptions, withNotifications: false, children: _jsx(
|
|
126
|
+
return (_jsx(CanopyCMSProvider, { ...themeOptions, withNotifications: false, children: _jsx(GateScreen, { children: modeMismatchNotice }) }));
|
|
126
127
|
}
|
|
127
|
-
return (_jsx(CanopyCMSProvider, { ...themeOptions, withNotifications: false, children: _jsx(
|
|
128
|
+
return (_jsx(CanopyCMSProvider, { ...themeOptions, withNotifications: false, children: _jsx(GateScreen, { children: state.phase === 'signed-out' ? (_jsx(Stack, { align: "center", gap: "md", "data-testid": "canopy-sign-in-screen", children: signIn })) : state.phase === 'error' ? (_jsx(RetryNotice, { title: "Couldn't load the editor", error: state.error ?? 'Unknown error', onRetry: retryInitial })) : (_jsxs(Stack, { align: "center", gap: "xs", "data-testid": "canopy-auth-checking", children: [_jsx(Loader, {}), _jsx(Text, { size: "sm", c: "dimmed", children: "Checking sign-in\u2026" })] })) }) }));
|
|
128
129
|
}
|
|
129
130
|
return (_jsxs(EditorIdentityContext.Provider, { value: identity, children: [_jsxs(SWRProvider, { children: [_jsx(RevalidateOnReauth, { reauthCount: state.reauthCount }), children] }, state.user.userId), modeMismatchNotice && (_jsx(CanopyCMSProvider, { ...themeOptions, withNotifications: false, children: _jsx(Modal, { opened: true, onClose: keepOpen, withCloseButton: false, closeOnClickOutside: false, closeOnEscape: false, centered: true, size: "lg", children: modeMismatchNotice }) })), !modeMismatchNotice && state.phase === 'signed-out' && (
|
|
130
131
|
// The editor's own MantineProvider is inside <Editor>, so the overlay brings its own.
|
|
131
132
|
// Notifications stay with the editor's provider; a second container would duplicate them.
|
|
132
133
|
_jsx(CanopyCMSProvider, { ...themeOptions, withNotifications: false, children: _jsx(Modal, { opened: true, onClose: keepOpen, withCloseButton: false, closeOnClickOutside: false, closeOnEscape: false, centered: true, title: "Sign in to continue", children: _jsxs(Stack, { gap: "md", "data-testid": "canopy-sign-in-overlay", children: [_jsx(Text, { size: "sm", children: "Your session has ended. Unsaved edits stay in place if you sign back in as the same user." }), signIn] }) }) }))] }));
|
|
133
134
|
}
|
|
135
|
+
/** A screen shown in the editor's place, scrolling when it is taller than the window. */
|
|
136
|
+
function GateScreen({ children }) {
|
|
137
|
+
return (_jsx(EditorShell, { main: _jsx(Box, { h: "100%", style: { overflowY: 'auto' }, children: _jsx(Center, { mih: "100%", p: "md", style: { justifyContent: 'safe center', alignItems: 'safe center' }, children: children }) }) }));
|
|
138
|
+
}
|
|
134
139
|
/** The sign-in overlay closes only when the server accepts a session again; the mode one never. */
|
|
135
140
|
function keepOpen() { }
|
|
136
141
|
/** Blocking, with no Retry: the editor bundle itself is wrong, so only a rebuild helps. */
|
|
@@ -63,6 +63,16 @@ export interface EntryNavigatorProps {
|
|
|
63
63
|
readOnly?: boolean;
|
|
64
64
|
/** Entry paths with unsaved changes; each shows a dot, and so does every collection holding one. */
|
|
65
65
|
dirtyPaths?: ReadonlySet<string>;
|
|
66
|
+
/**
|
|
67
|
+
* A collection to bring into view: any filter clears, its ancestors and itself expand, and its
|
|
68
|
+
* row takes focus. `seq` makes a repeat ask for the same collection a new one.
|
|
69
|
+
*/
|
|
70
|
+
revealCollection?: {
|
|
71
|
+
path: string;
|
|
72
|
+
seq: number;
|
|
73
|
+
};
|
|
74
|
+
/** The reveal is done, or its collection is not in the tree. */
|
|
75
|
+
onRevealed?: () => void;
|
|
66
76
|
}
|
|
67
77
|
export declare const EntryNavigator: React.FC<EntryNavigatorProps>;
|
|
68
78
|
export default EntryNavigator;
|
|
@@ -12,6 +12,18 @@ const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "no
|
|
|
12
12
|
/** Where path rules refuse new entries, one line says so in place of the "…" menus they hide. */
|
|
13
13
|
const AddRefusedRow = () => (_jsx(Text, { size: "xs", c: "dimmed", mt: 4, "data-testid": "add-refused-message", children: "You can't add entries here." }));
|
|
14
14
|
const lastSegment = (path) => path.split('/').filter(Boolean).pop() ?? path;
|
|
15
|
+
/** A collection's node value; a single-entry collection's node is its entry. */
|
|
16
|
+
const collectionNodeValue = (nodes, path) => {
|
|
17
|
+
for (const node of nodes) {
|
|
18
|
+
if (node.value === `collection:${path}` || node.nodeProps?.collectionPath === path) {
|
|
19
|
+
return node.value;
|
|
20
|
+
}
|
|
21
|
+
const inner = node.children && collectionNodeValue(node.children, path);
|
|
22
|
+
if (inner)
|
|
23
|
+
return inner;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
15
27
|
/** The collection values below `nodes` that have children, as an all-expanded state. */
|
|
16
28
|
const expandedStateFor = (nodes, acc = {}) => {
|
|
17
29
|
for (const node of nodes) {
|
|
@@ -109,7 +121,7 @@ const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: t
|
|
|
109
121
|
borderRadius: '50%',
|
|
110
122
|
backgroundColor: 'var(--mantine-color-brand-6)',
|
|
111
123
|
} }) }));
|
|
112
|
-
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, }) => {
|
|
124
|
+
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
113
125
|
// Shadow the entry-mutation callbacks with undefined when readOnly, so every
|
|
114
126
|
// downstream reference (hasEntryMenu, canReorder, the menu items themselves)
|
|
115
127
|
// sees them as absent rather than threading a readOnly check through each
|
|
@@ -158,6 +170,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
158
170
|
status: entry?.status,
|
|
159
171
|
isEntry: true,
|
|
160
172
|
entryPath: entry?.path,
|
|
173
|
+
collectionPath: col.path,
|
|
161
174
|
slug: entry ? (entry.slug ?? lastSegment(entry.path)) : undefined,
|
|
162
175
|
itemReadOnly: entry?.readOnly,
|
|
163
176
|
},
|
|
@@ -389,6 +402,42 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
389
402
|
useEffect(() => {
|
|
390
403
|
hasScrolledRef.current = false;
|
|
391
404
|
}, []);
|
|
405
|
+
const treeBoxRef = useRef(null);
|
|
406
|
+
const servedRevealRef = useRef();
|
|
407
|
+
// Runs again as the filter clears and the expansion lands, then focuses the row a frame later.
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!revealCollection || !treeData || servedRevealRef.current === revealCollection.seq)
|
|
410
|
+
return;
|
|
411
|
+
const served = () => {
|
|
412
|
+
servedRevealRef.current = revealCollection.seq;
|
|
413
|
+
onRevealed?.();
|
|
414
|
+
};
|
|
415
|
+
if (query) {
|
|
416
|
+
setQuery('');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const value = collectionNodeValue(treeData, revealCollection.path);
|
|
420
|
+
if (!value) {
|
|
421
|
+
served();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const wanted = { ...calculatePathToEntry(value, treeData), [value]: true };
|
|
425
|
+
if (Object.keys(wanted).some((key) => !tree.expandedState[key])) {
|
|
426
|
+
const next = { ...tree.expandedState, ...wanted };
|
|
427
|
+
tree.setExpandedState(next);
|
|
428
|
+
localExpandedStateRef.current = next;
|
|
429
|
+
onExpandedStateChange?.(next);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const frame = requestAnimationFrame(() => {
|
|
433
|
+
const row = Array.from(treeBoxRef.current?.querySelectorAll('[role="treeitem"]') ?? []).find((item) => item.dataset.value === value);
|
|
434
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
435
|
+
row?.focus();
|
|
436
|
+
served();
|
|
437
|
+
});
|
|
438
|
+
return () => cancelAnimationFrame(frame);
|
|
439
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `tree` and the callbacks are fresh each render
|
|
440
|
+
}, [revealCollection, treeData, query, tree.expandedState]);
|
|
392
441
|
const clearFilter = () => {
|
|
393
442
|
setQuery('');
|
|
394
443
|
filterInputRef.current?.focus();
|
|
@@ -516,6 +565,6 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
516
565
|
onDeleteEntry(entryPath);
|
|
517
566
|
}, "data-testid": "delete-entry-menu-item", children: "Delete entry" })] }))] })] }))] })] }), isCollection && unavailableSchemaRefs && unavailableSchemaRefs.length > 0 && (_jsx(UnavailableTypeRow, { schemaRefs: unavailableSchemaRefs })), isCollection && addRefused && _jsx(AddRefusedRow, {})] }));
|
|
518
567
|
};
|
|
519
|
-
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "entry-navigator", children: [treeData.length > 0 && (_jsx(TextInput, { ref: filterInputRef, size: "sm", py: 4, "aria-label": "Filter entries", spellCheck: false, "data-autofocus": true, placeholder: "Filter by title or slug", value: query, onChange: (event) => setQuery(event.currentTarget.value), onKeyDown: handleFilterKeyDown, leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), rightSection: query ? _jsx(CloseButton, { size: "sm", "aria-label": "Clear filter", onClick: clearFilter }) : null, "data-mantine-stop-propagation": query ? 'true' : undefined, "data-testid": "entry-nav-filter" })), _jsxs(ScrollArea, { type: "auto", scrollbars: "y", offsetScrollbars: true, style: { flex: 1 }, children: [hiddenRootUnavailableRefs && hiddenRootUnavailableRefs.length > 0 && (_jsx(Box, { pt: "sm", children: _jsx(UnavailableTypeRow, { schemaRefs: hiddenRootUnavailableRefs }) })), hiddenRootAddRefused && (_jsx(Box, { pt: "sm", children: _jsx(AddRefusedRow, {}) })), treeData.length === 0 ? (loading ? (_jsxs(Group, { gap: "xs", py: "sm", children: [_jsx(Loader, { size: "xs" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Loading content\u2026" })] })) : (_jsx(Text, { size: "xs", c: "dimmed", py: "sm", children: "No content" }))) : filteredTree.length === 0 ? (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-filter-empty", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No entries match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: clearFilter, children: "Clear filter" })] })) : (_jsx(Box, { py: "sm", children: _jsx(Tree, { data: filteredTree, tree: tree, renderNode: renderNode, selectOnClick: false, levelOffset: "sm" }) }))] })] }));
|
|
568
|
+
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "entry-navigator", children: [treeData.length > 0 && (_jsx(TextInput, { ref: filterInputRef, size: "sm", py: 4, "aria-label": "Filter entries", spellCheck: false, "data-autofocus": true, placeholder: "Filter by title or slug", value: query, onChange: (event) => setQuery(event.currentTarget.value), onKeyDown: handleFilterKeyDown, leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), rightSection: query ? _jsx(CloseButton, { size: "sm", "aria-label": "Clear filter", onClick: clearFilter }) : null, "data-mantine-stop-propagation": query ? 'true' : undefined, "data-testid": "entry-nav-filter" })), _jsxs(ScrollArea, { type: "auto", scrollbars: "y", offsetScrollbars: true, style: { flex: 1 }, children: [hiddenRootUnavailableRefs && hiddenRootUnavailableRefs.length > 0 && (_jsx(Box, { pt: "sm", children: _jsx(UnavailableTypeRow, { schemaRefs: hiddenRootUnavailableRefs }) })), hiddenRootAddRefused && (_jsx(Box, { pt: "sm", children: _jsx(AddRefusedRow, {}) })), treeData.length === 0 ? (loading ? (_jsxs(Group, { gap: "xs", py: "sm", children: [_jsx(Loader, { size: "xs" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Loading content\u2026" })] })) : (_jsx(Text, { size: "xs", c: "dimmed", py: "sm", children: "No content" }))) : filteredTree.length === 0 ? (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-filter-empty", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No entries match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: clearFilter, children: "Clear filter" })] })) : (_jsx(Box, { py: "sm", ref: treeBoxRef, children: _jsx(Tree, { data: filteredTree, tree: tree, renderNode: renderNode, selectOnClick: false, levelOffset: "sm" }) }))] })] }));
|
|
520
569
|
};
|
|
521
570
|
export default EntryNavigator;
|
|
@@ -4,7 +4,7 @@ import { useRef, useState } from 'react';
|
|
|
4
4
|
import { ActionIcon, Badge, Box, Button, Group, Popover, ScrollArea, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { useIsomorphicEffect } from '@mantine/hooks';
|
|
6
6
|
import { IconChevronsDown, IconChevronsUp, IconListTree } from '@tabler/icons-react';
|
|
7
|
-
/** The padding of the scroller the form sits in, which `
|
|
7
|
+
/** The padding of the scroller the form sits in, which `shell/PaneLayout.tsx` sets; 0 where unset. */
|
|
8
8
|
const PANE_PADDING = 'var(--canopy-form-pane-padding, 0px)';
|
|
9
9
|
/** Below this header width "Outline" shows its icon alone, so the controls never clip. */
|
|
10
10
|
const COMPACT_WIDTH = 280;
|
|
@@ -10,7 +10,7 @@ export interface PreviewMarks {
|
|
|
10
10
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
11
11
|
* to reload the preview.
|
|
12
12
|
*/
|
|
13
|
-
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, }: {
|
|
13
|
+
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, }: {
|
|
14
14
|
src: string;
|
|
15
15
|
path: string;
|
|
16
16
|
data?: unknown;
|
|
@@ -33,4 +33,8 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
|
|
|
33
33
|
* distinct paths when the preview sends them within `MARK_REPORT_LIMITS`.
|
|
34
34
|
*/
|
|
35
35
|
onMarks?: (marks: PreviewMarks) => void;
|
|
36
|
+
/** Overrides the iframe's own layout, such as a fixed width and scale; the bar and chip stay unscaled. */
|
|
37
|
+
iframeStyle?: CSSProperties;
|
|
38
|
+
/** Replaces the chip's Retry, for a host whose own reload control remounts the frame. */
|
|
39
|
+
onReload?: () => void;
|
|
36
40
|
}) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -36,7 +36,7 @@ const sendDraftUpdate = (iframe, message, targetOrigin) => {
|
|
|
36
36
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
37
37
|
* to reload the preview.
|
|
38
38
|
*/
|
|
39
|
-
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, }) => {
|
|
39
|
+
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, }) => {
|
|
40
40
|
const iframeRef = useRef(null);
|
|
41
41
|
// Pin the preview origin from the src prop: outbound messages target it (never '*'),
|
|
42
42
|
// and inbound messages must come from it. An iframe that navigates cross-origin
|
|
@@ -190,7 +190,7 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
190
190
|
fontFamily: 'var(--mantine-font-family, inherit)',
|
|
191
191
|
fontSize: 12,
|
|
192
192
|
lineHeight: '20px',
|
|
193
|
-
}, children: [_jsx("span", { title: LIVE_UPDATES_OFF_HELP, children: "Live updates off" }), _jsx("span", { style: visuallyHidden, children: LIVE_UPDATES_OFF_HELP }), _jsx("button", { type: "button", onClick: retry, title: "Reload the preview", style: {
|
|
193
|
+
}, children: [_jsx("span", { title: LIVE_UPDATES_OFF_HELP, children: "Live updates off" }), _jsx("span", { style: visuallyHidden, children: LIVE_UPDATES_OFF_HELP }), _jsx("button", { type: "button", onClick: onReload ?? retry, title: "Reload the preview", style: {
|
|
194
194
|
border: 'none',
|
|
195
195
|
borderRadius: 999,
|
|
196
196
|
padding: '0 8px',
|
|
@@ -206,6 +206,7 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
206
206
|
width: '100%',
|
|
207
207
|
height: '100%',
|
|
208
208
|
border: 'none',
|
|
209
|
+
...iframeStyle,
|
|
209
210
|
}, onLoad: () => {
|
|
210
211
|
post();
|
|
211
212
|
postHighlight();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { PreviewWidth } from './hooks/useEditorLayout.js';
|
|
3
|
+
import { type PreviewMarks } from './PreviewFrame.js';
|
|
4
|
+
import type { InexactMark } from './preview-marks.js';
|
|
5
|
+
import type { PaneArrangement } from './shell/PaneLayout.js';
|
|
6
|
+
import { type PreviewMount } from './preview-path.js';
|
|
7
|
+
export interface PreviewPaneProps {
|
|
8
|
+
src: string;
|
|
9
|
+
/** The page's path for the bridge and the toolbar; defaults to `src`. */
|
|
10
|
+
path?: string;
|
|
11
|
+
/** Where the preview routes are mounted; the toolbar's path leaves out the `previewPrefix`. */
|
|
12
|
+
mount?: PreviewMount;
|
|
13
|
+
data?: unknown;
|
|
14
|
+
isLoading?: unknown;
|
|
15
|
+
highlightEnabled?: boolean;
|
|
16
|
+
assetBase?: string;
|
|
17
|
+
onPreviewError?: (error: {
|
|
18
|
+
message: string;
|
|
19
|
+
fieldPath?: string;
|
|
20
|
+
} | null) => void;
|
|
21
|
+
onMarks?: (marks: PreviewMarks) => void;
|
|
22
|
+
previewWidth: PreviewWidth;
|
|
23
|
+
onPreviewWidthChange: (width: PreviewWidth) => void;
|
|
24
|
+
/** Shown between the toolbar and the preview, such as a preview error. */
|
|
25
|
+
notice?: ReactNode;
|
|
26
|
+
/** With `onHighlightToggle`, the toolbar shows the highlight toggle. */
|
|
27
|
+
onHighlightToggle?: () => void;
|
|
28
|
+
/** The page marked nothing while highlighting; see `usePreviewMarks`. */
|
|
29
|
+
marksNothing?: boolean;
|
|
30
|
+
/** Marks naming no field of the entry, counted in the toolbar only with `showMarkDiagnostics`. */
|
|
31
|
+
inexactMarks?: readonly InexactMark[];
|
|
32
|
+
/** Admins only: diagnostics of the site's markup are no editor's to act on. */
|
|
33
|
+
showMarkDiagnostics?: boolean;
|
|
34
|
+
/** Shapes the size readout shown while the divider is dragged. */
|
|
35
|
+
layout?: PaneArrangement;
|
|
36
|
+
/** With `onMaximizedChange`, the toolbar offers Maximize, and Esc restores. */
|
|
37
|
+
maximized?: boolean;
|
|
38
|
+
onMaximizedChange?: (maximized: boolean) => void;
|
|
39
|
+
/** A drag of the preview/form divider is under way: the preview shows its size over itself. */
|
|
40
|
+
resizing?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
|
|
44
|
+
* fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
|
|
45
|
+
* down to fit a narrower pane.
|
|
46
|
+
*/
|
|
47
|
+
export declare const PreviewPane: ({ src, path, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { ActionIcon, Box, Group, Select, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
|
+
import { useClipboard, useElementSize, useWindowEvent } from '@mantine/hooks';
|
|
6
|
+
import { IconCheck, IconExternalLink, IconRefresh } from '@tabler/icons-react';
|
|
7
|
+
import { PreviewFrame } from './PreviewFrame.js';
|
|
8
|
+
import { HighlightToggle, MaximizeToggle, UnmappedMarksChip } from './PreviewToolbarTools.js';
|
|
9
|
+
import { displayPreviewPath } from './preview-path.js';
|
|
10
|
+
import { computePreviewViewport, formatPreviewWidthReadout, PREVIEW_DEVICE_WIDTHS, } from './preview-scale.js';
|
|
11
|
+
const WIDTH_OPTIONS = [
|
|
12
|
+
{ value: 'fit', label: 'Fit', detail: 'Pane width' },
|
|
13
|
+
{ value: 'desktop', label: 'Desktop', detail: `${PREVIEW_DEVICE_WIDTHS.desktop} px` },
|
|
14
|
+
{ value: 'tablet', label: 'Tablet', detail: `${PREVIEW_DEVICE_WIDTHS.tablet} px` },
|
|
15
|
+
{ value: 'mobile', label: 'Mobile', detail: `${PREVIEW_DEVICE_WIDTHS.mobile} px` },
|
|
16
|
+
];
|
|
17
|
+
const isPreviewWidth = (value) => WIDTH_OPTIONS.some((option) => option.value === value);
|
|
18
|
+
const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'tel', 'password', 'number']);
|
|
19
|
+
const hasTextToClear = (element) => ((element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type)) ||
|
|
20
|
+
element instanceof HTMLTextAreaElement) &&
|
|
21
|
+
!element.readOnly &&
|
|
22
|
+
element.value !== '';
|
|
23
|
+
const RELOAD_LABEL = 'Reload preview';
|
|
24
|
+
const OPEN_SAVED_LABEL = 'Open saved version in a new tab';
|
|
25
|
+
/**
|
|
26
|
+
* The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
|
|
27
|
+
* fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
|
|
28
|
+
* down to fit a narrower pane.
|
|
29
|
+
*/
|
|
30
|
+
export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, ...frameProps }) => {
|
|
31
|
+
const { ref: viewportRef, width, height } = useElementSize();
|
|
32
|
+
const rootRef = useRef(null);
|
|
33
|
+
const maximizeRef = useRef(null);
|
|
34
|
+
const restoreRef = useRef(null);
|
|
35
|
+
// Maximize and Restore swap places, so focus follows to the other. Restoring by other means,
|
|
36
|
+
// such as a preview click that focuses a form field, leaves focus where that put it.
|
|
37
|
+
const wasMaximized = useRef(maximized);
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
if (maximized && !wasMaximized.current)
|
|
40
|
+
restoreRef.current?.focus();
|
|
41
|
+
if (!maximized && wasMaximized.current && document.activeElement === document.body) {
|
|
42
|
+
maximizeRef.current?.focus();
|
|
43
|
+
}
|
|
44
|
+
wasMaximized.current = maximized;
|
|
45
|
+
}, [maximized]);
|
|
46
|
+
// Escape from the editor's shell, or with focus nowhere, restores: a drawer or modal outside it
|
|
47
|
+
// closes on its own Escape, an open popup marks its owner, and a text field with text keeps it.
|
|
48
|
+
// Captured, because an open tooltip's dismiss stops the key on the document. A key inside the
|
|
49
|
+
// preview frame never reaches this window.
|
|
50
|
+
useWindowEvent('keydown', (event) => {
|
|
51
|
+
if (!maximized || event.key !== 'Escape' || event.defaultPrevented)
|
|
52
|
+
return;
|
|
53
|
+
const target = event.target;
|
|
54
|
+
if (!(target instanceof HTMLElement))
|
|
55
|
+
return;
|
|
56
|
+
const inEditor = rootRef.current?.closest('[data-canopy-shell]')?.contains(target);
|
|
57
|
+
if (target !== document.body && !(inEditor ?? rootRef.current?.contains(target)))
|
|
58
|
+
return;
|
|
59
|
+
if (target.isContentEditable || hasTextToClear(target))
|
|
60
|
+
return;
|
|
61
|
+
if (target.closest('[data-mantine-stop-propagation="true"], [aria-haspopup][aria-expanded="true"]'))
|
|
62
|
+
return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
onMaximizedChange?.(false);
|
|
65
|
+
}, { capture: true });
|
|
66
|
+
const clipboard = useClipboard({ timeout: 2000 });
|
|
67
|
+
const reloadButtonRef = useRef(null);
|
|
68
|
+
// A dismissed notice takes its focused Close button with it; the toolbar takes the focus back.
|
|
69
|
+
// Focus anywhere else, such as a form field while a draft starts compiling, stays put.
|
|
70
|
+
const hadNotice = useRef(false);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (hadNotice.current && !notice && document.activeElement === document.body) {
|
|
73
|
+
reloadButtonRef.current?.focus();
|
|
74
|
+
}
|
|
75
|
+
hadNotice.current = Boolean(notice);
|
|
76
|
+
}, [notice]);
|
|
77
|
+
// Reloading remounts the frame for a fresh handshake. It drops a reported error too: a page
|
|
78
|
+
// that reports draft errors reports it again once the reloaded page renders the draft.
|
|
79
|
+
const [reloads, setReloads] = useState(0);
|
|
80
|
+
const reload = () => {
|
|
81
|
+
setReloads((count) => count + 1);
|
|
82
|
+
frameProps.onPreviewError?.(null);
|
|
83
|
+
};
|
|
84
|
+
const shownPath = displayPreviewPath(path, mount);
|
|
85
|
+
const viewport = computePreviewViewport(previewWidth, width, height);
|
|
86
|
+
const iframeStyle = previewWidth === 'fit'
|
|
87
|
+
? undefined
|
|
88
|
+
: {
|
|
89
|
+
inset: 'auto',
|
|
90
|
+
top: 0,
|
|
91
|
+
left: viewport.offsetX,
|
|
92
|
+
width: viewport.frameWidth,
|
|
93
|
+
height: viewport.frameHeight,
|
|
94
|
+
transform: `scale(${viewport.scale})`,
|
|
95
|
+
transformOrigin: '0 0',
|
|
96
|
+
background: 'var(--mantine-color-body)',
|
|
97
|
+
boxShadow: '0 0 0 1px var(--mantine-color-default-border)',
|
|
98
|
+
};
|
|
99
|
+
const copyStatus = clipboard.copied
|
|
100
|
+
? 'Path copied'
|
|
101
|
+
: clipboard.error
|
|
102
|
+
? "Couldn't copy the path"
|
|
103
|
+
: '';
|
|
104
|
+
const copyLabel = `Copy path ${shownPath}`;
|
|
105
|
+
return (_jsxs(Box, { ref: rootRef, style: {
|
|
106
|
+
display: 'flex',
|
|
107
|
+
flexDirection: 'column',
|
|
108
|
+
width: '100%',
|
|
109
|
+
height: '100%',
|
|
110
|
+
border: '1px solid var(--mantine-color-default-border)',
|
|
111
|
+
}, children: [_jsxs(Group, { role: "group", "aria-label": "Preview toolbar", "data-testid": "preview-toolbar", gap: 6, px: 6, style: {
|
|
112
|
+
// Wraps to a second row, rather than clipping, in a pane too narrow for one.
|
|
113
|
+
minHeight: 32,
|
|
114
|
+
rowGap: 2,
|
|
115
|
+
paddingBlock: 2,
|
|
116
|
+
flex: '0 0 auto',
|
|
117
|
+
borderBottom: '1px solid var(--mantine-color-default-border)',
|
|
118
|
+
background: 'var(--mantine-color-body)',
|
|
119
|
+
}, children: [_jsx(Tooltip, { label: copyStatus || copyLabel, multiline: true, maw: 360, style: { overflowWrap: 'anywhere' }, children: _jsx(UnstyledButton, { onClick: () => clipboard.copy(shownPath), "aria-label": copyLabel, style: { flex: '1 1 80px', minWidth: 0, borderRadius: 4, padding: '2px 4px' }, children: _jsx(Text, { component: "span", size: "xs", ff: "monospace", truncate: "end", display: "block", children: shownPath }) }) }), _jsx(VisuallyHidden, { role: "status", children: copyStatus }), _jsx(Select, { "aria-label": "Preview width", data: WIDTH_OPTIONS, value: previewWidth, onChange: (value) => {
|
|
120
|
+
if (isPreviewWidth(value))
|
|
121
|
+
onPreviewWidthChange(value);
|
|
122
|
+
}, 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 || (showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [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: src, 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: [_jsx(PreviewFrame, { src: src, path: path, ...frameProps, onReload: () => {
|
|
123
|
+
// Retry goes with the frame it sits in; the toolbar's reload offers the same action.
|
|
124
|
+
reload();
|
|
125
|
+
reloadButtonRef.current?.focus();
|
|
126
|
+
}, iframeStyle: iframeStyle, style: {
|
|
127
|
+
position: 'absolute',
|
|
128
|
+
inset: 0,
|
|
129
|
+
background: previewWidth === 'fit' ? undefined : 'var(--canopy-surface-muted)',
|
|
130
|
+
} }, reloads), resizing && width > 0 && (_jsx(Box, { "aria-hidden": true, "data-testid": "preview-resize-readout", style: {
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
top: '50%',
|
|
133
|
+
left: '50%',
|
|
134
|
+
transform: 'translate(-50%, -50%)',
|
|
135
|
+
padding: '6px 12px',
|
|
136
|
+
borderRadius: 6,
|
|
137
|
+
background: 'var(--mantine-color-body)',
|
|
138
|
+
color: 'var(--mantine-color-text)',
|
|
139
|
+
boxShadow: 'var(--mantine-shadow-md)',
|
|
140
|
+
fontSize: 'var(--mantine-font-size-lg)',
|
|
141
|
+
fontWeight: 600,
|
|
142
|
+
fontVariantNumeric: 'tabular-nums',
|
|
143
|
+
pointerEvents: 'none',
|
|
144
|
+
}, children: layout === 'stacked'
|
|
145
|
+
? `${Math.round(width)} × ${Math.round(height)} px`
|
|
146
|
+
: `${Math.round(width)} px` }))] })] }));
|
|
147
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import type { InexactMark } from './preview-marks.js';
|
|
3
|
+
export interface HighlightToggleProps {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
onToggle: () => void;
|
|
6
|
+
/** The page reported marking nothing while highlighting. */
|
|
7
|
+
marksNothing?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
|
|
10
|
+
export declare function HighlightToggle({ enabled, onToggle, marksNothing }: HighlightToggleProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
/** The admin's count of preview marks that name no field of the entry; see `findInexactMarks`. */
|
|
12
|
+
export declare function UnmappedMarksChip({ marks }: {
|
|
13
|
+
marks: readonly InexactMark[];
|
|
14
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
15
|
+
export interface MaximizeToggleProps {
|
|
16
|
+
maximized: boolean;
|
|
17
|
+
onChange: (maximized: boolean) => void;
|
|
18
|
+
maximizeRef?: Ref<HTMLButtonElement>;
|
|
19
|
+
restoreRef?: Ref<HTMLButtonElement>;
|
|
20
|
+
}
|
|
21
|
+
/** An icon to maximize; once maximized, a labelled Restore, which Esc also does. */
|
|
22
|
+
export declare function MaximizeToggle({ maximized, onChange, maximizeRef, restoreRef, }: MaximizeToggleProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useRef, useState } from 'react';
|
|
4
|
+
import { ActionIcon, Button, Group, Popover, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
|
+
import { IconArrowsMaximize, IconArrowsMinimize, IconMarquee } from '@tabler/icons-react';
|
|
6
|
+
const HIGHLIGHT_LABEL = 'Highlight fields in the preview';
|
|
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
|
+
/** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
|
|
9
|
+
export function HighlightToggle({ enabled, onToggle, marksNothing = false }) {
|
|
10
|
+
const noteId = useId();
|
|
11
|
+
const showNote = enabled && marksNothing;
|
|
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
|
+
}
|
|
14
|
+
const LISTED = 3;
|
|
15
|
+
// Module-level, so React never re-calls it on a re-render and pulls focus back while closing.
|
|
16
|
+
const focusOnMount = (node) => node?.focus();
|
|
17
|
+
const markList = (marks) => marks
|
|
18
|
+
.slice(0, LISTED)
|
|
19
|
+
.map((mark) => mark.path || '""')
|
|
20
|
+
.join(', ');
|
|
21
|
+
/** The admin's count of preview marks that name no field of the entry; see `findInexactMarks`. */
|
|
22
|
+
export function UnmappedMarksChip({ marks }) {
|
|
23
|
+
const [opened, setOpened] = useState(false);
|
|
24
|
+
const chipRef = useRef(null);
|
|
25
|
+
// Focus moves to Close as the dropdown mounts. Close and Escape hand it back to the chip; focus
|
|
26
|
+
// leaving otherwise, Tab included, just closes it, so there is no focus trap.
|
|
27
|
+
// Closed when the marks run out, so their return never reopens it and takes focus.
|
|
28
|
+
if (marks.length === 0) {
|
|
29
|
+
if (opened)
|
|
30
|
+
setOpened(false);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const one = marks.length === 1;
|
|
34
|
+
const closeToChip = () => {
|
|
35
|
+
setOpened(false);
|
|
36
|
+
chipRef.current?.focus();
|
|
37
|
+
};
|
|
38
|
+
return (_jsxs(Popover, { opened: opened, onChange: setOpened, closeOnEscape: false, width: 300, position: "bottom-end", withArrow: true, shadow: "md", children: [_jsx(Popover.Target, { children: _jsxs(UnstyledButton, { ref: chipRef, "data-testid": "preview-unmapped-chip",
|
|
39
|
+
// A controlled Popover's target toggles nothing itself.
|
|
40
|
+
// Pressed while open, it keeps focus in the dropdown, whose blur would close it first
|
|
41
|
+
// and let this click reopen it.
|
|
42
|
+
onMouseDown: (event) => {
|
|
43
|
+
if (opened)
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
}, onClick: () => (opened ? closeToChip() : setOpened(true)), style: {
|
|
46
|
+
flexShrink: 0,
|
|
47
|
+
borderRadius: 999,
|
|
48
|
+
minHeight: 24,
|
|
49
|
+
padding: '0 8px',
|
|
50
|
+
fontSize: 'var(--mantine-font-size-xs)',
|
|
51
|
+
fontWeight: 600,
|
|
52
|
+
whiteSpace: 'nowrap',
|
|
53
|
+
color: 'var(--mantine-color-text)',
|
|
54
|
+
background: 'var(--canopy-count-bg-orange)',
|
|
55
|
+
}, children: [marks.length, " unmapped", _jsxs(VisuallyHidden, { children: [" preview ", one ? 'mark' : 'marks'] })] }) }), _jsx(Popover.Dropdown, { onKeyDown: (event) => {
|
|
56
|
+
if (event.key === 'Escape')
|
|
57
|
+
closeToChip();
|
|
58
|
+
}, onBlur: (event) => {
|
|
59
|
+
// Not when the window itself loses focus, such as to the browser's console.
|
|
60
|
+
if (document.hasFocus() && !event.currentTarget.contains(event.relatedTarget)) {
|
|
61
|
+
setOpened(false);
|
|
62
|
+
}
|
|
63
|
+
}, children: _jsxs(Stack, { gap: 6, children: [_jsx(Text, { size: "sm", fw: 600, children: "Unmapped preview marks" }), _jsxs(Text, { size: "sm", children: [one
|
|
64
|
+
? "One element on this page carries a field mark that doesn't match a field of this entry: "
|
|
65
|
+
: `${marks.length} elements on this page carry field marks that don't match a field of this entry: `, _jsx(Text, { component: "span", ff: "monospace", size: "xs", style: { overflowWrap: 'anywhere' }, children: markList(marks) }), marks.length > LISTED ? `, and ${marks.length - LISTED} more` : '', ".", ' ', one
|
|
66
|
+
? "It's outlined, and clicking it focuses its nearest field, if it has one."
|
|
67
|
+
: "They're outlined, and clicking one focuses its nearest field, if it has one."] }), _jsxs(Text, { size: "sm", children: ["The browser console names the nearest field of ", one ? 'it' : 'each', ", for the site's developer to correct in its fieldProps."] }), _jsx(Group, { justify: "flex-end", children: _jsx(Button, { ref: focusOnMount, size: "compact-sm", variant: "default", onClick: closeToChip, children: "Close" }) })] }) })] }));
|
|
68
|
+
}
|
|
69
|
+
const MAXIMIZE_LABEL = 'Maximize preview';
|
|
70
|
+
/** An icon to maximize; once maximized, a labelled Restore, which Esc also does. */
|
|
71
|
+
export function MaximizeToggle({ maximized, onChange, maximizeRef, restoreRef, }) {
|
|
72
|
+
return maximized ? (_jsx(Tooltip, { label: "Show the form again \u00B7 Esc", children: _jsx(Button, { ref: restoreRef, size: "compact-sm", variant: "default", leftSection: _jsx(IconArrowsMinimize, { size: 14 }), "aria-keyshortcuts": "Escape", "data-testid": "preview-restore-button", onClick: () => onChange(false), children: "Restore" }) })) : (_jsx(Tooltip, { label: MAXIMIZE_LABEL, children: _jsx(ActionIcon, { ref: maximizeRef, variant: "subtle", size: "md", "aria-label": MAXIMIZE_LABEL, "data-testid": "preview-maximize-button", onClick: () => onChange(true), children: _jsx(IconArrowsMaximize, { size: 16 }) }) }));
|
|
73
|
+
}
|
|
@@ -100,7 +100,8 @@ export const FormCommentsMenu = ({ comments, entryPath }) => {
|
|
|
100
100
|
if (!formComments || !form)
|
|
101
101
|
return;
|
|
102
102
|
const active = document.activeElement;
|
|
103
|
-
|
|
103
|
+
// An inert form is covered by the content panel's overlay.
|
|
104
|
+
if (inOtherOverlay(active, form) || form.closest('[inert]'))
|
|
104
105
|
return;
|
|
105
106
|
event.preventDefault();
|
|
106
107
|
const field = active && form.contains(active) ? active.closest(FIELD_SCOPE_SELECTOR) : null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BranchStatus } from '../../types.js';
|
|
2
|
+
export interface BranchLockInput {
|
|
3
|
+
branchStatus: BranchStatus | undefined;
|
|
4
|
+
/** The protected base branch, read-only in prod. */
|
|
5
|
+
branchReadOnly?: boolean;
|
|
6
|
+
branchWriteBlocked?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
10
|
+
* nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
|
|
11
|
+
* base-branch read-only takes precedence over a workflow status lock.
|
|
12
|
+
*/
|
|
13
|
+
export declare function branchLockState({ branchStatus, branchReadOnly, branchWriteBlocked, }: BranchLockInput): {
|
|
14
|
+
statusLocked: boolean;
|
|
15
|
+
branchDataUnavailable: boolean;
|
|
16
|
+
};
|
|
17
|
+
export interface BranchLockBannerProps extends BranchLockInput {
|
|
18
|
+
branchName: string;
|
|
19
|
+
onBranchManagerOpen: () => void;
|
|
20
|
+
}
|
|
21
|
+
/** The banner region's explanation of a read-only branch; not dismissible while it applies. */
|
|
22
|
+
export declare function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }: BranchLockBannerProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Alert, Box, Button, Group, Text } from '@mantine/core';
|
|
3
|
+
import { IconLock } from '@tabler/icons-react';
|
|
4
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
5
|
+
/**
|
|
6
|
+
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
7
|
+
* nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
|
|
8
|
+
* base-branch read-only takes precedence over a workflow status lock.
|
|
9
|
+
*/
|
|
10
|
+
export function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
|
|
11
|
+
const statusLocked = branchWriteBlocked && !branchReadOnly;
|
|
12
|
+
// `branchStatus` can't describe the lock while branch data loads (undefined) or under version
|
|
13
|
+
// skew (an old server omits `branchWriteBlocked` but reports 'editing'), so both read as
|
|
14
|
+
// "details unavailable" rather than naming a status the lock contradicts.
|
|
15
|
+
const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
|
|
16
|
+
return { statusLocked, branchDataUnavailable };
|
|
17
|
+
}
|
|
18
|
+
/** The banner region's explanation of a read-only branch; not dismissible while it applies. */
|
|
19
|
+
export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
|
|
20
|
+
const { statusLocked, branchDataUnavailable } = branchLockState(lock);
|
|
21
|
+
if (!lock.branchReadOnly && !statusLocked)
|
|
22
|
+
return null;
|
|
23
|
+
const [testId, message, action] = lock.branchReadOnly
|
|
24
|
+
? [
|
|
25
|
+
'protected-branch-banner',
|
|
26
|
+
`You are viewing the protected base branch "${branchName}". Content is read-only — create a branch to make changes.`,
|
|
27
|
+
`${EDITOR_ACTIONS.newBranch}…`,
|
|
28
|
+
]
|
|
29
|
+
: [
|
|
30
|
+
'status-locked-banner',
|
|
31
|
+
branchDataUnavailable
|
|
32
|
+
? `Branch details for "${branchName}" could not be loaded, so content is locked until they arrive. Use Switch branch… to retry.`
|
|
33
|
+
: lock.branchStatus === 'submitted'
|
|
34
|
+
? `Branch "${branchName}" is submitted for review and locked for edits. Withdraw it from review to make changes.`
|
|
35
|
+
: `Branch "${branchName}" is ${lock.branchStatus} — content is read-only.`,
|
|
36
|
+
`${EDITOR_ACTIONS.switchBranch}…`,
|
|
37
|
+
];
|
|
38
|
+
return (_jsx(Box, { px: "md", py: "sm", children: _jsx(Alert, { icon: _jsx(IconLock, { size: 16 }), color: "yellow", variant: "light", "data-testid": testId, children: _jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "wrap", children: [_jsx(Text, { size: "sm", children: message }), _jsx(Button, { variant: "default", onClick: onBranchManagerOpen, children: action })] }) }) }));
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Ref } from 'react';
|
|
2
|
+
import type { LogicalPath } from '../../paths/types.js';
|
|
3
|
+
export interface BranchPillProps {
|
|
4
|
+
branchName: string;
|
|
5
|
+
/** The editor is busy; with no branch name yet, that is the branch list loading. */
|
|
6
|
+
loading: boolean;
|
|
7
|
+
/** The status label ("Editing", "In review", "Protected"), when the mode shows one. */
|
|
8
|
+
status?: {
|
|
9
|
+
label: string;
|
|
10
|
+
testId: string;
|
|
11
|
+
value: string;
|
|
12
|
+
};
|
|
13
|
+
/** How many entries on the branch have unsaved changes, counting any the list can't name yet. */
|
|
14
|
+
unsavedCount: number;
|
|
15
|
+
/** The listed entries with unsaved changes. */
|
|
16
|
+
unsavedEntries: Array<{
|
|
17
|
+
path: LogicalPath;
|
|
18
|
+
label: string;
|
|
19
|
+
}>;
|
|
20
|
+
onSwitchBranch: () => void;
|
|
21
|
+
onRefreshBranch: () => void;
|
|
22
|
+
onDiscardAll: () => void;
|
|
23
|
+
onEntrySelect: (path: LogicalPath) => void;
|
|
24
|
+
/** The pill, which stands in for a menu item that opened something. */
|
|
25
|
+
pillRef?: Ref<HTMLButtonElement>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The branch's name, status and unsaved count. Its menu holds the branch actions and lists the
|
|
29
|
+
* unsaved entries, each opening that entry. Below `sm` it is the branch icon and the count, its
|
|
30
|
+
* name then in its Tooltip, accessible name and menu label: the row's actions must not clip.
|
|
31
|
+
*/
|
|
32
|
+
export declare function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;
|