canopycms 0.1.0 → 0.2.0-int.119
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 +144 -41
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +98 -37
- 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/finalize.js +14 -1
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +19 -4
- package/dist/assets/store-s3.js +56 -13
- package/dist/assets/types.d.ts +26 -2
- package/dist/cli/cli.js +112 -34
- 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 +58 -36
- package/dist/editor/EntryNavigator.d.ts +8 -1
- package/dist/editor/EntryNavigator.js +31 -16
- 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 +11 -2
- package/dist/editor/PreviewFrame.js +20 -4
- package/dist/editor/PreviewPane.d.ts +15 -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 +2 -0
- package/dist/editor/components/BranchLockBanner.js +6 -2
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +29 -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/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/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/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 +20 -7
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +14 -7
- 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/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 +16 -0
- package/dist/editor/preview-bridge.js +12 -3
- package/dist/editor/preview-path.d.ts +11 -4
- package/dist/editor/preview-path.js +62 -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.js +2 -0
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Alert, Button, Center, Group, Progress, ScrollArea, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput, Tooltip, VisuallyHidden, } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Alert, Button, Center, Group, Progress, ScrollArea, SegmentedControl, Select, SimpleGrid, Stack, Switch, Text, TextInput, Tooltip, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { Dropzone } from '@mantine/dropzone';
|
|
6
6
|
import { openConfirm } from '../utils/confirm-modal.js';
|
|
7
7
|
import { IconAlertCircle, IconGridDots, IconLayoutGrid, IconList, IconSearch, IconUpload, IconX, } from '@tabler/icons-react';
|
|
@@ -42,8 +42,14 @@ const DENSITY_OPTIONS = [
|
|
|
42
42
|
},
|
|
43
43
|
{ value: 'list', label: _jsx(DensityLabel, { icon: _jsx(IconList, { size: 16 }), text: "List" }) },
|
|
44
44
|
];
|
|
45
|
+
/** A restored file's record: the same asset, no longer marked `hidden`. */
|
|
46
|
+
function withoutHidden(asset) {
|
|
47
|
+
const restored = { ...asset };
|
|
48
|
+
delete restored.hidden;
|
|
49
|
+
return restored;
|
|
50
|
+
}
|
|
45
51
|
/**
|
|
46
|
-
* Shared grid/list/upload/search/
|
|
52
|
+
* Shared grid/list/upload/search/remove core for both MediaLibrary
|
|
47
53
|
* presentations (Drawer "manage" mode, Modal "picker" mode) - see
|
|
48
54
|
* MediaLibrary.tsx for the two wrappers.
|
|
49
55
|
*/
|
|
@@ -51,40 +57,49 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
|
|
|
51
57
|
const client = useApiClient();
|
|
52
58
|
const { baseUrl } = useAssetContext();
|
|
53
59
|
const { userContext } = useUserContext();
|
|
54
|
-
|
|
55
|
-
//
|
|
60
|
+
const admin = isAdmin(userContext?.groups);
|
|
61
|
+
// Mirrors the server's rule in api/assets.ts's visibilityRefusal: an admin may
|
|
62
|
+
// remove or restore any file, anyone else only what they uploaded. Kept per-asset
|
|
56
63
|
// (not a single boolean) because ownership varies row by row. The server is
|
|
57
64
|
// still the authority - this only decides whether to render the control.
|
|
58
|
-
const
|
|
65
|
+
const canChangeVisibility = (asset) => {
|
|
59
66
|
if (mode !== 'manage')
|
|
60
67
|
return false;
|
|
61
|
-
if (
|
|
68
|
+
if (admin)
|
|
62
69
|
return true;
|
|
63
70
|
return !!asset.uploadedBy && asset.uploadedBy === userContext?.userId;
|
|
64
71
|
};
|
|
72
|
+
// Only an admin in the manage drawer can list removed files, so a picker never offers one.
|
|
73
|
+
const [showRemovedChoice, setShowRemoved] = useState(false);
|
|
74
|
+
const showRemoved = showRemovedChoice && admin && mode === 'manage';
|
|
65
75
|
const upload = useAssetUpload();
|
|
66
76
|
// `picker` mode is used exclusively by ImageField and the MDX image dialog
|
|
67
77
|
// (see MediaLibraryProps.mode's doc comment) - both image-only contexts.
|
|
68
78
|
// `manage` mode (the Editor's Media Library drawer) keeps accepting PDFs.
|
|
69
79
|
const imageOnly = mode === 'picker';
|
|
70
|
-
const library = useAssetLibrary(opened);
|
|
80
|
+
const library = useAssetLibrary(opened, showRemoved);
|
|
71
81
|
const { sort, density, setSort, setDensity } = useMediaLibraryPrefs();
|
|
72
82
|
const [kind, setKind] = useState('all');
|
|
73
83
|
const [filterText, setFilterText] = useState('');
|
|
74
84
|
const [actionError, setActionError] = useState(null);
|
|
75
|
-
// The result of the last remove, for the live region; the
|
|
76
|
-
// `id` re-keys the region's text, so a second
|
|
85
|
+
// The result of the last remove or restore, for the live region; the card may be gone.
|
|
86
|
+
// `id` re-keys the region's text, so a second action with the same words is announced too.
|
|
77
87
|
const [actionNotice, setActionNotice] = useState(null);
|
|
78
88
|
const searchRef = useRef(null);
|
|
79
|
-
// A
|
|
80
|
-
//
|
|
81
|
-
|
|
89
|
+
// A remove or restore takes the focused button with it (the card goes, or its button swaps), so
|
|
90
|
+
// focus moves to the search field once that has rendered (Mantine returns focus to the remove
|
|
91
|
+
// button when the confirm closes).
|
|
92
|
+
const focusAfterAction = useRef(false);
|
|
82
93
|
useEffect(() => {
|
|
83
|
-
if (!
|
|
94
|
+
if (!focusAfterAction.current)
|
|
84
95
|
return;
|
|
85
|
-
|
|
96
|
+
focusAfterAction.current = false;
|
|
86
97
|
searchRef.current?.focus();
|
|
87
98
|
});
|
|
99
|
+
const announce = (text) => {
|
|
100
|
+
setActionNotice((prev) => ({ id: (prev?.id ?? 0) + 1, text }));
|
|
101
|
+
focusAfterAction.current = true;
|
|
102
|
+
};
|
|
88
103
|
// Clearing hides the control that did it, so focus moves to the search field.
|
|
89
104
|
const clearSearch = () => {
|
|
90
105
|
setFilterText('');
|
|
@@ -142,39 +157,52 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
|
|
|
142
157
|
const handleReject = (rejections) => {
|
|
143
158
|
setActionError(rejections[0]?.errors[0]?.message ?? 'The file was rejected.');
|
|
144
159
|
};
|
|
145
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Runs a remove or restore; `failure` words its error, and the raw server message is for admins.
|
|
162
|
+
* A non-admin's 403 from the API is the ownership refusal, so it says so (`refusal`) rather than
|
|
163
|
+
* to retry. Restore shows only to admins, so it has none.
|
|
164
|
+
*/
|
|
165
|
+
const changeVisibility = async (request, failure, refusal, onDone) => {
|
|
146
166
|
setActionError(null);
|
|
147
|
-
const
|
|
148
|
-
// The raw server message is a diagnostic, shown to admins only.
|
|
149
|
-
const generic = (serverMessage) => failure((isAdmin(userContext?.groups) && serverMessage) || 'Try again.');
|
|
167
|
+
const fail = (serverMessage) => setActionError(`${failure} ${(admin && serverMessage) || 'Try again.'}`);
|
|
150
168
|
try {
|
|
151
|
-
const result = await
|
|
169
|
+
const result = await request();
|
|
152
170
|
if (!result.ok) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
if (refusal && result.status === 403 && !admin && !isNonApiResponse(result)) {
|
|
172
|
+
setActionError(`${failure} ${refusal}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
fail(result.error);
|
|
176
|
+
}
|
|
157
177
|
return;
|
|
158
178
|
}
|
|
159
|
-
|
|
160
|
-
setActionNotice((prev) => ({
|
|
161
|
-
id: (prev?.id ?? 0) + 1,
|
|
162
|
-
text: `Removed ${asset.filename} from library`,
|
|
163
|
-
}));
|
|
164
|
-
focusAfterRemove.current = true;
|
|
179
|
+
onDone();
|
|
165
180
|
}
|
|
166
181
|
catch (err) {
|
|
167
|
-
|
|
182
|
+
fail(getErrorMessage(err));
|
|
168
183
|
}
|
|
169
184
|
};
|
|
170
|
-
const
|
|
185
|
+
const handleRemove = (asset) => changeVisibility(() => client.assets.hide({ key: asset.hash32 }), `Couldn't remove ${asset.filename} from the library.`, 'You can only remove files you uploaded. Ask an admin to remove it.', () => {
|
|
186
|
+
if (showRemoved)
|
|
187
|
+
library.replace({ ...asset, hidden: true });
|
|
188
|
+
else
|
|
189
|
+
library.remove(asset.hash32);
|
|
190
|
+
announce(`Removed ${asset.filename} from library`);
|
|
191
|
+
});
|
|
192
|
+
const handleRestore = (asset) => changeVisibility(() => client.assets.restore({ key: asset.hash32 }), `Couldn't restore ${asset.filename} to the library.`, null, () => {
|
|
193
|
+
library.replace(withoutHidden(asset));
|
|
194
|
+
announce(`Restored ${asset.filename} to library`);
|
|
195
|
+
});
|
|
196
|
+
// A confirm, not an Undo: only an admin can restore, from the "Show removed files" view.
|
|
197
|
+
const handleRemoveClick = (asset) => {
|
|
171
198
|
openConfirm({
|
|
172
199
|
title: 'Remove from library',
|
|
173
|
-
children: (_jsxs(Text, { size: "sm", children: ["
|
|
200
|
+
children: (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", children: ["\u201C", asset.filename, "\u201D will no longer be listed in the media library or offered when choosing an image. Entries that already use it keep working, including published and earlier versions of the site."] }), _jsx(Text, { size: "sm", children: admin
|
|
201
|
+
? 'To restore it, turn on “Show removed files”, or upload it again.'
|
|
202
|
+
: 'To restore it, upload it again or ask an admin.' })] })),
|
|
174
203
|
labels: { confirm: 'Remove from library', cancel: 'Cancel' },
|
|
175
|
-
confirmProps: { color: 'red' },
|
|
176
204
|
onConfirm: () => {
|
|
177
|
-
void
|
|
205
|
+
void handleRemove(asset);
|
|
178
206
|
},
|
|
179
207
|
});
|
|
180
208
|
};
|
|
@@ -228,9 +256,9 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
|
|
|
228
256
|
})();
|
|
229
257
|
const renderList = () => {
|
|
230
258
|
if (density === 'list') {
|
|
231
|
-
return (_jsx(Stack, { gap: 4, children: visibleAssets.map((asset) => (_jsx(AssetListRow, { asset: asset, baseUrl: baseUrl, onSelect: mode === 'picker' ? onSelect : undefined,
|
|
259
|
+
return (_jsx(Stack, { gap: 4, children: visibleAssets.map((asset) => (_jsx(AssetListRow, { asset: asset, baseUrl: baseUrl, onSelect: mode === 'picker' ? onSelect : undefined, onRemove: canChangeVisibility(asset) ? handleRemoveClick : undefined, onRestore: canChangeVisibility(asset) ? handleRestore : undefined }, asset.hash32))) }));
|
|
232
260
|
}
|
|
233
|
-
return (_jsx(SimpleGrid, { cols: GRID_COLUMNS[density], spacing: "sm", children: visibleAssets.map((asset) => (_jsx(AssetCard, { asset: asset, baseUrl: baseUrl, density: density, onSelect: mode === 'picker' ? onSelect : undefined,
|
|
261
|
+
return (_jsx(SimpleGrid, { cols: GRID_COLUMNS[density], spacing: "sm", children: visibleAssets.map((asset) => (_jsx(AssetCard, { asset: asset, baseUrl: baseUrl, density: density, onSelect: mode === 'picker' ? onSelect : undefined, onRemove: canChangeVisibility(asset) ? handleRemoveClick : undefined, onRestore: canChangeVisibility(asset) ? handleRestore : undefined }, asset.hash32))) }));
|
|
234
262
|
};
|
|
235
263
|
const listContent = (_jsxs(_Fragment, { children: [!library.firstPageLoaded && !library.error && visibleAssets.length === 0 && (_jsx(MediaLibrarySkeleton, { density: density })), visibleAssets.length > 0 && renderList(), emptyState && (_jsxs(Stack, { align: "center", gap: "xs", py: "xl", "data-testid": "media-library-empty", children: [_jsx(Text, { size: "sm", c: "dimmed", ta: "center", children: emptyState }), _jsxs(Group, { gap: "xs", justify: "center", children: [needle && (_jsx(Button, { variant: "light", size: "xs", onClick: clearSearch, children: "Clear search" })), kind !== 'all' && !libraryEmpty && (_jsx(Button, { variant: "light", size: "xs", onClick: () => setKind('all'), children: "Show all files" }))] })] })), hasMore && (_jsxs(_Fragment, { children: [_jsx("div", { ref: setSentinel, "aria-hidden": "true", style: { height: 1 } }), _jsx(Group, { justify: "center", py: "sm", children: _jsx(Button, { variant: "light", onClick: showMore, "data-testid": "media-library-show-more", children: "Show more" }) })] }))] }));
|
|
236
264
|
return (
|
|
@@ -242,7 +270,7 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
|
|
|
242
270
|
(upload.progress !== null ? (_jsx(Progress, { value: upload.progress * 100, size: "sm", "data-testid": "upload-progress" })) : (_jsx(Progress, { value: 100, size: "sm", animated: true, "data-testid": "upload-progress-indeterminate" }))), upload.error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", "data-testid": "upload-error", children: upload.error })), _jsx(TextInput, { placeholder: "Search by filename", "aria-label": "Search by filename", leftSection: _jsx(IconSearch, { size: 16 }), rightSection: filterText ? (_jsx(Tooltip, { label: "Clear search", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "sm", "aria-label": "Clear search", onClick: clearSearch, children: _jsx(IconX, { size: 14 }) }) })) : null, ref: searchRef, value: filterText, onChange: (event) => setFilterText(event.currentTarget.value), "data-mantine-stop-propagation": filterText ? 'true' : undefined, onKeyDown: (event) => {
|
|
243
271
|
if (event.key === 'Escape' && filterText)
|
|
244
272
|
setFilterText('');
|
|
245
|
-
}, "data-testid": "media-library-filter" }), _jsxs(Group, { justify: "space-between", wrap: "wrap", gap: "xs", children: [imageOnly ? (_jsx("span", {})) : (_jsx(SegmentedControl, { "aria-label": "File type", data: KIND_OPTIONS, value: kind, onChange: (value) => setKind(KIND_OPTIONS.find((o) => o.value === value)?.value ?? 'all'), "data-testid": "media-library-kind" })), _jsxs(Group, { gap: "xs", wrap: "wrap", children: [_jsx(Select, { "aria-label": "Sort by", data: SORT_OPTIONS, value: sort, onChange: (value) => {
|
|
273
|
+
}, "data-testid": "media-library-filter" }), _jsxs(Group, { justify: "space-between", wrap: "wrap", gap: "xs", children: [imageOnly ? (_jsx("span", {})) : (_jsxs(Group, { gap: "md", wrap: "wrap", children: [_jsx(SegmentedControl, { "aria-label": "File type", data: KIND_OPTIONS, value: kind, onChange: (value) => setKind(KIND_OPTIONS.find((o) => o.value === value)?.value ?? 'all'), "data-testid": "media-library-kind" }), admin && mode === 'manage' && (_jsx(Switch, { label: "Show removed files", checked: showRemoved, onChange: (event) => setShowRemoved(event.currentTarget.checked), "data-testid": "media-library-show-removed" }))] })), _jsxs(Group, { gap: "xs", wrap: "wrap", children: [_jsx(Select, { "aria-label": "Sort by", data: SORT_OPTIONS, value: sort, onChange: (value) => {
|
|
246
274
|
const next = SORT_OPTIONS.find((option) => option.value === value);
|
|
247
275
|
if (next)
|
|
248
276
|
setSort(next.value);
|
|
@@ -15,16 +15,19 @@ export interface UseAssetLibraryResult {
|
|
|
15
15
|
retry: () => void;
|
|
16
16
|
/** Puts an uploaded asset in the list; the one already loaded under its hash is replaced. */
|
|
17
17
|
prepend: (asset: AssetRecord) => void;
|
|
18
|
-
/** Drops a
|
|
18
|
+
/** Drops a removed asset, including from a page still on its way. */
|
|
19
19
|
remove: (hash32: string) => void;
|
|
20
|
+
/** Replaces the loaded asset with the same hash in place, such as one just removed or restored. */
|
|
21
|
+
replace: (asset: AssetRecord) => void;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Loads the whole library in the background while `opened`: page 1 first, then each following
|
|
23
25
|
* page in turn, so search and sort see every asset. Closing (or unmounting) stops the walk, and
|
|
24
|
-
* reopening starts again from page 1.
|
|
26
|
+
* reopening, or changing `includeHidden`, starts again from page 1. `includeHidden` (admins only)
|
|
27
|
+
* also lists the files removed from the library, each marked `hidden`.
|
|
25
28
|
*
|
|
26
29
|
* Finalize dedups content, so a re-uploaded file comes back as an existing record that may also
|
|
27
30
|
* arrive in a later page; assets are merged by `hash32`.
|
|
28
31
|
*/
|
|
29
|
-
export declare function useAssetLibrary(opened: boolean): UseAssetLibraryResult;
|
|
32
|
+
export declare function useAssetLibrary(opened: boolean, includeHidden?: boolean): UseAssetLibraryResult;
|
|
30
33
|
export {};
|
|
@@ -18,12 +18,13 @@ function mergeAssets(existing, incoming, removed) {
|
|
|
18
18
|
/**
|
|
19
19
|
* Loads the whole library in the background while `opened`: page 1 first, then each following
|
|
20
20
|
* page in turn, so search and sort see every asset. Closing (or unmounting) stops the walk, and
|
|
21
|
-
* reopening starts again from page 1.
|
|
21
|
+
* reopening, or changing `includeHidden`, starts again from page 1. `includeHidden` (admins only)
|
|
22
|
+
* also lists the files removed from the library, each marked `hidden`.
|
|
22
23
|
*
|
|
23
24
|
* Finalize dedups content, so a re-uploaded file comes back as an existing record that may also
|
|
24
25
|
* arrive in a later page; assets are merged by `hash32`.
|
|
25
26
|
*/
|
|
26
|
-
export function useAssetLibrary(opened) {
|
|
27
|
+
export function useAssetLibrary(opened, includeHidden = false) {
|
|
27
28
|
const client = useApiClient();
|
|
28
29
|
const [assets, setAssets] = useState([]);
|
|
29
30
|
const [firstPageLoaded, setFirstPageLoaded] = useState(false);
|
|
@@ -44,9 +45,11 @@ export function useAssetLibrary(opened) {
|
|
|
44
45
|
let page;
|
|
45
46
|
let message = '';
|
|
46
47
|
try {
|
|
47
|
-
const result = await client.assets.list(
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const result = await client.assets.list({
|
|
49
|
+
limit: String(PAGE_SIZE),
|
|
50
|
+
...(cursor !== undefined && { cursor }),
|
|
51
|
+
...(includeHidden && { includeHidden: 'true' }),
|
|
52
|
+
});
|
|
50
53
|
if (result.ok && result.data)
|
|
51
54
|
page = result.data;
|
|
52
55
|
else
|
|
@@ -74,7 +77,7 @@ export function useAssetLibrary(opened) {
|
|
|
74
77
|
return;
|
|
75
78
|
}
|
|
76
79
|
// A cursor seen before means the listing moved under the walk (the local store restarts
|
|
77
|
-
// from page 1 when its anchor
|
|
80
|
+
// from page 1 when its anchor's meta file is gone), so pages may be missing: never report
|
|
78
81
|
// that as complete. Retry starts over.
|
|
79
82
|
if (requested.current.has(next)) {
|
|
80
83
|
failedCursor.current = undefined;
|
|
@@ -83,7 +86,7 @@ export function useAssetLibrary(opened) {
|
|
|
83
86
|
}
|
|
84
87
|
cursor = next;
|
|
85
88
|
}
|
|
86
|
-
}, [client]);
|
|
89
|
+
}, [client, includeHidden]);
|
|
87
90
|
const start = useCallback(() => {
|
|
88
91
|
generation.current += 1;
|
|
89
92
|
requested.current = new Set();
|
|
@@ -102,8 +105,9 @@ export function useAssetLibrary(opened) {
|
|
|
102
105
|
return () => {
|
|
103
106
|
generation.current += 1;
|
|
104
107
|
};
|
|
105
|
-
// Restart only when the drawer/modal (re)opens, not when `start`'s
|
|
106
|
-
|
|
108
|
+
// Restart only when the drawer/modal (re)opens or the listing changes, not when `start`'s
|
|
109
|
+
// identity changes.
|
|
110
|
+
}, [opened, includeHidden]);
|
|
107
111
|
const retry = useCallback(() => {
|
|
108
112
|
const cursor = failedCursor.current;
|
|
109
113
|
if (cursor === undefined) {
|
|
@@ -122,5 +126,8 @@ export function useAssetLibrary(opened) {
|
|
|
122
126
|
removed.current.add(hash32);
|
|
123
127
|
setAssets((prev) => prev.filter((existing) => existing.hash32 !== hash32));
|
|
124
128
|
}, []);
|
|
125
|
-
|
|
129
|
+
const replace = useCallback((asset) => {
|
|
130
|
+
setAssets((prev) => prev.map((existing) => (existing.hash32 === asset.hash32 ? asset : existing)));
|
|
131
|
+
}, []);
|
|
132
|
+
return { assets, firstPageLoaded, complete, error, retry, prepend, remove, replace };
|
|
126
133
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The height of the bar atop each half of the main region, the preview toolbar and the form
|
|
3
|
+
* header, so their bottom borders line up side by side. Border-box: it includes the bar's border,
|
|
4
|
+
* and the preview pane's own top border above its toolbar.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PANE_HEADER_HEIGHT = 40;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The height of the bar atop each half of the main region, the preview toolbar and the form
|
|
3
|
+
* header, so their bottom borders line up side by side. Border-box: it includes the bar's border,
|
|
4
|
+
* and the preview pane's own top border above its toolbar.
|
|
5
|
+
*/
|
|
6
|
+
export const PANE_HEADER_HEIGHT = 40;
|
|
@@ -111,7 +111,7 @@ const PermissionManagerDrawer = ({ opened, onClose, onExited, returnFocusTo, col
|
|
|
111
111
|
allowedUsers: currentTarget.allowedUsers,
|
|
112
112
|
});
|
|
113
113
|
}, [annotatedTree, localPermissions, updateNodePermission]);
|
|
114
|
-
return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Permissions",
|
|
114
|
+
return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Permissions", size: 700, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save permissions", onSave: handleSave, onDiscard: handleDiscard, onExited: onExited, returnFocusTo: returnFocusTo, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to edit permissions" })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: errorIsConflict ? 'yellow' : 'red', mb: "sm", title: errorIsConflict ? 'Not saved' : 'Error', withCloseButton: true, onClose: () => setError(null), children: error })), groupLoadError && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "orange", mb: "sm", title: "Warning", withCloseButton: true, onClose: clearGroupLoadError, children: groupLoadError })), _jsxs(Group, { gap: "xs", pb: "sm", children: [_jsx(Button, { variant: "subtle", onClick: expandAll, children: "Expand all" }), _jsx(Button, { variant: "subtle", onClick: collapseAll, children: "Collapse all" })] }), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading permissions..." })] })) : (_jsx(PermissionTree, { node: annotatedTree, expandedNodes: expandedNodes, selectedNode: selectedNode, canEdit: canEdit, groups: groupSelectData, activeLevel: activeLevel, userSearchResults: userSearchResults, isSearchingUsers: isSearchingUsers, showUserSearch: showUserSearch, userSearchQuery: userSearchQuery, userSearchError: userSearchError, showGroupSearch: showGroupSearch, groupSearchQuery: groupSearchQuery, filteredGroups: filteredGroups, onToggle: toggleNode, onSelect: selectNode, onSetActiveLevel: setActiveLevel, onUpdatePermission: updateNodePermission, onSearchUsers: setUserSearchQuery, onGetUserMetadata: onGetUserMetadata, onToggleUserSearch: toggleUserSearch, onAddUser: handleAddUser, onRemoveUser: handleRemoveUser, onSearchGroups: setGroupSearchQuery, onToggleGroupSearch: setShowGroupSearch, onAddGroup: handleAddGroup, onRemoveGroup: handleRemoveGroup })) })] }) }));
|
|
115
115
|
};
|
|
116
116
|
export { usePermissionTree } from './hooks/usePermissionTree.js';
|
|
117
117
|
export { useGroupsAndUsers } from './hooks/useGroupsAndUsers.js';
|
|
@@ -49,6 +49,16 @@ export declare const isOpaqueOrigin: (origin: string) => boolean;
|
|
|
49
49
|
* expected editor origin (same-origin unless `editorOrigin` is configured).
|
|
50
50
|
*/
|
|
51
51
|
export declare const isTrustedEditorMessage: (event: MessageEvent, editorOrigin?: string) => boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Preview → editor, once the page listens for drafts: the editor answers with the current draft.
|
|
54
|
+
* `siteChrome` says the page renders the site's header and footer when its URL carries
|
|
55
|
+
* `chrome=1`, so the editor offers the toggle that adds it.
|
|
56
|
+
*/
|
|
57
|
+
export interface PreviewReadyMessage {
|
|
58
|
+
type: typeof CANOPY_PREVIEW_READY;
|
|
59
|
+
path: string;
|
|
60
|
+
siteChrome?: boolean;
|
|
61
|
+
}
|
|
52
62
|
export interface PreviewFocusMessage {
|
|
53
63
|
type: typeof CANOPY_PREVIEW_FOCUS;
|
|
54
64
|
entryPath: string;
|
|
@@ -93,6 +103,11 @@ export declare const useCanopyPreview: <T>(opts: {
|
|
|
93
103
|
initialData: T;
|
|
94
104
|
/** Editor origin to trust for preview messages. Defaults to this page's own origin. */
|
|
95
105
|
editorOrigin?: string;
|
|
106
|
+
/**
|
|
107
|
+
* This page renders the site's header and footer when its URL carries `chrome=1`, so the editor
|
|
108
|
+
* offers a toggle for them; see `PreviewReadyMessage`.
|
|
109
|
+
*/
|
|
110
|
+
siteChrome?: boolean;
|
|
96
111
|
}) => {
|
|
97
112
|
data: T;
|
|
98
113
|
isLoading: PreviewLoadingState<T>;
|
|
@@ -106,6 +121,7 @@ export declare const useCanopyPreview: <T>(opts: {
|
|
|
106
121
|
*/
|
|
107
122
|
export declare const usePreviewData: <T>(path: string, initialData: T, opts?: {
|
|
108
123
|
editorOrigin?: string;
|
|
124
|
+
siteChrome?: boolean;
|
|
109
125
|
}) => {
|
|
110
126
|
data: T;
|
|
111
127
|
isLoading: PreviewLoadingState<T>;
|
|
@@ -73,7 +73,10 @@ export const useCanopyPreview = (opts) => {
|
|
|
73
73
|
const resolvedPath = resolvePreviewPath(opts.path);
|
|
74
74
|
const editorOrigin = opts.editorOrigin;
|
|
75
75
|
const bridgeOpts = { editorOrigin };
|
|
76
|
-
const { data, isLoading } = usePreviewData(resolvedPath, opts.initialData,
|
|
76
|
+
const { data, isLoading } = usePreviewData(resolvedPath, opts.initialData, {
|
|
77
|
+
editorOrigin,
|
|
78
|
+
siteChrome: opts.siteChrome,
|
|
79
|
+
});
|
|
77
80
|
const highlightEnabled = usePreviewHighlight(bridgeOpts);
|
|
78
81
|
usePreviewFocusEmitter(resolvedPath, bridgeOpts);
|
|
79
82
|
const fieldProps = createFieldProps();
|
|
@@ -107,6 +110,7 @@ export const usePreviewData = (path, initialData, opts) => {
|
|
|
107
110
|
// Every key of the loading state is optional, so an empty object is a valid one.
|
|
108
111
|
const [isLoading, setIsLoading] = useState({});
|
|
109
112
|
const editorOrigin = opts?.editorOrigin;
|
|
113
|
+
const siteChrome = opts?.siteChrome === true;
|
|
110
114
|
useEffect(() => {
|
|
111
115
|
// Only listen when actually framed by an editor; a standalone page (including
|
|
112
116
|
// one opened via window.open from a hostile site) must never accept drafts.
|
|
@@ -133,10 +137,15 @@ export const usePreviewData = (path, initialData, opts) => {
|
|
|
133
137
|
// so the first postMessage from the parent may arrive before this listener is set up.
|
|
134
138
|
const target = resolveMessageOrigin(editorOrigin);
|
|
135
139
|
if (!isOpaqueOrigin(target)) {
|
|
136
|
-
|
|
140
|
+
const ready = {
|
|
141
|
+
type: CANOPY_PREVIEW_READY,
|
|
142
|
+
path,
|
|
143
|
+
...(siteChrome ? { siteChrome } : {}),
|
|
144
|
+
};
|
|
145
|
+
window.parent.postMessage(ready, target);
|
|
137
146
|
}
|
|
138
147
|
return () => window.removeEventListener('message', handler);
|
|
139
|
-
}, [path, editorOrigin]);
|
|
148
|
+
}, [path, editorOrigin, siteChrome]);
|
|
140
149
|
return { data, isLoading };
|
|
141
150
|
};
|
|
142
151
|
/** React 18 warns about a layout effect in a server render, where no effect runs. */
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reduces a preview URL to what identifies the page, its path and query, with no origin,
|
|
3
|
-
* fragment
|
|
4
|
-
* by its own location. An absolute `src`,
|
|
5
|
-
* spells the same page two ways, and the
|
|
3
|
+
* fragment, trailing slash or `chrome=1`. The editor names a page by the `src` it built and the
|
|
4
|
+
* framed page by its own location. An absolute `src`, a host redirect that adds or drops a
|
|
5
|
+
* trailing slash, or the site header and footer toggle, spells the same page two ways, and the
|
|
6
|
+
* bridge must still match them.
|
|
6
7
|
* @internal Exported for tests.
|
|
7
8
|
*/
|
|
8
9
|
export declare const normalizePreviewPath: (url: string) => string;
|
|
@@ -13,9 +14,15 @@ export interface PreviewMount {
|
|
|
13
14
|
basePath?: string;
|
|
14
15
|
previewPrefix?: string;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* `src` asking for the site's header and footer, or `undefined` when it cannot. Only a page under
|
|
19
|
+
* the `previewPrefix` mount is a `createPreviewPage` route, which reads `chrome=1`; any other URL
|
|
20
|
+
* is a page of the site itself, which the editor leaves as it is.
|
|
21
|
+
*/
|
|
22
|
+
export declare const siteChromeSrc: (src: string, mount?: PreviewMount) => string | undefined;
|
|
16
23
|
/**
|
|
17
24
|
* The previewed page's path on the site, as the preview toolbar shows and copies it: without the
|
|
18
|
-
* `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
|
|
25
|
+
* `previewPrefix` mount, the `branch` query the top bar already names, `chrome=1`, or a fragment. A relative
|
|
19
26
|
* URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
|
|
20
27
|
* absolute `previewPrefix` mount is what was taken out.
|
|
21
28
|
*/
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes } from '../utils/url-prefix.js';
|
|
2
|
+
/**
|
|
3
|
+
* The query part that asks a `createPreviewPage` route to render the site's header and footer
|
|
4
|
+
* (`canopycms-next`'s `layout` option). It changes how the page looks, not which page it is.
|
|
5
|
+
*/
|
|
6
|
+
const SITE_CHROME_PART = 'chrome=1';
|
|
7
|
+
/**
|
|
8
|
+
* `search` without the parts `keep` refuses, as `?…`, or '' when none is left. Filtered as text:
|
|
9
|
+
* a URLSearchParams round trip would form-encode the rest (`flag` becomes `flag=`, `%20` `+`).
|
|
10
|
+
*/
|
|
11
|
+
const filterQuery = (search, keep) => {
|
|
12
|
+
const query = search
|
|
13
|
+
.slice(1)
|
|
14
|
+
.split('&')
|
|
15
|
+
.filter((part) => part !== '' && keep(part))
|
|
16
|
+
.join('&');
|
|
17
|
+
return query ? `?${query}` : '';
|
|
18
|
+
};
|
|
2
19
|
/**
|
|
3
20
|
* Reduces a preview URL to what identifies the page, its path and query, with no origin,
|
|
4
|
-
* fragment
|
|
5
|
-
* by its own location. An absolute `src`,
|
|
6
|
-
* spells the same page two ways, and the
|
|
21
|
+
* fragment, trailing slash or `chrome=1`. The editor names a page by the `src` it built and the
|
|
22
|
+
* framed page by its own location. An absolute `src`, a host redirect that adds or drops a
|
|
23
|
+
* trailing slash, or the site header and footer toggle, spells the same page two ways, and the
|
|
24
|
+
* bridge must still match them.
|
|
7
25
|
* @internal Exported for tests.
|
|
8
26
|
*/
|
|
9
27
|
export const normalizePreviewPath = (url) => {
|
|
@@ -14,27 +32,58 @@ export const normalizePreviewPath = (url) => {
|
|
|
14
32
|
catch {
|
|
15
33
|
return url;
|
|
16
34
|
}
|
|
17
|
-
|
|
35
|
+
const query = filterQuery(parsed.search, (part) => part !== SITE_CHROME_PART);
|
|
36
|
+
return `${stripTrailingSlashes(parsed.pathname) || '/'}${query}`;
|
|
18
37
|
};
|
|
19
38
|
/** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
|
|
20
39
|
export const isSamePreviewPath = (a, b) => normalizePreviewPath(a) === normalizePreviewPath(b);
|
|
21
|
-
/** `
|
|
22
|
-
const
|
|
40
|
+
/** The `previewPrefix` mount `url` sits under, and what follows it; `undefined` when not under one. */
|
|
41
|
+
const splitPreviewPrefix = (url, { basePath, previewPrefix }) => {
|
|
23
42
|
if (!previewPrefix || !stripTrailingSlashes(previewPrefix))
|
|
24
|
-
return
|
|
43
|
+
return undefined;
|
|
25
44
|
const mount = stripTrailingSlashes(joinUrlPrefix(basePath, joinUrlPrefix(previewPrefix, '/')));
|
|
26
45
|
if (!url.startsWith(mount))
|
|
27
|
-
return
|
|
46
|
+
return undefined;
|
|
28
47
|
const rest = url.slice(mount.length);
|
|
29
|
-
|
|
48
|
+
return rest === '' || /^[/?#]/.test(rest) ? { mount, rest } : undefined;
|
|
49
|
+
};
|
|
50
|
+
/** `url` with the `previewPrefix` mount taken out, so `/base/preview/posts/x` reads `/base/posts/x`. */
|
|
51
|
+
const stripPreviewPrefix = (url, previewMount) => {
|
|
52
|
+
const split = splitPreviewPrefix(url, previewMount);
|
|
53
|
+
if (!split)
|
|
30
54
|
return url;
|
|
55
|
+
const { mount, rest } = split;
|
|
31
56
|
// An absolute prefix skips `basePath`, so the page under it has none either.
|
|
32
|
-
const base = isAbsoluteUrl(mount)
|
|
57
|
+
const base = isAbsoluteUrl(mount)
|
|
58
|
+
? ''
|
|
59
|
+
: stripTrailingSlashes(joinUrlPrefix(previewMount.basePath, '/'));
|
|
33
60
|
return rest.startsWith('/') ? `${base}${rest}` : `${base || '/'}${rest}`;
|
|
34
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* `src` asking for the site's header and footer, or `undefined` when it cannot. Only a page under
|
|
64
|
+
* the `previewPrefix` mount is a `createPreviewPage` route, which reads `chrome=1`; any other URL
|
|
65
|
+
* is a page of the site itself, which the editor leaves as it is.
|
|
66
|
+
*/
|
|
67
|
+
export const siteChromeSrc = (src, mount = {}) => {
|
|
68
|
+
if (!splitPreviewPrefix(src, mount))
|
|
69
|
+
return undefined;
|
|
70
|
+
const hashIndex = src.indexOf('#');
|
|
71
|
+
const beforeHash = hashIndex === -1 ? src : src.slice(0, hashIndex);
|
|
72
|
+
const hash = hashIndex === -1 ? '' : src.slice(hashIndex);
|
|
73
|
+
const queryIndex = beforeHash.indexOf('?');
|
|
74
|
+
if (queryIndex !== -1 &&
|
|
75
|
+
beforeHash
|
|
76
|
+
.slice(queryIndex + 1)
|
|
77
|
+
.split('&')
|
|
78
|
+
.includes(SITE_CHROME_PART)) {
|
|
79
|
+
return src;
|
|
80
|
+
}
|
|
81
|
+
const separator = queryIndex === -1 ? '?' : beforeHash.endsWith('?') ? '' : '&';
|
|
82
|
+
return `${beforeHash}${separator}${SITE_CHROME_PART}${hash}`;
|
|
83
|
+
};
|
|
35
84
|
/**
|
|
36
85
|
* The previewed page's path on the site, as the preview toolbar shows and copies it: without the
|
|
37
|
-
* `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
|
|
86
|
+
* `previewPrefix` mount, the `branch` query the top bar already names, `chrome=1`, or a fragment. A relative
|
|
38
87
|
* URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
|
|
39
88
|
* absolute `previewPrefix` mount is what was taken out.
|
|
40
89
|
*/
|
|
@@ -47,14 +96,8 @@ export const displayPreviewPath = (src, mount = {}) => {
|
|
|
47
96
|
catch {
|
|
48
97
|
return url;
|
|
49
98
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const query = parsed.search
|
|
53
|
-
.slice(1)
|
|
54
|
-
.split('&')
|
|
55
|
-
.filter((part) => part !== '' && part.split('=', 1)[0] !== 'branch')
|
|
56
|
-
.join('&');
|
|
57
|
-
const path = `${parsed.pathname}${query ? `?${query}` : ''}`;
|
|
99
|
+
const query = filterQuery(parsed.search, (part) => part !== SITE_CHROME_PART && part.split('=', 1)[0] !== 'branch');
|
|
100
|
+
const path = `${parsed.pathname}${query}`;
|
|
58
101
|
if (!isAbsoluteUrl(url))
|
|
59
102
|
return path;
|
|
60
103
|
// `origin` is 'null' for a scheme the URL spec does not treat as special.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
2
|
export interface ActivityRailProps {
|
|
3
|
-
/** Rail buttons from the top: the
|
|
3
|
+
/** Rail buttons from the top: the activities. */
|
|
4
4
|
top: ReactNode;
|
|
5
5
|
/** Rail buttons pinned to the bottom, such as Settings. */
|
|
6
6
|
bottom?: ReactNode;
|
|
@@ -22,8 +22,6 @@ export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>
|
|
|
22
22
|
icon: ReactNode;
|
|
23
23
|
/** Highlights the button while its panel or drawer is open. */
|
|
24
24
|
active?: boolean;
|
|
25
|
-
/** Tinted at rest, as the site monogram is. */
|
|
26
|
-
tinted?: boolean;
|
|
27
25
|
/** Shown after the label in the tooltip, as the platform writes it ("⌘P"). */
|
|
28
26
|
shortcut?: string;
|
|
29
27
|
/** A count badge, hidden at zero; the label should say it too. */
|
|
@@ -75,11 +75,11 @@ export function ActivityRail({ top, bottom, account }) {
|
|
|
75
75
|
}, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
|
|
76
76
|
}
|
|
77
77
|
/** 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,
|
|
78
|
+
export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
|
|
79
79
|
const reasonId = useId();
|
|
80
80
|
const button = (_jsxs(ActionIcon, { ref: ref,
|
|
81
|
-
// Filled
|
|
82
|
-
variant: active ? 'filled' :
|
|
81
|
+
// Filled when active: a tint alone would show the state by colour, under 3:1.
|
|
82
|
+
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
83
|
return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsx(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
|
|
84
84
|
indicator: {
|
|
85
85
|
pointerEvents: 'none',
|
|
@@ -8,6 +8,21 @@ export interface EditorShellProps {
|
|
|
8
8
|
/** Drawers, modals and palettes, each portalling itself into `<body>`. */
|
|
9
9
|
overlays?: ReactNode;
|
|
10
10
|
}
|
|
11
|
+
/** The line between two shell regions, as under the top bar and a shown banner. */
|
|
12
|
+
export declare const SHELL_REGION_BORDER = "1px solid var(--canopy-input-border)";
|
|
13
|
+
/**
|
|
14
|
+
* Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
|
|
15
|
+
* Its pointer target is wider, `DIVIDER_TARGET_PX`. A finger's reaches 6px past each side of the
|
|
16
|
+
* bar: under the panes' separator, the narrow window's overlay panel keeps its header's close
|
|
17
|
+
* button clear, as that header's top padding is 10px.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DIVIDER_WIDTH_PX = 4;
|
|
20
|
+
export declare const DIVIDER_TARGET_PX: {
|
|
21
|
+
readonly fine: 12;
|
|
22
|
+
readonly coarse: 16;
|
|
23
|
+
};
|
|
24
|
+
/** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
|
|
25
|
+
export declare const DIVIDER_COLOR = "var(--canopy-input-border)";
|
|
11
26
|
/**
|
|
12
27
|
* The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
|
|
13
28
|
* overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
|