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
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- /** Docked beside the main region, or laid over the form pane only. */
2
+ /** Docked, pushing its neighbours aside, or laid over the form pane only. */
3
3
  type SidePanelMode = 'docked' | 'overlay';
4
4
  export interface SidePanelHostProps {
5
5
  title: string;
@@ -26,10 +26,12 @@ export interface SidePanelHostProps {
26
26
  'data-testid'?: string;
27
27
  }
28
28
  /**
29
- * A titled side panel, resizable from its inline-end edge. Docked, it is a column of the shell;
30
- * as an overlay it covers the form pane only, so the preview stays in view, and Escape closes it.
31
- * A `focusRequest` moves focus to its `data-autofocus` element, else to the panel; closing with
32
- * focus inside returns focus to `returnFocusTo`.
29
+ * A titled side panel, resizable from its inline-end edge. Docked, it pushes its neighbours aside
30
+ * and opens and closes at once: a sliding width would reflow the preview's page on every frame.
31
+ * As an overlay it covers the form pane only, so the preview stays in view, slides in and out as
32
+ * the drawers do unless the user prefers reduced motion, and Escape closes it. A `focusRequest`
33
+ * moves focus to its `data-autofocus` element, else to the panel; closing with focus inside
34
+ * returns focus to `returnFocusTo`.
33
35
  */
34
- export declare function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest, returnFocusTo, actions, children, id, 'data-testid': testId, }: SidePanelHostProps): import("react/jsx-runtime").JSX.Element | null;
36
+ export declare function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest, returnFocusTo, actions, children, id, 'data-testid': testId, }: SidePanelHostProps): import("react/jsx-runtime").JSX.Element;
35
37
  export {};
@@ -1,58 +1,89 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, useState, } from 'react';
4
- import { CloseButton, Group, Text, Tooltip } from '@mantine/core';
5
- import { useWindowEvent } from '@mantine/hooks';
6
- import { focusIfIdle } from '../utils/focus-return.js';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useId, useLayoutEffect, useRef, useState, } from 'react';
4
+ import { CloseButton, Group, Text, Tooltip, Transition } from '@mantine/core';
5
+ import { useReducedMotion, useWindowEvent } from '@mantine/hooks';
6
+ import { FOCUS_RETURN_ATTR, focusIfIdle } from '../utils/focus-return.js';
7
+ import { DIVIDER_COLOR, DIVIDER_TARGET_PX, DIVIDER_WIDTH_PX } from './EditorShell.js';
7
8
  const KEY_STEP_PX = 16;
8
9
  const RESIZING_ATTR = 'data-canopy-panel-resizing';
10
+ /** The drawers' slide: Mantine's `Transition` default. */
11
+ const SLIDE_MS = 250;
12
+ const targetOverhang = (target) => (target - DIVIDER_WIDTH_PX) / 2;
13
+ // The separator is the panel's last pixels; its `::before` is the wider pointer target.
9
14
  const PANEL_STYLES = `
10
15
  body[${RESIZING_ATTR}] { cursor: col-resize; user-select: none; }
11
16
  body[${RESIZING_ATTR}] iframe { pointer-events: none; }
12
17
  .canopy-side-panel-separator {
13
18
  position: absolute;
14
19
  inset-block: 0;
15
- inset-inline-end: -3px;
16
- width: 6px;
20
+ inset-inline-end: 0;
21
+ width: ${DIVIDER_WIDTH_PX}px;
22
+ background: ${DIVIDER_COLOR};
17
23
  cursor: col-resize;
18
24
  touch-action: none;
19
25
  z-index: 1;
20
26
  }
27
+ .canopy-side-panel-separator::before {
28
+ content: '';
29
+ position: absolute;
30
+ inset-block: 0;
31
+ inset-inline: -${targetOverhang(DIVIDER_TARGET_PX.fine)}px;
32
+ }
33
+ @media (pointer: coarse) {
34
+ .canopy-side-panel-separator::before {
35
+ inset-inline: -${targetOverhang(DIVIDER_TARGET_PX.coarse)}px;
36
+ }
37
+ }
21
38
  .canopy-side-panel-separator:hover,
39
+ .canopy-side-panel-separator:focus-visible,
22
40
  .canopy-side-panel-separator[data-dragging] {
23
41
  background: var(--canopy-focus-ring);
24
42
  }
25
43
  .canopy-side-panel-separator:focus-visible {
26
44
  outline: 2px solid var(--canopy-focus-ring);
27
- outline-offset: -2px;
28
45
  }
29
46
  `;
