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.
Files changed (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. 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
- if (matchesEntryQuery({ label, slug: node.nodeProps.slug }, query)) {
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({ label: String(node.label ?? ''), slug: node.nodeProps?.slug }, query));
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's title, truncated with an ellipsis. Its full text shows in a tooltip, and only while the
79
- * text is cut: the width is measured when the pointer or focus enters the row, not on every render.
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: "sm", fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) : (_jsx(Text, { ref: setElement, size: "sm", fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) }));
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(Box, { component: "span", role: "img", "aria-label": label, "data-testid": "unsaved-dot", style: {
118
- flexShrink: 0,
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
- // Collection-level mutation callbacks are nulled when readOnly, same as
467
- // the entry-level ones above -- this removes the collection menu button
468
- // entirely (hasCollectionMenu below) rather than leaving it enabled.
469
- const onAdd = readOnly ? undefined : node.nodeProps?.onAdd;
470
- const onEdit = readOnly ? undefined : node.nodeProps?.onEdit;
471
- const onAddSubCollection = readOnly
472
- ? undefined
473
- : node.nodeProps?.onAddSubCollection;
474
- const onDelete = readOnly ? undefined : node.nodeProps?.onDelete;
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 so once, in the branch banner.
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 canReorder = onReorderEntry && contentId && parentCollectionPath && typeof childIndex === 'number';
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. A read-only navigator already says so
502
- // once, in the branch banner, so only a lone read-only entry gets its own lock.
503
- const entryLocked = !readOnly && !!node.nodeProps?.itemReadOnly;
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" }), status && (_jsx(Badge, { size: "xs", variant: "light", color: "gray", style: { flexShrink: 0, maxWidth: 96 }, children: status })), 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 }) }) }))] }), _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) => {
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" }))] })] })), 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 && (_jsx(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
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: "Rename\u2026" })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
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: which entry this is, an outline of its fields to jump to, and
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: which entry this is, an outline of its fields to jump to, and
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
- padding: `8px ${PANE_PADDING}`,
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", children: [_jsx(Text, { fw: 600, size: "sm", truncate: "end", style: { minWidth: 0 }, children: title }), typeLabel && (_jsx(Badge, { variant: "light", color: "gray", size: "sm", tt: "none",
70
- // Gives way before the title in a narrow pane.
71
- style: { flexShrink: 2, 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,
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 { FieldRemoval } from './undo-removal.js';
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, or object Clear resets, for Undo. */
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
- // the Clear button (`onRemove`): shown only for an optional field that has a value.
241
- // Reset to `undefined`, never `{}`, which `isEmptyForRequired` (entry-validator.ts) reads
242
- // as present. The label is "Clear" (docs/ux-guidelines.md, Vocabulary), and it offers Undo.
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
- return wrapWithComments(_jsx(ObjectField, { label: label, required: objectField.required, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear
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": "Connecting to the preview", style: {
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;