canopycms 0.1.0 → 0.2.0-int.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -43
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +118 -47
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +29 -10
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +25 -6
- package/dist/assets/store-s3.js +81 -19
- package/dist/assets/types.d.ts +33 -4
- package/dist/cli/cli.js +151 -56
- package/dist/config/types.d.ts +3 -2
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +93 -39
- package/dist/editor/EntryNavigator.d.ts +17 -5
- package/dist/editor/EntryNavigator.js +66 -31
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +13 -2
- package/dist/editor/PreviewFrame.js +62 -5
- package/dist/editor/PreviewPane.d.ts +17 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +8 -7
- package/dist/editor/components/BranchLockBanner.js +30 -3
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchRowLock.d.ts +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -0
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +14 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +24 -9
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +18 -9
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +23 -0
- package/dist/editor/preview-bridge.js +105 -4
- package/dist/editor/preview-path.d.ts +16 -4
- package/dist/editor/preview-path.js +80 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.d.ts +1 -0
- package/dist/editor/theme.js +19 -1
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Box, Group, Select, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Box, Group, Select, Skeleton, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { useClipboard, useElementSize, useWindowEvent } from '@mantine/hooks';
|
|
6
6
|
import { IconCheck, IconExternalLink, IconRefresh } from '@tabler/icons-react';
|
|
7
7
|
import { PreviewFrame } from './PreviewFrame.js';
|
|
8
|
-
import { HighlightToggle, MaximizeToggle, UnmappedMarksChip } from './PreviewToolbarTools.js';
|
|
9
|
-
import {
|
|
8
|
+
import { HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
|
|
9
|
+
import { PANE_HEADER_HEIGHT } from './pane-header.js';
|
|
10
|
+
import { displayPreviewPath, siteChromeSrc } from './preview-path.js';
|
|
10
11
|
import { computePreviewViewport, formatPreviewWidthReadout, PREVIEW_DEVICE_WIDTHS, } from './preview-scale.js';
|
|
11
12
|
const WIDTH_OPTIONS = [
|
|
12
13
|
{ value: 'fit', label: 'Fit', detail: 'Pane width' },
|
|
@@ -27,7 +28,7 @@ const OPEN_SAVED_LABEL = 'Open saved version in a new tab';
|
|
|
27
28
|
* fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
|
|
28
29
|
* down to fit a narrower pane.
|
|
29
30
|
*/
|
|
30
|
-
export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, ...frameProps }) => {
|
|
31
|
+
export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }) => {
|
|
31
32
|
const { ref: viewportRef, width, height } = useElementSize();
|
|
32
33
|
const rootRef = useRef(null);
|
|
33
34
|
const maximizeRef = useRef(null);
|
|
@@ -74,13 +75,51 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
74
75
|
}
|
|
75
76
|
hadNotice.current = Boolean(notice);
|
|
76
77
|
}, [notice]);
|
|
78
|
+
// Pages asked for the header and footer stopped connecting, such as when the site's layout
|
|
79
|
+
// throws: for the rest of the session pages are framed without them, so drafts still show.
|
|
80
|
+
// `trying` until a page without them connects, which confirms the header and footer were the
|
|
81
|
+
// cause; a site that never connects at all is not blamed on them. The toggle and Reload retry.
|
|
82
|
+
const [chromeFallback, setChromeFallback] = useState('off');
|
|
77
83
|
// Reloading remounts the frame for a fresh handshake. It drops a reported error too: a page
|
|
78
84
|
// that reports draft errors reports it again once the reloaded page renders the draft.
|
|
79
85
|
const [reloads, setReloads] = useState(0);
|
|
80
86
|
const reload = () => {
|
|
81
87
|
setReloads((count) => count + 1);
|
|
88
|
+
setChromeFallback('off');
|
|
82
89
|
frameProps.onPreviewError?.(null);
|
|
83
90
|
};
|
|
91
|
+
// Whether the page framed last said it renders the site's header and footer. Kept across
|
|
92
|
+
// pages, so the toggle stays put while the next page connects.
|
|
93
|
+
const [chromeSupported, setChromeSupported] = useState(false);
|
|
94
|
+
const chromeSrc = siteChromeSrc(src, mount);
|
|
95
|
+
// Also what "Open saved version" opens, so it shows what the frame shows.
|
|
96
|
+
const liveSrc = siteChrome && chromeSrc && chromeFallback === 'off' ? chromeSrc : src;
|
|
97
|
+
// The frame changes page only once the open entry's data is in, so one render hands the new
|
|
98
|
+
// page its own draft: no draft is ever posted to the page of another entry. A reload or the
|
|
99
|
+
// toggle waits for that page too, since the held page has no draft to render.
|
|
100
|
+
const live = { page: src, src: liveSrc, path, key: reloads };
|
|
101
|
+
const [framed, setFramed] = useState(pending ? null : live);
|
|
102
|
+
if (!pending &&
|
|
103
|
+
(framed?.src !== live.src ||
|
|
104
|
+
framed.page !== src ||
|
|
105
|
+
framed.path !== path ||
|
|
106
|
+
framed.key !== reloads)) {
|
|
107
|
+
setFramed(live);
|
|
108
|
+
}
|
|
109
|
+
const frame = pending ? framed : live;
|
|
110
|
+
const onSiteChrome = (support) => {
|
|
111
|
+
setChromeSupported(support === 'supported');
|
|
112
|
+
if (!frame)
|
|
113
|
+
return;
|
|
114
|
+
const withChrome = frame.src !== frame.page;
|
|
115
|
+
if (support === 'unanswered' && withChrome)
|
|
116
|
+
setChromeFallback('trying');
|
|
117
|
+
if (support !== 'unanswered' && !withChrome) {
|
|
118
|
+
setChromeFallback((current) => (current === 'trying' ? 'confirmed' : current));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// Shown while on even without support, so a page that never connects can still turn it off.
|
|
122
|
+
const showChromeToggle = Boolean(onSiteChromeChange && chromeSrc && (chromeSupported || siteChrome));
|
|
84
123
|
const shownPath = displayPreviewPath(path, mount);
|
|
85
124
|
const viewport = computePreviewViewport(previewWidth, width, height);
|
|
86
125
|
const iframeStyle = previewWidth === 'fit'
|
|
@@ -109,8 +148,9 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
109
148
|
height: '100%',
|
|
110
149
|
border: '1px solid var(--mantine-color-default-border)',
|
|
111
150
|
}, 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
|
-
|
|
151
|
+
// Wraps to a second row, rather than clipping, in a pane too narrow for one. The pane's
|
|
152
|
+
// own top border makes up the last pixel, so this bar ends level with the form header.
|
|
153
|
+
minHeight: PANE_HEADER_HEIGHT - 1,
|
|
114
154
|
rowGap: 2,
|
|
115
155
|
paddingBlock: 2,
|
|
116
156
|
flex: '0 0 auto',
|
|
@@ -119,7 +159,14 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
119
159
|
}, 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
160
|
if (isPreviewWidth(value))
|
|
121
161
|
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 ||
|
|
162
|
+
}, 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 ||
|
|
163
|
+
showChromeToggle ||
|
|
164
|
+
(showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [showChromeToggle && onSiteChromeChange && (_jsx(SiteChromeToggle, { enabled: siteChrome, failed: chromeFallback === 'confirmed', onToggle: () => {
|
|
165
|
+
setChromeFallback('off');
|
|
166
|
+
onSiteChromeChange(!siteChrome);
|
|
167
|
+
} })), 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: liveSrc, 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: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
|
|
168
|
+
// A held page gets no draft and its reports go nowhere: it belongs to another entry.
|
|
169
|
+
data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
|
|
123
170
|
// Retry goes with the frame it sits in; the toolbar's reload offers the same action.
|
|
124
171
|
reload();
|
|
125
172
|
reloadButtonRef.current?.focus();
|
|
@@ -127,7 +174,7 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
127
174
|
position: 'absolute',
|
|
128
175
|
inset: 0,
|
|
129
176
|
background: previewWidth === 'fit' ? undefined : 'var(--canopy-surface-muted)',
|
|
130
|
-
} },
|
|
177
|
+
} }, frame.key)) : (_jsx(Box, { "aria-busy": "true", pos: "absolute", inset: 0, "data-testid": "preview-loading-skeleton", children: _jsx(Skeleton, { h: "100%", radius: 0 }) })), resizing && width > 0 && (_jsx(Box, { "aria-hidden": true, "data-testid": "preview-resize-readout", style: {
|
|
131
178
|
position: 'absolute',
|
|
132
179
|
top: '50%',
|
|
133
180
|
left: '50%',
|
|
@@ -8,6 +8,14 @@ export interface HighlightToggleProps {
|
|
|
8
8
|
}
|
|
9
9
|
/** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
|
|
10
10
|
export declare function HighlightToggle({ enabled, onToggle, marksNothing }: HighlightToggleProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export interface SiteChromeToggleProps {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
onToggle: () => void;
|
|
14
|
+
/** On, but pages with the header and footer don't connect while pages without them do. */
|
|
15
|
+
failed?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Shows and hides the site's header and footer; says so when the site couldn't show them. */
|
|
18
|
+
export declare function SiteChromeToggle({ enabled, onToggle, failed }: SiteChromeToggleProps): import("react/jsx-runtime").JSX.Element;
|
|
11
19
|
/** The admin's count of preview marks that name no field of the entry; see `findInexactMarks`. */
|
|
12
20
|
export declare function UnmappedMarksChip({ marks }: {
|
|
13
21
|
marks: readonly InexactMark[];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useRef, useState } from 'react';
|
|
4
4
|
import { ActionIcon, Button, Group, Popover, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
|
-
import { IconArrowsMaximize, IconArrowsMinimize, IconMarquee } from '@tabler/icons-react';
|
|
5
|
+
import { IconArrowsMaximize, IconArrowsMinimize, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
|
|
6
6
|
const HIGHLIGHT_LABEL = 'Highlight fields in the preview';
|
|
7
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
8
|
/** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
|
|
@@ -11,6 +11,14 @@ export function HighlightToggle({ enabled, onToggle, marksNothing = false }) {
|
|
|
11
11
|
const showNote = enabled && marksNothing;
|
|
12
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
13
|
}
|
|
14
|
+
const SITE_CHROME_LABEL = 'Show site header and footer';
|
|
15
|
+
const SITE_CHROME_FAILED_NOTE = "The site couldn't show its header and footer, so the preview shows pages without them. Turn this off and on, or reload the preview, to try again.";
|
|
16
|
+
/** Shows and hides the site's header and footer; says so when the site couldn't show them. */
|
|
17
|
+
export function SiteChromeToggle({ enabled, onToggle, failed = false }) {
|
|
18
|
+
const noteId = useId();
|
|
19
|
+
const showNote = enabled && failed;
|
|
20
|
+
return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: SITE_CHROME_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": SITE_CHROME_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-site-chrome-button", onClick: onToggle, children: _jsx(IconLayoutNavbar, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: SITE_CHROME_FAILED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Header and footer unavailable", _jsx(VisuallyHidden, { id: noteId, children: SITE_CHROME_FAILED_NOTE })] }) }))] }));
|
|
21
|
+
}
|
|
14
22
|
const LISTED = 3;
|
|
15
23
|
// Module-level, so React never re-calls it on a re-render and pulls focus back while closing.
|
|
16
24
|
const focusOnMount = (node) => node?.focus();
|
|
@@ -15,3 +15,9 @@ export declare const BRANCH_STATUS_PRESENTATION: Record<BranchStatus, BranchStat
|
|
|
15
15
|
* Returns undefined when there is nothing to show.
|
|
16
16
|
*/
|
|
17
17
|
export declare function branchStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* The status the top bar shows: only one that tells the user something, so not 'editing', the
|
|
20
|
+
* state every branch starts in. The Branches drawer shows every status through
|
|
21
|
+
* `branchStatusPresentation`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function branchBarStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
|
|
@@ -27,3 +27,13 @@ export function branchStatusPresentation(status, isProtected = false) {
|
|
|
27
27
|
return BRANCH_STATUS_PRESENTATION[status];
|
|
28
28
|
return { label: status, color: 'gray', variant: 'light' };
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The status the top bar shows: only one that tells the user something, so not 'editing', the
|
|
32
|
+
* state every branch starts in. The Branches drawer shows every status through
|
|
33
|
+
* `branchStatusPresentation`.
|
|
34
|
+
*/
|
|
35
|
+
export function branchBarStatusPresentation(status, isProtected = false) {
|
|
36
|
+
if (!isProtected && status === 'editing')
|
|
37
|
+
return undefined;
|
|
38
|
+
return branchStatusPresentation(status, isProtected);
|
|
39
|
+
}
|
|
@@ -12,15 +12,13 @@ export interface BranchCommentsProps {
|
|
|
12
12
|
onAddComment: (text: string, type: 'field' | 'entry' | 'branch', entryPath?: string, canopyPath?: string, threadId?: string) => Promise<void>;
|
|
13
13
|
/** Handler to resolve a thread */
|
|
14
14
|
onResolveThread: (threadId: string) => Promise<void>;
|
|
15
|
-
/** Auto-focus and expand */
|
|
16
|
-
autoFocus?: boolean;
|
|
17
15
|
/** Thread ID to highlight and scroll to */
|
|
18
16
|
highlightThreadId?: string;
|
|
19
17
|
/** Optional function to fetch user metadata for displaying user badges */
|
|
20
18
|
onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
|
|
21
19
|
}
|
|
22
20
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* The branch's existing discussion threads, at the top of BranchManager, in a ThreadCarousel.
|
|
22
|
+
* It renders nothing without one: a new branch comment starts in the Comments drawer.
|
|
25
23
|
*/
|
|
26
24
|
export declare const BranchComments: React.FC<BranchCommentsProps>;
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useMemo
|
|
4
|
-
import { Box, Button } from '@mantine/core';
|
|
3
|
+
import { useMemo } from 'react';
|
|
5
4
|
import { ThreadCarousel } from './ThreadCarousel.js';
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The branch's existing discussion threads, at the top of BranchManager, in a ThreadCarousel.
|
|
7
|
+
* It renders nothing without one: a new branch comment starts in the Comments drawer.
|
|
9
8
|
*/
|
|
10
|
-
export const BranchComments = ({ comments, currentUserId, canResolve, onAddComment, onResolveThread,
|
|
11
|
-
const [showCarousel, setShowCarousel] = useState(false);
|
|
9
|
+
export const BranchComments = ({ comments, currentUserId, canResolve, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
|
|
12
10
|
const branchThreads = useMemo(() => comments.filter((t) => t.type === 'branch'), [comments]);
|
|
13
|
-
const shouldShowCarousel = branchThreads.length > 0 || showCarousel || autoFocus;
|
|
14
11
|
const handleAddComment = async (text, threadId) => {
|
|
15
12
|
await onAddComment(text, 'branch', undefined, undefined, threadId);
|
|
16
13
|
};
|
|
17
|
-
if (
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
return (_jsx(ThreadCarousel, { threads: branchThreads, label: "Branch discussion", contextType: "branch", currentUserId: currentUserId, canResolve: canResolve, onAddComment: handleAddComment, onResolveThread: onResolveThread, autoFocus: autoFocus, autoOpenNewThread: showCarousel && branchThreads.length === 0, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }));
|
|
14
|
+
if (branchThreads.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsx(ThreadCarousel, { threads: branchThreads, label: "Branch discussion", contextType: "branch", currentUserId: currentUserId, canResolve: canResolve, onAddComment: handleAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }));
|
|
21
17
|
};
|
|
@@ -35,19 +35,13 @@ export interface ThreadCarouselProps {
|
|
|
35
35
|
currentUserId: string;
|
|
36
36
|
/** Whether the user may resolve any thread; a thread's own author always may resolve it. */
|
|
37
37
|
canResolve: boolean;
|
|
38
|
-
/** Handler to
|
|
39
|
-
onAddComment: (text: string, threadId
|
|
38
|
+
/** Handler to reply to a thread; new threads start in the Comments drawer. */
|
|
39
|
+
onAddComment: (text: string, threadId: string) => Promise<void>;
|
|
40
40
|
/** Handler to resolve a thread */
|
|
41
41
|
onResolveThread: (threadId: string) => Promise<void>;
|
|
42
|
-
/** Auto-focus and expand (from preview click) */
|
|
43
|
-
autoFocus?: boolean;
|
|
44
|
-
/** Auto-open new thread box */
|
|
45
|
-
autoOpenNewThread?: boolean;
|
|
46
42
|
/** Thread ID to highlight and scroll to */
|
|
47
43
|
highlightThreadId?: string;
|
|
48
44
|
/** Optional function to fetch user metadata for displaying user badges */
|
|
49
45
|
onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
|
|
50
|
-
/** Called when the user cancels the new-thread box (e.g. so a wrapper can collapse an empty carousel) */
|
|
51
|
-
onCancelNewThread?: () => void;
|
|
52
46
|
}
|
|
53
47
|
export declare const ThreadCarousel: React.FC<ThreadCarouselProps>;
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
4
|
-
import { ActionIcon,
|
|
4
|
+
import { ActionIcon, Group, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
|
5
5
|
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
|
6
6
|
import { InlineCommentThread } from './InlineCommentThread.js';
|
|
7
7
|
import { useTrackedWrites } from './use-tracked-writes.js';
|
|
8
|
-
|
|
9
|
-
export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, currentUserId, canResolve, onAddComment, onResolveThread, autoFocus, autoOpenNewThread, highlightThreadId, onGetUserMetadata, onCancelNewThread, }) => {
|
|
8
|
+
export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, currentUserId, canResolve, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
|
|
10
9
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
11
|
-
const [showNewThreadBox, setShowNewThreadBox] = useState(false);
|
|
12
|
-
const [newThreadText, setNewThreadText] = useState('');
|
|
13
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
14
10
|
const [carouselHeight, setCarouselHeight] = useState(400);
|
|
15
11
|
const [isResizing, setIsResizing] = useState(false);
|
|
16
12
|
const [highlightedThreadId, setHighlightedThreadId] = useState(undefined);
|
|
17
|
-
const [error, setError] = useState(null);
|
|
18
13
|
const scrollContainerRef = useRef(null);
|
|
19
14
|
const resizeStartY = useRef(0);
|
|
20
15
|
const resizeStartHeight = useRef(0);
|
|
@@ -24,17 +19,6 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
24
19
|
}
|
|
25
20
|
return a.resolved ? 1 : -1;
|
|
26
21
|
}), [threads]);
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
if (autoFocus && sortedThreads.length > 0) {
|
|
29
|
-
const firstUnresolved = sortedThreads.find((t) => !t.resolved);
|
|
30
|
-
if (firstUnresolved) {
|
|
31
|
-
const index = sortedThreads.findIndex((t) => t.id === firstUnresolved.id);
|
|
32
|
-
setCurrentIndex(index);
|
|
33
|
-
scrollToIndex(index);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollToIndex is stable
|
|
37
|
-
}, [autoFocus, sortedThreads]);
|
|
38
22
|
useEffect(() => {
|
|
39
23
|
if (highlightThreadId && sortedThreads.length > 0) {
|
|
40
24
|
const threadIndex = sortedThreads.findIndex((t) => t.id === highlightThreadId);
|
|
@@ -50,11 +34,6 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
50
34
|
}
|
|
51
35
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollToIndex is stable
|
|
52
36
|
}, [highlightThreadId, sortedThreads]);
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (autoOpenNewThread) {
|
|
55
|
-
setShowNewThreadBox(true);
|
|
56
|
-
}
|
|
57
|
-
}, [autoOpenNewThread]);
|
|
58
37
|
useEffect(() => {
|
|
59
38
|
const handleMouseMove = (e) => {
|
|
60
39
|
if (!isResizing)
|
|
@@ -119,32 +98,8 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
119
98
|
const resolveThread = track(onResolveThread);
|
|
120
99
|
// A resolved thread's controls unmount with the focused button, so focus lands here.
|
|
121
100
|
const labelRef = useRef(null);
|
|
122
|
-
const handleCreateNewThread = async () => {
|
|
123
|
-
if (!newThreadText.trim())
|
|
124
|
-
return;
|
|
125
|
-
setIsSubmitting(true);
|
|
126
|
-
setError(null);
|
|
127
|
-
try {
|
|
128
|
-
await addComment(newThreadText);
|
|
129
|
-
setNewThreadText('');
|
|
130
|
-
setShowNewThreadBox(false);
|
|
131
|
-
}
|
|
132
|
-
catch (err) {
|
|
133
|
-
setError(toCommentWriteFailure(err));
|
|
134
|
-
}
|
|
135
|
-
finally {
|
|
136
|
-
setIsSubmitting(false);
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
101
|
const unresolvedCount = sortedThreads.filter((t) => !t.resolved).length;
|
|
140
|
-
return (_jsx(Paper, { p: "sm", bg: "var(--mantine-color-default-hover)", style: { marginTop: 8 }, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { ref: labelRef, size: "sm", fw: 600, tabIndex: -1, children: [label, sortedThreads.length > 0 && ` (${sortedThreads.length})`] }), unresolvedCount > 0 && (_jsxs(Text, { size: "xs", c: "orange", fw: 600, children: [unresolvedCount, " unresolved"] }))] }),
|
|
141
|
-
width: 1,
|
|
142
|
-
height: 16,
|
|
143
|
-
background: 'var(--mantine-color-default-border)',
|
|
144
|
-
} })] })), _jsx(Button, { variant: "light", onClick: () => setShowNewThreadBox(!showNewThreadBox), children: "+ New" })] })] }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), showNewThreadBox && (_jsx(Paper, { withBorder: true, p: "sm", bg: "var(--mantine-color-body)", children: _jsxs(Stack, { gap: "xs", children: [_jsx(Textarea, { placeholder: "Start a new thread...", value: newThreadText, onChange: (e) => setNewThreadText(e.target.value), minRows: 2, disabled: isSubmitting, autoFocus: true, "data-testid": "new-thread-textarea" }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { onClick: handleCreateNewThread, loading: isSubmitting, disabled: !newThreadText.trim(), "data-testid": "create-thread-button", children: "Create Thread" }), _jsx(Button, { variant: "subtle", onClick: () => {
|
|
145
|
-
setShowNewThreadBox(false);
|
|
146
|
-
onCancelNewThread?.();
|
|
147
|
-
}, children: "Cancel" })] })] }) })), sortedThreads.length > 0 && (_jsxs("div", { style: {
|
|
102
|
+
return (_jsx(Paper, { p: "sm", bg: "var(--mantine-color-default-hover)", style: { marginTop: 8 }, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { ref: labelRef, size: "sm", fw: 600, tabIndex: -1, children: [label, sortedThreads.length > 0 && ` (${sortedThreads.length})`] }), unresolvedCount > 0 && (_jsxs(Text, { size: "xs", c: "orange", fw: 600, children: [unresolvedCount, " unresolved"] }))] }), _jsx(Group, { gap: "xs", children: sortedThreads.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: "Previous thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handlePrevious, disabled: currentIndex === 0, "aria-label": "Previous thread", children: _jsx(IconChevronLeft, { size: 16, "aria-hidden": true }) }) }), _jsxs(Text, { size: "xs", fw: 500, children: [currentIndex + 1, "/", sortedThreads.length] }), _jsx(Tooltip, { label: "Next thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handleNext, disabled: currentIndex === sortedThreads.length - 1, "aria-label": "Next thread", children: _jsx(IconChevronRight, { size: 16, "aria-hidden": true }) }) })] })) })] }), sortedThreads.length > 0 && (_jsxs("div", { style: {
|
|
148
103
|
position: 'relative',
|
|
149
104
|
width: '100%',
|
|
150
105
|
}, children: [_jsxs("div", { ref: scrollContainerRef, style: {
|
|
@@ -206,5 +161,5 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
206
161
|
height: 3,
|
|
207
162
|
borderRadius: 2,
|
|
208
163
|
background: 'var(--mantine-color-dimmed)',
|
|
209
|
-
} }) })] }))
|
|
164
|
+
} }) })] }))] }) }));
|
|
210
165
|
};
|
|
@@ -76,8 +76,11 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
|
|
|
76
76
|
setReplyText('');
|
|
77
77
|
}
|
|
78
78
|
await onResolve();
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// After the commit that settles the write, so focus never lands on a control that still
|
|
80
|
+
// reads as busy.
|
|
81
|
+
if (focusAfterResolve) {
|
|
82
|
+
requestAnimationFrame(() => setTimeout(() => moveFocusIfUnmoved(origin, focusAfterResolve()), 0));
|
|
83
|
+
}
|
|
81
84
|
}
|
|
82
85
|
catch (err) {
|
|
83
86
|
setError(toCommentWriteFailure(err));
|
|
@@ -6,14 +6,15 @@ export interface BranchLockInput {
|
|
|
6
6
|
branchWriteBlocked?: boolean;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* base-branch read-only takes precedence over a workflow status lock.
|
|
9
|
+
* Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
|
|
10
|
+
* @internal Exported for tests.
|
|
12
11
|
*/
|
|
13
|
-
export declare function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
export declare function branchLockBannerShown(lock: BranchLockInput): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Why the branch takes no writes, with the way forward; undefined when it does take them. The
|
|
15
|
+
* top bar's Save and the navigator's row locks say this; the banner describes the same lock.
|
|
16
|
+
*/
|
|
17
|
+
export declare function describeBranchLock(lock: BranchLockInput): string | undefined;
|
|
17
18
|
export interface BranchLockBannerProps extends BranchLockInput {
|
|
18
19
|
branchName: string;
|
|
19
20
|
onBranchManagerOpen: () => void;
|
|
@@ -7,7 +7,7 @@ import { EDITOR_ACTIONS } from '../copy.js';
|
|
|
7
7
|
* nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
|
|
8
8
|
* base-branch read-only takes precedence over a workflow status lock.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
|
|
11
11
|
const statusLocked = branchWriteBlocked && !branchReadOnly;
|
|
12
12
|
// `branchStatus` can't describe the lock while branch data loads (undefined) or under version
|
|
13
13
|
// skew (an old server omits `branchWriteBlocked` but reports 'editing'), so both read as
|
|
@@ -15,10 +15,37 @@ export function branchLockState({ branchStatus, branchReadOnly = false, branchWr
|
|
|
15
15
|
const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
|
|
16
16
|
return { statusLocked, branchDataUnavailable };
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
|
|
20
|
+
* @internal Exported for tests.
|
|
21
|
+
*/
|
|
22
|
+
export function branchLockBannerShown(lock) {
|
|
23
|
+
return !!lock.branchReadOnly || branchLockState(lock).statusLocked;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why the branch takes no writes, with the way forward; undefined when it does take them. The
|
|
27
|
+
* top bar's Save and the navigator's row locks say this; the banner describes the same lock.
|
|
28
|
+
*/
|
|
29
|
+
export function describeBranchLock(lock) {
|
|
30
|
+
if (!branchLockBannerShown(lock))
|
|
31
|
+
return undefined;
|
|
32
|
+
const { statusLocked, branchDataUnavailable } = branchLockState(lock);
|
|
33
|
+
if (lock.branchReadOnly)
|
|
34
|
+
return 'The base branch is read-only. Create a branch to make changes.';
|
|
35
|
+
if (branchDataUnavailable)
|
|
36
|
+
return 'Branch details could not be loaded. Use "Switch branch…" to retry.';
|
|
37
|
+
if (!statusLocked)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (lock.branchStatus === 'submitted')
|
|
40
|
+
return 'This branch is in review. Withdraw it to make changes.';
|
|
41
|
+
if (lock.branchStatus === 'approved')
|
|
42
|
+
return 'This branch is approved. Withdraw it to make changes.';
|
|
43
|
+
return `This branch is ${lock.branchStatus}. Its content is read-only.`;
|
|
44
|
+
}
|
|
18
45
|
/** The banner region's explanation of a read-only branch; not dismissible while it applies. */
|
|
19
46
|
export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
|
|
20
|
-
const {
|
|
21
|
-
if (!lock
|
|
47
|
+
const { branchDataUnavailable } = branchLockState(lock);
|
|
48
|
+
if (!branchLockBannerShown(lock))
|
|
22
49
|
return null;
|
|
23
50
|
const [testId, message, action] = lock.branchReadOnly
|
|
24
51
|
? [
|
|
@@ -4,7 +4,7 @@ export interface BranchPillProps {
|
|
|
4
4
|
branchName: string;
|
|
5
5
|
/** The editor is busy; with no branch name yet, that is the branch list loading. */
|
|
6
6
|
loading: boolean;
|
|
7
|
-
/** The status label ("
|
|
7
|
+
/** The status label ("In review", "Protected"), set only for a status worth a place in the bar. */
|
|
8
8
|
status?: {
|
|
9
9
|
label: string;
|
|
10
10
|
testId: string;
|
|
@@ -25,8 +25,9 @@ export interface BranchPillProps {
|
|
|
25
25
|
pillRef?: Ref<HTMLButtonElement>;
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* The branch's name, status and unsaved count
|
|
29
|
-
* unsaved entries, each opening that entry. Below `sm` it is the
|
|
30
|
-
* name then in its Tooltip, accessible name and menu label: the
|
|
28
|
+
* The branch's name, status and unsaved count: the unsaved mark and a number. Its menu holds the
|
|
29
|
+
* branch actions and lists the unsaved entries, each opening that entry. Below `sm` it is the
|
|
30
|
+
* branch icon and the count, its name then in its Tooltip, accessible name and menu label: the
|
|
31
|
+
* row's actions must not clip.
|
|
31
32
|
*/
|
|
32
33
|
export declare function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -7,12 +7,14 @@ import { IconChevronDown, IconGitBranch } from '@tabler/icons-react';
|
|
|
7
7
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
8
|
import { DestructiveMenuItem } from './DestructiveMenuItem.js';
|
|
9
9
|
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
10
|
+
import { UnsavedMark } from './UnsavedMark.js';
|
|
10
11
|
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
11
12
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
12
13
|
/**
|
|
13
|
-
* The branch's name, status and unsaved count
|
|
14
|
-
* unsaved entries, each opening that entry. Below `sm` it is the
|
|
15
|
-
* name then in its Tooltip, accessible name and menu label: the
|
|
14
|
+
* The branch's name, status and unsaved count: the unsaved mark and a number. Its menu holds the
|
|
15
|
+
* branch actions and lists the unsaved entries, each opening that entry. Below `sm` it is the
|
|
16
|
+
* branch icon and the count, its name then in its Tooltip, accessible name and menu label: the
|
|
17
|
+
* row's actions must not clip.
|
|
16
18
|
*/
|
|
17
19
|
export function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }) {
|
|
18
20
|
const trigger = useRef(null);
|
|
@@ -23,11 +25,7 @@ export function BranchPill({ branchName, loading, status, unsavedCount: unsaved,
|
|
|
23
25
|
const name = branchName || (loading ? 'Loading branches…' : 'No branch open');
|
|
24
26
|
return (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: status ? `${name} · ${status.label}` : name, disabled: wide, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "default", size: "sm", miw: 0, px: { base: 8, sm: 14 },
|
|
25
27
|
// The count is the bar's one branch-wide unsaved signal: the row wraps before it clips.
|
|
26
|
-
style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Text, { span: true, size: "xs", fw: 600, px: 6,
|
|
27
|
-
flexShrink: 0,
|
|
28
|
-
borderRadius: 'var(--mantine-radius-xl)',
|
|
29
|
-
background: 'var(--canopy-count-bg)',
|
|
30
|
-
}, "data-testid": "branch-unsaved-count", children: [unsaved, _jsx(Box, { component: "span", visibleFrom: "sm", children: ' unsaved' }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: ' unsaved' })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
|
|
28
|
+
style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Box, { component: "span", display: "inline-flex", style: { alignItems: 'center', gap: 4, flexShrink: 0 }, "data-testid": "branch-unsaved-count", children: [_jsx(UnsavedMark, { "aria-hidden": "true" }), _jsx(Text, { span: true, size: "xs", fw: 600, "aria-hidden": "true", children: unsaved }), _jsx(VisuallyHidden, { component: "span", children: `, ${plural(unsaved, 'entry', 'entries')} with unsaved changes` })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
|
|
31
29
|
? `Discard all changes (${plural(unsaved, 'entry', 'entries')})`
|
|
32
30
|
: 'Discard all changes' })] })] }));
|
|
33
31
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The lock a navigator row shows in the slot its "…" menu would fill, on a branch that takes no
|
|
4
|
+
* writes. It matches the menu button's footprint so rows do not shift between branches, and takes
|
|
5
|
+
* the menu's name; the reason is its Tooltip and its description. It stays `aria-disabled` rather
|
|
6
|
+
* than `disabled`, so the reason opens on keyboard focus and touch too. Space is stopped here: the
|
|
7
|
+
* tree would read it as expand/collapse.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BranchRowLock: React.FC<{
|
|
10
|
+
label: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId } from 'react';
|
|
4
|
+
import { ActionIcon, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { IconLock } from '@tabler/icons-react';
|
|
6
|
+
/**
|
|
7
|
+
* The lock a navigator row shows in the slot its "…" menu would fill, on a branch that takes no
|
|
8
|
+
* writes. It matches the menu button's footprint so rows do not shift between branches, and takes
|
|
9
|
+
* the menu's name; the reason is its Tooltip and its description. It stays `aria-disabled` rather
|
|
10
|
+
* than `disabled`, so the reason opens on keyboard focus and touch too. Space is stopped here: the
|
|
11
|
+
* tree would read it as expand/collapse.
|
|
12
|
+
*/
|
|
13
|
+
export const BranchRowLock = ({ label, reason }) => {
|
|
14
|
+
const reasonId = useId();
|
|
15
|
+
return (_jsx(Tooltip, { label: reason, withinPortal: true, position: "bottom-end", multiline: true, w: 240, events: { hover: true, focus: true, touch: true }, children: _jsxs(ActionIcon, { size: "xs", variant: "transparent", c: "dimmed", "aria-disabled": true, "aria-label": label, "aria-describedby": reasonId, "data-testid": "branch-lock", style: { cursor: 'default' }, onClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
|
|
16
|
+
if (event.nativeEvent.code === 'Space')
|
|
17
|
+
event.stopPropagation();
|
|
18
|
+
}, children: [_jsx(IconLock, { size: 14, "aria-hidden": true }), _jsx(VisuallyHidden, { component: "span", id: reasonId, children: reason })] }) }));
|
|
19
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx
|
|
3
|
-
import {
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
4
4
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
5
|
-
export const BranchesDrawer = ({ opened, onClose, confirmOpen, returnFocusTo, children, }) => (_jsx(
|
|
5
|
+
export const BranchesDrawer = ({ opened, onClose, confirmOpen, returnFocusTo, children, }) => (_jsx(OverlayDrawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: "Branches", size: 420, ...focusReturnProps(returnFocusTo), children: children }));
|
|
@@ -20,8 +20,6 @@ export interface EditorHeaderProps {
|
|
|
20
20
|
}>;
|
|
21
21
|
/** Entries with unsaved changes on the branch. */
|
|
22
22
|
modifiedCount: number;
|
|
23
|
-
/** Opens the content panel. */
|
|
24
|
-
onNavigatorOpen: () => void;
|
|
25
23
|
/** Opens the content panel on a collection. */
|
|
26
24
|
onCollectionOpen: (collectionPath: string) => void;
|
|
27
25
|
/** The entry menu's trigger. */
|
|
@@ -31,12 +29,10 @@ export interface EditorHeaderProps {
|
|
|
31
29
|
/** Replaces the open entry with the server's copy. */
|
|
32
30
|
onFileReload: () => void;
|
|
33
31
|
onFileDiscardDraft: () => void;
|
|
34
|
-
/** Opens the open entry's
|
|
32
|
+
/** Opens the open entry's "Change URL" dialog. */
|
|
35
33
|
onEntryRename: () => void;
|
|
36
34
|
/** Asks to delete the open entry. */
|
|
37
35
|
onEntryDelete: () => void;
|
|
38
|
-
/** The "All content" crumb. */
|
|
39
|
-
allContentRef?: Ref<HTMLButtonElement>;
|
|
40
36
|
onEntrySelect: (path: LogicalPath) => void;
|
|
41
37
|
/** Re-reads the branch and its entry list, keeping drafts. */
|
|
42
38
|
onBranchReloadData: () => void;
|
|
@@ -98,4 +94,4 @@ export interface EditorHeaderProps {
|
|
|
98
94
|
* The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
|
|
99
95
|
* pill, Submit or Withdraw, and Save with Save all.
|
|
100
96
|
*/
|
|
101
|
-
export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount,
|
|
97
|
+
export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving, savedSignal, savedHere, entryListFailed, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, branchReadOnly, branchWriteBlocked, branchLoading, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|