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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useState } from 'react';
|
|
4
|
+
import { Box, Button, Loader, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
+
import { IconCheck, IconLoader2 } from '@tabler/icons-react';
|
|
7
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
|
+
const KEPT_HINT = 'Unsaved changes are kept on this device until you save.';
|
|
9
|
+
const SAVE_ALL_WAIT = 'Wait for Save all to finish.';
|
|
10
|
+
const SAVED_SHOWN_MS = 2000;
|
|
11
|
+
// Below `sm` the status words are visually hidden, so Save keeps its own width beside an icon.
|
|
12
|
+
const LABEL_TEXT_CLASS = 'canopy-save-label-text';
|
|
13
|
+
const LABEL_STYLES = `
|
|
14
|
+
@media (max-width: 47.99em) {
|
|
15
|
+
.${LABEL_TEXT_CLASS} {
|
|
16
|
+
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
|
17
|
+
clip: rect(0 0 0 0); white-space: nowrap;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
/**
|
|
22
|
+
* Every label in one grid cell, only `shown` visible, so the button keeps the widest one's width
|
|
23
|
+
* and the header never shifts.
|
|
24
|
+
*/
|
|
25
|
+
function SaveLabels({ shown, spinner }) {
|
|
26
|
+
const labels = {
|
|
27
|
+
save: EDITOR_ACTIONS.save,
|
|
28
|
+
saving: (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", display: "inline-flex", w: 14, children: shown === 'saving' &&
|
|
29
|
+
(spinner ? (_jsx(Loader, { size: 14, color: "currentColor" })) : (_jsx(IconLoader2, { size: 14, "aria-hidden": "true" }))) }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saving\u2026" })] })),
|
|
30
|
+
saved: (_jsxs(_Fragment, { children: [_jsx(IconCheck, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saved" })] })),
|
|
31
|
+
};
|
|
32
|
+
return (_jsx(Box, { component: "span", pos: "relative", style: { display: 'inline-grid' }, children: Object.keys(labels).map((key) => (_jsx(Box, { component: "span", "aria-hidden": key === shown ? undefined : true, style: {
|
|
33
|
+
gridArea: '1 / 1',
|
|
34
|
+
display: 'inline-flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'center',
|
|
37
|
+
gap: 6,
|
|
38
|
+
visibility: key === shown ? 'visible' : 'hidden',
|
|
39
|
+
}, children: labels[key] }, key))) }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The open entry's Save, reading "Saving…" then "Saved" as a save lands. While the entry has
|
|
43
|
+
* unsaved changes, its Tooltip and description say they are kept on this device.
|
|
44
|
+
*/
|
|
45
|
+
export function EntrySaveButton({ onSave, saveBlockedReason, busy, saveAllRunning, saving = false, savedSignal, savedHere = false, entryDirty, draftStorageFailed, }) {
|
|
46
|
+
const keptHintId = useId();
|
|
47
|
+
const reducedMotion = useReducedMotion();
|
|
48
|
+
// Each landed Save shows "Saved" once, as it lands and only on its own entry: one that landed
|
|
49
|
+
// before mount or while another entry was open shows nothing later.
|
|
50
|
+
const [seenSignal, setSeenSignal] = useState(savedSignal ?? 0);
|
|
51
|
+
const [flashing, setFlashing] = useState();
|
|
52
|
+
// Leaving the saved entry ends its "Saved", so a quick return doesn't show or announce it again.
|
|
53
|
+
const [wasHere, setWasHere] = useState(savedHere);
|
|
54
|
+
if (wasHere !== savedHere) {
|
|
55
|
+
setWasHere(savedHere);
|
|
56
|
+
if (!savedHere)
|
|
57
|
+
setFlashing(undefined);
|
|
58
|
+
}
|
|
59
|
+
// Save all ends any "Saved", so a run that finishes within its window doesn't bring it back.
|
|
60
|
+
const [wasRunning, setWasRunning] = useState(saveAllRunning);
|
|
61
|
+
if (wasRunning !== saveAllRunning) {
|
|
62
|
+
setWasRunning(saveAllRunning);
|
|
63
|
+
if (saveAllRunning)
|
|
64
|
+
setFlashing(undefined);
|
|
65
|
+
}
|
|
66
|
+
if (savedSignal !== undefined && savedSignal > seenSignal) {
|
|
67
|
+
setSeenSignal(savedSignal);
|
|
68
|
+
setFlashing(savedHere && !saveAllRunning ? savedSignal : undefined);
|
|
69
|
+
}
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (flashing === undefined)
|
|
72
|
+
return;
|
|
73
|
+
const timer = setTimeout(() => setFlashing(undefined), SAVED_SHOWN_MS);
|
|
74
|
+
return () => clearTimeout(timer);
|
|
75
|
+
}, [flashing]);
|
|
76
|
+
// Blocked, Save stays focusable (`aria-disabled`, not `disabled`), so its reason opens on focus.
|
|
77
|
+
const saveBlocked = !!saveBlockedReason || busy || saveAllRunning;
|
|
78
|
+
const label = saving
|
|
79
|
+
? 'saving'
|
|
80
|
+
: flashing !== undefined &&
|
|
81
|
+
flashing === savedSignal &&
|
|
82
|
+
savedHere &&
|
|
83
|
+
!entryDirty &&
|
|
84
|
+
!saveAllRunning
|
|
85
|
+
? 'saved'
|
|
86
|
+
: 'save';
|
|
87
|
+
// A "Saving…" or "Saved" label stays readable: disabled styling takes it under 4.5:1.
|
|
88
|
+
const saveGreyed = saveBlocked && label === 'save';
|
|
89
|
+
const blockedReason = saveAllRunning ? SAVE_ALL_WAIT : saveBlockedReason;
|
|
90
|
+
// Why Save is blocked, else where unsaved changes are held; a blocked Save's reason leads.
|
|
91
|
+
const keptHint = entryDirty && !draftStorageFailed && !blockedReason ? KEPT_HINT : '';
|
|
92
|
+
const saveTooltip = blockedReason || keptHint;
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx(VisuallyHidden, { role: "status", children: label === 'saved' ? 'Saved' : '' }), keptHint && (_jsx("span", { hidden: true, id: keptHintId, children: keptHint })), _jsx("style", { children: LABEL_STYLES }), _jsx(Tooltip, { label: saveTooltip,
|
|
94
|
+
// Not beside "Saving…" or "Saved", nor while busy, except Save all's wait: its reloads flip `busy`.
|
|
95
|
+
disabled: !saveTooltip || label !== 'save' || (busy && !saveAllRunning), multiline: true, maw: 280,
|
|
96
|
+
// Down, away from the top bar, whose Save all a reason opening upward would cover.
|
|
97
|
+
position: "bottom-end", children: _jsx(Button, { "data-testid": "save-button", variant: "filled", size: "sm", style: { flexShrink: 0 }, onClick: () => {
|
|
98
|
+
if (!saveBlocked)
|
|
99
|
+
onSave();
|
|
100
|
+
}, "data-disabled": saveGreyed ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, ...(keptHint ? { 'aria-describedby': keptHintId } : {}), children: _jsx(SaveLabels, { shown: label, spinner: !reducedMotion }) }) })] }));
|
|
101
|
+
}
|
|
@@ -5,7 +5,7 @@ export interface NoEditPermissionNoticeProps {
|
|
|
5
5
|
/**
|
|
6
6
|
* Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
|
|
7
7
|
* when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives. A locked
|
|
8
|
-
* or protected branch instead
|
|
8
|
+
* or protected branch instead says so in the branch bar's status over a read-only form, the rule
|
|
9
9
|
* `contentReadOnly` in `Editor.tsx` holds.
|
|
10
10
|
*/
|
|
11
11
|
export declare function NoEditPermissionNotice({ entryLabel }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -3,7 +3,7 @@ import { Text } from '@mantine/core';
|
|
|
3
3
|
/**
|
|
4
4
|
* Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
|
|
5
5
|
* when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives. A locked
|
|
6
|
-
* or protected branch instead
|
|
6
|
+
* or protected branch instead says so in the branch bar's status over a read-only form, the rule
|
|
7
7
|
* `contentReadOnly` in `Editor.tsx` holds.
|
|
8
8
|
*/
|
|
9
9
|
export function NoEditPermissionNotice({ entryLabel }) {
|
|
@@ -4,6 +4,8 @@ export interface RetryToastMessageProps {
|
|
|
4
4
|
onRetry: () => void;
|
|
5
5
|
/** The button's label, when the way forward is not a plain retry. */
|
|
6
6
|
actionLabel?: string;
|
|
7
|
+
/** A developer diagnostic, shown only once "Details" is opened. */
|
|
8
|
+
details?: string;
|
|
7
9
|
}
|
|
8
10
|
/** The body of an error toast whose failure the user can try again from the toast. */
|
|
9
|
-
export declare function RetryToastMessage({ message, onRetry, actionLabel, }: RetryToastMessageProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare function RetryToastMessage({ message, onRetry, actionLabel, details, }: RetryToastMessageProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useState } from 'react';
|
|
4
|
+
import { Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
|
|
3
5
|
/** The body of an error toast whose failure the user can try again from the toast. */
|
|
4
|
-
export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', }) {
|
|
5
|
-
|
|
6
|
+
export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', details, }) {
|
|
7
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
8
|
+
const detailsId = useId();
|
|
9
|
+
return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
|
|
6
10
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React, { type ReactNode, type Ref } from 'react';
|
|
2
|
+
import type { SaveAllProgress } from '../hooks/save-all.js';
|
|
3
|
+
export interface SaveAllButtonProps {
|
|
4
|
+
onSaveAll: () => void;
|
|
5
|
+
/** Entries on the branch with unsaved changes; the branch bar shows the count. */
|
|
6
|
+
unsavedCount: number;
|
|
7
|
+
/** Why Save all can't act now, other than its own run, a save in flight or nothing unsaved. */
|
|
8
|
+
blockedReason?: string;
|
|
9
|
+
/** Set while Save all runs. */
|
|
10
|
+
saveAllProgress: SaveAllProgress | null;
|
|
11
|
+
/** A single Save is in flight, on any entry; Save all can't start beside it. */
|
|
12
|
+
singleSaving: boolean;
|
|
13
|
+
/** The editor is loading or saving. */
|
|
14
|
+
busy: boolean;
|
|
15
|
+
/** A caret menu joined to the button, making it a split button. */
|
|
16
|
+
caret?: ReactNode;
|
|
17
|
+
buttonRef?: Ref<HTMLButtonElement>;
|
|
18
|
+
}
|
|
19
|
+
/** Saves every unsaved entry on the branch; the result is a toast (`SaveAllResult`). */
|
|
20
|
+
export declare function SaveAllButton({ onSaveAll, unsavedCount, blockedReason, saveAllProgress, singleSaving, busy, caret, buttonRef, }: SaveAllButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export declare const HIDDEN_STYLE: React.CSSProperties;
|
|
22
|
+
/** Unsaved changes live only in this tab. Icon-only below `lg`, its text then its Tooltip. */
|
|
23
|
+
export declare function DraftStorageWarning({ shown }: {
|
|
24
|
+
shown: boolean;
|
|
25
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Box, Button, Loader, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
+
import { IconAlertTriangle, IconLoader2 } from '@tabler/icons-react';
|
|
7
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
|
+
/**
|
|
9
|
+
* Every label in one grid cell, only `shown` visible, so the button keeps the widest one's width
|
|
10
|
+
* and the top bar doesn't shift as the count or progress changes.
|
|
11
|
+
*/
|
|
12
|
+
function SaveAllLabels({ shown, labels }) {
|
|
13
|
+
return (_jsx(Box, { component: "span", style: { display: 'inline-grid', fontVariantNumeric: 'tabular-nums' }, children: labels.map((label, index) => (_jsx(Box, { component: "span", "aria-hidden": index === shown ? undefined : true, style: {
|
|
14
|
+
gridArea: '1 / 1',
|
|
15
|
+
display: 'inline-flex',
|
|
16
|
+
alignItems: 'center',
|
|
17
|
+
justifyContent: 'center',
|
|
18
|
+
gap: 6,
|
|
19
|
+
visibility: index === shown ? 'visible' : 'hidden',
|
|
20
|
+
}, children: label }, index))) }));
|
|
21
|
+
}
|
|
22
|
+
/** Saves every unsaved entry on the branch; the result is a toast (`SaveAllResult`). */
|
|
23
|
+
export function SaveAllButton({ onSaveAll, unsavedCount, blockedReason, saveAllProgress, singleSaving, busy, caret, buttonRef, }) {
|
|
24
|
+
const reducedMotion = useReducedMotion();
|
|
25
|
+
// A running Save all shows only its progress: a lock arriving mid-run would grey it.
|
|
26
|
+
const reason = saveAllProgress
|
|
27
|
+
? undefined
|
|
28
|
+
: (blockedReason ??
|
|
29
|
+
(singleSaving
|
|
30
|
+
? 'Wait for the save to finish.'
|
|
31
|
+
: unsavedCount === 0
|
|
32
|
+
? 'No entry on this branch has unsaved changes.'
|
|
33
|
+
: busy
|
|
34
|
+
? 'Wait for the current save or load to finish.'
|
|
35
|
+
: undefined));
|
|
36
|
+
// Blocked, it stays focusable (`aria-disabled`, not `disabled`), so its reason opens on focus.
|
|
37
|
+
const blocked = !!reason || !!saveAllProgress;
|
|
38
|
+
// The branch's largest count yet sizes the progress, so the width grows only as a digit is added.
|
|
39
|
+
const count = Math.max(unsavedCount, saveAllProgress?.total ?? 0, 1);
|
|
40
|
+
const [widest, setWidest] = useState(count);
|
|
41
|
+
if (count > widest)
|
|
42
|
+
setWidest(count);
|
|
43
|
+
const progress = (done, total) => (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", display: "inline-flex", w: 14, children: saveAllProgress &&
|
|
44
|
+
(reducedMotion ? (_jsx(IconLoader2, { size: 14, "aria-hidden": "true" })) : (_jsx(Loader, { size: 14, color: "currentColor" }))) }), `${Math.min(done + 1, total)} of ${total}…`] }));
|
|
45
|
+
// Progress reads "2 of 3…" beside the spinner.
|
|
46
|
+
const labels = [
|
|
47
|
+
EDITOR_ACTIONS.saveAll,
|
|
48
|
+
saveAllProgress ? progress(saveAllProgress.done, saveAllProgress.total) : progress(0, 1),
|
|
49
|
+
progress(widest, widest),
|
|
50
|
+
];
|
|
51
|
+
return (_jsxs(_Fragment, { children: [_jsx(VisuallyHidden, { role: "status", children: saveAllProgress
|
|
52
|
+
? `Saving ${saveAllProgress.total} ${saveAllProgress.total === 1 ? 'entry' : 'entries'}…`
|
|
53
|
+
: '' }), _jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => (_jsx("div", { ...props, role: caret ? 'group' : undefined, "aria-label": caret ? 'Save all and branch actions' : undefined })), children: [_jsx(Tooltip, { label: reason, disabled: !reason, multiline: true, maw: 300,
|
|
54
|
+
// Beside its button, in the bar's free middle, so it covers neither the entry's Save
|
|
55
|
+
// below nor a stacked row of the bar; below when the left edge has no room for it.
|
|
56
|
+
position: "left", middlewares: { flip: { fallbackPlacements: ['bottom-end'] }, shift: true }, children: _jsx(Button, { ref: buttonRef, "data-testid": "save-all-button",
|
|
57
|
+
// The open entry's Save is the editor's one filled primary.
|
|
58
|
+
variant: "light", size: "sm", style: { flexShrink: 0 }, onClick: () => {
|
|
59
|
+
if (!blocked)
|
|
60
|
+
onSaveAll();
|
|
61
|
+
}, "data-disabled": reason ? true : undefined, "aria-disabled": blocked ? true : undefined, "aria-label": saveAllProgress
|
|
62
|
+
? `Saving ${Math.min(saveAllProgress.done + 1, saveAllProgress.total)} of ${saveAllProgress.total}…`
|
|
63
|
+
: undefined, children: _jsx(SaveAllLabels, { shown: saveAllProgress ? 1 : 0, labels: labels }) }) }), caret] })] }));
|
|
64
|
+
}
|
|
65
|
+
const STORAGE_FAILED_TEXT = 'Unsaved · not kept on this device';
|
|
66
|
+
const STORAGE_FAILED_HINT = "Save before you leave or reload, or they'll be lost.";
|
|
67
|
+
// Visually hidden, as Mantine's VisuallyHidden, for a region that must stay mounted.
|
|
68
|
+
export const HIDDEN_STYLE = {
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
width: 1,
|
|
71
|
+
height: 1,
|
|
72
|
+
overflow: 'hidden',
|
|
73
|
+
clip: 'rect(0 0 0 0)',
|
|
74
|
+
whiteSpace: 'nowrap',
|
|
75
|
+
};
|
|
76
|
+
/** Unsaved changes live only in this tab. Icon-only below `lg`, its text then its Tooltip. */
|
|
77
|
+
export function DraftStorageWarning({ shown }) {
|
|
78
|
+
return (
|
|
79
|
+
// Always mounted so the warning is announced; visible, at a fixed width, only when shown.
|
|
80
|
+
_jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: shown ? { flexShrink: 0 } : HIDDEN_STYLE, children: shown && (_jsx(Tooltip, { label: `${STORAGE_FAILED_TEXT}. ${STORAGE_FAILED_HINT}`, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: "var(--canopy-danger-text)", "data-testid": "unsaved-indicator", children: [_jsx(Box, { component: "span", visibleFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsx(Box, { component: "span", hiddenFrom: "lg", display: "inline-flex", "aria-hidden": "true", children: _jsx(IconAlertTriangle, { size: 18 }) }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsxs(VisuallyHidden, { component: "span", children: [". ", STORAGE_FAILED_HINT] })] }) })) }));
|
|
81
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type ElementProps } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
|
-
* The editor's one "unsaved" mark, a dot
|
|
4
|
+
* The editor's one "unsaved" mark, a dot.
|
|
5
5
|
* `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
|
|
6
6
|
* The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
|
|
7
7
|
* It forwards its ref, so a Tooltip can wrap it.
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { forwardRef } from 'react';
|
|
4
4
|
import { Box } from '@mantine/core';
|
|
5
5
|
/**
|
|
6
|
-
* The editor's one "unsaved" mark, a dot
|
|
6
|
+
* The editor's one "unsaved" mark, a dot.
|
|
7
7
|
* `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
|
|
8
8
|
* The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
|
|
9
9
|
* It forwards its ref, so a Tooltip can wrap it.
|
package/dist/editor/copy.d.ts
CHANGED
package/dist/editor/copy.js
CHANGED
|
@@ -76,8 +76,8 @@ export declare const buildCollectionLabels: (collections?: EditorCollection[]) =
|
|
|
76
76
|
*/
|
|
77
77
|
export declare const entryTypeLabel: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
|
|
78
78
|
/**
|
|
79
|
-
* The type shown under an entry's label
|
|
80
|
-
*
|
|
79
|
+
* The type shown under an entry's label in the content tree: `entryTypeLabel`, except for a
|
|
80
|
+
* singleton, whose label already is its type's (`singletonLabel`). @internal Exported for tests.
|
|
81
81
|
*/
|
|
82
82
|
export declare const entryTypeLine: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
|
|
83
83
|
/**
|
|
@@ -222,8 +222,8 @@ export const entryTypeLabel = (collections, entry) => {
|
|
|
222
222
|
return findEntryType(collections, entry)?.label ?? humanizeName(entry.entryType);
|
|
223
223
|
};
|
|
224
224
|
/**
|
|
225
|
-
* The type shown under an entry's label
|
|
226
|
-
*
|
|
225
|
+
* The type shown under an entry's label in the content tree: `entryTypeLabel`, except for a
|
|
226
|
+
* singleton, whose label already is its type's (`singletonLabel`). @internal Exported for tests.
|
|
227
227
|
*/
|
|
228
228
|
export const entryTypeLine = (collections, entry) => {
|
|
229
229
|
const type = findEntryType(collections, entry);
|
|
@@ -1,22 +1,16 @@
|
|
|
1
|
-
/** Entry search
|
|
2
|
-
|
|
1
|
+
/** Entry search and the detail line shared by the content tree's rows. */
|
|
2
|
+
/** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
|
|
3
|
+
export declare function matchesEntryQuery(entry: {
|
|
3
4
|
label: string;
|
|
4
5
|
/** The entry's title, when its label is something else (a singleton's type label). */
|
|
5
6
|
title?: string;
|
|
6
7
|
slug?: string;
|
|
7
|
-
|
|
8
|
-
collectionName?: string;
|
|
9
|
-
}
|
|
10
|
-
/** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
|
|
11
|
-
export declare function matchesEntryQuery(entry: Pick<SearchableEntry, 'label' | 'title' | 'slug'>, query: string): boolean;
|
|
12
|
-
export declare function filterEntries<T extends SearchableEntry>(entries: readonly T[], query: string): T[];
|
|
13
|
-
export interface EntryGroup<T> {
|
|
14
|
-
heading: string;
|
|
15
|
-
entries: T[];
|
|
16
|
-
}
|
|
8
|
+
}, query: string): boolean;
|
|
17
9
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* that share a heading stay separate groups.
|
|
10
|
+
* An entry's detail line, "type · route", with whichever of the two it has: a singleton's label
|
|
11
|
+
* already names its type, and an entry with no page has no route.
|
|
21
12
|
*/
|
|
22
|
-
export declare
|
|
13
|
+
export declare const entryDetailLine: ({ typeLabel, route, }: {
|
|
14
|
+
typeLabel?: string;
|
|
15
|
+
route?: string;
|
|
16
|
+
}) => string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Entry search
|
|
1
|
+
/** Entry search and the detail line shared by the content tree's rows. */
|
|
2
2
|
/** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
|
|
3
3
|
export function matchesEntryQuery(entry, query) {
|
|
4
4
|
const needle = query.trim().toLowerCase();
|
|
@@ -6,28 +6,8 @@ export function matchesEntryQuery(entry, query) {
|
|
|
6
6
|
return true;
|
|
7
7
|
return [entry.label, entry.title, entry.slug].some((text) => text?.toLowerCase().includes(needle));
|
|
8
8
|
}
|
|
9
|
-
export function filterEntries(entries, query) {
|
|
10
|
-
return entries.filter((entry) => matchesEntryQuery(entry, query));
|
|
11
|
-
}
|
|
12
9
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* that share a heading stay separate groups.
|
|
10
|
+
* An entry's detail line, "type · route", with whichever of the two it has: a singleton's label
|
|
11
|
+
* already names its type, and an entry with no page has no route.
|
|
16
12
|
*/
|
|
17
|
-
export
|
|
18
|
-
const groups = new Map();
|
|
19
|
-
for (const entry of entries) {
|
|
20
|
-
const key = entry.collectionPath ?? entry.collectionName ?? '';
|
|
21
|
-
const group = groups.get(key);
|
|
22
|
-
if (group) {
|
|
23
|
-
group.entries.push(entry);
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
const heading = (entry.collectionPath ? labelFor?.(entry.collectionPath) : undefined) ??
|
|
27
|
-
entry.collectionName ??
|
|
28
|
-
entry.collectionPath ??
|
|
29
|
-
'Root';
|
|
30
|
-
groups.set(key, { heading, entries: [entry] });
|
|
31
|
-
}
|
|
32
|
-
return [...groups.values()];
|
|
33
|
-
}
|
|
13
|
+
export const entryDetailLine = ({ typeLabel, route, }) => [typeLabel, route].filter(Boolean).join(' · ');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Shows `url`, pushed, or replaced keeping the entry's other state; never rewrites the URL shown. */
|
|
2
|
+
export declare function writeEditorUrl(url: string, push: boolean): void;
|
|
3
|
+
/** Stamps the history entry the editor opened on, keeping Next.js's keys: the URL is unchanged. */
|
|
4
|
+
export declare function markEditorHistoryStart(): void;
|
|
5
|
+
/** On `popstate`: entries moved, undefined if an end is not this run's, null for an undo's. */
|
|
6
|
+
export declare function recordHistoryMove(): number | undefined | null;
|
|
7
|
+
export declare function undoHistoryMove(moved: number): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Session-history writes stamped with their position, so a refused Back can be undone. */
|
|
2
|
+
const STAMP_KEY = 'canopyHistory';
|
|
3
|
+
// Next.js's router keys: its patched history methods copy `__NA` and the tree back and record the
|
|
4
|
+
// URL, but do neither for state already holding `__NA` or `_N` (app-router.js, next 15.5).
|
|
5
|
+
const ROUTER_KEYS = ['__NA', '__PRIVATE_NEXTJS_INTERNALS_TREE', '_N'];
|
|
6
|
+
// A run restarts wherever the position is lost, so positions from two runs are never compared.
|
|
7
|
+
let run = '';
|
|
8
|
+
let position;
|
|
9
|
+
let undoTarget;
|
|
10
|
+
const newRun = () => {
|
|
11
|
+
run = Math.random().toString(36).slice(2);
|
|
12
|
+
position = undefined;
|
|
13
|
+
};
|
|
14
|
+
const currentState = () => {
|
|
15
|
+
const state = window.history.state;
|
|
16
|
+
return state !== null && typeof state === 'object' ? { ...state } : {};
|
|
17
|
+
};
|
|
18
|
+
const stampIn = (state) => {
|
|
19
|
+
const value = state[STAMP_KEY];
|
|
20
|
+
if (value === null || typeof value !== 'object')
|
|
21
|
+
return undefined;
|
|
22
|
+
const [stampRun, at] = [Reflect.get(value, 'run'), Reflect.get(value, 'position')];
|
|
23
|
+
return typeof stampRun === 'string' && typeof at === 'number'
|
|
24
|
+
? { run: stampRun, position: at }
|
|
25
|
+
: undefined;
|
|
26
|
+
};
|
|
27
|
+
const samePage = (a, b) => {
|
|
28
|
+
const [x, y] = [new URL(a, window.location.href), new URL(b, window.location.href)];
|
|
29
|
+
const query = (u) => new URLSearchParams(u.search).toString();
|
|
30
|
+
return (x.origin === y.origin && x.pathname === y.pathname && query(x) === query(y) && x.hash === y.hash);
|
|
31
|
+
};
|
|
32
|
+
/** Shows `url`, pushed, or replaced keeping the entry's other state; never rewrites the URL shown. */
|
|
33
|
+
export function writeEditorUrl(url, push) {
|
|
34
|
+
if (samePage(url, window.location.href))
|
|
35
|
+
return;
|
|
36
|
+
if (!run)
|
|
37
|
+
newRun();
|
|
38
|
+
const at = (position ?? 0) + (push ? 1 : 0);
|
|
39
|
+
const kept = push ? {} : currentState();
|
|
40
|
+
for (const key of ROUTER_KEYS)
|
|
41
|
+
delete kept[key];
|
|
42
|
+
const method = push ? window.history.pushState : window.history.replaceState;
|
|
43
|
+
method.call(window.history, { ...kept, [STAMP_KEY]: { run, position: at } }, '', url);
|
|
44
|
+
position = at;
|
|
45
|
+
}
|
|
46
|
+
/** Stamps the history entry the editor opened on, keeping Next.js's keys: the URL is unchanged. */
|
|
47
|
+
export function markEditorHistoryStart() {
|
|
48
|
+
const state = currentState();
|
|
49
|
+
const stamp = stampIn(state);
|
|
50
|
+
if (stamp) {
|
|
51
|
+
run = stamp.run;
|
|
52
|
+
position = stamp.position;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
newRun();
|
|
56
|
+
position = 0;
|
|
57
|
+
window.history.replaceState({ ...state, [STAMP_KEY]: { run, position } }, '', window.location.href);
|
|
58
|
+
}
|
|
59
|
+
/** On `popstate`: entries moved, undefined if an end is not this run's, null for an undo's. */
|
|
60
|
+
export function recordHistoryMove() {
|
|
61
|
+
const arrived = stampIn(currentState());
|
|
62
|
+
const undone = undoTarget !== undefined &&
|
|
63
|
+
arrived?.run === undoTarget.run &&
|
|
64
|
+
arrived.position === undoTarget.position;
|
|
65
|
+
undoTarget = undefined;
|
|
66
|
+
const moved = arrived?.run === run && position !== undefined ? arrived.position - position : undefined;
|
|
67
|
+
if (arrived) {
|
|
68
|
+
run = arrived.run;
|
|
69
|
+
position = arrived.position;
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
newRun();
|
|
73
|
+
return undone ? null : moved;
|
|
74
|
+
}
|
|
75
|
+
export function undoHistoryMove(moved) {
|
|
76
|
+
if (position === undefined)
|
|
77
|
+
return;
|
|
78
|
+
undoTarget = { run, position: position - moved };
|
|
79
|
+
window.history.go(-moved);
|
|
80
|
+
}
|
|
@@ -5,6 +5,7 @@ import { notifications } from '@mantine/notifications';
|
|
|
5
5
|
import { Text } from '@mantine/core';
|
|
6
6
|
import { useApiClient } from '../context/index.js';
|
|
7
7
|
import { requestBranchCreate } from './create-branch-request.js';
|
|
8
|
+
import { writeEditorUrl } from './editor-history.js';
|
|
8
9
|
const MAX_LISTED_LABELS = 5;
|
|
9
10
|
/** 'Unsaved changes in: "A", "B".' */
|
|
10
11
|
export const describeUnsaved = ({ labels }) => {
|
|
@@ -25,10 +26,11 @@ export function useBranchActions(options) {
|
|
|
25
26
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
26
27
|
const performBranchSwitch = (next) => {
|
|
27
28
|
options.setBranchName(next);
|
|
29
|
+
// Its own history entry; a Back onto another branch already shows this URL, so writes nothing.
|
|
28
30
|
if (typeof window !== 'undefined') {
|
|
29
31
|
const url = new URL(window.location.href);
|
|
30
32
|
url.searchParams.set('branch', next);
|
|
31
|
-
|
|
33
|
+
writeEditorUrl(url.toString(), true);
|
|
32
34
|
}
|
|
33
35
|
options.onBranchSwitch?.(next);
|
|
34
36
|
};
|
|
@@ -55,7 +55,7 @@ interface BranchSummary {
|
|
|
55
55
|
/**
|
|
56
56
|
* Server-computed: content writes are rejected, because the branch is the
|
|
57
57
|
* read-only base branch OR its status is past 'editing'. `readOnly` still
|
|
58
|
-
* says WHICH, for
|
|
58
|
+
* says WHICH, for the status copy.
|
|
59
59
|
*/
|
|
60
60
|
writeBlocked: boolean;
|
|
61
61
|
/**
|
|
@@ -90,10 +90,14 @@ export interface UseBranchManagerReturn {
|
|
|
90
90
|
currentBranch: BranchListItem | undefined;
|
|
91
91
|
/** The listing failed and nothing earlier stands in for it. */
|
|
92
92
|
branchesLoadFailed: boolean;
|
|
93
|
+
/** A listing has arrived, so a branch missing from it is not still loading. */
|
|
94
|
+
branchesLoaded: boolean;
|
|
93
95
|
/** Shows a branch the server just created before any listing includes it. */
|
|
94
96
|
addCreatedBranch: (branch: BranchListItem) => void;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
/** Confirms first; the dialog returns focus to `returnFocusTo()` when given, else its opener. */
|
|
98
|
+
handleSubmit: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
|
|
99
|
+
/** Confirms first; the dialog returns focus to `returnFocusTo()` when given, else its opener. */
|
|
100
|
+
handleWithdraw: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
|
|
97
101
|
handleRequestChanges: (branchName: string) => Promise<void>;
|
|
98
102
|
handleDelete: (branchName: string) => Promise<void>;
|
|
99
103
|
handleReloadBranchData: () => Promise<void>;
|