canopycms 0.2.0-int.119 → 0.2.0-int.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +102 -46
  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/editor/Editor.js +37 -5
  15. package/dist/editor/EntryNavigator.d.ts +9 -4
  16. package/dist/editor/EntryNavigator.js +36 -16
  17. package/dist/editor/PreviewFrame.d.ts +3 -1
  18. package/dist/editor/PreviewFrame.js +43 -2
  19. package/dist/editor/PreviewPane.d.ts +2 -0
  20. package/dist/editor/components/BranchLockBanner.d.ts +7 -8
  21. package/dist/editor/components/BranchLockBanner.js +25 -2
  22. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  23. package/dist/editor/components/BranchRowLock.js +19 -0
  24. package/dist/editor/components/EditorHeader.js +3 -18
  25. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  26. package/dist/editor/components/PreviewLinkToast.js +33 -0
  27. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  28. package/dist/editor/fields/FieldLabel.js +1 -1
  29. package/dist/editor/fields/ImageField.d.ts +1 -1
  30. package/dist/editor/fields/ImageField.js +36 -27
  31. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  32. package/dist/editor/hooks/useBranchManager.js +1 -0
  33. package/dist/editor/media/AssetCard.js +4 -2
  34. package/dist/editor/media/AssetListRow.js +4 -2
  35. package/dist/editor/media/CropStep.d.ts +8 -10
  36. package/dist/editor/media/CropStep.js +39 -27
  37. package/dist/editor/media/crop-availability.d.ts +6 -0
  38. package/dist/editor/media/crop-availability.js +19 -0
  39. package/dist/editor/media/crop-math.d.ts +19 -13
  40. package/dist/editor/media/crop-math.js +50 -11
  41. package/dist/editor/preview-bridge.d.ts +7 -0
  42. package/dist/editor/preview-bridge.js +93 -1
  43. package/dist/editor/preview-path.d.ts +5 -0
  44. package/dist/editor/preview-path.js +18 -0
  45. package/dist/editor/theme.d.ts +1 -0
  46. package/dist/editor/theme.js +17 -1
  47. package/dist/server.d.ts +2 -0
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +2 -2
@@ -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,12 @@
1
+ import React from 'react';
2
+ /**
3
+ * The lock a navigator row shows in the slot its "…" menu would fill, on a branch that takes no
4
+ * writes. It matches the menu button's footprint so rows do not shift between branches, and takes
5
+ * the menu's name; the reason is its Tooltip and its description. It stays `aria-disabled` rather
6
+ * than `disabled`, so the reason opens on keyboard focus and touch too. Space is stopped here: the
7
+ * tree would read it as expand/collapse.
8
+ */
9
+ export declare const BranchRowLock: React.FC<{
10
+ label: string;
11
+ reason: string;
12
+ }>;
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
4
+ import { ActionIcon, Tooltip, VisuallyHidden } from '@mantine/core';
5
+ import { IconLock } from '@tabler/icons-react';
6
+ /**
7
+ * The lock a navigator row shows in the slot its "…" menu would fill, on a branch that takes no
8
+ * writes. It matches the menu button's footprint so rows do not shift between branches, and takes
9
+ * the menu's name; the reason is its Tooltip and its description. It stays `aria-disabled` rather
10
+ * than `disabled`, so the reason opens on keyboard focus and touch too. Space is stopped here: the
11
+ * tree would read it as expand/collapse.
12
+ */
13
+ export const BranchRowLock = ({ label, reason }) => {
14
+ const reasonId = useId();
15
+ return (_jsx(Tooltip, { label: reason, withinPortal: true, position: "bottom-end", multiline: true, w: 240, events: { hover: true, focus: true, touch: true }, children: _jsxs(ActionIcon, { size: "xs", variant: "transparent", c: "dimmed", "aria-disabled": true, "aria-label": label, "aria-describedby": reasonId, "data-testid": "branch-lock", style: { cursor: 'default' }, onClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
16
+ if (event.nativeEvent.code === 'Space')
17
+ event.stopPropagation();
18
+ }, children: [_jsx(IconLock, { size: 14, "aria-hidden": true }), _jsx(VisuallyHidden, { component: "span", id: reasonId, children: reason })] }) }));
19
+ };
@@ -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
  };