30
47
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
31
48
  /**
32
- * A titled side panel, resizable from its inline-end edge. Docked, it is a column of the shell;
33
- * as an overlay it covers the form pane only, so the preview stays in view, and Escape closes it.
34
- * A `focusRequest` moves focus to its `data-autofocus` element, else to the panel; closing with
35
- * focus inside returns focus to `returnFocusTo`.
49
+ * A titled side panel, resizable from its inline-end edge. Docked, it pushes its neighbours aside
50
+ * and opens and closes at once: a sliding width would reflow the preview's page on every frame.
51
+ * As an overlay it covers the form pane only, so the preview stays in view, slides in and out as
52
+ * the drawers do unless the user prefers reduced motion, and Escape closes it. A `focusRequest`
53
+ * moves focus to its `data-autofocus` element, else to the panel; closing with focus inside
54
+ * returns focus to `returnFocusTo`.
36
55
  */
37
56
  export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest = 0, returnFocusTo, actions, children, id, 'data-testid': testId, }) {
38
57
  const fallbackId = useId();
39
58
  const panelId = id ?? fallbackId;
40
59
  const titleId = `${panelId}-title`;
41
- const panelRef = useRef(null);
60
+ // State, not a ref: an overlay's section mounts a frame after `opened`, as its slide starts.
61
+ const [panel, setPanel] = useState(null);
42
62
  const focusInside = useRef(false);
63
+ const closingWithFocus = useRef(false);
43
64
  const wasOpened = useRef(opened);
44
65
  const servedFocusRequest = useRef(focusRequest);
66
+ const reduceMotion = useReducedMotion();
67
+ // The slide's direction, read from the page as each opening starts, as the resize keys read it.
68
+ const anchorRef = useRef(null);
69
+ const [rtl, setRtl] = useState(false);
70
+ useLayoutEffect(() => {
71
+ const anchor = anchorRef.current;
72
+ if (opened && anchor)
73
+ setRtl(getComputedStyle(anchor).direction === 'rtl');
74
+ }, [opened]);
45
75
  const drag = useRef(null);
46
76
  const [dragWidth, setDragWidth] = useState(null);
47
77
  if (!opened && dragWidth !== null)
48
78
  setDragWidth(null);
49
79
  const shownWidth = clamp(dragWidth ?? width, minWidth, maxWidth);
50
80
  useEffect(() => {
51
- const panel = panelRef.current;
52
- const asked = opened && focusRequest !== servedFocusRequest.current;
53
- servedFocusRequest.current = focusRequest;
54
- if (!asked || !panel)
81
+ // A request made while closed lapses; one made as it opens waits for the section to mount.
82
+ if (!opened)
83
+ servedFocusRequest.current = focusRequest;
84
+ if (!opened || !panel || focusRequest === servedFocusRequest.current)
55
85
  return;
86
+ servedFocusRequest.current = focusRequest;
56
87
  const autofocus = () => panel.querySelector('[data-autofocus]');
57
88
  const field = autofocus();
58
89
  if (field) {
@@ -73,13 +104,21 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
73
104
  });
74
105
  observer.observe(panel, { childList: true, subtree: true });
75
106
  return () => observer.disconnect();
76
- }, [opened, focusRequest]);
107
+ }, [opened, focusRequest, panel]);
108
+ // A sliding-out overlay is inert, so it neither holds focus nor takes the pointer. Whether focus
109
+ // was inside is read first, as `inert` can blur it.
110
+ useLayoutEffect(() => {
111
+ if (!opened && wasOpened.current)
112
+ closingWithFocus.current = focusInside.current;
113
+ panel?.toggleAttribute('inert', !opened);
114
+ }, [opened, panel]);
77
115
  useEffect(() => {
78
116
  if (!opened && wasOpened.current) {
79
- // Its separator is gone, and with it the pointer capture that would have ended a drag.
117
+ // Its separator is gone or inert, and with it the pointer capture that would end a drag.
80
118
  drag.current = null;
81
119
  document.body.removeAttribute(RESIZING_ATTR);
82
- if (focusInside.current) {
120
+ if (closingWithFocus.current) {
121
+ closingWithFocus.current = false;
83
122
  focusInside.current = false;
84
123
  focusIfIdle(returnFocusTo?.() ?? null);
85
124
  }
@@ -94,15 +133,13 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
94
133
  event.key === 'Escape' &&
95
134
  !event.defaultPrevented &&
96
135
  event.target instanceof Node &&
97
- panelRef.current?.contains(event.target)) {
136
+ panel?.contains(event.target)) {
98
137
  event.preventDefault();
99
138
  onClose();
100
139
  }
101
140
  });
102
141
  // A drag cut short by unmounting must not leave the page's iframes deaf to the pointer.
103
142
  useEffect(() => () => document.body.removeAttribute(RESIZING_ATTR), []);
104
- if (!opened)
105
- return null;
106
143
  const endDrag = () => {
107
144
  const state = drag.current;
108
145
  if (!state)
@@ -170,15 +207,15 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
170
207
  display: 'flex',
171
208
  flexDirection: 'column',
172
209
  background: 'var(--mantine-color-body)',
173
- borderInlineEnd: '1px solid var(--canopy-input-border)',
210
+ paddingInlineEnd: DIVIDER_WIDTH_PX,
174
211
  };
175
- return (_jsxs("section", { ref: panelRef, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, style: style, onFocus: () => {
176
- focusInside.current = true;
177
- }, onBlur: (event) => {
178
- if (!event.currentTarget.contains(event.relatedTarget))
179
- focusInside.current = false;
180
- }, children: [_jsx("style", { children: PANEL_STYLES }), _jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", px: "md", py: "xs", style: { borderBlockEnd: '1px solid var(--canopy-input-border)' }, children: [_jsx(Text, { id: titleId, component: "h2", fw: 600, size: "md", m: 0, children: title }), _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [actions, _jsx(Tooltip, { label: `Close ${title.toLowerCase()} panel`, children: _jsx(CloseButton, { "aria-label": `Close ${title.toLowerCase()} panel`, onClick: onClose }) })] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }), _jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${title.toLowerCase()} panel`, "aria-controls": panelId, "aria-valuenow": Math.round(shownWidth), "aria-valuemin": minWidth, "aria-valuemax": maxWidth,
181
- // A focusable separator is a window splitter, a widget; jsx-a11y counts every one static.
182
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
183
- tabIndex: 0, className: "canopy-side-panel-separator", "data-dragging": dragWidth !== null || undefined, ...separatorHandlers })] }));
212
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), _jsx(Transition, { mounted: opened, transition: rtl ? 'slide-left' : 'slide-right', duration: overlay && !reduceMotion ? SLIDE_MS : 0, children: (transitionStyles) => (_jsxs("section", { ref: setPanel, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, [FOCUS_RETURN_ATTR]: opened ? undefined : '', style: { ...style, ...transitionStyles }, onFocus: () => {
213
+ focusInside.current = true;
214
+ }, onBlur: (event) => {
215
+ if (!event.currentTarget.contains(event.relatedTarget))
216
+ focusInside.current = false;
217
+ }, children: [_jsx("style", { children: PANEL_STYLES }), _jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", px: "md", py: "xs", style: { borderBlockEnd: '1px solid var(--canopy-input-border)' }, children: [_jsx(Text, { id: titleId, component: "h2", fw: 600, size: "md", m: 0, children: title }), _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [actions, _jsx(Tooltip, { label: `Close ${title.toLowerCase()} panel`, children: _jsx(CloseButton, { "aria-label": `Close ${title.toLowerCase()} panel`, onClick: onClose }) })] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }), _jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${title.toLowerCase()} panel`, "aria-controls": panelId, "aria-valuenow": Math.round(shownWidth), "aria-valuemin": minWidth, "aria-valuemax": maxWidth,
218
+ // A focusable separator is a window splitter, a widget; jsx-a11y counts every one static.
219
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
220
+ tabIndex: 0, className: "canopy-side-panel-separator", "data-dragging": dragWidth !== null || undefined, ...separatorHandlers })] })) })] }));
184
221
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Box, Group } from '@mantine/core';
4
+ import { SHELL_REGION_BORDER } from './EditorShell.js';
4
5
  const TOP_BAR_HEIGHT = 48;
