canopycms 0.1.0-int.115 → 0.1.0

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