@@ -88,6 +88,8 @@ export interface UseBranchManagerReturn {
88
88
  branches: BranchListItem[];
89
89
  branchSummaries: BranchSummary[];
90
90
  currentBranch: BranchListItem | undefined;
91
+ /** The listing failed and nothing earlier stands in for it. */
92
+ branchesLoadFailed: boolean;
91
93
  /** Shows a branch the server just created before any listing includes it. */
92
94
  addCreatedBranch: (branch: BranchListItem) => void;
93
95
  handleSubmit: (branchName: string) => Promise<void>;
@@ -601,6 +601,7 @@ export function useBranchManager(options) {
601
601
  branches,
602
602
  branchSummaries,
603
603
  currentBranch,
604
+ branchesLoadFailed: !!branchesError && !branchesData,
604
605
  addCreatedBranch,
605
606
  handleSubmit,
606
607
  handleWithdraw,
@@ -2,12 +2,12 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { ActionIcon, Badge, Card, Text, Tooltip } from '@mantine/core';
4
4
  import { IconEyeOff, IconFileTypePdf, IconPhotoOff, IconRestore } from '@tabler/icons-react';
5
+ import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
5
6
  import { EDITOR_ACTIONS } from '../copy.js';
6
7
  import { formatRelativeTime } from '../relative-time.js';
7
8
  import { editorImageSrc } from './editor-image-src.js';
8
9
  import { CARD_THUMBNAIL_HEIGHT } from './media-library-view.js';
9
10
  import { useThumbnailFailure } from './useThumbnailFailure.js';
10
- const THUMBNAIL_WIDTH = 160;
11
11
  /** Over the thumbnail, so opaque: the default fill. */
12
12
  const overlayActionStyle = {
13
13
  position: 'absolute',
@@ -24,7 +24,9 @@ export const AssetCard = ({ asset, baseUrl, onSelect, onRemove, onRestore, densi
24
24
  const compact = density === 'compact';
25
25
  const removed = asset.hidden === true;
26
26
  const thumbnailHeight = CARD_THUMBNAIL_HEIGHT[density];
27
- const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', { width: THUMBNAIL_WIDTH });
27
+ const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', {
28
+ width: EDITOR_THUMBNAIL_WIDTHS.card,
29
+ });
28
30
  const [thumbnailFailed, markThumbnailFailed] = useThumbnailFailure(thumbnailSrc);
29
31
  const handleKeyDown = (event) => {
30
32
  if (!onSelect)
@@ -2,13 +2,13 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { ActionIcon, Badge, Group, Text, Tooltip, UnstyledButton } from '@mantine/core';
4
4
  import { IconEyeOff, IconFileTypePdf, IconPhotoOff, IconRestore } from '@tabler/icons-react';
5
+ import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
5
6
  import { EDITOR_ACTIONS } from '../copy.js';
6
7
  import { formatRelativeTime } from '../relative-time.js';
7
8
  import { editorImageSrc } from './editor-image-src.js';
8
9
  import { formatBytes } from './format-bytes.js';
9
10
  import { useThumbnailFailure } from './useThumbnailFailure.js';
10
11
  const THUMBNAIL_SIZE = 40;
11
- const THUMBNAIL_WIDTH = 80;
12
12
  const rowStyle = {
13
13
  width: '100%',
14
14
  padding: 6,
@@ -32,7 +32,9 @@ const thumbnailBoxStyle = {
32
32
  */
33
33
  export const AssetListRow = ({ asset, baseUrl, onSelect, onRemove, onRestore, }) => {
34
34
  const removed = asset.hidden === true;
35
- const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', { width: THUMBNAIL_WIDTH });
35
+ const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', {
36
+ width: EDITOR_THUMBNAIL_WIDTHS.listRow,
37
+ });
36
38
  const [thumbnailFailed, markThumbnailFailed] = useThumbnailFailure(thumbnailSrc);
37
39
  const hasDimensions = asset.width !== undefined && asset.height !== undefined;
38
40
  const content = (_jsxs(Group, { gap: "sm", wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx("div", { style: thumbnailBoxStyle, children: asset.kind === 'pdf' ? (_jsx(IconFileTypePdf, { size: 24, stroke: 1.5, "aria-hidden": true })) : thumbnailFailed ? (_jsx(IconPhotoOff, { size: 20, stroke: 1.5, color: "var(--mantine-color-dimmed)", "data-testid": `asset-row-thumbnail-fallback-${asset.hash32}`, "aria-label": "Preview unavailable" })) : (
@@ -5,20 +5,18 @@ export interface CropStepProps {
5
5
  onClose: () => void;
6
6
  /** Full-frame image src to crop - `assetUrl()` with no crop directive, so the user always crops the original frame. */
7
7
  imageSrc: string;
8
- /** Numeric aspect ratio (width/height) parsed from the field config's "W:H" string. */
9
- aspect: number;
10
- /** Existing crop rect, if re-cropping - seeds react-easy-crop's initial selection (best effort). */
8
+ /** Numeric aspect ratio (width/height) parsed from the field config's "W:H" string. Absent: the crop box is free-form. */
9
+ aspect?: number;
10
+ /** Existing crop rect, if re-cropping - the box it opens with. */
11
11
  initialCrop?: CropRect;
12
12
  onConfirm: (rect: CropRect) => void;
13
13
  }
14
14
  /**
15
- * react-easy-crop wrapped in a Modal for the ImageField crop step. Percentage
16
- * -> normalized-rect conversion is delegated to crop-math.ts's pure,
17
- * independently-unit-tested `cropAreaPercentToRect`.
15
+ * react-image-crop in a Modal for the ImageField crop step. Box -> normalized-rect
16
+ * conversion is delegated to crop-math.ts's pure, independently-unit-tested
17
+ * `cropAreaPercentToRect`.
18
18
  *
19
- * The pan/zoom/pending-selection state lives in `CropStepBody`, mounted only
20
- * while `opened` and keyed on `imageSrc` - so a fresh crop session always
21
- * starts from fresh state (React remounts on key change) without an effect
22
- * that would otherwise need to call `setState` synchronously to reset it.
19
+ * The box state lives in `CropStepBody`, mounted only while `opened` and keyed on `imageSrc`,
20
+ * so a fresh crop session always starts from a fresh box (React remounts on key change).
23
21
  */
24
22
  export declare const CropStep: React.FC<CropStepProps>;