canopycms 0.1.0 → 0.2.0-int.121
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 +202 -43
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +118 -47
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +29 -10
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +25 -6
- package/dist/assets/store-s3.js +81 -19
- package/dist/assets/types.d.ts +33 -4
- package/dist/cli/cli.js +151 -56
- package/dist/config/types.d.ts +3 -2
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +93 -39
- package/dist/editor/EntryNavigator.d.ts +17 -5
- package/dist/editor/EntryNavigator.js +66 -31
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +13 -2
- package/dist/editor/PreviewFrame.js +62 -5
- package/dist/editor/PreviewPane.d.ts +17 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +8 -7
- package/dist/editor/components/BranchLockBanner.js +30 -3
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchRowLock.d.ts +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -0
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +14 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +24 -9
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +18 -9
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +23 -0
- package/dist/editor/preview-bridge.js +105 -4
- package/dist/editor/preview-path.d.ts +16 -4
- package/dist/editor/preview-path.js +80 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.d.ts +1 -0
- package/dist/editor/theme.js +19 -1
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -5,7 +5,10 @@ import { ActionIcon, Badge, Box, Button, CloseButton, Group, Highlight, Loader,
|
|
|
5
5
|
import { IconArrowDown, IconAlertTriangle, IconArrowUp, IconDots, IconEdit, IconFolderPlus, IconLock, IconPlus, IconSearch, IconTrash, } from '@tabler/icons-react';
|
|
6
6
|
import { calculatePathToEntry } from './editor-utils.js';
|
|
7
7
|
import { matchesEntryQuery } from './entry-search.js';
|
|
8
|
+
import { EDITOR_ACTIONS } from './copy.js';
|
|
8
9
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
10
|
+
import { UnsavedMark } from './components/UnsavedMark.js';
|
|
11
|
+
import { BranchRowLock } from './components/BranchRowLock.js';
|
|
9
12
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
10
13
|
import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
|
|
11
14
|
const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", mt: 4, "data-testid": "unavailable-type-message", children: [_jsx(Box, { c: "orange", style: { display: 'flex', flexShrink: 0, paddingTop: 2 }, children: _jsx(IconAlertTriangle, { size: 14, "aria-hidden": true }) }), _jsx(Text, { size: "xs", c: "orange", children: _jsx(UnavailableTypeMessage, { schemaRefs: schemaRefs }) })] }));
|
|
@@ -44,7 +47,8 @@ const filterTree = (nodes, query) => {
|
|
|
44
47
|
for (const node of nodes) {
|
|
45
48
|
const label = String(node.label ?? '');
|
|
46
49
|
if (node.nodeProps?.isEntry) {
|
|
47
|
-
|
|
50
|
+
const { slug, title } = node.nodeProps;
|
|
51
|
+
if (matchesEntryQuery({ label, title, slug }, query)) {
|
|
48
52
|
kept.push(node);
|
|
49
53
|
}
|
|
50
54
|
}
|
|
@@ -71,14 +75,19 @@ const firstEntryPath = (nodes, query) => {
|
|
|
71
75
|
}
|
|
72
76
|
};
|
|
73
77
|
walk(nodes);
|
|
74
|
-
const match = entries.find((node) => matchesEntryQuery({
|
|
78
|
+
const match = entries.find((node) => matchesEntryQuery({
|
|
79
|
+
label: String(node.label ?? ''),
|
|
80
|
+
title: node.nodeProps?.title,
|
|
81
|
+
slug: node.nodeProps?.slug,
|
|
82
|
+
}, query));
|
|
75
83
|
return (match ?? entries[0])?.value;
|
|
76
84
|
};
|
|
77
85
|
/**
|
|
78
|
-
* A row
|
|
79
|
-
* text is cut: the width is measured when the pointer or focus enters the row, not on every
|
|
86
|
+
* A line of a row, truncated with an ellipsis. Its full text shows in a tooltip, and only while
|
|
87
|
+
* the text is cut: the width is measured when the pointer or focus enters the row, not on every
|
|
88
|
+
* render.
|
|
80
89
|
*/
|
|
81
|
-
const TruncatedLabel = ({ label, highlight, fw, }) => {
|
|
90
|
+
const TruncatedLabel = ({ label, highlight = '', fw, size = 'sm', c }) => {
|
|
82
91
|
const elementRef = useRef(null);
|
|
83
92
|
const [truncated, setTruncated] = useState(false);
|
|
84
93
|
const setElement = useCallback((node) => {
|
|
@@ -100,7 +109,7 @@ const TruncatedLabel = ({ label, highlight, fw, }) => {
|
|
|
100
109
|
row.removeEventListener('focusin', measure);
|
|
101
110
|
};
|
|
102
111
|
}, []);
|
|
103
|
-
return (_jsx(Tooltip, { label: label, openDelay: 400, withinPortal: true, disabled: !truncated, children: highlight ? (_jsx(Highlight, { ref: setElement, highlight: highlight, size:
|
|
112
|
+
return (_jsx(Tooltip, { label: label, openDelay: 400, withinPortal: true, disabled: !truncated, children: highlight ? (_jsx(Highlight, { ref: setElement, highlight: highlight, size: size, fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) : (_jsx(Text, { ref: setElement, size: size, fw: fw, c: c, truncate: "end", style: { minWidth: 0 }, children: label })) }));
|
|
104
113
|
};
|
|
105
114
|
/** The keys Mantine's tree acts on, matched as it matches them, by `code`. */
|
|
106
115
|
const TREE_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']);
|
|
@@ -114,14 +123,8 @@ const stopTreeKeys = (event) => {
|
|
|
114
123
|
if (TREE_KEYS.has(event.nativeEvent.code))
|
|
115
124
|
event.stopPropagation();
|
|
116
125
|
};
|
|
117
|
-
const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(
|
|
118
|
-
|
|
119
|
-
width: 8,
|
|
120
|
-
height: 8,
|
|
121
|
-
borderRadius: '50%',
|
|
122
|
-
backgroundColor: 'var(--mantine-color-brand-6)',
|
|
123
|
-
} }) }));
|
|
124
|
-
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
126
|
+
const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
|
|
127
|
+
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
125
128
|
// Shadow the entry-mutation callbacks with undefined when readOnly, so every
|
|
126
129
|
// downstream reference (hasEntryMenu, canReorder, the menu items themselves)
|
|
127
130
|
// sees them as absent rather than threading a readOnly check through each
|
|
@@ -147,6 +150,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
147
150
|
label: entry.label,
|
|
148
151
|
nodeProps: {
|
|
149
152
|
status: entry.status,
|
|
153
|
+
title: entry.title,
|
|
154
|
+
missing: entry.missing,
|
|
155
|
+
noPage: entry.noPage,
|
|
150
156
|
isEntry: true,
|
|
151
157
|
entryPath: entry.path,
|
|
152
158
|
slug: entry.slug ?? lastSegment(entry.path),
|
|
@@ -168,6 +174,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
168
174
|
label: entry?.label ?? col.label,
|
|
169
175
|
nodeProps: {
|
|
170
176
|
status: entry?.status,
|
|
177
|
+
title: entry?.title,
|
|
178
|
+
missing: entry?.missing,
|
|
179
|
+
noPage: entry?.noPage,
|
|
171
180
|
isEntry: true,
|
|
172
181
|
entryPath: entry?.path,
|
|
173
182
|
collectionPath: col.path,
|
|
@@ -275,6 +284,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
275
284
|
label: item.label,
|
|
276
285
|
nodeProps: {
|
|
277
286
|
status: item.status,
|
|
287
|
+
title: item.title,
|
|
288
|
+
missing: item.missing,
|
|
289
|
+
noPage: item.noPage,
|
|
278
290
|
isEntry: true,
|
|
279
291
|
entryPath: item.path,
|
|
280
292
|
slug: item.slug ?? lastSegment(item.path),
|
|
@@ -463,15 +475,18 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
463
475
|
const Chevron = ({ expanded, visible }) => (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", style: { flexShrink: 0, opacity: visible ? 0.9 : 0 }, children: expanded ? (_jsx("path", { d: "M7.41 8.59 12 13.17 16.59 8.59 18 10l-6 6-6-6z" })) : (_jsx("path", { d: "M10 6 8.59 7.41 13.17 12 8.59 16.59 10 18l6-6z" })) }));
|
|
464
476
|
const renderNode = ({ node, elementProps, hasChildren, expanded, level, }) => {
|
|
465
477
|
const status = node.nodeProps?.status;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
478
|
+
const entryMissing = !!node.nodeProps?.missing;
|
|
479
|
+
const entryNoPage = !!node.nodeProps?.noPage;
|
|
480
|
+
// Collection-level mutation callbacks are nulled when readOnly, same as the entry-level ones
|
|
481
|
+
// above, so hasCollectionMenu is false; a row that would have had a menu shows its lock.
|
|
482
|
+
const rawOnAdd = node.nodeProps?.onAdd;
|
|
483
|
+
const rawOnEdit = node.nodeProps?.onEdit;
|
|
484
|
+
const rawOnAddSubCollection = node.nodeProps?.onAddSubCollection;
|
|
485
|
+
const rawOnDelete = node.nodeProps?.onDelete;
|
|
486
|
+
const onAdd = readOnly ? undefined : rawOnAdd;
|
|
487
|
+
const onEdit = readOnly ? undefined : rawOnEdit;
|
|
488
|
+
const onAddSubCollection = readOnly ? undefined : rawOnAddSubCollection;
|
|
489
|
+
const onDelete = readOnly ? undefined : rawOnDelete;
|
|
475
490
|
const entryPath = node.nodeProps?.entryPath;
|
|
476
491
|
const contentId = node.nodeProps?.contentId;
|
|
477
492
|
const parentCollectionPath = node.nodeProps?.parentCollectionPath;
|
|
@@ -481,7 +496,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
481
496
|
const isEntry = node.nodeProps?.isEntry;
|
|
482
497
|
const conflictNotice = node.nodeProps?.conflictNotice;
|
|
483
498
|
const unavailableSchemaRefs = node.nodeProps?.unavailableSchemaRefs;
|
|
484
|
-
// A read-only navigator already says
|
|
499
|
+
// A read-only navigator already says writes are refused, so the add-refused line is not repeated.
|
|
485
500
|
const addRefused = !readOnly && !!node.nodeProps?.addRefused;
|
|
486
501
|
const isLeaf = !hasChildren || isEntry;
|
|
487
502
|
const selected = node.value === selectedPath;
|
|
@@ -491,21 +506,36 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
491
506
|
// Mantine only provides hasChildren=true if children.length > 0, but we want
|
|
492
507
|
// collections to always be expandable
|
|
493
508
|
const showChevron = hasChildren || Boolean(isCollection);
|
|
494
|
-
const
|
|
509
|
+
const reorderPosition = contentId && parentCollectionPath && typeof childIndex === 'number';
|
|
510
|
+
const canReorder = onReorderEntry && reorderPosition;
|
|
495
511
|
const canMoveUp = canReorder && childIndex > 0;
|
|
496
512
|
const canMoveDown = canReorder && typeof totalChildrenCount === 'number' && childIndex < totalChildrenCount - 1;
|
|
497
513
|
// Collections show menu for add/edit/delete actions OR for reordering (subcollections)
|
|
498
514
|
const hasCollectionMenu = isCollection && (onAdd || onEdit || onAddSubCollection || onDelete || canReorder);
|
|
499
515
|
// An unavailable entry cannot be written, so its menu (rename, delete, reorder) is not offered.
|
|
500
516
|
const entryUnavailable = node.nodeProps?.unavailable;
|
|
501
|
-
// The same holds for an entry this user may not edit
|
|
502
|
-
//
|
|
503
|
-
const entryLocked =
|
|
517
|
+
// The same holds for an entry this user may not edit, on any branch: its own lock stays, since a
|
|
518
|
+
// path rule, not the branch, refuses it, and the branch lock leaves that row's slot empty.
|
|
519
|
+
const entryLocked = !!node.nodeProps?.itemReadOnly;
|
|
504
520
|
const hasEntryMenu = isEntry &&
|
|
505
521
|
entryPath &&
|
|
506
522
|
!entryUnavailable &&
|
|
507
523
|
!entryLocked &&
|
|
508
524
|
(onDeleteEntry || onRenameEntry || onReorderEntry);
|
|
525
|
+
// The lock stands where the menu would, on exactly the rows that would otherwise have one. It
|
|
526
|
+
// is always shown: on hover alone, touch and keyboard users would see neither menu nor reason.
|
|
527
|
+
const wouldHaveCollectionMenu = isCollection &&
|
|
528
|
+
!!(rawOnAdd ||
|
|
529
|
+
rawOnEdit ||
|
|
530
|
+
rawOnAddSubCollection ||
|
|
531
|
+
rawOnDelete ||
|
|
532
|
+
(onReorderEntryProp && reorderPosition));
|
|
533
|
+
const wouldHaveEntryMenu = isEntry &&
|
|
534
|
+
!!entryPath &&
|
|
535
|
+
!entryUnavailable &&
|
|
536
|
+
!entryLocked &&
|
|
537
|
+
!!(onDeleteEntryProp || onRenameEntryProp || onReorderEntryProp);
|
|
538
|
+
const showsLockSlot = readOnly && (wouldHaveCollectionMenu || wouldHaveEntryMenu);
|
|
509
539
|
const showEntryDirtyDot = isEntry && !!entryPath && !!dirtyPaths?.has(entryPath);
|
|
510
540
|
const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
|
|
511
541
|
const adminItems = onAddSubCollection || onEdit || onDelete;
|
|
@@ -533,7 +563,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
533
563
|
flexShrink: 0,
|
|
534
564
|
alignItems: 'center',
|
|
535
565
|
justifyContent: 'center',
|
|
536
|
-
}, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }),
|
|
566
|
+
}, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsxs(Stack, { gap: 0, style: { flex: 1, minWidth: 0 }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [_jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }), conflictNotice && (_jsx(Tooltip, { label: "This content was updated on the base branch \u2014 a reviewer will reconcile", withArrow: true, children: _jsx(Badge, { size: "xs", variant: "light", color: "orange", "data-testid": "conflict-badge", style: { flexShrink: 0 }, children: "Conflict" }) })), entryLocked && (_jsx(Tooltip, { label: "You can't edit this entry", withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": "You can't edit this entry", "data-testid": "entry-lock", c: "dimmed", style: { display: 'inline-flex', flexShrink: 0 }, children: _jsx(IconLock, { size: 14, "aria-hidden": true }) }) }))] }), status && (_jsx(Box, { "data-testid": "entry-nav-type", style: { minWidth: 0 }, children: _jsx(TruncatedLabel, { label: status, size: "xs",
|
|
567
|
+
// A missing file is an error, in error text, not a dimmed type.
|
|
568
|
+
c: entryMissing ? 'var(--canopy-danger-text)' : 'dimmed' }) }))] })] }), _jsxs(Group, { gap: 4, wrap: "nowrap", style: { flexShrink: 0 }, children: [hasCollectionMenu && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Collection actions", "data-testid": `collection-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: (event) => {
|
|
537
569
|
event.stopPropagation();
|
|
538
570
|
onAdd();
|
|
539
571
|
}, "data-testid": "add-entry-menu-item", children: "Add entry\u2026" })), canReorder && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
|
|
@@ -551,10 +583,13 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
551
583
|
}, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
|
|
552
584
|
event.stopPropagation();
|
|
553
585
|
onDelete();
|
|
554
|
-
}, children: "Delete collection" }))] })] })),
|
|
586
|
+
}, children: "Delete collection" }))] })] })), showsLockSlot &&
|
|
587
|
+
(readOnlyReason ? (_jsx(BranchRowLock, { label: isCollection ? 'Collection actions' : 'Entry actions', reason: readOnlyReason })) : (
|
|
588
|
+
// The branch's state is still loading: hold the slot so rows do not shift.
|
|
589
|
+
_jsx(Box, { w: 18, h: 18, "aria-hidden": true, "data-testid": "branch-lock-pending" }))), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
|
|
555
590
|
event.stopPropagation();
|
|
556
591
|
onRenameEntry(entryPath);
|
|
557
|
-
}, "data-testid": "rename-entry-menu-item", children: "
|
|
592
|
+
}, "data-testid": "rename-entry-menu-item", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
|
|
558
593
|
event.stopPropagation();
|
|
559
594
|
onReorderEntry(parentCollectionPath, contentId, 'up');
|
|
560
595
|
}, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
|
|
@@ -9,6 +9,7 @@ export interface OutlineNode {
|
|
|
9
9
|
children?: OutlineNode[];
|
|
10
10
|
}
|
|
11
11
|
export interface FormHeaderProps {
|
|
12
|
+
/** The entry's name, for assistive tech only: the top bar's breadcrumb shows it. */
|
|
12
13
|
title: string;
|
|
13
14
|
/** The entry type's label, shown as a badge. */
|
|
14
15
|
typeLabel?: string;
|
|
@@ -19,7 +20,7 @@ export interface FormHeaderProps {
|
|
|
19
20
|
onExpandAll?: (expanded: boolean) => void;
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
|
-
* The form's sticky header:
|
|
23
|
+
* The form's sticky header: the entry's type, an outline of its fields to jump to, and
|
|
23
24
|
* Expand all / Collapse all for every list and block field in it.
|
|
24
25
|
*/
|
|
25
26
|
export declare const FormHeader: React.FC<FormHeaderProps>;
|
|
@@ -4,6 +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
|
+
import { PANE_HEADER_HEIGHT } from './pane-header.js';
|
|
7
8
|
/** The padding of the scroller the form sits in, which `shell/PaneLayout.tsx` sets; 0 where unset. */
|
|
8
9
|
const PANE_PADDING = 'var(--canopy-form-pane-padding, 0px)';
|
|
9
10
|
/** Below this header width "Outline" shows its icon alone, so the controls never clip. */
|
|
@@ -28,7 +29,7 @@ const OutlineList = ({ nodes, field, nested = false, onChoose }) => (_jsx("ul",
|
|
|
28
29
|
return (_jsxs("li", { children: [node.path === undefined ? (_jsx(Text, { size: "xs", fw: 600, c: "dimmed", px: "xs", pt: 6, pb: 2, children: node.label })) : (_jsx(UnstyledButton, { onClick: () => onChoose(node.path), "aria-label": field ? `${field} › ${name}` : undefined, display: "block", w: "100%", py: 4, px: "xs", style: { borderRadius: 'var(--mantine-radius-sm)' }, className: "canopy-outline-item", "data-testid": "form-outline-item", children: _jsxs(Text, { size: "sm", truncate: "end", fw: field ? undefined : 500, children: [node.label, node.secondary && (_jsxs(Text, { span: true, c: "dimmed", size: "sm", children: [' ', node.secondary] }))] }) })), node.children && node.children.length > 0 && (_jsx(OutlineList, { nodes: node.children, field: node.path === undefined ? undefined : node.label, nested: true, onChoose: onChoose }))] }, node.path ?? `group-${idx}`));
|
|
29
30
|
}) }));
|
|
30
31
|
/**
|
|
31
|
-
* The form's sticky header:
|
|
32
|
+
* The form's sticky header: the entry's type, an outline of its fields to jump to, and
|
|
32
33
|
* Expand all / Collapse all for every list and block field in it.
|
|
33
34
|
*/
|
|
34
35
|
export const FormHeader = ({ title, typeLabel, outline, onSelect, onExpandAll, }) => {
|
|
@@ -63,12 +64,15 @@ export const FormHeader = ({ title, typeLabel, outline, onSelect, onExpandAll, }
|
|
|
63
64
|
top: `calc(-1 * ${PANE_PADDING})`,
|
|
64
65
|
zIndex: 3,
|
|
65
66
|
margin: `calc(-1 * ${PANE_PADDING}) calc(-1 * ${PANE_PADDING}) 0`,
|
|
66
|
-
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
minHeight: PANE_HEADER_HEIGHT,
|
|
70
|
+
padding: `2px ${PANE_PADDING}`,
|
|
67
71
|
backgroundColor: 'var(--canopy-surface-muted)',
|
|
68
72
|
borderBottom: '1px solid var(--mantine-color-default-border)',
|
|
69
|
-
}, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap",
|
|
70
|
-
// Gives way before the
|
|
71
|
-
style: {
|
|
73
|
+
}, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx(VisuallyHidden, { "data-testid": "form-header-title", children: title }), typeLabel && (_jsx(Badge, { variant: "light", color: "gray", size: "sm", tt: "none",
|
|
74
|
+
// Gives way before the controls in a narrow pane.
|
|
75
|
+
style: { minWidth: 0 }, children: typeLabel })), _jsxs(Group, { gap: 4, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsxs(Popover, { opened: outlineOpen, onChange: setOutlineOpen, position: "bottom-end", width: 300, shadow: "md", withinPortal: true,
|
|
72
76
|
// Focus enters the list; Escape hands it back to "Outline" (`closeOnEscape`). A chosen
|
|
73
77
|
// field takes it instead, so closing returns it nowhere.
|
|
74
78
|
trapFocus: true, returnFocus: false, children: [_jsx(Tooltip, { label: "Outline", disabled: !compact, opened: compact ? undefined : false, children: _jsx(Popover.Target, { children: _jsx(Button, { variant: "subtle", color: "gray", c: "dimmed", size: "compact-sm", px: compact ? 6 : undefined, leftSection: _jsx(IconListTree, { size: 14, "aria-hidden": true }), styles: compact ? { section: { marginInlineEnd: 0 } } : undefined, onClick: () => setOutlineOpen((open) => !open), "data-testid": "form-outline-button", children: compact ? _jsx(VisuallyHidden, { children: "Outline" }) : 'Outline' }) }) }), _jsx(Popover.Dropdown, { p: 4, children: _jsx(ScrollArea.Autosize, { mah: 400, type: "auto", children: _jsx("nav", { "aria-label": "Outline", "data-testid": "form-outline", children: _jsx(OutlineList, { nodes: outline, onChoose: (path) => {
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import type { ContentFormat, EntrySchema, FieldConfig } from '../config/index.js';
|
|
3
3
|
import type { CommentThread } from '../comment-store.js';
|
|
4
4
|
import type { UserSearchResult } from '../auth/types.js';
|
|
5
|
-
import type
|
|
5
|
+
import { type FieldRemoval } from './undo-removal.js';
|
|
6
6
|
export type FormValue = Record<string, unknown>;
|
|
7
7
|
export interface CustomFieldRenderProps {
|
|
8
8
|
field: FieldConfig;
|
|
@@ -41,8 +41,13 @@ export interface FormRendererProps {
|
|
|
41
41
|
format?: ContentFormat;
|
|
42
42
|
/** Shows the value without accepting edits. Comments stay open. */
|
|
43
43
|
readOnly?: boolean;
|
|
44
|
-
/** Told of each list item, block or image Remove takes out,
|
|
44
|
+
/** Told of each list item, block or image Remove takes out, for Undo. */
|
|
45
45
|
onRemoved?: (removal: FieldRemoval) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The entry's saved copy, which `value` is while there is no draft. A new one (a save, a reload)
|
|
48
|
+
* or showing it unchanged (a discard) ends every object's Undo clear.
|
|
49
|
+
*/
|
|
50
|
+
savedValue?: FormValue;
|
|
46
51
|
/** Resolves comment authors to names in field and entry threads; without it they show as ids. */
|
|
47
52
|
onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
|
|
48
53
|
/**
|
|
@@ -35,6 +35,7 @@ import { FormExpansionContext } from './fields/form-expansion.js';
|
|
|
35
35
|
import { FormHeader } from './FormHeader.js';
|
|
36
36
|
import { buildFormOutline, hasCollapsibleList } from './form-outline.js';
|
|
37
37
|
import { findFieldTarget, focusFieldTarget } from './field-target.js';
|
|
38
|
+
import { canRestoreRemoval, movedThreadsNote, openThreadsWithin, removalAnchors, restoreRemoval, } from './undo-removal.js';
|
|
38
39
|
import { schemaLabel } from './schema-label.js';
|
|
39
40
|
/**
|
|
40
41
|
* How an Undo toast names a removed object-list item: by its title field or first non-empty
|
|
@@ -94,7 +95,7 @@ const FieldBoundary = ({ canopyPath, resetKey, update, build, fallback }) => {
|
|
|
94
95
|
setGeneration(liveGeneration.current);
|
|
95
96
|
}, fallback: fallback, children: _jsx(FieldControl, { build: () => build(guardedUpdate) }) }));
|
|
96
97
|
};
|
|
97
|
-
export const FormRenderer = ({ fields, value, onChange, customRenderers, branch = 'main', comments = [], currentEntryPath, currentUserId, canResolve = false, focusedFieldPath, highlightThreadId, onAddComment, onResolveThread, conflictNotice = false, fieldErrors, format, readOnly = false, onRemoved, onGetUserMetadata, header, notice, commentBlockedReason, removedItemFieldLabel, }) => {
|
|
98
|
+
export const FormRenderer = ({ fields, value, onChange, customRenderers, branch = 'main', comments = [], currentEntryPath, currentUserId, canResolve = false, focusedFieldPath, highlightThreadId, onAddComment, onResolveThread, conflictNotice = false, fieldErrors, format, readOnly = false, onRemoved, savedValue, onGetUserMetadata, header, notice, commentBlockedReason, removedItemFieldLabel, }) => {
|
|
98
99
|
const emit = readOnly ? noop : onChange;
|
|
99
100
|
const formRef = useRef(null);
|
|
100
101
|
const customTypes = new Set(Object.keys(customRenderers ?? {}));
|
|
@@ -109,6 +110,50 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
109
110
|
return Object.keys(fieldErrors ?? {}).some((key) => key === row || key.startsWith(`${row}.`) || key.startsWith(`${row}[`));
|
|
110
111
|
};
|
|
111
112
|
const [formComments] = useState(createFormComments);
|
|
113
|
+
// An object's Undo clear lasts while it can put the object back where it was (checked by
|
|
114
|
+
// `canRestoreRemoval`), and ends with the entry, the branch, a save or a discard. It has no
|
|
115
|
+
// timer: it stays in the form for as long as the object is left unedited.
|
|
116
|
+
const [cleared, setCleared] = useState({
|
|
117
|
+
scope: boundaryResetKey,
|
|
118
|
+
savedValue,
|
|
119
|
+
byPath: {},
|
|
120
|
+
});
|
|
121
|
+
const clearScope = !readOnly && value !== savedValue ? boundaryResetKey : undefined;
|
|
122
|
+
const clearsLive = cleared.scope === clearScope && cleared.savedValue === savedValue;
|
|
123
|
+
const offeredClears = Object.fromEntries(Object.entries(clearsLive ? cleared.byPath : {}).filter(([, clear]) => canRestoreRemoval(fields, value, clear.removal)));
|
|
124
|
+
if (Object.keys(offeredClears).length !== Object.keys(cleared.byPath).length) {
|
|
125
|
+
setCleared({ scope: boundaryResetKey, savedValue, byPath: offeredClears });
|
|
126
|
+
}
|
|
127
|
+
const clearObject = (path, previous, label, update) => {
|
|
128
|
+
const removal = { kind: 'value', path, value: previous, label: `"${label}"` };
|
|
129
|
+
// Taken from the value this render shows, which the clear has not reached yet.
|
|
130
|
+
const anchors = removalAnchors(fields, value, removal);
|
|
131
|
+
// Open threads keyed to items inside it leave their fields for the entry's thread list.
|
|
132
|
+
const moved = currentEntryPath ? openThreadsWithin(comments, currentEntryPath, path) : 0;
|
|
133
|
+
update(undefined);
|
|
134
|
+
setCleared((prev) => ({
|
|
135
|
+
scope: boundaryResetKey,
|
|
136
|
+
savedValue,
|
|
137
|
+
byPath: {
|
|
138
|
+
...(prev.scope === boundaryResetKey && prev.savedValue === savedValue ? prev.byPath : {}),
|
|
139
|
+
[normalizeCanopyPath(path)]: {
|
|
140
|
+
removal: { ...removal, anchors },
|
|
141
|
+
note: movedThreadsNote(moved),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
};
|
|
146
|
+
// Puts the object back into the value as it is now, so edits made elsewhere since are kept.
|
|
147
|
+
const undoClear = (canopyPath) => {
|
|
148
|
+
const clear = offeredClears[canopyPath];
|
|
149
|
+
const restored = clear ? restoreRemoval(fields, value, clear.removal) : undefined;
|
|
150
|
+
setCleared((prev) => {
|
|
151
|
+
const { [canopyPath]: _undone, ...rest } = prev.byPath;
|
|
152
|
+
return { ...prev, byPath: rest };
|
|
153
|
+
});
|
|
154
|
+
if (restored)
|
|
155
|
+
emit(restored);
|
|
156
|
+
};
|
|
112
157
|
// The field last focused on one entry is never offered on the next.
|
|
113
158
|
useEffect(() => formComments.noteFocus(undefined), [formComments, boundaryResetKey]);
|
|
114
159
|
// Wraps the rendered control in an error boundary, and with an inline validation message
|
|
@@ -237,23 +282,13 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
237
282
|
}) }, fieldKey(path)));
|
|
238
283
|
}
|
|
239
284
|
// A non-list object has no list item to remove, so its only way back to `undefined` is
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
285
|
+
// Clear (`onClear`): offered only for an optional field that has a value. Reset to
|
|
286
|
+
// `undefined`, never `{}`, which `isEmptyForRequired` (entry-validator.ts) reads as
|
|
287
|
+
// present. The label is "Clear" (docs/ux-guidelines.md, Vocabulary), and it offers Undo.
|
|
243
288
|
const hasValue = currentValue !== undefined && currentValue !== null;
|
|
244
289
|
const canClear = !readOnly && objectField.required !== true && hasValue;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
update(undefined);
|
|
248
|
-
onRemoved?.({
|
|
249
|
-
kind: 'value',
|
|
250
|
-
path,
|
|
251
|
-
value: currentValue,
|
|
252
|
-
label: `"${label}"`,
|
|
253
|
-
action: 'clear',
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
: undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
290
|
+
const clear = offeredClears[canopyPath];
|
|
291
|
+
return wrapWithComments(_jsx(ObjectField, { label: label, required: objectField.required, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onClear: canClear ? () => clearObject(path, currentValue, label, update) : undefined, onUndoClear: clear ? () => undoClear(canopyPath) : undefined, clearNote: clear?.note, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
257
292
|
}
|
|
258
293
|
case 'code':
|
|
259
294
|
return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
@@ -4,13 +4,18 @@ export interface PreviewMarks {
|
|
|
4
4
|
count: number;
|
|
5
5
|
paths?: string[];
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* What a page's handshake says of the site's header and footer: its ready message says it renders
|
|
9
|
+
* them for `chrome=1` or that it does not, or no ready message came in `READY_TIMEOUT_MS`.
|
|
10
|
+
*/
|
|
11
|
+
export type SiteChromeSupport = 'supported' | 'unsupported' | 'unanswered';
|
|
7
12
|
/**
|
|
8
13
|
* Lightweight iframe wrapper to keep the preview in sync with form state.
|
|
9
14
|
* It posts the latest draft data to the iframe after load and when data changes. A progress bar
|
|
10
15
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
11
|
-
* to reload the preview.
|
|
16
|
+
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
12
17
|
*/
|
|
13
|
-
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, }: {
|
|
18
|
+
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy, }: {
|
|
14
19
|
src: string;
|
|
15
20
|
path: string;
|
|
16
21
|
data?: unknown;
|
|
@@ -37,4 +42,10 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
|
|
|
37
42
|
iframeStyle?: CSSProperties;
|
|
38
43
|
/** Replaces the chip's Retry, for a host whose own reload control remounts the frame. */
|
|
39
44
|
onReload?: () => void;
|
|
45
|
+
/** Called with what the page's handshake says of the site's header and footer. */
|
|
46
|
+
onSiteChrome?: (support: SiteChromeSupport) => void;
|
|
47
|
+
/** A preview link's absolute URL, unfollowed; focus follows into the next page unless moved. */
|
|
48
|
+
onNavigate?: (href: string) => void;
|
|
49
|
+
/** The editor is loading the next page's data: this page stays in view, dimmed and inert. */
|
|
50
|
+
busy?: boolean;
|
|
40
51
|
}) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_NAVIGATE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
|
|
5
5
|
/**
|
|
6
6
|
* How long after the iframe's `load` the preview has to send its ready message before the frame
|
|
7
7
|
* says live updates are off. Ready can also arrive before `load`, which waits for images too.
|
|
@@ -22,6 +22,21 @@ const visuallyHidden = {
|
|
|
22
22
|
const isMarkPaths = (paths) => Array.isArray(paths) &&
|
|
23
23
|
paths.length <= MARK_REPORT_LIMITS.paths &&
|
|
24
24
|
paths.every((path) => typeof path === 'string' && path.length <= MARK_REPORT_LIMITS.pathLength);
|
|
25
|
+
/** Only http(s) on the preview's origin, so the editor never opens a `javascript:` URL or another site. */
|
|
26
|
+
const isPreviewNavigateMessage = (data, previewOrigin) => {
|
|
27
|
+
if (typeof data !== 'object' || data === null)
|
|
28
|
+
return false;
|
|
29
|
+
const { type, href } = data;
|
|
30
|
+
if (type !== CANOPY_PREVIEW_NAVIGATE || typeof href !== 'string')
|
|
31
|
+
return false;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(href);
|
|
34
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin === previewOrigin;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
25
40
|
const sendDraftUpdate = (iframe, message, targetOrigin) => {
|
|
26
41
|
if (!iframe?.contentWindow)
|
|
27
42
|
return;
|
|
@@ -34,9 +49,9 @@ const sendDraftUpdate = (iframe, message, targetOrigin) => {
|
|
|
34
49
|
* Lightweight iframe wrapper to keep the preview in sync with form state.
|
|
35
50
|
* It posts the latest draft data to the iframe after load and when data changes. A progress bar
|
|
36
51
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
37
|
-
* to reload the preview.
|
|
52
|
+
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
38
53
|
*/
|
|
39
|
-
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, }) => {
|
|
54
|
+
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy = false, }) => {
|
|
40
55
|
const iframeRef = useRef(null);
|
|
41
56
|
// Pin the preview origin from the src prop: outbound messages target it (never '*'),
|
|
42
57
|
// and inbound messages must come from it. An iframe that navigates cross-origin
|
|
@@ -51,10 +66,15 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
51
66
|
const [retries, setRetries] = useState(0);
|
|
52
67
|
// A new src is a new page, with a handshake of its own.
|
|
53
68
|
const [prevSrc, setPrevSrc] = useState(src);
|
|
69
|
+
// The frame a followed link was clicked in is replaced, which drops focus to the editor's body.
|
|
70
|
+
const [followingLink, setFollowingLink] = useState(false);
|
|
71
|
+
const [focusOnLoad, setFocusOnLoad] = useState(false);
|
|
54
72
|
if (src !== prevSrc) {
|
|
55
73
|
setPrevSrc(src);
|
|
56
74
|
setHandshake('waiting');
|
|
57
75
|
setLoaded(false);
|
|
76
|
+
setFocusOnLoad(followingLink);
|
|
77
|
+
setFollowingLink(false);
|
|
58
78
|
}
|
|
59
79
|
useEffect(() => {
|
|
60
80
|
if (!loaded || handshake !== 'waiting')
|
|
@@ -71,6 +91,10 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
71
91
|
const hasDraft = data !== undefined;
|
|
72
92
|
const syncPending = hasDraft && handshake === 'waiting';
|
|
73
93
|
const liveUpdatesOff = hasDraft && handshake === 'missing';
|
|
94
|
+
// React 18 has no `inert` prop, so it is set on the DOM.
|
|
95
|
+
useLayoutEffect(() => {
|
|
96
|
+
iframeRef.current?.toggleAttribute('inert', busy);
|
|
97
|
+
}, [busy, retries, src]);
|
|
74
98
|
// Inject the progress bar keyframe animation once per page.
|
|
75
99
|
useEffect(() => {
|
|
76
100
|
const styleId = 'canopycms-preview-sync-style';
|
|
@@ -109,12 +133,31 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
109
133
|
const postHighlightRef = useRef(postHighlight);
|
|
110
134
|
const onPreviewErrorRef = useRef(onPreviewError);
|
|
111
135
|
const onMarksRef = useRef(onMarks);
|
|
136
|
+
const onSiteChromeRef = useRef(onSiteChrome);
|
|
137
|
+
const onNavigateRef = useRef(onNavigate);
|
|
112
138
|
useEffect(() => {
|
|
113
139
|
postRef.current = post;
|
|
114
140
|
postHighlightRef.current = postHighlight;
|
|
115
141
|
onPreviewErrorRef.current = onPreviewError;
|
|
116
142
|
onMarksRef.current = onMarks;
|
|
143
|
+
onSiteChromeRef.current = onSiteChrome;
|
|
144
|
+
onNavigateRef.current = onNavigate;
|
|
117
145
|
});
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (handshake === 'missing')
|
|
148
|
+
onSiteChromeRef.current?.('unanswered');
|
|
149
|
+
}, [handshake]);
|
|
150
|
+
// Focus the user puts anywhere before the next page loads is theirs to keep.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!followingLink && !focusOnLoad)
|
|
153
|
+
return;
|
|
154
|
+
const keep = () => {
|
|
155
|
+
setFollowingLink(false);
|
|
156
|
+
setFocusOnLoad(false);
|
|
157
|
+
};
|
|
158
|
+
document.addEventListener('focusin', keep);
|
|
159
|
+
return () => document.removeEventListener('focusin', keep);
|
|
160
|
+
}, [followingLink, focusOnLoad]);
|
|
118
161
|
useEffect(() => {
|
|
119
162
|
post();
|
|
120
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- post is tracked via ref
|
|
@@ -138,6 +181,9 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
138
181
|
postRef.current();
|
|
139
182
|
postHighlightRef.current();
|
|
140
183
|
setHandshake('ready');
|
|
184
|
+
onSiteChromeRef.current?.(event.data.siteChrome === true
|
|
185
|
+
? 'supported'
|
|
186
|
+
: 'unsupported');
|
|
141
187
|
}
|
|
142
188
|
else if (type === CANOPY_PREVIEW_ERROR) {
|
|
143
189
|
const msg = event.data;
|
|
@@ -156,11 +202,15 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
156
202
|
onMarksRef.current?.({ count, ...(isMarkPaths(paths) ? { paths } : {}) });
|
|
157
203
|
}
|
|
158
204
|
}
|
|
205
|
+
else if (isPreviewNavigateMessage(event.data, previewOrigin)) {
|
|
206
|
+
onNavigateRef.current?.(event.data.href);
|
|
207
|
+
setFollowingLink(true);
|
|
208
|
+
}
|
|
159
209
|
};
|
|
160
210
|
window.addEventListener('message', handleMessage);
|
|
161
211
|
return () => window.removeEventListener('message', handleMessage);
|
|
162
212
|
}, [previewOrigin]);
|
|
163
|
-
return (_jsxs("div", { className: className, style: { position: 'relative', overflow: 'hidden', ...style }, children: [syncPending && (_jsx("div", { role: "progressbar", "aria-label":
|
|
213
|
+
return (_jsxs("div", { className: className, "aria-busy": busy || undefined, style: { position: 'relative', overflow: 'hidden', ...style }, children: [(syncPending || busy) && (_jsx("div", { role: "progressbar", "aria-label": busy ? 'Loading the entry' : 'Connecting to the preview', style: {
|
|
164
214
|
position: 'absolute',
|
|
165
215
|
top: 0,
|
|
166
216
|
left: 0,
|
|
@@ -207,9 +257,16 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
207
257
|
height: '100%',
|
|
208
258
|
border: 'none',
|
|
209
259
|
...iframeStyle,
|
|
260
|
+
// Eased, so a page that arrives at once barely dims.
|
|
261
|
+
transition: 'opacity 150ms',
|
|
262
|
+
...(busy ? { opacity: 0.5 } : {}),
|
|
210
263
|
}, onLoad: () => {
|
|
211
264
|
post();
|
|
212
265
|
postHighlight();
|
|
213
266
|
setLoaded(true);
|
|
267
|
+
if (focusOnLoad) {
|
|
268
|
+
iframeRef.current?.focus();
|
|
269
|
+
setFocusOnLoad(false);
|
|
270
|
+
}
|
|
214
271
|
} }, `${retries}:${src}`)] }));
|
|
215
272
|
};
|
|
@@ -5,9 +5,16 @@ import type { InexactMark } from './preview-marks.js';
|
|
|
5
5
|
import type { PaneArrangement } from './shell/PaneLayout.js';
|
|
6
6
|
import { type PreviewMount } from './preview-path.js';
|
|
7
7
|
export interface PreviewPaneProps {
|
|
8
|
+
/** The open entry's page. */
|
|
8
9
|
src: string;
|
|
9
10
|
/** The page's path for the bridge and the toolbar; defaults to `src`. */
|
|
10
11
|
path?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The open entry's data is still loading, so `data` is not its draft yet. The page framed last
|
|
14
|
+
* stays in view, dimmed, until it arrives; with none framed yet, the viewport holds a skeleton.
|
|
15
|
+
* The toolbar already names the open entry.
|
|
16
|
+
*/
|
|
17
|
+
pending?: boolean;
|
|
11
18
|
/** Where the preview routes are mounted; the toolbar's path leaves out the `previewPrefix`. */
|
|
12
19
|
mount?: PreviewMount;
|
|
13
20
|
data?: unknown;
|
|
@@ -38,10 +45,19 @@ export interface PreviewPaneProps {
|
|
|
38
45
|
onMaximizedChange?: (maximized: boolean) => void;
|
|
39
46
|
/** A drag of the preview/form divider is under way: the preview shows its size over itself. */
|
|
40
47
|
resizing?: boolean;
|
|
48
|
+
/** The site's header and footer are asked for, by `chrome=1` on a `previewPrefix` page. */
|
|
49
|
+
siteChrome?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* With it, the toolbar offers the site header and footer toggle once the page says it can show
|
|
52
|
+
* them, and whenever `siteChrome` is on, so it can always be turned off.
|
|
53
|
+
*/
|
|
54
|
+
onSiteChromeChange?: (siteChrome: boolean) => void;
|
|
55
|
+
/** Called with the absolute URL of a link clicked in the preview; see `PreviewFrame`. */
|
|
56
|
+
onNavigate?: (href: string) => void;
|
|
41
57
|
}
|
|
42
58
|
/**
|
|
43
59
|
* The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
|
|
44
60
|
* fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
|
|
45
61
|
* down to fit a narrower pane.
|
|
46
62
|
*/
|
|
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;
|
|
63
|
+
export declare const PreviewPane: ({ src, path, pending, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, siteChrome, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
|