5
6
  /**
6
7
  * The shell's top bar: one 48px row from `md` up. Narrower, `leading` takes a row of its own
@@ -8,7 +9,7 @@ const TOP_BAR_HEIGHT = 48;
8
9
  */
9
10
  export function TopBar({ leading, trailing }) {
10
11
  return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", px: "sm", py: 6, mih: TOP_BAR_HEIGHT, style: {
11
- borderBottom: '1px solid var(--canopy-input-border)',
12
+ borderBottom: SHELL_REGION_BORDER,
12
13
  background: 'var(--mantine-color-body)',
13
14
  }, children: [_jsx(Box, { flex: { base: '1 0 100%', md: '1 1 0%' }, miw: 0, children: leading }), _jsx(Group, { gap: 6, wrap: "wrap", align: "center", justify: "flex-end", flex: { base: '1 0 100%', md: '0 1 auto' }, miw: 0, children: trailing })] }));
14
15
  }
@@ -4,6 +4,7 @@ import '@mantine/core/styles.css';
4
4
  import '@mantine/dates/styles.css';
5
5
  import '@mantine/notifications/styles.css';
6
6
  import '@mantine/dropzone/styles.css';
7
+ import 'react-image-crop/dist/ReactCrop.css';
7
8
  import '@mdxeditor/editor/style.css';
