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
@@ -0,0 +1,80 @@
1
+ /** Session-history writes stamped with their position, so a refused Back can be undone. */
2
+ const STAMP_KEY = 'canopyHistory';
3
+ // Next.js's router keys: its patched history methods copy `__NA` and the tree back and record the
4
+ // URL, but do neither for state already holding `__NA` or `_N` (app-router.js, next 15.5).
5
+ const ROUTER_KEYS = ['__NA', '__PRIVATE_NEXTJS_INTERNALS_TREE', '_N'];
6
+ // A run restarts wherever the position is lost, so positions from two runs are never compared.
7
+ let run = '';
8
+ let position;
9
+ let undoTarget;
10
+ const newRun = () => {
11
+ run = Math.random().toString(36).slice(2);
12
+ position = undefined;
13
+ };
14
+ const currentState = () => {
15
+ const state = window.history.state;
16
+ return state !== null && typeof state === 'object' ? { ...state } : {};
17
+ };
18
+ const stampIn = (state) => {
19
+ const value = state[STAMP_KEY];
20
+ if (value === null || typeof value !== 'object')
21
+ return undefined;
22
+ const [stampRun, at] = [Reflect.get(value, 'run'), Reflect.get(value, 'position')];
23
+ return typeof stampRun === 'string' && typeof at === 'number'
24
+ ? { run: stampRun, position: at }
25
+ : undefined;
26
+ };
27
+ const samePage = (a, b) => {
28
+ const [x, y] = [new URL(a, window.location.href), new URL(b, window.location.href)];
29
+ const query = (u) => new URLSearchParams(u.search).toString();
30
+ return (x.origin === y.origin && x.pathname === y.pathname && query(x) === query(y) && x.hash === y.hash);
31
+ };
32
+ /** Shows `url`, pushed, or replaced keeping the entry's other state; never rewrites the URL shown. */
33
+ export function writeEditorUrl(url, push) {
34
+ if (samePage(url, window.location.href))
35
+ return;
36
+ if (!run)
37
+ newRun();
38
+ const at = (position ?? 0) + (push ? 1 : 0);
39
+ const kept = push ? {} : currentState();
40
+ for (const key of ROUTER_KEYS)
41
+ delete kept[key];
42
+ const method = push ? window.history.pushState : window.history.replaceState;
43
+ method.call(window.history, { ...kept, [STAMP_KEY]: { run, position: at } }, '', url);
44
+ position = at;
45
+ }
46
+ /** Stamps the history entry the editor opened on, keeping Next.js's keys: the URL is unchanged. */
47
+ export function markEditorHistoryStart() {
48
+ const state = currentState();
49
+ const stamp = stampIn(state);
50
+ if (stamp) {
51
+ run = stamp.run;
52
+ position = stamp.position;
53
+ return;
54
+ }
55
+ newRun();
56
+ position = 0;
57
+ window.history.replaceState({ ...state, [STAMP_KEY]: { run, position } }, '', window.location.href);
58
+ }
59
+ /** On `popstate`: entries moved, undefined if an end is not this run's, null for an undo's. */
60
+ export function recordHistoryMove() {
61
+ const arrived = stampIn(currentState());
62
+ const undone = undoTarget !== undefined &&
63
+ arrived?.run === undoTarget.run &&
64
+ arrived.position === undoTarget.position;
65
+ undoTarget = undefined;
66
+ const moved = arrived?.run === run && position !== undefined ? arrived.position - position : undefined;
67
+ if (arrived) {
68
+ run = arrived.run;
69
+ position = arrived.position;
70
+ }
71
+ else
72
+ newRun();
73
+ return undone ? null : moved;
74
+ }
75
+ export function undoHistoryMove(moved) {
76
+ if (position === undefined)
77
+ return;
78
+ undoTarget = { run, position: position - moved };
79
+ window.history.go(-moved);
80
+ }
@@ -5,6 +5,7 @@ import { notifications } from '@mantine/notifications';
5
5
  import { Text } from '@mantine/core';
6
6
  import { useApiClient } from '../context/index.js';
7
7
  import { requestBranchCreate } from './create-branch-request.js';
