canopycms 0.2.0-int.119 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -62
- package/dist/api/assets.js +20 -10
- 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 +15 -9
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-s3.d.ts +7 -3
- package/dist/assets/store-s3.js +25 -6
- package/dist/assets/types.d.ts +7 -2
- package/dist/cli/cli.js +40 -23
- package/dist/config/types.d.ts +3 -2
- package/dist/dev-content-watcher.js +8 -1
- package/dist/editor/Editor.js +60 -7
- package/dist/editor/EntryNavigator.d.ts +9 -4
- package/dist/editor/EntryNavigator.js +39 -17
- package/dist/editor/PreviewFrame.d.ts +3 -1
- package/dist/editor/PreviewFrame.js +43 -2
- package/dist/editor/PreviewPane.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.d.ts +7 -8
- package/dist/editor/components/BranchLockBanner.js +25 -2
- package/dist/editor/components/BranchRowLock.d.ts +14 -0
- package/dist/editor/components/BranchRowLock.js +21 -0
- package/dist/editor/components/EditorHeader.js +3 -18
- package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- 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/hooks/editor-history.d.ts +7 -0
- package/dist/editor/hooks/editor-history.js +80 -0
- package/dist/editor/hooks/useBranchActions.js +3 -1
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +9 -2
- package/dist/editor/hooks/useEntryManager.js +131 -17
- package/dist/editor/media/AssetCard.js +4 -2
- package/dist/editor/media/AssetListRow.js +4 -2
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- 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/preview-bridge.d.ts +7 -0
- package/dist/editor/preview-bridge.js +93 -1
- package/dist/editor/preview-path.d.ts +5 -0
- package/dist/editor/preview-path.js +18 -0
- package/dist/editor/theme.d.ts +1 -0
- package/dist/editor/theme.js +38 -1
- 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
|
@@ -8,6 +8,7 @@ import { matchesEntryQuery } from './entry-search.js';
|
|
|
8
8
|
import { EDITOR_ACTIONS } from './copy.js';
|
|
9
9
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
10
10
|
import { UnsavedMark } from './components/UnsavedMark.js';
|
|
11
|
+
import { BranchRowLock } from './components/BranchRowLock.js';
|
|
11
12
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
12
13
|
import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
|
|
13
14
|
const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", mt: 4, "data-testid": "unavailable-type-message", children: [_jsx(Box, { c: "orange", style: { display: 'flex', flexShrink: 0, paddingTop: 2 }, children: _jsx(IconAlertTriangle, { size: 14, "aria-hidden": true }) }), _jsx(Text, { size: "xs", c: "orange", children: _jsx(UnavailableTypeMessage, { schemaRefs: schemaRefs }) })] }));
|
|
@@ -123,7 +124,7 @@ const stopTreeKeys = (event) => {
|
|
|
123
124
|
event.stopPropagation();
|
|
124
125
|
};
|
|
125
126
|
const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
|
|
126
|
-
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
127
|
+
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
127
128
|
// Shadow the entry-mutation callbacks with undefined when readOnly, so every
|
|
128
129
|
// downstream reference (hasEntryMenu, canReorder, the menu items themselves)
|
|
129
130
|
// sees them as absent rather than threading a readOnly check through each
|
|
@@ -476,15 +477,16 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
476
477
|
const status = node.nodeProps?.status;
|
|
477
478
|
const entryMissing = !!node.nodeProps?.missing;
|
|
478
479
|
const entryNoPage = !!node.nodeProps?.noPage;
|
|
479
|
-
// Collection-level mutation callbacks are nulled when readOnly, same as
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
480
|
+
// Collection-level mutation callbacks are nulled when readOnly, same as the entry-level ones
|
|
481
|
+
// above, so hasCollectionMenu is false; a row that would have had a menu shows its lock.
|
|
482
|
+
const rawOnAdd = node.nodeProps?.onAdd;
|
|
483
|
+
const rawOnEdit = node.nodeProps?.onEdit;
|
|
484
|
+
const rawOnAddSubCollection = node.nodeProps?.onAddSubCollection;
|
|
485
|
+
const rawOnDelete = node.nodeProps?.onDelete;
|
|
486
|
+
const onAdd = readOnly ? undefined : rawOnAdd;
|
|
487
|
+
const onEdit = readOnly ? undefined : rawOnEdit;
|
|
488
|
+
const onAddSubCollection = readOnly ? undefined : rawOnAddSubCollection;
|
|
489
|
+
const onDelete = readOnly ? undefined : rawOnDelete;
|
|
488
490
|
const entryPath = node.nodeProps?.entryPath;
|
|
489
491
|
const contentId = node.nodeProps?.contentId;
|
|
490
492
|
const parentCollectionPath = node.nodeProps?.parentCollectionPath;
|
|
@@ -494,7 +496,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
494
496
|
const isEntry = node.nodeProps?.isEntry;
|
|
495
497
|
const conflictNotice = node.nodeProps?.conflictNotice;
|
|
496
498
|
const unavailableSchemaRefs = node.nodeProps?.unavailableSchemaRefs;
|
|
497
|
-
// A read-only navigator already says
|
|
499
|
+
// A read-only navigator already says writes are refused, so the add-refused line is not repeated.
|
|
498
500
|
const addRefused = !readOnly && !!node.nodeProps?.addRefused;
|
|
499
501
|
const isLeaf = !hasChildren || isEntry;
|
|
500
502
|
const selected = node.value === selectedPath;
|
|
@@ -504,26 +506,42 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
504
506
|
// Mantine only provides hasChildren=true if children.length > 0, but we want
|
|
505
507
|
// collections to always be expandable
|
|
506
508
|
const showChevron = hasChildren || Boolean(isCollection);
|
|
507
|
-
const
|
|
509
|
+
const reorderPosition = contentId && parentCollectionPath && typeof childIndex === 'number';
|
|
510
|
+
const canReorder = onReorderEntry && reorderPosition;
|
|
508
511
|
const canMoveUp = canReorder && childIndex > 0;
|
|
509
512
|
const canMoveDown = canReorder && typeof totalChildrenCount === 'number' && childIndex < totalChildrenCount - 1;
|
|
510
513
|
// Collections show menu for add/edit/delete actions OR for reordering (subcollections)
|
|
511
514
|
const hasCollectionMenu = isCollection && (onAdd || onEdit || onAddSubCollection || onDelete || canReorder);
|
|
512
515
|
// An unavailable entry cannot be written, so its menu (rename, delete, reorder) is not offered.
|
|
513
516
|
const entryUnavailable = node.nodeProps?.unavailable;
|
|
514
|
-
// The same holds for an entry this user may not edit
|
|
515
|
-
//
|
|
516
|
-
const entryLocked =
|
|
517
|
+
// The same holds for an entry this user may not edit, on any branch: its own lock stays, since a
|
|
518
|
+
// path rule, not the branch, refuses it, and the branch lock leaves that row's slot empty.
|
|
519
|
+
const entryLocked = !!node.nodeProps?.itemReadOnly;
|
|
517
520
|
const hasEntryMenu = isEntry &&
|
|
518
521
|
entryPath &&
|
|
519
522
|
!entryUnavailable &&
|
|
520
523
|
!entryLocked &&
|
|
521
524
|
(onDeleteEntry || onRenameEntry || onReorderEntry);
|
|
525
|
+
// The lock holds the slot where the menu would be, on exactly the rows that would otherwise have
|
|
526
|
+
// one, so rows never shift. The stylesheet reveals it on the hovered, focused or selected
|
|
527
|
+
// row (`data-nav-selected`), which keeps touch and keyboard users' route to the reason.
|
|
528
|
+
const wouldHaveCollectionMenu = isCollection &&
|
|
529
|
+
!!(rawOnAdd ||
|
|
530
|
+
rawOnEdit ||
|
|
531
|
+
rawOnAddSubCollection ||
|
|
532
|
+
rawOnDelete ||
|
|
533
|
+
(onReorderEntryProp && reorderPosition));
|
|
534
|
+
const wouldHaveEntryMenu = isEntry &&
|
|
535
|
+
!!entryPath &&
|
|
536
|
+
!entryUnavailable &&
|
|
537
|
+
!entryLocked &&
|
|
538
|
+
!!(onDeleteEntryProp || onRenameEntryProp || onReorderEntryProp);
|
|
539
|
+
const showsLockSlot = readOnly && (wouldHaveCollectionMenu || wouldHaveEntryMenu);
|
|
522
540
|
const showEntryDirtyDot = isEntry && !!entryPath && !!dirtyPaths?.has(entryPath);
|
|
523
541
|
const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
|
|
524
542
|
const adminItems = onAddSubCollection || onEdit || onDelete;
|
|
525
543
|
const hasCollectionContentItems = onAdd || canReorder;
|
|
526
|
-
return (_jsxs(Box, { ...elementProps, ref: selected ? selectedNodeRef : undefined, "data-nav-row": true, "data-testid": `entry-nav-item-${testIdSuffix}`, onClick: (event) => {
|
|
544
|
+
return (_jsxs(Box, { ...elementProps, ref: selected ? selectedNodeRef : undefined, "data-nav-row": true, "data-nav-selected": selected ? '' : undefined, "data-testid": `entry-nav-item-${testIdSuffix}`, onClick: (event) => {
|
|
527
545
|
elementProps.onClick(event);
|
|
528
546
|
if (isLeaf && isEntry) {
|
|
529
547
|
onSelect(node.value);
|
|
@@ -566,7 +584,11 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
566
584
|
}, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
|
|
567
585
|
event.stopPropagation();
|
|
568
586
|
onDelete();
|
|
569
|
-
}, children: "Delete collection" }))] })] })),
|
|
587
|
+
}, children: "Delete collection" }))] })] })), showsLockSlot &&
|
|
588
|
+
(readOnlyReason ? (_jsx(BranchRowLock, { label: isCollection ? 'Collection actions' : 'Entry actions', reason: readOnlyReason })) : (
|
|
589
|
+
// The branch's state is still loading: hold the slot so rows do not shift. It draws
|
|
590
|
+
// nothing, so it needs no reveal.
|
|
591
|
+
_jsx(Box, { w: 18, h: 18, "aria-hidden": true, "data-testid": "branch-lock-pending" }))), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
|
|
570
592
|
event.stopPropagation();
|
|
571
593
|
onRenameEntry(entryPath);
|
|
572
594
|
}, "data-testid": "rename-entry-menu-item", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
|
|
@@ -15,7 +15,7 @@ export type SiteChromeSupport = 'supported' | 'unsupported' | 'unanswered';
|
|
|
15
15
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
16
16
|
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
17
17
|
*/
|
|
18
|
-
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, busy, }: {
|
|
18
|
+
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy, }: {
|
|
19
19
|
src: string;
|
|
20
20
|
path: string;
|
|
21
21
|
data?: unknown;
|
|
@@ -44,6 +44,8 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
|
|
|
44
44
|
onReload?: () => void;
|
|
45
45
|
/** Called with what the page's handshake says of the site's header and footer. */
|
|
46
46
|
onSiteChrome?: (support: SiteChromeSupport) => void;
|
|
47
|
+
/** A preview link's absolute URL, unfollowed; focus follows into the next page unless moved. */
|
|
48
|
+
onNavigate?: (href: string) => void;
|
|
47
49
|
/** The editor is loading the next page's data: this page stays in view, dimmed and inert. */
|
|
48
50
|
busy?: boolean;
|
|
49
51
|
}) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,7 +1,7 @@
|
|
|
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 { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
|
|
4
|
+
import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_NAVIGATE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
|
|
5
5
|
/**
|
|
6
6
|
* How long after the iframe's `load` the preview has to send its ready message before the frame
|
|
7
7
|
* says live updates are off. Ready can also arrive before `load`, which waits for images too.
|
|
@@ -22,6 +22,21 @@ const visuallyHidden = {
|
|
|
22
22
|
const isMarkPaths = (paths) => Array.isArray(paths) &&
|
|
23
23
|
paths.length <= MARK_REPORT_LIMITS.paths &&
|
|
24
24
|
paths.every((path) => typeof path === 'string' && path.length <= MARK_REPORT_LIMITS.pathLength);
|
|
25
|
+
/** Only http(s) on the preview's origin, so the editor never opens a `javascript:` URL or another site. */
|
|
26
|
+
const isPreviewNavigateMessage = (data, previewOrigin) => {
|
|
27
|
+
if (typeof data !== 'object' || data === null)
|
|
28
|
+
return false;
|
|
29
|
+
const { type, href } = data;
|
|
30
|
+
if (type !== CANOPY_PREVIEW_NAVIGATE || typeof href !== 'string')
|
|
31
|
+
return false;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(href);
|
|
34
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin === previewOrigin;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
25
40
|
const sendDraftUpdate = (iframe, message, targetOrigin) => {
|
|
26
41
|
if (!iframe?.contentWindow)
|
|
27
42
|
return;
|
|
@@ -36,7 +51,7 @@ const sendDraftUpdate = (iframe, message, targetOrigin) => {
|
|
|
36
51
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
37
52
|
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
38
53
|
*/
|
|
39
|
-
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, busy = false, }) => {
|
|
54
|
+
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy = false, }) => {
|
|
40
55
|
const iframeRef = useRef(null);
|
|
41
56
|
// Pin the preview origin from the src prop: outbound messages target it (never '*'),
|
|
42
57
|
// and inbound messages must come from it. An iframe that navigates cross-origin
|
|
@@ -51,10 +66,15 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
51
66
|
const [retries, setRetries] = useState(0);
|
|
52
67
|
// A new src is a new page, with a handshake of its own.
|
|
53
68
|
const [prevSrc, setPrevSrc] = useState(src);
|
|
69
|
+
// The frame a followed link was clicked in is replaced, which drops focus to the editor's body.
|
|
70
|
+
const [followingLink, setFollowingLink] = useState(false);
|
|
71
|
+
const [focusOnLoad, setFocusOnLoad] = useState(false);
|
|
54
72
|
if (src !== prevSrc) {
|
|
55
73
|
setPrevSrc(src);
|
|
56
74
|
setHandshake('waiting');
|
|
57
75
|
setLoaded(false);
|
|
76
|
+
setFocusOnLoad(followingLink);
|
|
77
|
+
setFollowingLink(false);
|
|
58
78
|
}
|
|
59
79
|
useEffect(() => {
|
|
60
80
|
if (!loaded || handshake !== 'waiting')
|
|
@@ -114,17 +134,30 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
114
134
|
const onPreviewErrorRef = useRef(onPreviewError);
|
|
115
135
|
const onMarksRef = useRef(onMarks);
|
|
116
136
|
const onSiteChromeRef = useRef(onSiteChrome);
|
|
137
|
+
const onNavigateRef = useRef(onNavigate);
|
|
117
138
|
useEffect(() => {
|
|
118
139
|
postRef.current = post;
|
|
119
140
|
postHighlightRef.current = postHighlight;
|
|
120
141
|
onPreviewErrorRef.current = onPreviewError;
|
|
121
142
|
onMarksRef.current = onMarks;
|
|
122
143
|
onSiteChromeRef.current = onSiteChrome;
|
|
144
|
+
onNavigateRef.current = onNavigate;
|
|
123
145
|
});
|
|
124
146
|
useEffect(() => {
|
|
125
147
|
if (handshake === 'missing')
|
|
126
148
|
onSiteChromeRef.current?.('unanswered');
|
|
127
149
|
}, [handshake]);
|
|
150
|
+
// Focus the user puts anywhere before the next page loads is theirs to keep.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!followingLink && !focusOnLoad)
|
|
153
|
+
return;
|
|
154
|
+
const keep = () => {
|
|
155
|
+
setFollowingLink(false);
|
|
156
|
+
setFocusOnLoad(false);
|
|
157
|
+
};
|
|
158
|
+
document.addEventListener('focusin', keep);
|
|
159
|
+
return () => document.removeEventListener('focusin', keep);
|
|
160
|
+
}, [followingLink, focusOnLoad]);
|
|
128
161
|
useEffect(() => {
|
|
129
162
|
post();
|
|
130
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- post is tracked via ref
|
|
@@ -169,6 +202,10 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
169
202
|
onMarksRef.current?.({ count, ...(isMarkPaths(paths) ? { paths } : {}) });
|
|
170
203
|
}
|
|
171
204
|
}
|
|
205
|
+
else if (isPreviewNavigateMessage(event.data, previewOrigin)) {
|
|
206
|
+
onNavigateRef.current?.(event.data.href);
|
|
207
|
+
setFollowingLink(true);
|
|
208
|
+
}
|
|
172
209
|
};
|
|
173
210
|
window.addEventListener('message', handleMessage);
|
|
174
211
|
return () => window.removeEventListener('message', handleMessage);
|
|
@@ -227,5 +264,9 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
227
264
|
post();
|
|
228
265
|
postHighlight();
|
|
229
266
|
setLoaded(true);
|
|
267
|
+
if (focusOnLoad) {
|
|
268
|
+
iframeRef.current?.focus();
|
|
269
|
+
setFocusOnLoad(false);
|
|
270
|
+
}
|
|
230
271
|
} }, `${retries}:${src}`)] }));
|
|
231
272
|
};
|
|
@@ -52,6 +52,8 @@ export interface PreviewPaneProps {
|
|
|
52
52
|
* them, and whenever `siteChrome` is on, so it can always be turned off.
|
|
53
53
|
*/
|
|
54
54
|
onSiteChromeChange?: (siteChrome: boolean) => void;
|
|
55
|
+
/** Called with the absolute URL of a link clicked in the preview; see `PreviewFrame`. */
|
|
56
|
+
onNavigate?: (href: string) => void;
|
|
55
57
|
}
|
|
56
58
|
/**
|
|
57
59
|
* The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
|
|
@@ -6,16 +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 branchLockState({ branchStatus, branchReadOnly, branchWriteBlocked, }: BranchLockInput): {
|
|
14
|
-
statusLocked: boolean;
|
|
15
|
-
branchDataUnavailable: boolean;
|
|
16
|
-
};
|
|
17
|
-
/** Whether the banner renders; the top bar reads the same answer, so the two cannot drift. */
|
|
18
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;
|
|
19
18
|
export interface BranchLockBannerProps extends BranchLockInput {
|
|
20
19
|
branchName: string;
|
|
21
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,33 @@ export function branchLockState({ branchStatus, branchReadOnly = false, branchWr
|
|
|
15
15
|
const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
|
|
16
16
|
return { statusLocked, branchDataUnavailable };
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
|
|
20
|
+
* @internal Exported for tests.
|
|
21
|
+
*/
|
|
19
22
|
export function branchLockBannerShown(lock) {
|
|
20
23
|
return !!lock.branchReadOnly || branchLockState(lock).statusLocked;
|
|
21
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
|
+
}
|
|
22
45
|
/** The banner region's explanation of a read-only branch; not dismissible while it applies. */
|
|
23
46
|
export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
|
|
24
47
|
const { branchDataUnavailable } = branchLockState(lock);
|
|
@@ -0,0 +1,14 @@
|
|
|
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. `data-row-lock` marks it for the theme stylesheet, which
|
|
8
|
+
* draws it only on the hovered, focused or selected row (opacity, never `display`, so
|
|
9
|
+
* focusing it reveals it); a touch user sees it on the row they tapped.
|
|
10
|
+
*/
|
|
11
|
+
export declare const BranchRowLock: React.FC<{
|
|
12
|
+
label: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
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. `data-row-lock` marks it for the theme stylesheet, which
|
|
12
|
+
* draws it only on the hovered, focused or selected row (opacity, never `display`, so
|
|
13
|
+
* focusing it reveals it); a touch user sees it on the row they tapped.
|
|
14
|
+
*/
|
|
15
|
+
export const BranchRowLock = ({ label, reason }) => {
|
|
16
|
+
const reasonId = useId();
|
|
17
|
+
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", "data-row-lock": true, style: { cursor: 'default' }, onClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
|
|
18
|
+
if (event.nativeEvent.code === 'Space')
|
|
19
|
+
event.stopPropagation();
|
|
20
|
+
}, children: [_jsx(IconLock, { size: 14, "aria-hidden": true }), _jsx(VisuallyHidden, { component: "span", id: reasonId, children: reason })] }) }));
|
|
21
|
+
};
|
|
@@ -6,7 +6,7 @@ import { branchBarStatusPresentation } from '../branch-status.js';
|
|
|
6
6
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
7
7
|
import { SYSTEM_USER_ID } from '../../system-users.js';
|
|
8
8
|
import { TopBar } from '../shell/TopBar.js';
|
|
9
|
-
import {
|
|
9
|
+
import { describeBranchLock } from './BranchLockBanner.js';
|
|
10
10
|
import { BranchPill } from './BranchPill.js';
|
|
11
11
|
import { EntryBreadcrumb } from './EntryBreadcrumb.js';
|
|
12
12
|
import { SaveSplitButton } from './SaveSplitButton.js';
|
|
@@ -15,23 +15,8 @@ import { SaveSplitButton } from './SaveSplitButton.js';
|
|
|
15
15
|
* pill, Submit or Withdraw, and Save with Save all.
|
|
16
16
|
*/
|
|
17
17
|
export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving = false, savedSignal, savedHere = false, entryListFailed = false, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, }) {
|
|
18
|
-
|
|
19
|
-
const {
|
|
20
|
-
// Why the branch takes no writes, with the way forward; shared by Save and Save all. It is set
|
|
21
|
-
// exactly when BranchLockBanner renders, which says the same.
|
|
22
|
-
const branchLockReason = !branchLockBannerShown(lock)
|
|
23
|
-
? undefined
|
|
24
|
-
: branchReadOnly
|
|
25
|
-
? 'The base branch is read-only. Create a branch to make changes.'
|
|
26
|
-
: branchDataUnavailable
|
|
27
|
-
? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
|
|
28
|
-
: statusLocked
|
|
29
|
-
? branchStatus === 'submitted'
|
|
30
|
-
? 'This branch is in review. Withdraw it to make changes.'
|
|
31
|
-
: branchStatus === 'approved'
|
|
32
|
-
? 'This branch is approved. Withdraw it to make changes.'
|
|
33
|
-
: `This branch is ${branchStatus}. Its content is read-only.`
|
|
34
|
-
: undefined;
|
|
18
|
+
// Shared by Save and Save all; set exactly when BranchLockBanner renders.
|
|
19
|
+
const branchLockReason = describeBranchLock({ branchStatus, branchReadOnly, branchWriteBlocked });
|
|
35
20
|
const entryLockReason = entryReadOnly
|
|
36
21
|
? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
|
|
37
22
|
: undefined;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Says a preview link's page has no entry, so it went to a new tab: once the editor is in view, as
|
|
3
|
+
* the toast's timer runs while hidden. A blocked tab's note stays, linking the page for a new click.
|
|
4
|
+
*/
|
|
5
|
+
export declare const showPreviewLinkOutcome: (href: string, path: string, opened: boolean) => void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Anchor } from '@mantine/core';
|
|
4
|
+
import { notifications } from '@mantine/notifications';
|
|
5
|
+
/**
|
|
6
|
+
* Says a preview link's page has no entry, so it went to a new tab: once the editor is in view, as
|
|
7
|
+
* the toast's timer runs while hidden. A blocked tab's note stays, linking the page for a new click.
|
|
8
|
+
*/
|
|
9
|
+
export const showPreviewLinkOutcome = (href, path, opened) => {
|
|
10
|
+
if (!opened) {
|
|
11
|
+
notifications.show({
|
|
12
|
+
color: 'yellow',
|
|
13
|
+
autoClose: false,
|
|
14
|
+
message: (_jsxs(_Fragment, { children: [path, " has no entry here, and your browser blocked the new tab.", ' ', _jsxs(Anchor, { href: href, target: "_blank", rel: "noopener noreferrer", children: ["Open ", path] })] })),
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const show = () => notifications.show({
|
|
19
|
+
color: 'blue',
|
|
20
|
+
message: `${path} has no entry here, so it opened in a new tab.`,
|
|
21
|
+
});
|
|
22
|
+
setTimeout(() => {
|
|
23
|
+
if (document.visibilityState === 'visible')
|
|
24
|
+
return show();
|
|
25
|
+
const onVisible = () => {
|
|
26
|
+
if (document.visibilityState !== 'visible')
|
|
27
|
+
return;
|
|
28
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
29
|
+
show();
|
|
30
|
+
};
|
|
31
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
32
|
+
}, 300);
|
|
33
|
+
};
|
|
@@ -18,7 +18,7 @@ export interface FieldLabelProps {
|
|
|
18
18
|
labelId?: string;
|
|
19
19
|
/** Slot right after the label text, for the field's comment control. */
|
|
20
20
|
commentControl?: React.ReactNode;
|
|
21
|
-
/** Right-aligned slot for field actions (Expand all, an object's "…" menu). */
|
|
21
|
+
/** Right-aligned slot for field actions (Expand all, an object's "…" menu); it wraps under the label when the row is too narrow for both. */
|
|
22
22
|
actions?: React.ReactNode;
|
|
23
23
|
}
|
|
24
24
|
export declare const FieldLabel: React.FC<FieldLabelProps>;
|
|
@@ -6,7 +6,7 @@ export const FieldLabel = ({ label, required, description, descriptionBaseId, ht
|
|
|
6
6
|
const hasRow = Boolean(label) || Boolean(commentControl) || Boolean(actions);
|
|
7
7
|
if (!hasRow && !description)
|
|
8
8
|
return null;
|
|
9
|
-
return (_jsxs(Stack, { gap: 2, children: [hasRow && (_jsxs(Group, { justify: "space-between", wrap: "
|
|
9
|
+
return (_jsxs(Stack, { gap: 2, children: [hasRow && (_jsxs(Group, { justify: "space-between", wrap: "wrap", gap: "xs", align: "center", children: [_jsxs(Group, { gap: 4, wrap: "nowrap", style: { minWidth: 0, flex: '1 1 8rem' }, children: [label && (_jsx(Input.Label, { required: required, htmlFor: htmlFor, id: labelId, labelElement: htmlFor ? 'label' : 'div', children: label })), commentControl] }), actions && (_jsx(Group, { gap: "xs", wrap: "nowrap", style: { flexShrink: 0, marginLeft: 'auto' }, children: actions }))] })), descriptionBaseId && (_jsx(FieldDescription, { baseId: descriptionBaseId, description: description }))] }));
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
12
|
* A Mantine input under a `FieldLabel` row, in place of the input's own `label` prop, so the
|
|
@@ -11,7 +11,7 @@ export interface ImageFieldProps {
|
|
|
11
11
|
description?: string;
|
|
12
12
|
value: ImageFieldValue | undefined;
|
|
13
13
|
onChange: (value: ImageFieldValue | undefined) => void;
|
|
14
|
-
/** "W:H" aspect ratio - when set, picking
|
|
14
|
+
/** "W:H" aspect ratio - when set, picking or uploading a new raster opens the crop step before the value commits, and Crop keeps that shape. Without it, a pick commits as is and Crop is free-form. */
|
|
15
15
|
aspect?: string;
|
|
16
16
|
/** Allow empty alt text. Default: required (accessibility). */
|
|
17
17
|
altOptional?: boolean;
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
-
import { Alert, Button, Group, Stack, Text, TextInput } from '@mantine/core';
|
|
4
|
+
import { Alert, Button, Group, Paper, Stack, Text, TextInput, Tooltip, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { Dropzone } from '@mantine/dropzone';
|
|
6
6
|
import { IconAlertCircle, IconPhoto, IconPhotoOff, IconUpload } from '@tabler/icons-react';
|
|
7
|
-
import {
|
|
7
|
+
import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
|
|
8
8
|
import { useAssetContext } from '../context/index.js';
|
|
9
9
|
import { CropStep } from '../media/CropStep.js';
|
|
10
|
+
import { cropBlockedReason } from '../media/crop-availability.js';
|
|
10
11
|
import { editorImageSrc } from '../media/editor-image-src.js';
|
|
11
|
-
import { parseAspectRatio } from '../media/crop-math.js';
|
|
12
|
+
import { isFullFrameCrop, parseAspectRatio } from '../media/crop-math.js';
|
|
12
13
|
import { MediaLibrary } from '../media/MediaLibrary.js';
|
|
13
14
|
import { useAssetUpload } from '../media/useAssetUpload.js';
|
|
14
15
|
import { ACCEPTED_IMAGE_MIME_TYPES, MAX_UPLOAD_BYTES } from '../media/upload-constants.js';
|
|
15
16
|
import { useFieldCommentControl } from '../comments/field-comment-context.js';
|
|
16
|
-
import {
|
|
17
|
+
import { groupDescriptionProps } from './FieldDescription.js';
|
|
17
18
|
import { FieldLabel } from './FieldLabel.js';
|
|
18
|
-
const PREVIEW_WIDTH =
|
|
19
|
+
const PREVIEW_WIDTH = EDITOR_THUMBNAIL_WIDTHS.imageField;
|
|
19
20
|
export const ImageField = ({ id, label, description, value, onChange, aspect, altOptional, dataCanopyField, errors, readOnly = false, onRemoved, }) => {
|
|
20
21
|
const generatedId = useId();
|
|
21
22
|
const inputId = id ?? generatedId;
|
|
@@ -34,8 +35,8 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
|
|
|
34
35
|
const previewSrc = value
|
|
35
36
|
? editorImageSrc(value.src, baseUrl, { width: PREVIEW_WIDTH, crop: value.crop })
|
|
36
37
|
: undefined;
|
|
37
|
-
|
|
38
|
-
const
|
|
38
|
+
const cropBlocked = value ? cropBlockedReason(value.src) : undefined;
|
|
39
|
+
const cropReasonId = `${inputId}-crop-reason`;
|
|
39
40
|
// Broken-preview fallback, mirroring AssetCard.tsx's thumbnail fallback (same
|
|
40
41
|
// icon/copy, same reset-during-render pattern). Covers both an asset broken
|
|
41
42
|
// at upload time and a transform failing at render time.
|
|
@@ -71,7 +72,7 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
|
|
|
71
72
|
};
|
|
72
73
|
const handleAssetReady = (asset) => {
|
|
73
74
|
setPickerOpen(false);
|
|
74
|
-
if (aspectRatio !== undefined) {
|
|
75
|
+
if (aspectRatio !== undefined && !cropBlockedReason(asset.src)) {
|
|
75
76
|
setCropRequest({ kind: 'new', asset });
|
|
76
77
|
}
|
|
77
78
|
else {
|
|
@@ -102,12 +103,16 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
|
|
|
102
103
|
onRemoved?.(value);
|
|
103
104
|
field?.focus();
|
|
104
105
|
};
|
|
106
|
+
// A whole-frame rectangle stores no crop: it would only turn the image's URL into a derivative
|
|
107
|
+
// that looks the same, and it is how an editor takes a crop back off.
|
|
105
108
|
const handleCropConfirm = (rect) => {
|
|
109
|
+
const crop = isFullFrameCrop(rect) ? undefined : rect;
|
|
106
110
|
if (cropRequest?.kind === 'new') {
|
|
107
|
-
commitAsset(cropRequest.asset,
|
|
111
|
+
commitAsset(cropRequest.asset, crop);
|
|
108
112
|
}
|
|
109
113
|
else if (cropRequest?.kind === 'existing' && value) {
|
|
110
|
-
|
|
114
|
+
const { crop: _previous, ...rest } = value;
|
|
115
|
+
edit(crop ? { ...rest, crop } : rest);
|
|
111
116
|
}
|
|
112
117
|
setCropRequest(null);
|
|
113
118
|
};
|
|
@@ -117,20 +122,24 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
|
|
|
117
122
|
? editorImageSrc(value.src, baseUrl)
|
|
118
123
|
: '';
|
|
119
124
|
const cropInitial = cropRequest?.kind === 'existing' ? value?.crop : undefined;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
125
|
+
const actions = hasValue && !readOnly ? (_jsxs(_Fragment, { children: [_jsx(Button, { size: "compact-sm", variant: "light", onClick: () => setPickerOpen(true), "data-testid": `image-field-replace-${dataCanopyField}`, children: "Replace" }), _jsx(Tooltip, { label: cropBlocked, disabled: !cropBlocked, position: "bottom-end", multiline: true, maw: 280, children: _jsx(Button, { size: "compact-sm", variant: "light", onClick: () => {
|
|
126
|
+
if (!cropBlocked)
|
|
127
|
+
setCropRequest({ kind: 'existing' });
|
|
128
|
+
}, "data-disabled": cropBlocked ? true : undefined, "aria-disabled": cropBlocked ? true : undefined, "aria-describedby": cropBlocked ? cropReasonId : undefined, "data-testid": `image-field-crop-${dataCanopyField}`, children: "Crop" }) }), cropBlocked && _jsx(VisuallyHidden, { id: cropReasonId, children: cropBlocked }), _jsx(Button, { size: "compact-sm", variant: "subtle", color: "red", onClick: handleRemove, "data-testid": `image-field-remove-${dataCanopyField}`, children: "Remove" })] })) : undefined;
|
|
129
|
+
return (_jsx(Paper, { withBorder: true, p: "md", bg: "var(--canopy-surface-subtle)", shadow: "xs", "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), role: "group", "aria-label": label, "data-removal-focus": true, tabIndex: -1, children: _jsxs(Stack, { gap: "sm", children: [_jsx(FieldLabel, { label: label, description: description, descriptionBaseId: inputId, commentControl: commentControl, actions: actions }), !hasValue && readOnly ? (_jsx(Text, { size: "sm", c: "dimmed", "data-testid": `image-field-empty-${dataCanopyField}`, children: "No image" })) : !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
|
|
130
|
+
width: PREVIEW_WIDTH,
|
|
131
|
+
height: 160,
|
|
132
|
+
display: 'flex',
|
|
133
|
+
flexDirection: 'column',
|
|
134
|
+
alignItems: 'center',
|
|
135
|
+
justifyContent: 'center',
|
|
136
|
+
gap: 4,
|
|
137
|
+
borderRadius: 'var(--mantine-radius-sm)',
|
|
138
|
+
background: 'var(--canopy-surface-muted)',
|
|
139
|
+
}, children: [_jsx(IconPhotoOff, { size: 28, stroke: 1.5, color: "var(--mantine-color-dimmed)" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Preview unavailable" })] })) : (_jsx("img", { src: previewSrc, alt: value.alt, style: {
|
|
140
|
+
maxWidth: PREVIEW_WIDTH,
|
|
141
|
+
maxHeight: PREVIEW_WIDTH,
|
|
142
|
+
borderRadius: 'var(--mantine-radius-sm)',
|
|
143
|
+
display: 'block',
|
|
144
|
+
}, onError: () => setPreviewFailed(true) })), errors?.src && (_jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: errors.src })), _jsx(TextInput, { id: inputId, ref: altInputRef, label: "Alt text", required: !altOptional, readOnly: readOnly, value: value.alt, onChange: (event) => handleAltChange(event.currentTarget.value), error: errors?.alt, size: "sm", "data-testid": `image-field-alt-${dataCanopyField}` }), errors?.crop && (_jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: errors.crop }))] })), !readOnly && (_jsx(MediaLibrary, { opened: pickerOpen, onClose: () => setPickerOpen(false), mode: "picker", onSelect: handleAssetReady })), !readOnly && (_jsx(CropStep, { opened: cropRequest !== null, onClose: () => setCropRequest(null), imageSrc: cropImageSrc, aspect: aspectRatio, initialCrop: cropInitial, onConfirm: handleCropConfirm }))] }) }));
|
|
136
145
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Shows `url`, pushed, or replaced keeping the entry's other state; never rewrites the URL shown. */
|
|
2
|
+
export declare function writeEditorUrl(url: string, push: boolean): void;
|
|
3
|
+
/** Stamps the history entry the editor opened on, keeping Next.js's keys: the URL is unchanged. */
|
|
4
|
+
export declare function markEditorHistoryStart(): void;
|
|
5
|
+
/** On `popstate`: entries moved, undefined if an end is not this run's, null for an undo's. */
|
|
6
|
+
export declare function recordHistoryMove(): number | undefined | null;
|
|
7
|
+
export declare function undoHistoryMove(moved: number): void;
|