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.
- package/CHANGELOG.md +102 -46
- 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/editor/Editor.js +37 -5
- package/dist/editor/EntryNavigator.d.ts +9 -4
- package/dist/editor/EntryNavigator.js +36 -16
- 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 +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -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/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- 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 +17 -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
|
@@ -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,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 {
|
|
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
|
};
|
|
@@ -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>;
|
|
@@ -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 ?? '', {
|
|
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 ?? '', {
|
|
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
|
|
10
|
-
/** Existing crop rect, if re-cropping -
|
|
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-
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
*
|
|
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>;
|