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.
Files changed (56) hide show
  1. package/CHANGELOG.md +123 -62
  2. package/dist/api/assets.js +20 -10
  3. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  4. package/dist/assets/editor-thumbnail-widths.js +7 -0
  5. package/dist/assets/finalize.d.ts +7 -6
  6. package/dist/assets/finalize.js +15 -9
  7. package/dist/assets/materialize.d.ts +17 -2
  8. package/dist/assets/materialize.js +57 -4
  9. package/dist/assets/store-s3.d.ts +7 -3
  10. package/dist/assets/store-s3.js +25 -6
  11. package/dist/assets/types.d.ts +7 -2
  12. package/dist/cli/cli.js +40 -23
  13. package/dist/config/types.d.ts +3 -2
  14. package/dist/dev-content-watcher.js +8 -1
  15. package/dist/editor/Editor.js +60 -7
  16. package/dist/editor/EntryNavigator.d.ts +9 -4
  17. package/dist/editor/EntryNavigator.js +39 -17
  18. package/dist/editor/PreviewFrame.d.ts +3 -1
  19. package/dist/editor/PreviewFrame.js +43 -2
  20. package/dist/editor/PreviewPane.d.ts +2 -0
  21. package/dist/editor/components/BranchLockBanner.d.ts +7 -8
  22. package/dist/editor/components/BranchLockBanner.js +25 -2
  23. package/dist/editor/components/BranchRowLock.d.ts +14 -0
  24. package/dist/editor/components/BranchRowLock.js +21 -0
  25. package/dist/editor/components/EditorHeader.js +3 -18
  26. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  27. package/dist/editor/components/PreviewLinkToast.js +33 -0
  28. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  29. package/dist/editor/fields/FieldLabel.js +1 -1
  30. package/dist/editor/fields/ImageField.d.ts +1 -1
  31. package/dist/editor/fields/ImageField.js +36 -27
  32. package/dist/editor/hooks/editor-history.d.ts +7 -0
  33. package/dist/editor/hooks/editor-history.js +80 -0
  34. package/dist/editor/hooks/useBranchActions.js +3 -1
  35. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  36. package/dist/editor/hooks/useBranchManager.js +6 -2
  37. package/dist/editor/hooks/useEntryManager.d.ts +9 -2
  38. package/dist/editor/hooks/useEntryManager.js +131 -17
  39. package/dist/editor/media/AssetCard.js +4 -2
  40. package/dist/editor/media/AssetListRow.js +4 -2
  41. package/dist/editor/media/CropStep.d.ts +8 -10
  42. package/dist/editor/media/CropStep.js +39 -27
  43. package/dist/editor/media/crop-availability.d.ts +6 -0
  44. package/dist/editor/media/crop-availability.js +19 -0
  45. package/dist/editor/media/crop-math.d.ts +19 -13
  46. package/dist/editor/media/crop-math.js +50 -11
  47. package/dist/editor/preview-bridge.d.ts +7 -0
  48. package/dist/editor/preview-bridge.js +93 -1
  49. package/dist/editor/preview-path.d.ts +5 -0
  50. package/dist/editor/preview-path.js +18 -0
  51. package/dist/editor/theme.d.ts +1 -0
  52. package/dist/editor/theme.js +38 -1
  53. package/dist/server.d.ts +2 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. 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
- // the entry-level ones above -- this removes the collection menu button
481
- // entirely (hasCollectionMenu below) rather than leaving it enabled.
482
- const onAdd = readOnly ? undefined : node.nodeProps?.onAdd;
483
- const onEdit = readOnly ? undefined : node.nodeProps?.onEdit;
484
- const onAddSubCollection = readOnly
485
- ? undefined
486
- : node.nodeProps?.onAddSubCollection;
487
- const onDelete = readOnly ? undefined : node.nodeProps?.onDelete;
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 so once, in the branch banner.
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 canReorder = onReorderEntry && contentId && parentCollectionPath && typeof childIndex === 'number';
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. A read-only navigator already says so
515
- // once, in the branch banner, so only a lone read-only entry gets its own lock.
516
- const entryLocked = !readOnly && !!node.nodeProps?.itemReadOnly;
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" }))] })] })), 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) => {
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
- * Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
10
- * nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
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
- export function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
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
- /** Whether the banner renders; the top bar reads the same answer, so the two cannot drift. */
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 { branchLockBannerShown, branchLockState } from './BranchLockBanner.js';
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
- const lock = { branchStatus, branchReadOnly, branchWriteBlocked };
19
- const { statusLocked, branchDataUnavailable } = branchLockState(lock);
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: "nowrap", gap: "xs", align: "center", children: [_jsxs(Group, { gap: 4, wrap: "nowrap", style: { minWidth: 0 }, 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 }, children: actions }))] })), descriptionBaseId && (_jsx(FieldDescription, { baseId: descriptionBaseId, description: description }))] }));
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/uploading a new image opens the crop step before the value commits, and a "Crop" button appears on the filled state. */
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 { isAssetStoreSrc } from '../../assets/asset-url.js';
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 { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
17
+ import { groupDescriptionProps } from './FieldDescription.js';
17
18
  import { FieldLabel } from './FieldLabel.js';
18
- const PREVIEW_WIDTH = 320;
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
- // `assetUrl` never applies a crop to a src outside the asset store, so one stored there never renders.
38
- const canCrop = aspectRatio !== undefined && !!value && isAssetStoreSrc(value.src);
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, rect);
111
+ commitAsset(cropRequest.asset, crop);
108
112
  }
109
113
  else if (cropRequest?.kind === 'existing' && value) {
110
- edit({ ...value, crop: rect });
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
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), role: "group", "aria-label": label, "data-removal-focus": true, tabIndex: -1, children: [_jsx(FieldLabel, { label: label, commentControl: commentControl }), _jsx(FieldDescription, { baseId: inputId, description: description }), !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: {
121
- width: PREVIEW_WIDTH,
122
- height: 160,
123
- display: 'flex',
124
- flexDirection: 'column',
125
- alignItems: 'center',
126
- justifyContent: 'center',
127
- gap: 4,
128
- borderRadius: 'var(--mantine-radius-sm)',
129
- background: 'var(--canopy-surface-muted)',
130
- }, 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: {
131
- maxWidth: PREVIEW_WIDTH,
132
- maxHeight: PREVIEW_WIDTH,
133
- borderRadius: 'var(--mantine-radius-sm)',
134
- display: 'block',
135
- }, 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}` }), !readOnly && (_jsxs(Group, { gap: "xs", children: [_jsx(Button, { variant: "light", onClick: () => setPickerOpen(true), "data-testid": `image-field-replace-${dataCanopyField}`, children: "Replace" }), canCrop && (_jsx(Button, { variant: "light", onClick: () => setCropRequest({ kind: 'existing' }), "data-testid": `image-field-crop-${dataCanopyField}`, children: "Crop" })), _jsx(Button, { variant: "subtle", color: "red", onClick: handleRemove, "data-testid": `image-field-remove-${dataCanopyField}`, children: "Remove" })] })), 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 })), aspectRatio !== undefined && !readOnly && (_jsx(CropStep, { opened: cropRequest !== null, onClose: () => setCropRequest(null), imageSrc: cropImageSrc, aspect: aspectRatio, initialCrop: cropInitial, onConfirm: handleCropConfirm }))] }));
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;