8
+ import { writeEditorUrl } from './editor-history.js';
8
9
  const MAX_LISTED_LABELS = 5;
9
10
  /** 'Unsaved changes in: "A", "B".' */
10
11
  export const describeUnsaved = ({ labels }) => {
@@ -25,10 +26,11 @@ export function useBranchActions(options) {
25
26
  const [confirmOpen, setConfirmOpen] = useState(false);
26
27
  const performBranchSwitch = (next) => {
27
28
  options.setBranchName(next);
29
+ // Its own history entry; a Back onto another branch already shows this URL, so writes nothing.
28
30
  if (typeof window !== 'undefined') {
29
31
  const url = new URL(window.location.href);
30
32
  url.searchParams.set('branch', next);
31
- window.history.replaceState({}, '', url.toString());
33
+ writeEditorUrl(url.toString(), true);
32
34
  }
33
35
  options.onBranchSwitch?.(next);
34
36
  };
@@ -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>;
@@ -18,6 +18,7 @@ import { RetryToastMessage } from '../components/RetryToastMessage.js';
18
18
  import { isNonApiResponse } from '../../api/client.js';
19
19
  import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
20
20
  import { describeUnsaved } from './useBranchActions.js';
21
+ import { writeEditorUrl } from './editor-history.js';
21
22
  const BRANCHES_LOAD_FAILED_TOAST_ID = 'canopy-branches-load-failed';
22
23
  const BRANCH_OUTCOME_UNKNOWN_TOAST_ID = 'canopy-branch-outcome-unknown';
23
24
  /** The error a refused branch action throws; a gateway's 5xx says nothing of whether it ran. */
@@ -583,16 +584,18 @@ export function useBranchManager(options) {
583
584
  const handleReloadBranchData = async () => {
584
585
  await loadBranches();
585
586
  };
587
+ // The editor's own page; a Back off it to a page of the host leaves that page's URL alone.
588
+ const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
586
589
  useEffect(() => {
587
590
  if (typeof window === 'undefined')
588
591
  return;
589
- if (!branchName)
592
+ if (!branchName || window.location.pathname !== editorPathRef.current)
590
593
  return;
591
594
  const url = new URL(window.location.href);
592
595
  const current = url.searchParams.get('branch');
593
596
  if (current !== branchName) {
594
597
  url.searchParams.set('branch', branchName);
595
- window.history.replaceState({}, '', url.toString());
598
+ writeEditorUrl(url.toString(), false);
596
599
  }
597
600
  }, [branchName]);
598
601
  return {
@@ -601,6 +604,7 @@ export function useBranchManager(options) {
601
604
  branches,
602
605
  branchSummaries,
603
606
  currentBranch,
607
+ branchesLoadFailed: !!branchesError && !branchesData,
604
608
  addCreatedBranch,
605
609
  handleSubmit,
606
610
  handleWithdraw,
@@ -4,6 +4,8 @@ import type { LogicalPath } from '../../paths/types.js';
4
4
  import type { FormValue } from '../FormRenderer.js';
5
5
  import type { EntryFieldError } from '../../validation/entry-validator.js';
6
6
  /** @internal Exported for tests. */
7
+ export declare const RESTORED_ENTRY_GONE_MESSAGE = "The entry you returned to is no longer on this branch, so the home page opened.";
8
+ /** @internal Exported for tests. */
7
9
  export { listAllEntries } from './useEntriesData.js';
8
10
  /**
9
11
  * Thrown by saveEntry when the API returns a non-200 response. Carries the
@@ -37,12 +39,17 @@ export interface UseEntryManagerOptions {
37
39
  /** An entry's route on the site (`buildPreviewRoute`), which picks the home page to open first. */
38
40
  resolvePreviewRoute?: (entry: EditorEntry) => string | undefined;
39
41
  setBusy: (busy: boolean) => void;
42
+ /** Switches to the branch a Back or Forward reached; rejects when the switch does not happen. */
43
+ switchBranch?: (branch: string) => Promise<void>;
44
+ /** Called when a Back or Forward has opened another entry. */
45
+ onHistoryRestore?: () => void;
40
46
  }
41
47
  export interface UseEntryManagerReturn {
42
48
  selectedPath: string;
49
+ /** Opens an entry the user chose, adding a history entry, so Back returns to the one before. */
43
50
  setSelectedPath: (path: string) => void;
44
- /** Leaves no entry open, as after deleting the open one, instead of opening the first. */
45
- closeEntry: () => void;
51
+ /** Leaves no entry open if `path` on `branch` is still open, as after deleting it. */
52
+ closeEntry: (path: string, branch: string) => void;
46
53
  entries: EditorEntry[];
47
54
  setEntries: (entries: EditorEntry[]) => void;
48
55
  collections: EditorCollection[];
@@ -10,7 +10,11 @@ import { SCHEMA_UNAVAILABLE_CODE } from '../unavailable-entry-type.js';
10
10
  import { entriesKey, fetchEntriesAndSchema } from './useEntriesData.js';
11
11
  import { RetryToastMessage } from '../components/RetryToastMessage.js';
12
12
  import { isGatewayFailure, isUnanswered, MayHaveLandedError, UnansweredError } from '../unanswered.js';
13
+ import { sanitizeBranchName } from '../../paths/branch-name.js';
14
+ import { markEditorHistoryStart, recordHistoryMove, undoHistoryMove, writeEditorUrl, } from './editor-history.js';
13
15
  const ENTRIES_LOAD_FAILED_TOAST_ID = 'canopy-entries-load-failed';
16
+ /** @internal Exported for tests. */
17
+ export const RESTORED_ENTRY_GONE_MESSAGE = 'The entry you returned to is no longer on this branch, so the home page opened.';
14
18
  // Re-exported so existing imports of `listAllEntries` from this module keep
15
19
  // working -- the implementation lives in useEntriesData.ts alongside the
16
20
  // other fetch/key/type pieces useEntryManager shares with the SWR layer.
@@ -56,6 +60,23 @@ const EMPTY_SCHEMAS = [];
56
60
  * from an entry that crashes the editor); null opens the home page (`resolveHomeEntry`).
57
61
  */
58
62
  const readUrlEntry = () => typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('entry');
63
+ /** The current URL with `branch` and the `entry` that `selectedPath` shows, as `readUrlEntry` reads it. */
64
+ const entryUrl = (selectedPath, noEntry, branch) => {
65
+ const url = new URL(window.location.href);
66
+ if (branch)
67
+ url.searchParams.set('branch', branch);
68
+ if (selectedPath) {
69
+ url.searchParams.set('entry', selectedPath);
70
+ }
71
+ else if (noEntry) {
72
+ // Kept, so a reload still opens no entry.
73
+ url.searchParams.set('entry', '');
74
+ }
75
+ else {
76
+ url.searchParams.delete('entry');
77
+ }
78
+ return url.toString();
79
+ };
59
80
  /**
60
81
  * Custom hook for managing editor entries (CRUD operations).
61
82
  */
@@ -102,6 +123,18 @@ export function useEntryManager(options) {
102
123
  selectedPathRef.current = selectedPath;
103
124
  // Counts selections through the returned setter, not this hook's own fallbacks.
104
125
  const selectionsByCallerRef = useRef(0);
126
+ // The path the user chose, which the URL sync pushes; any other selection replaces the URL.
127
+ const pushTargetRef = useRef(null);
128
+ // The `entry` a Back onto another branch opens once that branch shows; a later move drops it.
129
+ const entryAfterBranchChangeRef = useRef(undefined);
130
+ // Counts Back and Forward moves, so a branch switch one asked for knows when another followed.
131
+ const historyMovesRef = useRef(0);
132
+ // The path a Back or Forward opened, until the entries show whether it is still there.
133
+ const restoredPathRef = useRef(null);
134
+ // The editor's own page: a Back off it to another page of the host is no move between entries.
135
+ const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
136
+ const onHistoryRestoreRef = useRef(options.onHistoryRestore);
137
+ onHistoryRestoreRef.current = options.onHistoryRestore;
105
138
  const [navigatorOpen, setNavigatorOpen] = useState(() => readUrlEntry() === '');
106
139
  const isInitialMount = useRef(true);
107
140
  const hasSyncedFromUrl = useRef(false);
@@ -188,13 +221,18 @@ export function useEntryManager(options) {
188
221
  const noEntryRequested = useRef(initialUrlEntry.current === '');
189
222
  const selectEntryPath = useCallback((path) => {
190
223
  selectionsByCallerRef.current += 1;
224
+ pushTargetRef.current = path;
191
225
  setSelectedPath(path);
192
226
  }, []);
193
227
  // No entry open, as the URL's empty `entry` asks, where a lost selection opens the home page.
194
- const closeEntry = useCallback(() => {
228
+ // Replaces the history entry: it follows a delete, which Back could not undo.
229
+ const closeEntry = useCallback((path, branch) => {
230
+ if (selectedPathRef.current !== path || currentBranchRef.current !== branch)
231
+ return;
195
232
  noEntryRequested.current = true;
196
- selectEntryPath('');
197
- }, [selectEntryPath]);
233
+ selectionsByCallerRef.current += 1;
234
+ setSelectedPath('');
235
+ }, []);
198
236
  const collectionByPath = useMemo(() => {
199
237
  const map = new Map();
200
238
  const walk = (collections) => {
@@ -470,6 +508,7 @@ export function useEntryManager(options) {
470
508
  // still empty when this function runs (slow server / cold start).
471
509
  const createdEntry = refreshed.find((e) => e.collectionPath === createModalCollection.path && e.slug === slug);
472
510
  if (createdEntry) {
511
+ pushTargetRef.current = createdEntry.path;
473
512
  setSelectedPath(createdEntry.path);
474
513
  }
475
514
  else {
@@ -566,8 +605,16 @@ export function useEntryManager(options) {
566
605
  isInitialMount.current = false;
567
606
  }
568
607
  else {
569
- noEntryRequested.current = false;
570
- setSelectedPath('');
608
+ const restored = entryAfterBranchChangeRef.current;
609
+ entryAfterBranchChangeRef.current = undefined;
610
+ const entry = restored?.branch === options.branchName ? restored.entry : undefined;
611
+ noEntryRequested.current = entry === '';
612
+ setSelectedPath(entry ?? '');
613
+ if (entry !== undefined) {
614
+ restoredPathRef.current = entry;
615
+ setNavigatorOpen(entry === '');
616
+ onHistoryRestoreRef.current?.();
617
+ }
571
618
  // Bound version-map growth across switches. Correctness no longer
572
619
  // depends on this clear: tokens are keyed by `${branch}:${contentId}`
573
620
  // (see entryVersionsRef), so a late response from the previous branch
@@ -703,33 +750,100 @@ export function useEntryManager(options) {
703
750
  return;
704
751
  }
705
752
  }
753
+ const restored = restoredPathRef.current === selectedPath;
754
+ if (restored)
755
+ restoredPathRef.current = null;
706
756
  // If the selected entry exists, keep it
707
757
  if (entriesState.find((e) => e.path === selectedPath))
708
758
  return;
709
759
  if (selectedPath === '' && noEntryRequested.current)
710
760
  return;
761
+ if (restored && selectedPath) {
762
+ notifications.show({ message: RESTORED_ENTRY_GONE_MESSAGE, color: 'blue' });
763
+ }
711
764
  setSelectedPath(resolveHomeEntry(entriesState, resolvePreviewRouteRef.current)?.path ?? '');
712
765
  }, [entriesState, selectedPath]);
713
766
  // Update URL when selection changes (skip until URL sync has happened)
714
767
  useEffect(() => {
715
768
  if (typeof window === 'undefined')
716
769
  return;
770
+ // Pushed only once listed: a path the check above replaces would leave a stale entry behind.
771
+ const push = pushTargetRef.current === selectedPath && entriesState.some((e) => e.path === selectedPath);
772
+ pushTargetRef.current = null;
717
773
  // Don't update URL until we've synced from it first
718
774
  if (!hasSyncedFromUrl.current)
719
775
  return;
720
- const url = new URL(window.location.href);
721
- if (selectedPath) {
722
- url.searchParams.set('entry', selectedPath);
723
- }
724
- else if (noEntryRequested.current) {
725
- // Kept, so a reload still opens no entry.
726
- url.searchParams.set('entry', '');
727
- }
728
- else {
729
- url.searchParams.delete('entry');
730
- }
731
- window.history.replaceState({}, '', url.toString());
776
+ if (window.location.pathname !== editorPathRef.current)
777
+ return;
778
+ writeEditorUrl(entryUrl(selectedPath, noEntryRequested.current, currentBranchRef.current), push);
732
779
  }, [selectedPath]);
780
+ const entriesRef = useRef(entriesState);
781
+ entriesRef.current = entriesState;
782
+ const switchBranchRef = useRef(options.switchBranch);
783
+ switchBranchRef.current = options.switchBranch;
784
+ // Back and Forward open the entry their URL names, without a reload.
785
+ useEffect(() => {
786
+ if (typeof window === 'undefined')
787
+ return;
788
+ markEditorHistoryStart();
789
+ const restore = () => {
790
+ const moved = recordHistoryMove();
791
+ if (moved === null || window.location.pathname !== editorPathRef.current)
792
+ return;
793
+ historyMovesRef.current += 1;
794
+ entryAfterBranchChangeRef.current = undefined;
795
+ const params = new URLSearchParams(window.location.search);
796
+ const entry = params.get('entry');
797
+ const branch = params.get('branch');
798
+ const current = currentBranchRef.current;
799
+ // A legacy name and its sanitized form are one branch; a switch would key drafts by the other.
800
+ if (branch && sanitizeBranchName(branch) !== sanitizeBranchName(current)) {
801
+ const move = historyMovesRef.current;
802
+ const stay = () => {
803
+ if (move !== historyMovesRef.current)
804
+ return;
805
+ if (moved)
806
+ undoHistoryMove(moved);
807
+ else {
808
+ const url = entryUrl(selectedPathRef.current, noEntryRequested.current, currentBranchRef.current);
809
+ writeEditorUrl(url, false);
810
+ }
811
+ };
812
+ const switchBranch = switchBranchRef.current;
813
+ if (!switchBranch) {
814
+ stay();
815
+ return;
816
+ }
817
+ entryAfterBranchChangeRef.current = { branch, entry };
818
+ if (!hasSyncedFromUrl.current)
819
+ initialUrlEntry.current = entry;
820
+ switchBranch(branch).catch(() => {
821
+ if (move === historyMovesRef.current)
822
+ entryAfterBranchChangeRef.current = undefined;
823
+ stay();
824
+ });
825
+ return;
826
+ }
827
+ // A Back is a selection, so a rename, move or re-create landing afterwards leaves it open.
828
+ selectionsByCallerRef.current += 1;
829
+ noEntryRequested.current = entry === '';
830
+ setNavigatorOpen(entry === '');
831
+ if (!hasSyncedFromUrl.current) {
832
+ initialUrlEntry.current = entry;
833
+ setSelectedPath(entry ?? '');
834
+ return;
835
+ }
836
+ const target = entry ?? resolveHomeEntry(entriesRef.current, resolvePreviewRouteRef.current)?.path ?? '';
837
+ if (target === selectedPathRef.current)
838
+ return;
839
+ restoredPathRef.current = target;
840
+ // A path no longer listed falls back to the home page, through the selection check above.
841
+ setSelectedPath(target);
842
+ onHistoryRestoreRef.current?.();
843
+ };
844
+ window.addEventListener('popstate', restore);
845
+ return () => window.removeEventListener('popstate', restore);
846
+ }, []);
733
847
  return {
734
848
  selectedPath,
735
849
  setSelectedPath: selectEntryPath,
@@ -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>;
@@ -1,41 +1,53 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
- import { Button, Group, Modal, Slider, Stack, Text } from '@mantine/core';
5
- import Cropper from 'react-easy-crop';
6
- import { cropAreaPercentToRect, cropRectToAreaPercent } from './crop-math.js';
7
- const MIN_ZOOM = 1;
8
- const MAX_ZOOM = 3;
9
- const ZOOM_STEP = 0.05;
4
+ import { Button, Group, Modal, Stack, Text } from '@mantine/core';
5
+ import ReactCrop from 'react-image-crop';
6
+ import { cropAreaPercentToRect, initialCropBox } from './crop-math.js';
7
+ const MIN_BOX_PX = 16;
8
+ const CORNER_LABEL = (corner) => `${corner} corner. Arrow keys resize the box.`;
9
+ const ARIA_LABELS = {
10
+ cropArea: 'Crop box. Arrow keys move it; hold Shift for larger steps.',
11
+ nwDragHandle: CORNER_LABEL('Top-left'),
12
+ neDragHandle: CORNER_LABEL('Top-right'),
13
+ seDragHandle: CORNER_LABEL('Bottom-right'),
14
+ swDragHandle: CORNER_LABEL('Bottom-left'),
15
+ nDragHandle: 'Top edge. Up and down arrow keys resize the box.',
16
+ sDragHandle: 'Bottom edge. Up and down arrow keys resize the box.',
17
+ eDragHandle: 'Right edge. Left and right arrow keys resize the box.',
18
+ wDragHandle: 'Left edge. Left and right arrow keys resize the box.',
19
+ };
20
+ const PREVIEW_MAX_HEIGHT = 360;
21
+ const toPercentCrop = (box) => ({
22
+ unit: '%',
23
+ ...box,
24
+ });
10
25
  /**
11
- * react-easy-crop wrapped in a Modal for the ImageField crop step. Percentage
12
- * -> normalized-rect conversion is delegated to crop-math.ts's pure,
13
- * independently-unit-tested `cropAreaPercentToRect`.
26
+ * react-image-crop in a Modal for the ImageField crop step. Box -> normalized-rect
27
+ * conversion is delegated to crop-math.ts's pure, independently-unit-tested
28
+ * `cropAreaPercentToRect`.
14
29
  *
15
- * The pan/zoom/pending-selection state lives in `CropStepBody`, mounted only
16
- * while `opened` and keyed on `imageSrc` - so a fresh crop session always
17
- * starts from fresh state (React remounts on key change) without an effect
18
- * that would otherwise need to call `setState` synchronously to reset it.
30
+ * The box state lives in `CropStepBody`, mounted only while `opened` and keyed on `imageSrc`,
31
+ * so a fresh crop session always starts from a fresh box (React remounts on key change).
19
32
  */
20
33
  export const CropStep = ({ opened, onClose, imageSrc, aspect, initialCrop, onConfirm, }) => {
21
34
  return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Crop image", size: "lg", overlayProps: { blur: 2 }, "data-testid": "crop-step-modal", children: opened && (_jsx(CropStepBody, { imageSrc: imageSrc, aspect: aspect, initialCrop: initialCrop, onConfirm: onConfirm, onClose: onClose }, imageSrc)) }));
22
35
  };
23
36
  const CropStepBody = ({ imageSrc, aspect, initialCrop, onConfirm, onClose, }) => {
24
- const [crop, setCrop] = useState({ x: 0, y: 0 });
25
- const [zoom, setZoom] = useState(1);
26
- const [pendingArea, setPendingArea] = useState(null);
27
- const handleConfirm = () => {
28
- if (!pendingArea)
29
- return;
30
- const rect = cropAreaPercentToRect(pendingArea);
31
- if (rect)
32
- onConfirm(rect);
37
+ const [box, setBox] = useState(undefined);
38
+ const [loadFailed, setLoadFailed] = useState(false);
39
+ // The box can't be seeded until the image's displayed size is known, so Apply stays off until then.
40
+ const rect = box ? cropAreaPercentToRect(box) : null;
41
+ const handleImageLoad = (event) => {
42
+ const { width, height } = event.currentTarget;
43
+ setBox(toPercentCrop(initialCropBox({ imageWidth: width, imageHeight: height, aspect, existing: initialCrop })));
33
44
  };
34
- const initialCroppedAreaPercentages = initialCrop ? cropRectToAreaPercent(initialCrop) : undefined;
35
45
  return (_jsxs(Stack, { gap: "md", children: [_jsx("div", { style: {
36
- position: 'relative',
37
- width: '100%',
38
- height: 360,
46
+ display: 'flex',
47
+ justifyContent: 'center',
39
48
  background: 'var(--mantine-color-black)',
40
- }, children: _jsx(Cropper, { image: imageSrc, crop: crop, zoom: zoom, aspect: aspect, initialCroppedAreaPercentages: initialCroppedAreaPercentages, onCropChange: setCrop, onZoomChange: setZoom, onCropComplete: (croppedArea) => setPendingArea(croppedArea) }) }), _jsxs(Group, { gap: "sm", align: "center", children: [_jsx(Text, { size: "sm", w: 50, children: "Zoom" }), _jsx(Slider, { style: { flex: 1 }, min: MIN_ZOOM, max: MAX_ZOOM, step: ZOOM_STEP, value: zoom, onChange: setZoom, label: (value) => `${value.toFixed(2)}x` })] }), _jsxs(Group, { justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleConfirm, disabled: !pendingArea, "data-testid": "crop-step-apply", children: "Apply crop" })] })] }));
49
+ '--rc-focus-color': 'var(--canopy-focus-ring)',
50
+ }, children: _jsx(ReactCrop, { crop: box, aspect: aspect, minWidth: MIN_BOX_PX, minHeight: MIN_BOX_PX, keepSelection: true, ariaLabels: ARIA_LABELS, onChange: (_pixels, percent) => setBox(percent), children: _jsx("img", { src: imageSrc, alt: "", style: { maxHeight: PREVIEW_MAX_HEIGHT, maxWidth: '100%', display: 'block' }, onLoad: handleImageLoad, onError: () => setLoadFailed(true) }) }) }), loadFailed && (_jsx(Text, { size: "sm", c: "var(--canopy-danger-text)", role: "alert", children: "This image couldn't be loaded, so it can't be cropped." })), _jsx(Text, { size: "sm", c: "dimmed", children: aspect === undefined
51
+ ? 'Drag the box to move it, or its edges and corners to resize it. With a keyboard, arrow keys move it; Shift takes larger steps.'
52
+ : 'Drag the box to move it, or a corner to resize it. Its shape is fixed. With a keyboard, arrow keys move it; Shift takes larger steps.' }), _jsxs(Group, { justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: () => rect && onConfirm(rect), disabled: !rect, "data-testid": "crop-step-apply", children: "Apply crop" })] })] }));
41
53
  };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Why `src` can't be cropped, as the full sentence its disabled Crop button shows; `undefined`