8
9
  export type CanopyColorInput = MantineColorsTuple | string;
9
10
  export interface CanopyColorConfig {
@@ -10,6 +10,7 @@ import '@mantine/core/styles.css';
10
10
  import '@mantine/dates/styles.css';
11
11
  import '@mantine/notifications/styles.css';
12
12
  import '@mantine/dropzone/styles.css';
13
+ import 'react-image-crop/dist/ReactCrop.css';
13
14
  import '@mdxeditor/editor/style.css';
14
15
  const defaultCanopyColors = {
15
16
  brand: '#3b82f6',
@@ -254,6 +255,7 @@ const primaryFill = (theme, scheme) => {
254
255
  * dark, at 3:1 on gray.1 or dark.5. Mantine draws focus rings, focused borders and a checked
255
256
  * Switch in `--mantine-primary-color-filled`, so that becomes the ring; `canopyComponents` points
256
257
  * Checkbox, Radio and Tabs at it. A tick on it is white, or black where white is under 3:1.
258
+ * `--canopy-unsaved-mark` is the same colour, named for the dot that marks unsaved changes.
257
259
  */
258
260
  const focusRing = (theme, scheme) => {
259
261
  const scale = theme.colors[theme.primaryColor];
@@ -274,6 +276,7 @@ const focusRing = (theme, scheme) => {
274
276
  ? ring.value
275
277
  : `var(--mantine-color-${theme.primaryColor}-${ring.shade})`,
276
278
  '--canopy-focus-ring-contrast': contrast,
279
+ '--canopy-unsaved-mark': 'var(--canopy-focus-ring)',
277
280
  '--mantine-primary-color-filled': 'var(--canopy-focus-ring)',
278
281
  };
279
282
  };
@@ -316,7 +319,8 @@ const canopyCssVariablesResolver = (theme) => ({
316
319
  * What Mantine draws from class rules, out of the theme variables' reach: unchecked control borders,
317
320
  * Switch thumbs, white text on the primary colour (moved to the fill), and dimmed text Mantine
318
321
  * draws itself (a blocked menu item, a combobox's empty message, outside days). Outside days lose
319
- * Mantine's half opacity, under 4.5:1.
322
+ * Mantine's half opacity, under 4.5:1. The crop box and its handles sit on an arbitrary image, where
323
+ * no single colour holds 3:1, so they take a white and black pair.
320
324
  */
321
325
  const CANOPY_STYLES = `
322
326
  .mantine-Checkbox-input:not(:checked, :disabled, [data-indeterminate], [data-error]),
@@ -335,6 +339,20 @@ const CANOPY_STYLES = `
335
339
  --switch-thumb-bg: var(--canopy-switch-thumb-on, var(--mantine-color-white));
336
340
  --switch-thumb-bd: var(--canopy-switch-thumb-on, var(--mantine-color-white));
337
341
  }
342
+ .ReactCrop .ReactCrop__crop-selection:focus {
343
+ outline: 2px solid var(--mantine-color-white);
344
+ outline-offset: 0;
345
+ box-shadow: 0 0 0 4px var(--mantine-color-black);
346
+ }
347
+ .ReactCrop .ReactCrop__drag-handle {
348
+ --rc-drag-handle-bg-colour: var(--mantine-color-white);
349
+ border-color: var(--mantine-color-black);
350
+ }
351
+ .ReactCrop .ReactCrop__drag-handle:focus {
352
+ background: var(--canopy-focus-ring);
353
+ outline: 2px solid var(--mantine-color-white);
354
+ box-shadow: 0 0 0 4px var(--mantine-color-black);
355
+ }
338
356
  [data-combobox-selected] {
339
357
  --mantine-primary-color-filled: var(--canopy-primary-fill);
340
358
  }
@@ -3,12 +3,18 @@ import type { CommentThread } from '../comment-store.js';
3
3
  import { type CanopyPathSegment } from './canopy-path.js';
4
4
  /**
5
5
  * One list a removal sits inside (a block list, or an object list holding the removed item's
6
- * list): the index of the item enclosing the removal, and every other item.
6
+ * list): the index of the item enclosing the removal, and that item's `ITEM_KEY`, which an edit
7
+ * or a move keeps. An item with no usable key is anchored by every other item's content instead.
7
8
  */
8
- export interface RemovalAnchor {
9
+ export type RemovalAnchor = {
9
10
  index: number;
11
+ } & ({
12
+ key: string;
13
+ siblings?: never;
14
+ } | {
15
+ key?: never;
10
16
  siblings: unknown[];
11
- }
17
+ });
12
18
  /**
13
19
  * What a form's Remove took out, for Undo. Paths are canopy paths (`blocks[2].items`), which skip
14
20
  * a block's `value` wrapper; `restoreRemoval` resolves them against the value it restores into.
@@ -30,11 +36,10 @@ export type FieldRemoval = ({
30
36
  }) & {
31
37
  /**
32
38
  * The enclosing lists as they were before the removal (`removalAnchors`). Undo puts nothing
33
- * back once one has changed, so an item never lands in whichever block now holds its index.
39
+ * back once another item holds an enclosing index, so an item never lands in whichever block
40
+ * now holds it; an edit to any item keeps a keyed anchor.
34
41
  */
35
42
  anchors?: RemovalAnchor[];
36
- /** `clear` for an optional object's Clear, which its toast calls clearing, not removing. */
37
- action?: 'remove' | 'clear';
38
43
  };
39
44
  /**
40
45
  * The canopy path `restoreRemoval` puts a removal back at: a list item returns to its old index,
@@ -43,13 +48,17 @@ export type FieldRemoval = ({
43
48
  export declare function restoredFieldPath(fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval): CanopyPathSegment[];
44
49
  /** The enclosing lists of a removal in `value`, taken before the removal applies; see `anchors`. */
45
50
  export declare function removalAnchors(fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval): RemovalAnchor[] | undefined;
51
+ /** Whether `restoreRemoval` would put the removal back, without building the restored value. */
52
+ export declare const canRestoreRemoval: (fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval) => boolean;
46
53
  /**
47
- * `value` with a removal put back, or undefined when it can't be: the list or field is gone, an
48
- * enclosing list has changed, the item is already there, or the image field has been filled.
49
- * Edits made after the removal are kept, because it changes only the one list or field, in the
50
- * value as it is now.
54
+ * `value` with a removal put back, or undefined when it can't be: the list or field is gone,
55
+ * another item holds an enclosing index, the item is already there, or the image field or object
56
+ * has been filled. Edits made after the removal are kept, because it changes only the one list or
57
+ * field, in the value as it is now.
51
58
  */
52
59
  export declare function restoreRemoval(fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval): Record<string, unknown> | undefined;
60
+ /** Where a removal's moved threads went, said with its Undo; undefined when none moved. */
61
+ export declare const movedThreadsNote: (count: number) => string | undefined;
53
62
  /**
54
63
  * How many open field threads a removal at `path` moves to the entry: those inside it keyed to an
55
64
  * item at or below it. One keyed only above stays on its field, which a Clear leaves in place.
@@ -1,6 +1,12 @@
1
1
  import equal from 'fast-deep-equal';
2
+ import { ITEM_KEY, isItemKey } from '../item-keys.js';
2
3
  import { isPlainRecord } from '../validation/field-traversal.js';
3
4
  import { normalizeCanopyPath, parseCanopyPath } from './canopy-path.js';
5
+ const itemKeyAt = (list, index) => {
6
+ const item = list[index];
7
+ const key = isPlainRecord(item) ? item[ITEM_KEY] : undefined;
8
+ return isItemKey(key) ? key : undefined;
9
+ };
4
10
  const findField = (fields, name) => {
5
11
  for (const field of fields) {
6
12
  if (field.type === 'group') {
@@ -22,8 +28,8 @@ const resolvePath = (fields, value, path) => {
22
28
  let scope = fields;
23
29
  let node = value;
24
30
  const out = [];
25
- const anchors = [];
26
- const anchor = (list, index) => anchors.push({ index, siblings: list.filter((_, i) => i !== index) });
31
+ const lists = [];
32
+ const anchor = (list, index) => lists.push({ list, index });
27
33
  let i = 0;
28
34
  while (i < path.length) {
29
35
  const name = path[i];
@@ -34,7 +40,7 @@ const resolvePath = (fields, value, path) => {
34
40
  node = isPlainRecord(node) ? node[name] : undefined;
35
41
  i += 1;
36
42
  if (i === path.length)
37
- return { path: out, anchors };
43
+ return { path: out, lists };
38
44
  const index = path[i];
39
45
  if (field.type === 'block') {
40
46
  const block = typeof index === 'number' && Array.isArray(node) ? node[index] : undefined;
@@ -65,7 +71,7 @@ const resolvePath = (fields, value, path) => {
65
71
  return undefined;
66
72
  }
67
73
  }
68
- return { path: out, anchors };
74
+ return { path: out, lists };
69
75
  };
70
76
  const getAt = (root, path) => path.reduce((node, segment) => {
71
77
  if (typeof segment === 'number')
@@ -89,9 +95,15 @@ const sameContent = (a, b) => equal(JSON.parse(JSON.stringify(a ?? null)), JSON.
89
95
  const removalPath = (removal) => removal.kind === 'list-item' ? removal.listPath : removal.path;
90
96
  const anchorsHold = (then, now) => then === undefined ||
91
97
  (then.length === now.length &&
92
- then.every((anchor, i) => anchor.index === now[i].index &&
98
+ then.every((anchor, i) => {
99
+ const { list, index } = now[i];
100
+ if (anchor.index !== index)
101
+ return false;
102
+ if (anchor.key !== undefined)
103
+ return itemKeyAt(list, index) === anchor.key;
93
104
  // By content as stored, so the server's copy of the same items still holds.
94
- sameContent(anchor.siblings, now[i].siblings)));
105
+ return sameContent(anchor.siblings, list.filter((_, j) => j !== index));
106
+ }));
95
107
  /**
96
108
  * The canopy path `restoreRemoval` puts a removal back at: a list item returns to its old index,
97
109
  * or to the list's end when the list is now shorter than that.
@@ -105,17 +117,17 @@ export function restoredFieldPath(fields, value, removal) {
105
117
  }
106
118
  /** The enclosing lists of a removal in `value`, taken before the removal applies; see `anchors`. */
107
119
  export function removalAnchors(fields, value, removal) {
108
- return resolvePath(fields, value, removalPath(removal))?.anchors;
120
+ return resolvePath(fields, value, removalPath(removal))?.lists.map(({ list, index }) => {
121
+ const key = itemKeyAt(list, index);
122
+ return key !== undefined
123
+ ? { index, key }
124
+ : { index, siblings: list.filter((_, j) => j !== index) };
125
+ });
109
126
  }
110
- /**
111
- * `value` with a removal put back, or undefined when it can't be: the list or field is gone, an
112
- * enclosing list has changed, the item is already there, or the image field has been filled.
113
- * Edits made after the removal are kept, because it changes only the one list or field, in the
114
- * value as it is now.
115
- */
116
- export function restoreRemoval(fields, value, removal) {
127
+ /** Where and what `restoreRemoval` writes, or undefined when it can't; see there. */
128
+ const planRestore = (fields, value, removal) => {
117
129
  const resolved = resolvePath(fields, value, removalPath(removal));
118
- if (!resolved || !anchorsHold(removal.anchors, resolved.anchors))
130
+ if (!resolved || !anchorsHold(removal.anchors, resolved.lists))
119
131
  return undefined;
120
132
  const { path } = resolved;
121
133
  // An enclosing object cleared since would come back holding only this.
@@ -139,9 +151,25 @@ export function restoreRemoval(fields, value, removal) {
139
151
  return undefined;
140
152
  next = removal.value;
141
153
  }
142
- const restored = setAt(value, path, next);
154
+ return { path, next };
155
+ };
156
+ /** Whether `restoreRemoval` would put the removal back, without building the restored value. */
157
+ export const canRestoreRemoval = (fields, value, removal) => planRestore(fields, value, removal) !== undefined;
158
+ /**
159
+ * `value` with a removal put back, or undefined when it can't be: the list or field is gone,
160
+ * another item holds an enclosing index, the item is already there, or the image field or object
161
+ * has been filled. Edits made after the removal are kept, because it changes only the one list or
162
+ * field, in the value as it is now.
163
+ */
164
+ export function restoreRemoval(fields, value, removal) {
165
+ const plan = planRestore(fields, value, removal);
166
+ const restored = plan ? setAt(value, plan.path, plan.next) : undefined;
143
167
  return isPlainRecord(restored) ? restored : undefined;
144
168
  }
169
+ /** Where a removal's moved threads went, said with its Undo; undefined when none moved. */
170
+ export const movedThreadsNote = (count) => count > 0
171
+ ? `Its ${count === 1 ? 'comment thread' : `${count} comment threads`} moved to "Comment on this entry".`
172
+ : undefined;
145
173
  /**
146
174
  * How many open field threads a removal at `path` moves to the entry: those inside it keyed to an
147
175
  * item at or below it. One keyed only above stays on its field, which a Clear leaves in place.
package/dist/server.d.ts CHANGED
@@ -195,6 +195,8 @@ export { authenticatedAssetBase, readAssetBase } from './editor/raw-asset-base.j
195
195
  export { setServerPreviewAssetBaseGetter } from './editor/preview-asset-base.js';
196
196
  /** The asset store `createAssetStore` returns and `materializeAssets` takes. */
197
197
  export type { AssetStore } from './assets/types.js';
198
+ /** What `AssetStore.presignPublicObjectRead` returns: the URL and how long it may be reused. */
199
+ export type { PresignedRead } from './assets/types.js';
198
200
  /** What a create-only store write did: `created`, or `already-exists` (the stored object was kept). */
199
201
  export type { CreateOnlyResult } from './assets/types.js';
200
202
  /** Thrown by `materializeAssets` when sharp cannot load and keys are missing, so none can be produced. */
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.1.0";
1
+ export declare const CANOPYCMS_VERSION = "0.2.0-int.121";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.1.0';
3
+ export const CANOPYCMS_VERSION = '0.2.0-int.121';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.1.0",
4
+ "version": "0.2.0-int.121",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -143,7 +143,7 @@
143
143
  "pathe": "^1.1.2",
144
144
  "picocolors": "^1.1.1",
145
145
  "proper-lockfile": "^4.1.2",
146
- "react-easy-crop": "^6.2.2",
146
+ "react-image-crop": "^11.1.2",
147
147
  "react-remove-scroll": "^2.7.2",
148
148
  "react-resizable-panels": "4.14.1",
149
149
  "sanitize-html": "^2.17.5",