canopycms 0.2.0-int.121 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -78
- package/dist/api/branch.d.ts +2 -2
- package/dist/cli/cli.js +0 -18
- package/dist/cli/generate-ai-content.js +0 -6
- package/dist/cli/init.js +0 -6
- package/dist/dev-content-watcher.js +8 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +3 -3
- package/dist/editor/Editor.js +276 -130
- package/dist/editor/EntryNavigator.d.ts +44 -9
- package/dist/editor/EntryNavigator.js +278 -104
- package/dist/editor/branch-lock.d.ts +42 -0
- package/dist/editor/branch-lock.js +79 -0
- package/dist/editor/branch-status.d.ts +0 -6
- package/dist/editor/branch-status.js +0 -10
- package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
- package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
- package/dist/editor/components/BranchPill.d.ts +3 -18
- package/dist/editor/components/BranchPill.js +7 -10
- package/dist/editor/components/BranchRowLock.d.ts +3 -1
- package/dist/editor/components/BranchRowLock.js +4 -2
- package/dist/editor/components/EditorHeader.d.ts +24 -52
- package/dist/editor/components/EditorHeader.js +151 -71
- package/dist/editor/components/EditorRail.d.ts +8 -8
- package/dist/editor/components/EditorRail.js +7 -5
- package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
- package/dist/editor/components/EntryBreadcrumb.js +3 -3
- package/dist/editor/components/EntryHeader.d.ts +60 -0
- package/dist/editor/components/EntryHeader.js +45 -0
- package/dist/editor/components/EntrySaveButton.d.ts +24 -0
- package/dist/editor/components/EntrySaveButton.js +101 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
- package/dist/editor/components/NoEditPermissionNotice.js +1 -1
- package/dist/editor/components/RetryToastMessage.d.ts +3 -1
- package/dist/editor/components/RetryToastMessage.js +8 -4
- package/dist/editor/components/SaveAllButton.d.ts +25 -0
- package/dist/editor/components/SaveAllButton.js +81 -0
- package/dist/editor/components/UnsavedMark.d.ts +1 -1
- package/dist/editor/components/UnsavedMark.js +1 -1
- package/dist/editor/components/index.d.ts +1 -1
- package/dist/editor/components/index.js +1 -1
- package/dist/editor/copy.d.ts +0 -1
- package/dist/editor/copy.js +0 -1
- package/dist/editor/editor-utils.d.ts +2 -2
- package/dist/editor/editor-utils.js +2 -2
- package/dist/editor/entry-search.d.ts +10 -16
- package/dist/editor/entry-search.js +4 -24
- package/dist/editor/hooks/editor-history.d.ts +7 -0
- package/dist/editor/hooks/editor-history.js +80 -0
- package/dist/editor/hooks/useBranchActions.js +3 -1
- package/dist/editor/hooks/useBranchManager.d.ts +7 -3
- package/dist/editor/hooks/useBranchManager.js +18 -11
- package/dist/editor/hooks/useEntryManager.d.ts +12 -2
- package/dist/editor/hooks/useEntryManager.js +141 -20
- package/dist/editor/shell/ActivityRail.d.ts +4 -0
- package/dist/editor/shell/ActivityRail.js +13 -4
- package/dist/editor/shell/EditorShell.d.ts +6 -7
- package/dist/editor/shell/EditorShell.js +9 -13
- package/dist/editor/shell/PaneLayout.d.ts +1 -0
- package/dist/editor/shell/PaneLayout.js +23 -5
- package/dist/editor/shell/SidePanelHost.d.ts +11 -4
- package/dist/editor/shell/SidePanelHost.js +95 -7
- package/dist/editor/shell/TopBar.d.ts +4 -4
- package/dist/editor/shell/TopBar.js +6 -3
- package/dist/editor/theme.js +22 -1
- package/dist/editor/utils/focus-return.d.ts +10 -2
- package/dist/editor/utils/focus-return.js +40 -6
- package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
- package/dist/operating-mode/client-safe-strategy.js +1 -10
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/editor/components/BranchLockBanner.d.ts +0 -23
- package/dist/editor/components/BranchLockBanner.js +0 -66
- package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
- package/dist/editor/components/QuickOpenPalette.js +0 -71
- package/dist/editor/components/SaveSplitButton.d.ts +0 -31
- package/dist/editor/components/SaveSplitButton.js +0 -121
|
@@ -5,6 +5,7 @@ import { useSWRConfig } from 'swr';
|
|
|
5
5
|
import { Alert, Stack, Text } from '@mantine/core';
|
|
6
6
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
|
7
7
|
import { openConfirm } from '../utils/confirm-modal.js';
|
|
8
|
+
import { focusReturnProps } from '../utils/focus-return.js';
|
|
8
9
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
9
10
|
import { notifications } from '@mantine/notifications';
|
|
10
11
|
import { useApiClient } from '../context/index.js';
|
|
@@ -18,6 +19,7 @@ import { RetryToastMessage } from '../components/RetryToastMessage.js';
|
|
|
18
19
|
import { isNonApiResponse } from '../../api/client.js';
|
|
19
20
|
import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
|
|
20
21
|
import { describeUnsaved } from './useBranchActions.js';
|
|
22
|
+
import { writeEditorUrl } from './editor-history.js';
|
|
21
23
|
const BRANCHES_LOAD_FAILED_TOAST_ID = 'canopy-branches-load-failed';
|
|
22
24
|
const BRANCH_OUTCOME_UNKNOWN_TOAST_ID = 'canopy-branch-outcome-unknown';
|
|
23
25
|
/** The error a refused branch action throws; a gateway's 5xx says nothing of whether it ran. */
|
|
@@ -70,7 +72,7 @@ const confirmModalHandlers = (onConfirm, onDismiss) => {
|
|
|
70
72
|
* With `unsaved` edits on the branch, the dialog says they are left out: a submit sends only what
|
|
71
73
|
* is saved, and the branch is read-only in review, so they wait as drafts until it is editable.
|
|
72
74
|
*/
|
|
73
|
-
const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss) => {
|
|
75
|
+
const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss, returnFocusTo) => {
|
|
74
76
|
const leftOut = unsaved && unsaved.count > 0 ? unsaved : undefined;
|
|
75
77
|
openConfirm({
|
|
76
78
|
title: EDITOR_ACTIONS.submitForReview,
|
|
@@ -81,6 +83,7 @@ const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss) => {
|
|
|
81
83
|
},
|
|
82
84
|
confirmProps: { color: 'brand', 'data-testid': 'confirm-submit-branch' },
|
|
83
85
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
86
|
+
...focusReturnProps(returnFocusTo),
|
|
84
87
|
});
|
|
85
88
|
};
|
|
86
89
|
/**
|
|
@@ -101,7 +104,7 @@ export function withdrawPullRequestBullet(pr) {
|
|
|
101
104
|
return `Leave the merged ${label} as it is`;
|
|
102
105
|
return `Convert ${label} to a draft`;
|
|
103
106
|
}
|
|
104
|
-
const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
|
|
107
|
+
const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
|
|
105
108
|
const bullets = [
|
|
106
109
|
withdrawPullRequestBullet(pr),
|
|
107
110
|
'Change the branch status back to "editing"',
|
|
@@ -113,6 +116,7 @@ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
|
|
|
113
116
|
labels: { confirm: EDITOR_ACTIONS.withdrawFromReview, cancel: 'Cancel' },
|
|
114
117
|
confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
|
|
115
118
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
119
|
+
...focusReturnProps(returnFocusTo),
|
|
116
120
|
});
|
|
117
121
|
};
|
|
118
122
|
const showRequestChangesConfirmation = (branchName, onConfirm, onDismiss) => {
|
|
@@ -316,11 +320,11 @@ export function useBranchManager(options) {
|
|
|
316
320
|
// (see Editor.tsx's `branchContentLocked` for the full rationale, which
|
|
317
321
|
// applies identically here).
|
|
318
322
|
isProtected: b.isProtected ?? true,
|
|
319
|
-
// NOT flipped to `?? true`: `readOnly` only selects WHICH lock
|
|
320
|
-
//
|
|
323
|
+
// NOT flipped to `?? true`: `readOnly` only selects WHICH lock the status
|
|
324
|
+
// names (base-branch read-only vs. status lock) once something is
|
|
321
325
|
// already known to be locked via `writeBlocked`/`submitBlocked` --
|
|
322
326
|
// it never gates a write by itself. Defaulting it true on missing data
|
|
323
|
-
// would mislabel a status lock as a base-branch lock (wrong
|
|
327
|
+
// would mislabel a status lock as a base-branch lock (wrong status
|
|
324
328
|
// copy), not under-lock anything, so `?? false` stays correct here.
|
|
325
329
|
readOnly: b.readOnly ?? false,
|
|
326
330
|
writeBlocked: b.writeBlocked ?? true,
|
|
@@ -406,7 +410,7 @@ export function useBranchManager(options) {
|
|
|
406
410
|
}
|
|
407
411
|
notifications.show({ message: err instanceof Error ? err.message : fallback, color: 'red' });
|
|
408
412
|
};
|
|
409
|
-
const handleSubmit = (requested) => {
|
|
413
|
+
const handleSubmit = (requested, returnFocusTo) => {
|
|
410
414
|
const branchNameToSubmit = listedName(requested);
|
|
411
415
|
return singleFlight('submit', branchNameToSubmit, async () => {
|
|
412
416
|
// Drafts belong to the open branch, so only its submit can leave edits behind. Without
|
|
@@ -463,11 +467,11 @@ export function useBranchManager(options) {
|
|
|
463
467
|
},
|
|
464
468
|
// Dismissal is not an error: the call sites log rejections to the console. Settled
|
|
465
469
|
// once, by onCancel OR onClose, so an Escape/overlay dismissal can't leave this pending.
|
|
466
|
-
() => resolve());
|
|
470
|
+
() => resolve(), returnFocusTo);
|
|
467
471
|
});
|
|
468
472
|
});
|
|
469
473
|
};
|
|
470
|
-
const handleWithdraw = (requested) => {
|
|
474
|
+
const handleWithdraw = (requested, returnFocusTo) => {
|
|
471
475
|
const branchNameToWithdraw = listedName(requested);
|
|
472
476
|
return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
|
|
473
477
|
showWithdrawConfirmation(branchNameToWithdraw, pullRequestOf(branchNameToWithdraw), async () => {
|
|
@@ -490,7 +494,7 @@ export function useBranchManager(options) {
|
|
|
490
494
|
finally {
|
|
491
495
|
options.setBusy(false);
|
|
492
496
|
}
|
|
493
|
-
}, () => resolve());
|
|
497
|
+
}, () => resolve(), returnFocusTo);
|
|
494
498
|
}));
|
|
495
499
|
};
|
|
496
500
|
const handleRequestChanges = (branchNameForChanges) => singleFlight('request-changes', branchNameForChanges, () => new Promise((resolve) => {
|
|
@@ -583,16 +587,18 @@ export function useBranchManager(options) {
|
|
|
583
587
|
const handleReloadBranchData = async () => {
|
|
584
588
|
await loadBranches();
|
|
585
589
|
};
|
|
590
|
+
// The editor's own page; a Back off it to a page of the host leaves that page's URL alone.
|
|
591
|
+
const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
|
|
586
592
|
useEffect(() => {
|
|
587
593
|
if (typeof window === 'undefined')
|
|
588
594
|
return;
|
|
589
|
-
if (!branchName)
|
|
595
|
+
if (!branchName || window.location.pathname !== editorPathRef.current)
|
|
590
596
|
return;
|
|
591
597
|
const url = new URL(window.location.href);
|
|
592
598
|
const current = url.searchParams.get('branch');
|
|
593
599
|
if (current !== branchName) {
|
|
594
600
|
url.searchParams.set('branch', branchName);
|
|
595
|
-
|
|
601
|
+
writeEditorUrl(url.toString(), false);
|
|
596
602
|
}
|
|
597
603
|
}, [branchName]);
|
|
598
604
|
return {
|
|
@@ -602,6 +608,7 @@ export function useBranchManager(options) {
|
|
|
602
608
|
branchSummaries,
|
|
603
609
|
currentBranch,
|
|
604
610
|
branchesLoadFailed: !!branchesError && !branchesData,
|
|
611
|
+
branchesLoaded: !!branchesData,
|
|
605
612
|
addCreatedBranch,
|
|
606
613
|
handleSubmit,
|
|
607
614
|
handleWithdraw,
|
|
@@ -3,6 +3,9 @@ import type { EditorEntry, EditorCollection } from '../Editor.js';
|
|
|
3
3
|
import type { LogicalPath } from '../../paths/types.js';
|
|
4
4
|
import type { FormValue } from '../FormRenderer.js';
|
|
5
5
|
import type { EntryFieldError } from '../../validation/entry-validator.js';
|
|
6
|
+
import type { BranchBarStatus } from '../branch-lock.js';
|
|
7
|
+
/** @internal Exported for tests. */
|
|
8
|
+
export declare const RESTORED_ENTRY_GONE_MESSAGE = "The entry you returned to is no longer on this branch, so the home page opened.";
|
|
6
9
|
/** @internal Exported for tests. */
|
|
7
10
|
export { listAllEntries } from './useEntriesData.js';
|
|
8
11
|
/**
|
|
@@ -37,12 +40,19 @@ export interface UseEntryManagerOptions {
|
|
|
37
40
|
/** An entry's route on the site (`buildPreviewRoute`), which picks the home page to open first. */
|
|
38
41
|
resolvePreviewRoute?: (entry: EditorEntry) => string | undefined;
|
|
39
42
|
setBusy: (busy: boolean) => void;
|
|
43
|
+
/** The bar's row: a failed load is toasted after 'loading' ends, and never on 'not-available'. */
|
|
44
|
+
branchBarState?: BranchBarStatus['value'];
|
|
45
|
+
/** Switches to the branch a Back or Forward reached; rejects when the switch does not happen. */
|
|
46
|
+
switchBranch?: (branch: string) => Promise<void>;
|
|
47
|
+
/** Called when a Back or Forward has opened another entry. */
|
|
48
|
+
onHistoryRestore?: () => void;
|
|
40
49
|
}
|
|
41
50
|
export interface UseEntryManagerReturn {
|
|
42
51
|
selectedPath: string;
|
|
52
|
+
/** Opens an entry the user chose, adding a history entry, so Back returns to the one before. */
|
|
43
53
|
setSelectedPath: (path: string) => void;
|
|
44
|
-
/** Leaves no entry open
|
|
45
|
-
closeEntry: () => void;
|
|
54
|
+
/** Leaves no entry open if `path` on `branch` is still open, as after deleting it. */
|
|
55
|
+
closeEntry: (path: string, branch: string) => void;
|
|
46
56
|
entries: EditorEntry[];
|
|
47
57
|
setEntries: (entries: EditorEntry[]) => void;
|
|
48
58
|
collections: EditorCollection[];
|
|
@@ -10,7 +10,12 @@ import { SCHEMA_UNAVAILABLE_CODE } from '../unavailable-entry-type.js';
|
|
|
10
10
|
import { entriesKey, fetchEntriesAndSchema } from './useEntriesData.js';
|
|
11
11
|
import { RetryToastMessage } from '../components/RetryToastMessage.js';
|
|
12
12
|
import { isGatewayFailure, isUnanswered, MayHaveLandedError, UnansweredError } from '../unanswered.js';
|
|
13
|
+
import { sanitizeBranchName } from '../../paths/branch-name.js';
|
|
14
|
+
import { isApiTimeoutError } from '../../api/client.js';
|
|
15
|
+
import { markEditorHistoryStart, recordHistoryMove, undoHistoryMove, writeEditorUrl, } from './editor-history.js';
|
|
13
16
|
const ENTRIES_LOAD_FAILED_TOAST_ID = 'canopy-entries-load-failed';
|
|
17
|
+
/** @internal Exported for tests. */
|
|
18
|
+
export const RESTORED_ENTRY_GONE_MESSAGE = 'The entry you returned to is no longer on this branch, so the home page opened.';
|
|
14
19
|
// Re-exported so existing imports of `listAllEntries` from this module keep
|
|
15
20
|
// working -- the implementation lives in useEntriesData.ts alongside the
|
|
16
21
|
// other fetch/key/type pieces useEntryManager shares with the SWR layer.
|
|
@@ -56,6 +61,23 @@ const EMPTY_SCHEMAS = [];
|
|
|
56
61
|
* from an entry that crashes the editor); null opens the home page (`resolveHomeEntry`).
|
|
57
62
|
*/
|
|
58
63
|
const readUrlEntry = () => typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('entry');
|
|
64
|
+
/** The current URL with `branch` and the `entry` that `selectedPath` shows, as `readUrlEntry` reads it. */
|
|
65
|
+
const entryUrl = (selectedPath, noEntry, branch) => {
|
|
66
|
+
const url = new URL(window.location.href);
|
|
67
|
+
if (branch)
|
|
68
|
+
url.searchParams.set('branch', branch);
|
|
69
|
+
if (selectedPath) {
|
|
70
|
+
url.searchParams.set('entry', selectedPath);
|
|
71
|
+
}
|
|
72
|
+
else if (noEntry) {
|
|
73
|
+
// Kept, so a reload still opens no entry.
|
|
74
|
+
url.searchParams.set('entry', '');
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
url.searchParams.delete('entry');
|
|
78
|
+
}
|
|
79
|
+
return url.toString();
|
|
80
|
+
};
|
|
59
81
|
/**
|
|
60
82
|
* Custom hook for managing editor entries (CRUD operations).
|
|
61
83
|
*/
|
|
@@ -102,6 +124,18 @@ export function useEntryManager(options) {
|
|
|
102
124
|
selectedPathRef.current = selectedPath;
|
|
103
125
|
// Counts selections through the returned setter, not this hook's own fallbacks.
|
|
104
126
|
const selectionsByCallerRef = useRef(0);
|
|
127
|
+
// The path the user chose, which the URL sync pushes; any other selection replaces the URL.
|
|
128
|
+
const pushTargetRef = useRef(null);
|
|
129
|
+
// The `entry` a Back onto another branch opens once that branch shows; a later move drops it.
|
|
130
|
+
const entryAfterBranchChangeRef = useRef(undefined);
|
|
131
|
+
// Counts Back and Forward moves, so a branch switch one asked for knows when another followed.
|
|
132
|
+
const historyMovesRef = useRef(0);
|
|
133
|
+
// The path a Back or Forward opened, until the entries show whether it is still there.
|
|
134
|
+
const restoredPathRef = useRef(null);
|
|
135
|
+
// The editor's own page: a Back off it to another page of the host is no move between entries.
|
|
136
|
+
const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
|
|
137
|
+
const onHistoryRestoreRef = useRef(options.onHistoryRestore);
|
|
138
|
+
onHistoryRestoreRef.current = options.onHistoryRestore;
|
|
105
139
|
const [navigatorOpen, setNavigatorOpen] = useState(() => readUrlEntry() === '');
|
|
106
140
|
const isInitialMount = useRef(true);
|
|
107
141
|
const hasSyncedFromUrl = useRef(false);
|
|
@@ -188,13 +222,18 @@ export function useEntryManager(options) {
|
|
|
188
222
|
const noEntryRequested = useRef(initialUrlEntry.current === '');
|
|
189
223
|
const selectEntryPath = useCallback((path) => {
|
|
190
224
|
selectionsByCallerRef.current += 1;
|
|
225
|
+
pushTargetRef.current = path;
|
|
191
226
|
setSelectedPath(path);
|
|
192
227
|
}, []);
|
|
193
228
|
// No entry open, as the URL's empty `entry` asks, where a lost selection opens the home page.
|
|
194
|
-
|
|
229
|
+
// Replaces the history entry: it follows a delete, which Back could not undo.
|
|
230
|
+
const closeEntry = useCallback((path, branch) => {
|
|
231
|
+
if (selectedPathRef.current !== path || currentBranchRef.current !== branch)
|
|
232
|
+
return;
|
|
195
233
|
noEntryRequested.current = true;
|
|
196
|
-
|
|
197
|
-
|
|
234
|
+
selectionsByCallerRef.current += 1;
|
|
235
|
+
setSelectedPath('');
|
|
236
|
+
}, []);
|
|
198
237
|
const collectionByPath = useMemo(() => {
|
|
199
238
|
const map = new Map();
|
|
200
239
|
const walk = (collections) => {
|
|
@@ -470,6 +509,7 @@ export function useEntryManager(options) {
|
|
|
470
509
|
// still empty when this function runs (slow server / cold start).
|
|
471
510
|
const createdEntry = refreshed.find((e) => e.collectionPath === createModalCollection.path && e.slug === slug);
|
|
472
511
|
if (createdEntry) {
|
|
512
|
+
pushTargetRef.current = createdEntry.path;
|
|
473
513
|
setSelectedPath(createdEntry.path);
|
|
474
514
|
}
|
|
475
515
|
else {
|
|
@@ -566,8 +606,16 @@ export function useEntryManager(options) {
|
|
|
566
606
|
isInitialMount.current = false;
|
|
567
607
|
}
|
|
568
608
|
else {
|
|
569
|
-
|
|
570
|
-
|
|
609
|
+
const restored = entryAfterBranchChangeRef.current;
|
|
610
|
+
entryAfterBranchChangeRef.current = undefined;
|
|
611
|
+
const entry = restored?.branch === options.branchName ? restored.entry : undefined;
|
|
612
|
+
noEntryRequested.current = entry === '';
|
|
613
|
+
setSelectedPath(entry ?? '');
|
|
614
|
+
if (entry !== undefined) {
|
|
615
|
+
restoredPathRef.current = entry;
|
|
616
|
+
setNavigatorOpen(entry === '');
|
|
617
|
+
onHistoryRestoreRef.current?.();
|
|
618
|
+
}
|
|
571
619
|
// Bound version-map growth across switches. Correctness no longer
|
|
572
620
|
// depends on this clear: tokens are keyed by `${branch}:${contentId}`
|
|
573
621
|
// (see entryVersionsRef), so a late response from the previous branch
|
|
@@ -659,12 +707,14 @@ export function useEntryManager(options) {
|
|
|
659
707
|
// to a branch that fails again still reports.
|
|
660
708
|
const notifiedErrorBranchRef = useRef(null);
|
|
661
709
|
useEffect(() => {
|
|
662
|
-
if (!entriesError || !options.branchName) {
|
|
710
|
+
if (!entriesError || !options.branchName || options.branchBarState === 'not-available') {
|
|
663
711
|
if (notifiedErrorBranchRef.current !== null)
|
|
664
712
|
notifications.hide(ENTRIES_LOAD_FAILED_TOAST_ID);
|
|
665
713
|
notifiedErrorBranchRef.current = null;
|
|
666
714
|
return;
|
|
667
715
|
}
|
|
716
|
+
if (options.branchBarState === 'loading')
|
|
717
|
+
return;
|
|
668
718
|
if (notifiedErrorBranchRef.current === options.branchName)
|
|
669
719
|
return;
|
|
670
720
|
const branch = options.branchName;
|
|
@@ -675,8 +725,12 @@ export function useEntryManager(options) {
|
|
|
675
725
|
notifications.show({
|
|
676
726
|
id: ENTRIES_LOAD_FAILED_TOAST_ID,
|
|
677
727
|
title: 'Could not load content',
|
|
728
|
+
// A timeout's message is written for people; any other is a diagnostic, kept for Details.
|
|
678
729
|
message: createElement(RetryToastMessage, {
|
|
679
|
-
message:
|
|
730
|
+
message: isApiTimeoutError(entriesError)
|
|
731
|
+
? entriesError.message
|
|
732
|
+
: "The editor couldn't fetch this branch's entries.",
|
|
733
|
+
details: isApiTimeoutError(entriesError) ? undefined : getErrorMessage(entriesError),
|
|
680
734
|
onRetry: () => {
|
|
681
735
|
notifications.hide(ENTRIES_LOAD_FAILED_TOAST_ID);
|
|
682
736
|
notifiedErrorBranchRef.current = null;
|
|
@@ -686,7 +740,7 @@ export function useEntryManager(options) {
|
|
|
686
740
|
color: 'red',
|
|
687
741
|
autoClose: false,
|
|
688
742
|
});
|
|
689
|
-
}, [entriesError, options.branchName]);
|
|
743
|
+
}, [entriesError, options.branchName, options.branchBarState]);
|
|
690
744
|
// Validate selected entry when entries change
|
|
691
745
|
useEffect(() => {
|
|
692
746
|
if (selectedPath)
|
|
@@ -703,33 +757,100 @@ export function useEntryManager(options) {
|
|
|
703
757
|
return;
|
|
704
758
|
}
|
|
705
759
|
}
|
|
760
|
+
const restored = restoredPathRef.current === selectedPath;
|
|
761
|
+
if (restored)
|
|
762
|
+
restoredPathRef.current = null;
|
|
706
763
|
// If the selected entry exists, keep it
|
|
707
764
|
if (entriesState.find((e) => e.path === selectedPath))
|
|
708
765
|
return;
|
|
709
766
|
if (selectedPath === '' && noEntryRequested.current)
|
|
710
767
|
return;
|
|
768
|
+
if (restored && selectedPath) {
|
|
769
|
+
notifications.show({ message: RESTORED_ENTRY_GONE_MESSAGE, color: 'blue' });
|
|
770
|
+
}
|
|
711
771
|
setSelectedPath(resolveHomeEntry(entriesState, resolvePreviewRouteRef.current)?.path ?? '');
|
|
712
772
|
}, [entriesState, selectedPath]);
|
|
713
773
|
// Update URL when selection changes (skip until URL sync has happened)
|
|
714
774
|
useEffect(() => {
|
|
715
775
|
if (typeof window === 'undefined')
|
|
716
776
|
return;
|
|
777
|
+
// Pushed only once listed: a path the check above replaces would leave a stale entry behind.
|
|
778
|
+
const push = pushTargetRef.current === selectedPath && entriesState.some((e) => e.path === selectedPath);
|
|
779
|
+
pushTargetRef.current = null;
|
|
717
780
|
// Don't update URL until we've synced from it first
|
|
718
781
|
if (!hasSyncedFromUrl.current)
|
|
719
782
|
return;
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
else if (noEntryRequested.current) {
|
|
725
|
-
// Kept, so a reload still opens no entry.
|
|
726
|
-
url.searchParams.set('entry', '');
|
|
727
|
-
}
|
|
728
|
-
else {
|
|
729
|
-
url.searchParams.delete('entry');
|
|
730
|
-
}
|
|
731
|
-
window.history.replaceState({}, '', url.toString());
|
|
783
|
+
if (window.location.pathname !== editorPathRef.current)
|
|
784
|
+
return;
|
|
785
|
+
writeEditorUrl(entryUrl(selectedPath, noEntryRequested.current, currentBranchRef.current), push);
|
|
732
786
|
}, [selectedPath]);
|
|
787
|
+
const entriesRef = useRef(entriesState);
|
|
788
|
+
entriesRef.current = entriesState;
|
|
789
|
+
const switchBranchRef = useRef(options.switchBranch);
|
|
790
|
+
switchBranchRef.current = options.switchBranch;
|
|
791
|
+
// Back and Forward open the entry their URL names, without a reload.
|
|
792
|
+
useEffect(() => {
|
|
793
|
+
if (typeof window === 'undefined')
|
|
794
|
+
return;
|
|
795
|
+
markEditorHistoryStart();
|
|
796
|
+
const restore = () => {
|
|
797
|
+
const moved = recordHistoryMove();
|
|
798
|
+
if (moved === null || window.location.pathname !== editorPathRef.current)
|
|
799
|
+
return;
|
|
800
|
+
historyMovesRef.current += 1;
|
|
801
|
+
entryAfterBranchChangeRef.current = undefined;
|
|
802
|
+
const params = new URLSearchParams(window.location.search);
|
|
803
|
+
const entry = params.get('entry');
|
|
804
|
+
const branch = params.get('branch');
|
|
805
|
+
const current = currentBranchRef.current;
|
|
806
|
+
// A legacy name and its sanitized form are one branch; a switch would key drafts by the other.
|
|
807
|
+
if (branch && sanitizeBranchName(branch) !== sanitizeBranchName(current)) {
|
|
808
|
+
const move = historyMovesRef.current;
|
|
809
|
+
const stay = () => {
|
|
810
|
+
if (move !== historyMovesRef.current)
|
|
811
|
+
return;
|
|
812
|
+
if (moved)
|
|
813
|
+
undoHistoryMove(moved);
|
|
814
|
+
else {
|
|
815
|
+
const url = entryUrl(selectedPathRef.current, noEntryRequested.current, currentBranchRef.current);
|
|
816
|
+
writeEditorUrl(url, false);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
const switchBranch = switchBranchRef.current;
|
|
820
|
+
if (!switchBranch) {
|
|
821
|
+
stay();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
entryAfterBranchChangeRef.current = { branch, entry };
|
|
825
|
+
if (!hasSyncedFromUrl.current)
|
|
826
|
+
initialUrlEntry.current = entry;
|
|
827
|
+
switchBranch(branch).catch(() => {
|
|
828
|
+
if (move === historyMovesRef.current)
|
|
829
|
+
entryAfterBranchChangeRef.current = undefined;
|
|
830
|
+
stay();
|
|
831
|
+
});
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// A Back is a selection, so a rename, move or re-create landing afterwards leaves it open.
|
|
835
|
+
selectionsByCallerRef.current += 1;
|
|
836
|
+
noEntryRequested.current = entry === '';
|
|
837
|
+
setNavigatorOpen(entry === '');
|
|
838
|
+
if (!hasSyncedFromUrl.current) {
|
|
839
|
+
initialUrlEntry.current = entry;
|
|
840
|
+
setSelectedPath(entry ?? '');
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const target = entry ?? resolveHomeEntry(entriesRef.current, resolvePreviewRouteRef.current)?.path ?? '';
|
|
844
|
+
if (target === selectedPathRef.current)
|
|
845
|
+
return;
|
|
846
|
+
restoredPathRef.current = target;
|
|
847
|
+
// A path no longer listed falls back to the home page, through the selection check above.
|
|
848
|
+
setSelectedPath(target);
|
|
849
|
+
onHistoryRestoreRef.current?.();
|
|
850
|
+
};
|
|
851
|
+
window.addEventListener('popstate', restore);
|
|
852
|
+
return () => window.removeEventListener('popstate', restore);
|
|
853
|
+
}, []);
|
|
733
854
|
return {
|
|
734
855
|
selectedPath,
|
|
735
856
|
setSelectedPath: selectEntryPath,
|
|
@@ -26,10 +26,14 @@ export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>
|
|
|
26
26
|
shortcut?: string;
|
|
27
27
|
/** A count badge, hidden at zero; the label should say it too. */
|
|
28
28
|
count?: number;
|
|
29
|
+
/** A mark at the button's top-right corner, such as an unsaved dot; the label should say it too. */
|
|
30
|
+
corner?: ReactNode;
|
|
29
31
|
countTestId?: string;
|
|
30
32
|
tooltipDisabled?: boolean;
|
|
31
33
|
/** Disables the button, which stays focusable, and its tooltip gives this reason instead. */
|
|
32
34
|
disabledReason?: string;
|
|
33
35
|
}
|
|
36
|
+
/** Sets the buttons after it apart. It carries no `data-rail-item`, so the arrow keys skip it. */
|
|
37
|
+
export declare function RailDivider(): import("react/jsx-runtime").JSX.Element;
|
|
34
38
|
/** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
|
|
35
39
|
export declare const RailButton: React.ForwardRefExoticComponent<RailButtonProps & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { forwardRef, useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
-
import { ActionIcon, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Box, Divider, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
5
|
import { useElementSize, useMergedRef } from '@mantine/hooks';
|
|
6
6
|
const ACTIVITY_RAIL_WIDTH = 44;
|
|
7
7
|
const RAIL_ITEM_ATTR = 'data-rail-item';
|
|
@@ -74,18 +74,27 @@ export function ActivityRail({ top, bottom, account }) {
|
|
|
74
74
|
}
|
|
75
75
|
}, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
|
|
76
76
|
}
|
|
77
|
+
/** Sets the buttons after it apart. It carries no `data-rail-item`, so the arrow keys skip it. */
|
|
78
|
+
export function RailDivider() {
|
|
79
|
+
return _jsx(Divider, { w: 24, my: 2 });
|
|
80
|
+
}
|
|
77
81
|
/** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
|
|
78
|
-
export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
|
|
82
|
+
export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, corner, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
|
|
79
83
|
const reasonId = useId();
|
|
80
84
|
const button = (_jsxs(ActionIcon, { ref: ref,
|
|
81
85
|
// Filled when active: a tint alone would show the state by colour, under 3:1.
|
|
82
86
|
variant: active ? 'filled' : 'subtle', color: active ? undefined : 'gray', size: "lg", radius: "md", "aria-label": label, ...rest, "aria-disabled": disabledReason ? true : rest['aria-disabled'], "aria-describedby": disabledReason ? reasonId : rest['aria-describedby'], "data-disabled": disabledReason ? true : undefined, onClick: disabledReason ? undefined : onClick, [RAIL_ITEM_ATTR]: railId, children: [icon, disabledReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: disabledReason }))] }));
|
|
83
|
-
return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children:
|
|
87
|
+
return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsxs(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
|
|
84
88
|
indicator: {
|
|
85
89
|
pointerEvents: 'none',
|
|
86
90
|
// Mantine paints the pill again in a ::before, from this variable.
|
|
87
91
|
'--indicator-color': 'var(--canopy-count-bg)',
|
|
88
92
|
'--indicator-text-color': 'var(--mantine-color-text)',
|
|
89
93
|
},
|
|
90
|
-
}, children: button
|
|
94
|
+
}, children: [button, corner && (_jsx(Box, { component: "span", "aria-hidden": true, pos: "absolute", top: 5, right: 5, style: {
|
|
95
|
+
pointerEvents: 'none',
|
|
96
|
+
lineHeight: 0,
|
|
97
|
+
borderRadius: '50%',
|
|
98
|
+
boxShadow: '0 0 0 2px var(--mantine-color-body)',
|
|
99
|
+
}, children: corner }))] }) }));
|
|
91
100
|
});
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
export interface EditorShellProps {
|
|
3
3
|
topBar?: ReactNode;
|
|
4
|
-
banner?: ReactNode;
|
|
5
4
|
rail?: ReactNode;
|
|
6
5
|
panel?: ReactNode;
|
|
7
6
|
main: ReactNode;
|
|
8
7
|
/** Drawers, modals and palettes, each portalling itself into `<body>`. */
|
|
9
8
|
overlays?: ReactNode;
|
|
10
9
|
}
|
|
11
|
-
/** The line between two shell regions, as under the top bar
|
|
10
|
+
/** The line between two shell regions, as under the top bar. */
|
|
12
11
|
export declare const SHELL_REGION_BORDER = "1px solid var(--canopy-input-border)";
|
|
13
12
|
/**
|
|
14
13
|
* Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
|
|
@@ -24,9 +23,9 @@ export declare const DIVIDER_TARGET_PX: {
|
|
|
24
23
|
/** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
|
|
25
24
|
export declare const DIVIDER_COLOR = "var(--canopy-input-border)";
|
|
26
25
|
/**
|
|
27
|
-
* The editor's window: a full-viewport grid of regions (top bar,
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* The editor's window: a full-viewport grid of regions (top bar, rail, panel, main, overlays),
|
|
27
|
+
* portalled into `<body>` above the host page, whose chrome it hides and makes inert and whose
|
|
28
|
+
* scrolling it stops. Layout only: features plug into the regions. The rail sits at the inline
|
|
29
|
+
* start, opposite the toasts (`theme.tsx`), and comes first after the top bar in focus order.
|
|
31
30
|
*/
|
|
32
|
-
export declare function EditorShell({ topBar,
|
|
31
|
+
export declare function EditorShell({ topBar, rail, panel, main, overlays }: EditorShellProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -4,7 +4,7 @@ import { useRef } from 'react';
|
|
|
4
4
|
import { Portal, getDefaultZIndex } from '@mantine/core';
|
|
5
5
|
import { RemoveScroll } from 'react-remove-scroll';
|
|
6
6
|
import { useHideHostChrome } from './host-chrome.js';
|
|
7
|
-
/** The line between two shell regions, as under the top bar
|
|
7
|
+
/** The line between two shell regions, as under the top bar. */
|
|
8
8
|
export const SHELL_REGION_BORDER = '1px solid var(--canopy-input-border)';
|
|
9
9
|
/**
|
|
10
10
|
* Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
|
|
@@ -16,10 +16,6 @@ export const DIVIDER_WIDTH_PX = 4;
|
|
|
16
16
|
export const DIVIDER_TARGET_PX = { fine: 12, coarse: 16 };
|
|
17
17
|
/** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
|
|
18
18
|
export const DIVIDER_COLOR = 'var(--canopy-input-border)';
|
|
19
|
-
// A banner component may render nothing, so the region's line follows its content, not the prop.
|
|
20
|
-
const SHELL_STYLES = `
|
|
21
|
-
[data-canopy-region='banner']:not(:empty) { border-block-end: ${SHELL_REGION_BORDER}; }
|
|
22
|
-
`;
|
|
23
19
|
const gridStyle = {
|
|
24
20
|
position: 'fixed',
|
|
25
21
|
inset: 0,
|
|
@@ -27,21 +23,21 @@ const gridStyle = {
|
|
|
27
23
|
// under Mantine's drawers and modals, leaving the layer between for MDXEditor's popups.
|
|
28
24
|
zIndex: getDefaultZIndex('modal') - 2,
|
|
29
25
|
display: 'grid',
|
|
30
|
-
gridTemplateAreas: '"topbar topbar topbar" "
|
|
31
|
-
gridTemplateRows: 'auto
|
|
26
|
+
gridTemplateAreas: '"topbar topbar topbar" "rail panel main"',
|
|
27
|
+
gridTemplateRows: 'auto minmax(0, 1fr)',
|
|
32
28
|
gridTemplateColumns: 'auto auto minmax(0, 1fr)',
|
|
33
29
|
background: 'var(--canopy-surface-subtle)',
|
|
34
30
|
color: 'var(--mantine-color-text)',
|
|
35
31
|
};
|
|
36
32
|
const region = (area) => ({ gridArea: area, minWidth: 0, minHeight: 0 });
|
|
37
33
|
/**
|
|
38
|
-
* The editor's window: a full-viewport grid of regions (top bar,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
34
|
+
* The editor's window: a full-viewport grid of regions (top bar, rail, panel, main, overlays),
|
|
35
|
+
* portalled into `<body>` above the host page, whose chrome it hides and makes inert and whose
|
|
36
|
+
* scrolling it stops. Layout only: features plug into the regions. The rail sits at the inline
|
|
37
|
+
* start, opposite the toasts (`theme.tsx`), and comes first after the top bar in focus order.
|
|
42
38
|
*/
|
|
43
|
-
export function EditorShell({ topBar,
|
|
39
|
+
export function EditorShell({ topBar, rail, panel, main, overlays }) {
|
|
44
40
|
const anchorRef = useRef(null);
|
|
45
41
|
useHideHostChrome(anchorRef);
|
|
46
|
-
return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [
|
|
42
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
|
|
47
43
|
}
|
|
@@ -5,6 +5,7 @@ export declare const SPLIT_PERCENT_MIN = 15;
|
|
|
5
5
|
export declare const SPLIT_PERCENT_MAX = 85;
|
|
6
6
|
export declare const DEFAULT_SIDE_SPLIT_PERCENT = 52;
|
|
7
7
|
export declare const DEFAULT_STACKED_SPLIT_PERCENT = 58;
|
|
8
|
+
export declare const FORM_PANE_ID = "canopy-form-pane";
|
|
8
9
|
export interface PaneLayoutProps {
|
|
9
10
|
arrangement: PaneArrangement;
|
|
10
11
|
/** The preview's share (percent) beside the form; follows this prop when it changes. */
|
|
@@ -14,11 +14,14 @@ const FORM = 'canopy-form';
|
|
|
14
14
|
// the form also keeps a usable minimum in a narrow window.
|
|
15
15
|
const PREVIEW_LIMITS = { minSize: `${SPLIT_PERCENT_MIN}%`, maxSize: `${SPLIT_PERCENT_MAX}%` };
|
|
16
16
|
const FORM_MIN_PX = 160;
|
|
17
|
+
export const FORM_PANE_ID = 'canopy-form-pane';
|
|
17
18
|
const ASIDE_TOP = '--canopy-form-aside-top';
|
|
18
19
|
const ASIDE_WIDTH = '--canopy-form-aside-width';
|
|
19
20
|
// The pointer target is the group's `resizeTargetMinimumSize`, wider than the bar it draws. Lifted
|
|
20
|
-
// over the panes' positioned contents, which would otherwise paint over its focus outline.
|
|
21
|
-
|
|
21
|
+
// over the panes' positioned contents, which would otherwise paint over its focus outline. The form
|
|
22
|
+
// pane's ring is inset, since the window's edges clip it outside, and repeated on its non-scrolling
|
|
23
|
+
// panel above the pane, whose sticky header would cover it.
|
|
24
|
+
const PANE_STYLES = `
|
|
22
25
|
.canopy-pane-separator {
|
|
23
26
|
position: relative;
|
|
24
27
|
z-index: 1;
|
|
@@ -32,6 +35,19 @@ const SEPARATOR_STYLES = `
|
|
|
32
35
|
.canopy-pane-separator:focus-visible {
|
|
33
36
|
outline: 2px solid var(--canopy-focus-ring);
|
|
34
37
|
}
|
|
38
|
+
#${FORM_PANE_ID}:focus-visible {
|
|
39
|
+
outline: 2px solid var(--canopy-focus-ring);
|
|
40
|
+
outline-offset: -2px;
|
|
41
|
+
}
|
|
42
|
+
.canopy-form-panel:has(> #${FORM_PANE_ID}:focus-visible)::after {
|
|
43
|
+
outline: 2px solid var(--canopy-focus-ring);
|
|
44
|
+
outline-offset: -2px;
|
|
45
|
+
content: '';
|
|
46
|
+
position: absolute;
|
|
47
|
+
inset: 0;
|
|
48
|
+
z-index: 1;
|
|
49
|
+
pointer-events: none;
|
|
50
|
+
}
|
|
35
51
|
`;
|
|
36
52
|
const splitLayout = (percent) => ({ [PREVIEW]: percent, [FORM]: 100 - percent });
|
|
37
53
|
/** The resizable preview/form split that fills the shell's main region. */
|
|
@@ -126,7 +142,7 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
|
|
|
126
142
|
const maximizedStyle = aside
|
|
127
143
|
? { insetBlock: 0, insetInlineEnd: 0, insetInlineStart: `var(${ASIDE_WIDTH}, 0px)` }
|
|
128
144
|
: { inset: 0 };
|
|
129
|
-
return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children:
|
|
145
|
+
return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children: PANE_STYLES }), aside && (_jsxs("div", { "data-testid": "form-aside", "data-lifted": previewMaximized || undefined, style: {
|
|
130
146
|
position: 'absolute',
|
|
131
147
|
insetInlineStart: 0,
|
|
132
148
|
bottom: 0,
|
|
@@ -154,12 +170,14 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
|
|
|
154
170
|
style: previewMaximized
|
|
155
171
|
? // Over the separator, which sits over the panes.
|
|
156
172
|
{ overflow: 'auto', position: 'absolute', zIndex: 2, ...maximizedStyle }
|
|
157
|
-
: { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, pos: "relative", h: "100%",
|
|
173
|
+
: { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, className: "canopy-form-panel", pos: "relative", h: "100%",
|
|
158
174
|
// `formOverlay` stacks within this pane, so a maximized preview always covers it.
|
|
159
175
|
style: {
|
|
160
176
|
isolation: 'isolate',
|
|
161
177
|
marginInlineStart: aside ? `var(${ASIDE_WIDTH}, 0px)` : undefined,
|
|
162
|
-
}, children: [_jsx(Box, { ref: formRef,
|
|
178
|
+
}, children: [_jsx(Box, { ref: formRef, id: FORM_PANE_ID,
|
|
179
|
+
// Takes focus when an opened entry's form has no field to focus.
|
|
180
|
+
tabIndex: -1, role: "region", "aria-label": "Form", "data-testid": "form-pane", h: "100%", w: "100%", style: {
|
|
163
181
|
// FormHeader reads this to span the padding.
|
|
164
182
|
'--canopy-form-pane-padding': '20px',
|
|
165
183
|
padding: 'var(--canopy-form-pane-padding)',
|