canopycms 0.1.0 → 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 (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { forwardRef } from 'react';
4
+ import { Box } from '@mantine/core';
5
+ /**
6
+ * The editor's one "unsaved" mark, a dot, in the breadcrumb, the branch pill and the content tree.
7
+ * `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
8
+ * The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
9
+ * It forwards its ref, so a Tooltip can wrap it.
10
+ */
11
+ export const UnsavedMark = forwardRef(function UnsavedMark(props, ref) {
12
+ return (_jsx(Box, { ref: ref, component: "span", ...props, style: {
13
+ display: 'inline-block',
14
+ flexShrink: 0,
15
+ width: 8,
16
+ height: 8,
17
+ borderRadius: '50%',
18
+ backgroundColor: 'var(--canopy-unsaved-mark, var(--mantine-color-brand-6))',
19
+ } }));
20
+ });
@@ -29,11 +29,14 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
29
29
  // A light or outline badge tints its label with the badge colour, too faint for a name.
30
30
  const nameColor = inBadge && badgeVariant !== 'filled' ? 'var(--mantine-color-text)' : undefined;
31
31
  const wrap = (content, tooltip, personLabel) => {
32
- const wrapped = inBadge ? (_jsx(Badge, { variant: badgeVariant, color: color, pr: onRemove ? 3 : 8, pl: 4, rightSection: onRemove ? (_jsx(Tooltip, { label: `Remove ${personLabel}`, position: "bottom", children: _jsx(ActionIcon, { size: "xs", radius: "xl", variant: "transparent", onClick: () => onRemove(userId), onMouseEnter: () => setOnRemoveButton(true), onMouseLeave: () => setOnRemoveButton(false), onFocus: () => setOnRemoveButton(true), onBlur: () => setOnRemoveButton(false), "aria-label": `Remove ${personLabel}`, style: { color: nameColor ?? 'var(--mantine-color-white)' }, children: _jsx(IconX, { size: 12, stroke: 2.5 }) }) })) : undefined, styles: {
32
+ const wrapped = inBadge ? (_jsx(Badge, { variant: badgeVariant, color: color, pr: onRemove ? 3 : 8, pl: 4,
33
+ // A long name shrinks the badge to its container instead of wrapping inside its fixed height.
34
+ maw: "100%", rightSection: onRemove ? (_jsx(Tooltip, { label: `Remove ${personLabel}`, position: "bottom", children: _jsx(ActionIcon, { size: "xs", radius: "xl", variant: "transparent", onClick: () => onRemove(userId), onMouseEnter: () => setOnRemoveButton(true), onMouseLeave: () => setOnRemoveButton(false), onFocus: () => setOnRemoveButton(true), onBlur: () => setOnRemoveButton(false), "aria-label": `Remove ${personLabel}`, style: { color: nameColor ?? 'var(--mantine-color-white)' }, children: _jsx(IconX, { size: 12, stroke: 2.5 }) }) })) : undefined, styles: {
33
35
  label: {
34
36
  display: 'flex',
35
37
  alignItems: 'center',
36
38
  gap: '4px',
39
+ minWidth: 0,
37
40
  },
38
41
  }, children: content })) : (content);
39
42
  return showEmailTooltip ? (_jsx(Tooltip, { label: tooltip, disabled: !tooltip || onRemoveButton, children: wrapped })) : (wrapped);
@@ -49,9 +52,9 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
49
52
  const describedWithEmail = emailDetail ? `${described}, ${emailDetail}` : described;
50
53
  // A filled badge keeps its white text; dimmed would not contrast with its fill.
51
54
  const fallbackColor = inBadge && badgeVariant === 'filled' ? undefined : 'dimmed';
52
- const unavailableText = (_jsx(Text, { size: textSize, c: fallbackColor, fs: "italic", children: unavailable }));
55
+ const unavailableText = (_jsx(Text, { size: textSize, c: fallbackColor, fs: "italic", truncate: true, children: unavailable }));
53
56
  const hiddenDetail = `${revealIdOnFallback ? ` · ${userId}` : ''}${emailDetail ? `, ${emailDetail}` : ''}`;
54
- return wrap(_jsxs(Group, { gap: 4, children: [_jsx(Avatar, { src: record?.avatarUrl, alt: "", size: avatarSize, color: "gray", "aria-hidden": true }), variant === 'avatar-only' ? (_jsx(VisuallyHidden, { component: "span", children: describedWithEmail })) : (_jsxs(_Fragment, { children: [variant === 'full' && record?.email ? (_jsxs("div", { children: [unavailableText, _jsx(Text, { size: "xs", c: "dimmed", children: record.email })] })) : (unavailableText), hiddenDetail && _jsx(VisuallyHidden, { component: "span", children: hiddenDetail })] }))] }), variant === 'avatar-only'
57
+ return wrap(_jsxs(Group, { gap: 4, wrap: "nowrap", miw: 0, children: [_jsx(Avatar, { src: record?.avatarUrl, alt: "", size: avatarSize, color: "gray", "aria-hidden": true }), variant === 'avatar-only' ? (_jsx(VisuallyHidden, { component: "span", children: describedWithEmail })) : (_jsxs(_Fragment, { children: [variant === 'full' && record?.email ? (_jsxs("div", { children: [unavailableText, _jsx(Text, { size: "xs", c: "dimmed", children: record.email })] })) : (unavailableText), hiddenDetail && _jsx(VisuallyHidden, { component: "span", children: hiddenDetail })] }))] }), variant === 'avatar-only'
55
58
  ? describedWithEmail
56
59
  : [revealIdOnFallback ? described : '', emailDetail].filter(Boolean).join(', '), described);
57
60
  }
@@ -89,23 +92,22 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
89
92
  const detail = showEmailTooltip && variant !== 'full' && userId !== 'anonymous' ? record.email : '';
90
93
  const detailText = detail ? (_jsx(VisuallyHidden, { component: "span", children: `, ${detail}` })) : null;
91
94
  const personLabel = userId === 'anonymous' ? 'Anonymous (Public)' : displayName;
95
+ // Alone, the avatar shows no name, so its tooltip gives it before the email. A name beside the
96
+ // avatar is cut with an ellipsis when long, so its tooltip gives the full name too; with no
97
+ // tooltip, the name carries it as a title.
98
+ const tooltip = variant === 'full' ? '' : [personLabel, detail].filter(Boolean).join(', ');
99
+ const nameTitle = showEmailTooltip && tooltip ? undefined : personLabel;
92
100
  const renderContent = () => {
93
101
  const avatar = renderAvatar();
94
102
  switch (variant) {
95
103
  case 'avatar-only':
96
104
  return (_jsxs(Group, { gap: 0, children: [avatar, _jsx(VisuallyHidden, { component: "span", children: personLabel }), detailText] }));
97
105
  case 'full':
98
- return (_jsxs(Group, { gap: 4, children: [avatar, _jsxs("div", { children: [_jsx(Text, { size: textSize, fw: 500, children: displayName }), _jsx(Text, { size: "xs", c: "dimmed", children: record.email })] })] }));
106
+ return (_jsxs(Group, { gap: 4, wrap: "nowrap", miw: 0, children: [avatar, _jsxs("div", { style: { minWidth: 0 }, children: [_jsx(Text, { size: textSize, fw: 500, truncate: true, title: displayName, children: displayName }), _jsx(Text, { size: "xs", c: "dimmed", truncate: true, title: record.email, children: record.email })] })] }));
99
107
  case 'avatar-name':
100
108
  default:
101
- return (_jsxs(Group, { gap: 4, children: [avatar, _jsx(Text, { size: textSize, fw: 500, c: nameColor, children: personLabel }), detailText] }));
109
+ return (_jsxs(Group, { gap: 4, wrap: "nowrap", miw: 0, children: [avatar, _jsx(Text, { size: textSize, fw: 500, c: nameColor, truncate: true, title: nameTitle, children: personLabel }), detailText] }));
102
110
  }
103
111
  };
104
- // Alone, the avatar shows no name, so its tooltip gives it before the email.
105
- const tooltip = variant === 'full'
106
- ? ''
107
- : variant === 'avatar-only'
108
- ? [personLabel, detail].filter(Boolean).join(', ')
109
- : record.email;
110
112
  return wrap(renderContent(), tooltip, personLabel);
111
113
  };
@@ -13,6 +13,10 @@ export declare const EDITOR_ACTIONS: {
13
13
  readonly switchBranch: "Switch branch";
14
14
  readonly newBranch: "New branch";
15
15
  readonly remove: "Remove";
16
+ readonly restore: "Restore";
16
17
  readonly clear: "Clear";
18
+ readonly undoClear: "Undo clear";
17
19
  readonly openEntry: "Open entry";
20
+ readonly changeUrl: "Change URL";
21
+ readonly changeSlug: "Change slug";
18
22
  };
@@ -13,6 +13,10 @@ export const EDITOR_ACTIONS = {
13
13
  switchBranch: 'Switch branch',
14
14
  newBranch: 'New branch',
15
15
  remove: 'Remove',
16
+ restore: 'Restore',
16
17
  clear: 'Clear',
18
+ undoClear: 'Undo clear',
17
19
  openEntry: 'Open entry',
20
+ changeUrl: 'Change URL',
21
+ changeSlug: 'Change slug',
18
22
  };
@@ -31,6 +31,14 @@ type PreviewEntry = {
31
31
  export declare const buildPreviewRoute: (entry: PreviewEntry, { previewBaseByCollection, contentRoot }: PreviewContext & {
32
32
  contentRoot?: string;
33
33
  }) => string | undefined;
34
+ /**
35
+ * The entry the editor opens when none is asked for: the one whose route is `/`, else the first
36
+ * with a preview, else the first. `routeFor` gives the route before any prefix or base path
37
+ * (`buildPreviewRoute`), so `previewPrefix` and `basePath` never hide the root page.
38
+ */
39
+ export declare const resolveHomeEntry: <T extends {
40
+ previewSrc?: string;
41
+ }>(entries: readonly T[], routeFor?: (entry: T) => string | undefined) => T | undefined;
34
42
  /**
35
43
  * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
36
44
  * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
@@ -67,19 +75,51 @@ export declare const buildCollectionLabels: (collections?: EditorCollection[]) =
67
75
  * untyped.
68
76
  */
69
77
  export declare const entryTypeLabel: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
70
- /** An entry's navigator badge: "Missing" when its file is gone, else its type's label or name. */
78
+ /**
79
+ * The type shown under an entry's label, in the content tree and quick open: `entryTypeLabel`,
80
+ * except for a singleton, whose label already is its type's (`singletonLabel`).
81
+ */
82
+ export declare const entryTypeLine: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
83
+ /**
84
+ * An entry's second line in the content tree: "Missing" when its file is gone, else its
85
+ * `entryTypeLine`, else its status read as words.
86
+ */
71
87
  export declare const entryStatusLabel: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "status" | "collectionPath" | "entryType">) => string | undefined;
72
- /** One crumb before the entry's own: "All content", a collection, or a folder of a nested slug. */
73
- export interface BreadcrumbSegment {
88
+ /**
89
+ * Where an entry's name comes from, which "Change URL" explains. The title chain is the server's
90
+ * (`resolveEntryTitle`): a non-empty title-field value, else a non-empty string `title` or `name`
91
+ * value whatever its field's type, else the type's label, else the slug. A labelled singleton is
92
+ * named by its type whatever its title. `fieldLabel` is absent when no field of that name is in the
93
+ * schema.
94
+ */
95
+ export type EntryNameSource = {
96
+ from: 'type';
97
+ } | {
98
+ from: 'title';
99
+ fieldLabel?: string;
100
+ } | {
101
+ from: 'slug';
102
+ };
103
+ /**
104
+ * `data` is the entry's value where it is at hand; without it, a schema with a title field, or a
105
+ * `title` or `name` field, is taken to name the entry from it.
106
+ */
107
+ export declare const entryNameSource: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType" | "schema">, data?: Record<string, unknown>) => EntryNameSource;
108
+ /** One crumb before the entry's own: a collection, or a folder of a nested slug. */
109
+ export type BreadcrumbSegment = {
110
+ label: string;
111
+ /** The collection the crumb reveals in the content panel. */
112
+ collectionPath: string;
113
+ folder?: undefined;
114
+ } | {
74
115
  label: string;
75
- /** The collection the crumb opens in the content panel; absent on "All content" and slug folders. */
76
- collectionPath?: string;
77
116
  /** A slug folder, which is not a node of the content tree, so it navigates nowhere. */
78
- folder?: true;
79
- }
117
+ folder: true;
118
+ collectionPath?: undefined;
119
+ };
80
120
  /**
81
- * Builds breadcrumb segments for an entry based on its collection hierarchy, starting with
82
- * "All content". The first path part is skipped as the content root, which the tree does not show.
121
+ * Builds breadcrumb segments for an entry based on its collection hierarchy. The first path part
122
+ * is skipped as the content root, which the tree does not show, so a root entry has none.
83
123
  */
84
124
  export declare const buildBreadcrumbSegments: (currentEntry: EditorEntry | undefined, collectionLabels: Map<string, string>) => BreadcrumbSegment[];
85
125
  /**
@@ -1,7 +1,8 @@
1
1
  // Import directly from normalize to avoid pulling in server-only branch.ts
2
2
  import { normalizeFilesystemPath } from '../paths/normalize.js';
3
- import { humanizeName } from './schema-label.js';
3
+ import { humanizeName, schemaLabel } from './schema-label.js';
4
4
  import { computeEntryUrl, isIndexSlug } from '../utils/entry-url.js';
5
+ import { titleFieldPath } from '../utils/title-field.js';
5
6
  import { isDataOnlyFormat } from '../utils/format.js';
6
7
  import { isAbsoluteUrl, joinUrlPrefix, matchTrailingSlash, readTrailingSlashEnv, stripTrailingSlashes, } from '../utils/url-prefix.js';
7
8
  /**
@@ -67,6 +68,18 @@ export const buildPreviewRoute = (entry, { previewBaseByCollection, contentRoot
67
68
  const trimmed = stripTrailingSlashes(basePathPart);
68
69
  return `${encoded ? `${trimmed}/${encoded}` : basePathPart || '/'}${suffix}`;
69
70
  };
71
+ /** Whether a `buildPreviewRoute` route is the site's root page: `/`, with any query or fragment. */
72
+ const isHomeRoute = (route) => route !== undefined &&
73
+ !isAbsoluteUrl(route) &&
74
+ stripTrailingSlashes(splitPathSuffix(route)[0]) === '';
75
+ /**
76
+ * The entry the editor opens when none is asked for: the one whose route is `/`, else the first
77
+ * with a preview, else the first. `routeFor` gives the route before any prefix or base path
78
+ * (`buildPreviewRoute`), so `previewPrefix` and `basePath` never hide the root page.
79
+ */
80
+ export const resolveHomeEntry = (entries, routeFor) => (routeFor && entries.find((entry) => isHomeRoute(routeFor(entry)))) ||
81
+ entries.find((entry) => entry.previewSrc) ||
82
+ entries[0];
70
83
  /**
71
84
  * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
72
85
  * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
@@ -129,11 +142,18 @@ export const buildWritePayload = (entry, value) => {
129
142
  body: typeof body === 'string' ? body : '',
130
143
  };
131
144
  };
145
+ /**
146
+ * The label an entry of this type goes by when the type allows only one entry (`maxItems: 1`):
147
+ * the type's own, as "Site settings" names the site's one settings entry. Undefined for any other
148
+ * type, and for an unlabelled one, whose entry keeps its title.
149
+ */
150
+ const singletonLabel = (type) => type.maxItems === 1 && type.label ? type.label : undefined;
132
151
  export const buildEntriesFromListResponse = ({ response, resolvePreviewSrc, flatSchema, }) => {
133
152
  return response.entries.map((entry) => {
134
153
  // Resolve schema from flatSchema using parentPath + name
135
154
  let schema = [];
136
155
  let unavailable;
156
+ let typeName;
137
157
  if (entry.collectionPath && entry.entryType) {
138
158
  const entryTypeItem = flatSchema.find((item) => item.type === 'entry-type' &&
139
159
  item.parentPath === entry.collectionPath &&
@@ -141,12 +161,14 @@ export const buildEntriesFromListResponse = ({ response, resolvePreviewSrc, flat
141
161
  if (entryTypeItem && entryTypeItem.type === 'entry-type') {
142
162
  schema = entryTypeItem.schema;
143
163
  unavailable = entryTypeItem.unavailable;
164
+ typeName = singletonLabel(entryTypeItem);
144
165
  }
145
166
  }
146
167
  return {
147
168
  path: entry.logicalPath,
148
169
  contentId: entry.contentId,
149
- label: entry.title || entry.slug || entry.collectionName || entry.collectionPath,
170
+ label: typeName || entry.title || entry.slug || entry.collectionName || entry.collectionPath,
171
+ ...(entry.title && { title: entry.title }),
150
172
  status: entry.exists === false ? 'missing' : (entry.entryType ?? 'entry'),
151
173
  schema: schema,
152
174
  previewSrc: resolvePreviewSrc(entry),
@@ -176,13 +198,7 @@ export const buildCollectionLabels = (collections) => {
176
198
  walk(collections);
177
199
  return map;
178
200
  };
179
- /**
180
- * An entry's type as its collection labels it, else its name read as words; undefined when
181
- * untyped.
182
- */
183
- export const entryTypeLabel = (collections, entry) => {
184
- if (!entry.entryType)
185
- return undefined;
201
+ const findEntryType = (collections, entry) => {
186
202
  const find = (nodes) => {
187
203
  for (const c of nodes) {
188
204
  if (c.path === entry.collectionPath)
@@ -194,21 +210,106 @@ export const entryTypeLabel = (collections, entry) => {
194
210
  return undefined;
195
211
  };
196
212
  const collection = collections ? find(collections) : undefined;
197
- const type = collection?.entryTypes?.find((t) => t.name === entry.entryType);
198
- return type?.label ?? humanizeName(entry.entryType);
213
+ return collection?.entryTypes?.find((t) => t.name === entry.entryType);
214
+ };
215
+ /**
216
+ * An entry's type as its collection labels it, else its name read as words; undefined when
217
+ * untyped.
218
+ */
219
+ export const entryTypeLabel = (collections, entry) => {
220
+ if (!entry.entryType)
221
+ return undefined;
222
+ return findEntryType(collections, entry)?.label ?? humanizeName(entry.entryType);
199
223
  };
200
- /** An entry's navigator badge: "Missing" when its file is gone, else its type's label or name. */
224
+ /**
225
+ * The type shown under an entry's label, in the content tree and quick open: `entryTypeLabel`,
226
+ * except for a singleton, whose label already is its type's (`singletonLabel`).
227
+ */
228
+ export const entryTypeLine = (collections, entry) => {
229
+ const type = findEntryType(collections, entry);
230
+ return type && singletonLabel(type) ? undefined : entryTypeLabel(collections, entry);
231
+ };
232
+ /**
233
+ * An entry's second line in the content tree: "Missing" when its file is gone, else its
234
+ * `entryTypeLine`, else its status read as words.
235
+ */
201
236
  export const entryStatusLabel = (collections, entry) => {
202
237
  if (entry.status === 'missing')
203
238
  return 'Missing';
204
- return entryTypeLabel(collections, entry) ?? (entry.status && humanizeName(entry.status));
239
+ if (entry.entryType)
240
+ return entryTypeLine(collections, entry);
241
+ return entry.status && humanizeName(entry.status);
242
+ };
243
+ /**
244
+ * `data` is the entry's value where it is at hand; without it, a schema with a title field, or a
245
+ * `title` or `name` field, is taken to name the entry from it.
246
+ */
247
+ export const entryNameSource = (collections, entry, data) => {
248
+ const type = findEntryType(collections, entry);
249
+ if (type && singletonLabel(type))
250
+ return { from: 'type' };
251
+ const titlePath = titleFieldPath(entry.schema);
252
+ if (data) {
253
+ const plain = data.title ?? data.name;
254
+ const plainName = isNonEmptyString(plain) ? (data.title != null ? 'title' : 'name') : undefined;
255
+ if (titlePath && isNonEmptyString(valueAt(data, titlePath))) {
256
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, titlePath) };
257
+ }
258
+ if (plainName)
259
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, [plainName]) };
260
+ }
261
+ else {
262
+ if (titlePath)
263
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, titlePath) };
264
+ const named = ['title', 'name'].find((name) => findField(entry.schema, name));
265
+ if (named)
266
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, [named]) };
267
+ }
268
+ if (type?.label)
269
+ return { from: 'type' };
270
+ return { from: 'slug' };
271
+ };
272
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== '';
273
+ /** The value at a data `path`, through nested objects. */
274
+ const valueAt = (data, path) => path.reduce((value, key) => value && typeof value === 'object' && !Array.isArray(value)
275
+ ? value[key]
276
+ : undefined, data);
277
+ /**
278
+ * The label of the field at a data `path`, with its enclosing objects' labels before it
279
+ * ("Hero › Heading"); undefined when the schema has no such field.
280
+ */
281
+ const pathLabel = (schema, path) => {
282
+ const labels = [];
283
+ let fields = schema;
284
+ for (const name of path) {
285
+ const field = fields && findField(fields, name);
286
+ if (!field)
287
+ return undefined;
288
+ labels.push(schemaLabel(field));
289
+ fields = field.type === 'object' && 'fields' in field && !field.list ? field.fields : undefined;
290
+ }
291
+ return labels.join(' › ');
292
+ };
293
+ const isInlineGroup = (field) => field.type === 'group';
294
+ /** The field named `name` among `fields`, looking through inline groups, which hold no data. */
295
+ const findField = (fields, name) => {
296
+ for (const field of fields) {
297
+ if (isInlineGroup(field)) {
298
+ const inner = findField(field.fields, name);
299
+ if (inner)
300
+ return inner;
301
+ }
302
+ else if (field.name === name)
303
+ return field;
304
+ }
305
+ return undefined;
205
306
  };
206
307
  /**
207
- * Builds breadcrumb segments for an entry based on its collection hierarchy, starting with
208
- * "All content". The first path part is skipped as the content root, which the tree does not show.
308
+ * Builds breadcrumb segments for an entry based on its collection hierarchy. The first path part
309
+ * is skipped as the content root, which the tree does not show, so a root entry has none.
209
310
  */
210
311
  export const buildBreadcrumbSegments = (currentEntry, collectionLabels) => {
211
- const segments = [{ label: 'All content' }];
312
+ const segments = [];
212
313
  if (!currentEntry)
213
314
  return segments;
214
315
  if (currentEntry.collectionPath) {
@@ -1,12 +1,14 @@
1
1
  /** Entry search shared by the navigator filter and the quick-open palette. */
2
2
  export interface SearchableEntry {
3
3
  label: string;
4
+ /** The entry's title, when its label is something else (a singleton's type label). */
5
+ title?: string;
4
6
  slug?: string;
5
7
  collectionPath?: string;
6
8
  collectionName?: string;
7
9
  }
8
- /** Case-insensitive substring match over label and slug; an empty query matches everything. */
9
- export declare function matchesEntryQuery(entry: Pick<SearchableEntry, 'label' | 'slug'>, query: string): boolean;
10
+ /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
11
+ export declare function matchesEntryQuery(entry: Pick<SearchableEntry, 'label' | 'title' | 'slug'>, query: string): boolean;
10
12
  export declare function filterEntries<T extends SearchableEntry>(entries: readonly T[], query: string): T[];
11
13
  export interface EntryGroup<T> {
12
14
  heading: string;
@@ -1,11 +1,10 @@
1
1
  /** Entry search shared by the navigator filter and the quick-open palette. */
2
- /** Case-insensitive substring match over label and slug; an empty query matches everything. */
2
+ /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
3
3
  export function matchesEntryQuery(entry, query) {
4
4
  const needle = query.trim().toLowerCase();
5
5
  if (!needle)
6
6
  return true;
7
- return (entry.label.toLowerCase().includes(needle) ||
8
- (entry.slug?.toLowerCase().includes(needle) ?? false));
7
+ return [entry.label, entry.title, entry.slug].some((text) => text?.toLowerCase().includes(needle));
9
8
  }
10
9
  export function filterEntries(entries, query) {
11
10
  return entries.filter((entry) => matchesEntryQuery(entry, query));
@@ -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, Clear). */
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
  };
@@ -11,7 +11,18 @@ export interface ObjectFieldProps {
11
11
  renderField: RenderField;
12
12
  path: Array<string | number>;
13
13
  dataCanopyField?: string;
14
- /** Set, shows a gray "Clear" resetting the field to `undefined`; omitted for list items. */
15
- onRemove?: () => void;
14
+ /** Set, a "…" menu offers Clear, resetting the field to `undefined`; omitted for list items. */
15
+ onClear?: () => void;
16
+ /** Set, an "Undo clear" button stands in the label row in place of the "…" menu. */
17
+ onUndoClear?: () => void;
18
+ /** Said with the Undo, such as where the object's comment threads went. */
19
+ clearNote?: string;
16
20
  }
21
+ /**
22
+ * An object field's group of fields. Its Clear is an Undo-instead-of-confirm removal
23
+ * (docs/ux-guidelines.md, Destructive actions) whose Undo stays in the label row, where the clear
24
+ * happened, with no timer, until the caller drops `onUndoClear`. FormRenderer drops it once it
25
+ * can no longer put the object back where it was, another entry or branch opens, or the entry is
26
+ * saved or its draft discarded.
27
+ */
17
28
  export declare const ObjectField: React.FC<ObjectFieldProps>;