3
+ * when it can. A crop is a directive on a transform URL, which `assetUrl` writes only for a raster
4
+ * in the asset store, so a vector file or a file outside the store never renders one.
5
+ */
6
+ export declare function cropBlockedReason(src: string): string | undefined;
@@ -0,0 +1,19 @@
1
+ import { ASSET_PREFIXES } from '../../assets/asset-prefixes.js';
2
+ import { isAssetStoreSrc } from '../../assets/asset-url.js';
3
+ const TRANSFORM_SPACE_PREFIX = `/${ASSET_PREFIXES.transform}/`;
4
+ /**
5
+ * Why `src` can't be cropped, as the full sentence its disabled Crop button shows; `undefined`
6
+ * when it can. A crop is a directive on a transform URL, which `assetUrl` writes only for a raster
7
+ * in the asset store, so a vector file or a file outside the store never renders one.
8
+ */
9
+ export function cropBlockedReason(src) {
10
+ const path = src.split(/[?#]/)[0] ?? src;
11
+ if (/\.svg$/i.test(path))
12
+ return "SVG images can't be cropped. Replace it with a PNG or JPEG to crop it.";
13
+ if (!isAssetStoreSrc(src)) {
14
+ return 'Crop works on media-library images. Replace this file with a library copy to crop it.';
15
+ }
16
+ if (!src.startsWith(TRANSFORM_SPACE_PREFIX))
17
+ return "This file type can't be cropped. Replace it with a PNG or JPEG to crop it.";
18
+ return undefined;
19
+ }