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
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Pure math for the ImageField crop step: converting between react-easy-crop's
3
- * percentage-based `Area` shape and CanopyCMS's normalized 0..1 `CropRect`
2
+ * Pure math for the ImageField crop step: converting between react-image-crop's
3
+ * percentage-based crop shape and CanopyCMS's normalized 0..1 `CropRect`
4
4
  * (the same shape stored in `ImageFieldValue.crop` and accepted by the `c=`
5
5
  * transform directive - see assets/transform-directives.ts). Kept dependency-free
6
- * (no react-easy-crop import) so it's trivially unit-testable and reusable
6
+ * (no react-image-crop import) so it's trivially unit-testable and reusable
7
7
  * without pulling in the cropper UI library.
8
8
  */
9
9
  import { type CropRect } from '../../assets/transform-directives.js';
10
- /** react-easy-crop's `Area` shape, expressed in percentages (0..100). */
10
+ /** A crop box in percentages (0..100) of the image, the shape react-image-crop reports for `unit: '%'`. */
11
11
  export interface CropAreaPercent {
12
12
  x: number;
13
13
  y: number;
@@ -15,8 +15,7 @@ export interface CropAreaPercent {
15
15
  height: number;
16
16
  }
17
17
  /**
18
- * Convert react-easy-crop's `onCropComplete(croppedArea, croppedAreaPixels)`
19
- * percentage area into a normalized `CropRect`, clamped to [0,1] and then
18
+ * Convert a percentage crop box into a normalized `CropRect`, clamped to [0,1] and then
20
19
  * rounded by `roundCropRect` — the same rounding the transform URL's canonical
21
20
  * form applies, so the stored crop is already canonical.
22
21
  *
@@ -24,12 +23,19 @@ export interface CropAreaPercent {
24
23
  * zero-area selection).
25
24
  */
26
25
  export declare function cropAreaPercentToRect(area: CropAreaPercent): CropRect | null;
26
+ /** "W:H" -> the numeric aspect ratio react-image-crop's `aspect` prop expects. Mirrors config/schemas/field.ts's ASPECT_RATIO_RE; returns undefined for anything malformed rather than throwing (defensive - the field config was already validated at schema-parse time). */
27
+ export declare function parseAspectRatio(aspect: string | undefined): number | undefined;
27
28
  /**
28
- * Inverse of `cropAreaPercentToRect` - seeds react-easy-crop's
29
- * `initialCroppedAreaPercentages` when re-opening the crop step for an
30
- * already-cropped image, so the user sees their previous selection instead
31
- * of starting over.
29
+ * The crop box the crop step opens with, once the image's displayed size is known.
30
+ * A stored crop is kept as it was drawn, unless the field has an `aspect` it no longer
31
+ * matches (the config changed since); then, like a first crop of a locked field, a box of that
32
+ * ratio centred on the image. A free field's first box is the whole frame, so Apply with no change trims nothing.
32
33
  */
33
- export declare function cropRectToAreaPercent(rect: CropRect): CropAreaPercent;
34
- /** "W:H" -> the numeric aspect ratio react-easy-crop's `aspect` prop expects. Mirrors config/schemas/field.ts's ASPECT_RATIO_RE; returns undefined for anything malformed rather than throwing (defensive - the field config was already validated at schema-parse time). */
35
- export declare function parseAspectRatio(aspect: string | undefined): number | undefined;
34
+ export declare function initialCropBox(opts: {
35
+ imageWidth: number;
36
+ imageHeight: number;
37
+ aspect?: number;
38
+ existing?: CropRect;
39
+ }): CropAreaPercent;
40
+ /** True for a rectangle covering the whole frame, which renders the image unchanged. */
41
+ export declare function isFullFrameCrop(rect: CropRect): boolean;
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Pure math for the ImageField crop step: converting between react-easy-crop's
3
- * percentage-based `Area` shape and CanopyCMS's normalized 0..1 `CropRect`
2
+ * Pure math for the ImageField crop step: converting between react-image-crop's
3
+ * percentage-based crop shape and CanopyCMS's normalized 0..1 `CropRect`
4
4
  * (the same shape stored in `ImageFieldValue.crop` and accepted by the `c=`
5
5
  * transform directive - see assets/transform-directives.ts). Kept dependency-free
6
- * (no react-easy-crop import) so it's trivially unit-testable and reusable
6
+ * (no react-image-crop import) so it's trivially unit-testable and reusable
7
7
  * without pulling in the cropper UI library.
8
8
  */
9
9
  import { isValidCropRect, roundCropRect } from '../../assets/transform-directives.js';
@@ -13,8 +13,7 @@ function clamp01(value) {
13
13
  return Math.min(1, Math.max(0, value));
14
14
  }
15
15
  /**
16
- * Convert react-easy-crop's `onCropComplete(croppedArea, croppedAreaPixels)`
17
- * percentage area into a normalized `CropRect`, clamped to [0,1] and then
16
+ * Convert a percentage crop box into a normalized `CropRect`, clamped to [0,1] and then
18
17
  * rounded by `roundCropRect` — the same rounding the transform URL's canonical
19
18
  * form applies, so the stored crop is already canonical.
20
19
  *
@@ -31,15 +30,14 @@ export function cropAreaPercentToRect(area) {
31
30
  return isValidCropRect(rect.x, rect.y, rect.w, rect.h) ? rect : null;
32
31
  }
33
32
  /**
34
- * Inverse of `cropAreaPercentToRect` - seeds react-easy-crop's
35
- * `initialCroppedAreaPercentages` when re-opening the crop step for an
36
- * already-cropped image, so the user sees their previous selection instead
37
- * of starting over.
33
+ * Inverse of `cropAreaPercentToRect` - seeds the crop box when re-opening
34
+ * the crop step for an already-cropped image, so the user sees their previous
35
+ * selection instead of starting over.
38
36
  */
39
- export function cropRectToAreaPercent(rect) {
37
+ function cropRectToAreaPercent(rect) {
40
38
  return { x: rect.x * 100, y: rect.y * 100, width: rect.w * 100, height: rect.h * 100 };
41
39
  }
42
- /** "W:H" -> the numeric aspect ratio react-easy-crop's `aspect` prop expects. Mirrors config/schemas/field.ts's ASPECT_RATIO_RE; returns undefined for anything malformed rather than throwing (defensive - the field config was already validated at schema-parse time). */
40
+ /** "W:H" -> the numeric aspect ratio react-image-crop's `aspect` prop expects. Mirrors config/schemas/field.ts's ASPECT_RATIO_RE; returns undefined for anything malformed rather than throwing (defensive - the field config was already validated at schema-parse time). */
43
41
  export function parseAspectRatio(aspect) {
44
42
  if (!aspect)
45
43
  return undefined;
@@ -48,3 +46,44 @@ export function parseAspectRatio(aspect) {
48
46
  return undefined;
49
47
  return Number(match[1]) / Number(match[2]);
50
48
  }
49
+ const INITIAL_BOX_FRACTION = 0.9;
50
+ const FREE_BOX = { x: 0, y: 0, width: 100, height: 100 };
51
+ /** How far a stored crop's pixel ratio may sit from the field's `aspect` before it is replaced by a fresh box. */
52
+ const ASPECT_TOLERANCE = 0.02;
53
+ /**
54
+ * The crop box the crop step opens with, once the image's displayed size is known.
55
+ * A stored crop is kept as it was drawn, unless the field has an `aspect` it no longer
56
+ * matches (the config changed since); then, like a first crop of a locked field, a box of that
57
+ * ratio centred on the image. A free field's first box is the whole frame, so Apply with no change trims nothing.
58
+ */
59
+ export function initialCropBox(opts) {
60
+ const { imageWidth, imageHeight, aspect, existing } = opts;
61
+ const sized = imageWidth > 0 && imageHeight > 0;
62
+ if (existing) {
63
+ const box = cropRectToAreaPercent(existing);
64
+ if (aspect === undefined || !sized)
65
+ return box;
66
+ const storedRatio = (existing.w * imageWidth) / (existing.h * imageHeight);
67
+ if (Math.abs(storedRatio - aspect) / aspect <= ASPECT_TOLERANCE)
68
+ return box;
69
+ }
70
+ if (aspect === undefined || !sized)
71
+ return { ...FREE_BOX };
72
+ const wide = aspect >= imageWidth / imageHeight;
73
+ const width = wide
74
+ ? imageWidth * INITIAL_BOX_FRACTION
75
+ : imageHeight * INITIAL_BOX_FRACTION * aspect;
76
+ const height = wide ? width / aspect : imageHeight * INITIAL_BOX_FRACTION;
77
+ const widthPct = (width / imageWidth) * 100;
78
+ const heightPct = (height / imageHeight) * 100;
79
+ return {
80
+ x: (100 - widthPct) / 2,
81
+ y: (100 - heightPct) / 2,
82
+ width: widthPct,
83
+ height: heightPct,
84
+ };
85
+ }
86
+ /** True for a rectangle covering the whole frame, which renders the image unchanged. */
87
+ export function isFullFrameCrop(rect) {
88
+ return rect.x === 0 && rect.y === 0 && rect.w === 1 && rect.h === 1;
89
+ }
@@ -5,6 +5,7 @@ export declare const CANOPY_PREVIEW_HIGHLIGHT = "canopycms:preview:highlight";
5
5
  export declare const CANOPY_PREVIEW_READY = "canopycms:preview:ready";
6
6
  export declare const CANOPY_PREVIEW_ERROR = "canopycms:preview:error";
7
7
  export declare const CANOPY_PREVIEW_MARKS = "canopycms:preview:marks";
8
+ export declare const CANOPY_PREVIEW_NAVIGATE = "canopycms:preview:navigate";
8
9
  /**
9
10
  * The shape of the preview's `isLoading` for data of type `T`: a `boolean` at each reference
10
11
  * position, `true` while the editor is still resolving that reference, under the same keys and
@@ -84,6 +85,11 @@ export interface PreviewMarksMessage {
84
85
  count: number;
85
86
  paths?: string[];
86
87
  }
88
+ /** Preview → editor: a plain click on a link to another page of this origin, left unfollowed. */
89
+ export interface PreviewNavigateMessage {
90
+ type: typeof CANOPY_PREVIEW_NAVIGATE;
91
+ href: string;
92
+ }
87
93
  /**
88
94
  * Preview → editor report that the current draft fails to compile/render
89
95
  * (e.g. malformed MDX). `message: null` clears an earlier error.
@@ -142,6 +148,7 @@ export declare const usePreviewHighlight: (opts?: {
142
148
  }) => boolean;
143
149
  /**
144
150
  * Hook for preview pages to emit focus messages when elements with data-canopy-path are clicked.
151
+ * A click that follows a link, ours or the browser's, goes to another page, not to a field.
145
152
  */
146
153
  export declare const usePreviewFocusEmitter: (entryPath: string, opts?: {
147
154
  editorOrigin?: string;
@@ -4,7 +4,7 @@
4
4
  * hosts through `canopycms/preview`, so everything it reaches stays free of CSS and editor UI
5
5
  * (`pnpm lint:bundle`); the editor's side is `PreviewFrame.tsx`.
6
6
  */
7
- import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
7
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
8
8
  import { createFieldProps } from './field-props.js';
9
9
  import { setPreviewAssetBase } from './preview-asset-base.js';
10
10
  import { isSamePreviewPath } from './preview-path.js';
@@ -15,6 +15,7 @@ export const CANOPY_PREVIEW_HIGHLIGHT = 'canopycms:preview:highlight';
15
15
  export const CANOPY_PREVIEW_READY = 'canopycms:preview:ready';
16
16
  export const CANOPY_PREVIEW_ERROR = 'canopycms:preview:error';
17
17
  export const CANOPY_PREVIEW_MARKS = 'canopycms:preview:marks';
18
+ export const CANOPY_PREVIEW_NAVIGATE = 'canopycms:preview:navigate';
18
19
  /**
19
20
  * Resolve a (possibly relative) URL to an origin for postMessage targeting.
20
21
  * Falls back to the page's own origin, so same-origin editor/preview setups
@@ -79,6 +80,7 @@ export const useCanopyPreview = (opts) => {
79
80
  });
80
81
  const highlightEnabled = usePreviewHighlight(bridgeOpts);
81
82
  usePreviewFocusEmitter(resolvedPath, bridgeOpts);
83
+ usePreviewLinkNavigation({ editorOrigin, highlighting: highlightEnabled });
82
84
  const fieldProps = createFieldProps();
83
85
  /**
84
86
  * Report that the current draft fails to compile/render (the editor surfaces it
@@ -271,8 +273,96 @@ export const usePreviewHighlight = (opts) => {
271
273
  }, [enabled, editorOrigin]);
272
274
  return enabled;
273
275
  };
276
+ const isPlainClick = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
277
+ const linkClick = (event) => {
278
+ // The composed path reaches a link inside a shadow root, which the retargeted `target` hides.
279
+ const anchor = event
280
+ .composedPath()
281
+ .find((node) => node instanceof Element && node.matches('a[href], area[href]'));
282
+ if (!anchor)
283
+ return undefined;
284
+ let url;
285
+ try {
286
+ url = new URL(anchor.getAttribute('href') ?? '', document.baseURI);
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
292
+ return undefined;
293
+ const here = new URL(window.location.href);
294
+ if (url.href.split('#')[0] === here.href.split('#')[0])
295
+ return undefined;
296
+ const target = anchor.getAttribute('target') ?? document.querySelector('base[target]')?.getAttribute('target');
297
+ if (!isPlainClick(event) || (target && target !== '_self') || anchor.hasAttribute('download')) {
298
+ return { kind: 'browser' };
299
+ }
300
+ return { kind: url.origin === here.origin ? 'navigate' : 'open', href: url.href };
301
+ };
302
+ const isInMark = (event) => event
303
+ .composedPath()
304
+ .some((node) => node instanceof Element && node.hasAttribute('data-canopy-path'));
305
+ /** Clicks `usePreviewLinkNavigation` followed itself, so click-to-focus leaves them be. */
306
+ const followedLinkClicks = new WeakSet();
307
+ /**
308
+ * Hook for preview pages to keep link clicks off public pages that may refuse framing. Captured on
309
+ * the window, so it runs before the page's own handlers, a client router's included.
310
+ */
311
+ const usePreviewLinkNavigation = (opts) => {
312
+ const editorOrigin = opts.editorOrigin;
313
+ const highlighting = useRef(opts.highlighting);
314
+ useEffect(() => {
315
+ highlighting.current = opts.highlighting;
316
+ });
317
+ useEffect(() => {
318
+ if (window.parent === window)
319
+ return;
320
+ const targetOrigin = resolveMessageOrigin(editorOrigin);
321
+ if (isOpaqueOrigin(targetOrigin))
322
+ return;
323
+ // Links stay the browser's until the editor speaks: a frame that isn't a trusted editor, such
324
+ // as a site embedding a public page, would drop the message and leave every link dead.
325
+ let connected = false;
326
+ const handleMessage = (event) => {
327
+ const type = event.data?.type;
328
+ if (type !== CANOPY_PREVIEW_HIGHLIGHT && type !== CANOPY_PREVIEW_MESSAGE)
329
+ return;
330
+ if (isTrustedEditorMessage(event, editorOrigin))
331
+ connected = true;
332
+ };
333
+ const handleClick = (event) => {
334
+ if (!connected || event.defaultPrevented)
335
+ return;
336
+ const link = linkClick(event);
337
+ if (!link)
338
+ return;
339
+ // While fields are outlined, a marked link is a field to pick, which click-to-focus does.
340
+ if (highlighting.current && isPlainClick(event) && isInMark(event)) {
341
+ event.preventDefault();
342
+ return;
343
+ }
344
+ if (link.kind === 'browser')
345
+ return;
346
+ event.preventDefault();
347
+ followedLinkClicks.add(event);
348
+ if (link.kind === 'open') {
349
+ window.open(link.href, '_blank', 'noopener,noreferrer');
350
+ return;
351
+ }
352
+ const msg = { type: CANOPY_PREVIEW_NAVIGATE, href: link.href };
353
+ window.parent.postMessage(msg, targetOrigin);
354
+ };
355
+ window.addEventListener('message', handleMessage);
356
+ window.addEventListener('click', handleClick, true);
357
+ return () => {
358
+ window.removeEventListener('message', handleMessage);
359
+ window.removeEventListener('click', handleClick, true);
360
+ };
361
+ }, [editorOrigin]);
362
+ };
274
363
  /**
275
364
  * Hook for preview pages to emit focus messages when elements with data-canopy-path are clicked.
365
+ * A click that follows a link, ours or the browser's, goes to another page, not to a field.
276
366
  */
277
367
  export const usePreviewFocusEmitter = (entryPath, opts) => {
278
368
  const editorOrigin = opts?.editorOrigin;
@@ -280,6 +370,8 @@ export const usePreviewFocusEmitter = (entryPath, opts) => {
280
370
  if (window.parent === window)
281
371
  return;
282
372
  const handleClick = (event) => {
373
+ if (followedLinkClicks.has(event) || (!event.defaultPrevented && linkClick(event)))
374
+ return;
283
375
  const target = event.target;
284
376
  if (!target)
285
377
  return;
@@ -27,3 +27,8 @@ export declare const siteChromeSrc: (src: string, mount?: PreviewMount) => strin
27
27
  * absolute `previewPrefix` mount is what was taken out.
28
28
  */
29
29
  export declare const displayPreviewPath: (src: string, mount?: PreviewMount) => string;
30
+ /**
31
+ * The page `url` shows, as origin plus `displayPreviewPath` without a trailing slash, so a preview
32
+ * link and the entry `src` framing its page match, under the `previewPrefix` mount or not.
33
+ */
34
+ export declare const previewPageKey: (url: string, mount: PreviewMount, base: string) => string | undefined;
@@ -105,3 +105,21 @@ export const displayPreviewPath = (src, mount = {}) => {
105
105
  ? `//${parsed.host}${path}`
106
106
  : `${parsed.protocol}//${parsed.host}${path}`;
107
107
  };
108
+ /**
109
+ * The page `url` shows, as origin plus `displayPreviewPath` without a trailing slash, so a preview
110
+ * link and the entry `src` framing its page match, under the `previewPrefix` mount or not.
111
+ */
112
+ export const previewPageKey = (url, mount, base) => {
113
+ let parsed;
114
+ try {
115
+ parsed = new URL(url, base);
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ // A relative mount is a path on the editor's origin, so a URL there is matched to it as a path.
121
+ const spelled = splitPreviewPrefix(parsed.href, mount) || parsed.origin !== new URL(base).origin
122
+ ? parsed.href
123
+ : `${parsed.pathname}${parsed.search}`;
124
+ return `${parsed.origin}${normalizePreviewPath(displayPreviewPath(spelled, mount))}`;
125
+ };
@@ -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',
@@ -318,7 +319,12 @@ const canopyCssVariablesResolver = (theme) => ({
318
319
  * What Mantine draws from class rules, out of the theme variables' reach: unchecked control borders,
319
320
  * Switch thumbs, white text on the primary colour (moved to the fill), and dimmed text Mantine
320
321
  * draws itself (a blocked menu item, a combobox's empty message, outside days). Outside days lose
321
- * 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. A navigator row's lock is
324
+ * drawn only while its row is hovered, focused (Mantine's Tree focuses the `li` around the row, on click and arrow keys), has focus
325
+ * within, or is selected. It hides by opacity alone, so it keeps its place and stays in the tab order
326
+ * and the accessibility tree; while hidden it takes no pointer events, so a tap on its slot reaches
327
+ * the row.
322
328
  */
323
329
  const CANOPY_STYLES = `
324
330
  .mantine-Checkbox-input:not(:checked, :disabled, [data-indeterminate], [data-error]),
@@ -337,6 +343,37 @@ const CANOPY_STYLES = `
337
343
  --switch-thumb-bg: var(--canopy-switch-thumb-on, var(--mantine-color-white));
338
344
  --switch-thumb-bd: var(--canopy-switch-thumb-on, var(--mantine-color-white));
339
345
  }
346
+ .ReactCrop .ReactCrop__crop-selection:focus {
347
+ outline: 2px solid var(--mantine-color-white);
348
+ outline-offset: 0;
349
+ box-shadow: 0 0 0 4px var(--mantine-color-black);
350
+ }
351
+ .ReactCrop .ReactCrop__drag-handle {
352
+ --rc-drag-handle-bg-colour: var(--mantine-color-white);
353
+ border-color: var(--mantine-color-black);
354
+ }
355
+ .ReactCrop .ReactCrop__drag-handle:focus {
356
+ background: var(--canopy-focus-ring);
357
+ outline: 2px solid var(--mantine-color-white);
358
+ box-shadow: 0 0 0 4px var(--mantine-color-black);
359
+ }
360
+ [data-nav-row] [data-row-lock] {
361
+ opacity: 0;
362
+ pointer-events: none;
363
+ transition: opacity 120ms ease;
364
+ }
365
+ [data-nav-row]:hover [data-row-lock],
366
+ [data-nav-row]:focus-within [data-row-lock],
367
+ [role='treeitem']:focus > [data-nav-row] [data-row-lock],
368
+ [data-nav-row][data-nav-selected] [data-row-lock] {
369
+ opacity: 1;
370
+ pointer-events: auto;
371
+ }
372
+ @media (prefers-reduced-motion: reduce) {
373
+ [data-nav-row] [data-row-lock] {
374
+ transition: none;
375
+ }
376
+ }
340
377
  [data-combobox-selected] {
341
378
  --mantine-primary-color-filled: var(--canopy-primary-fill);
342
379
  }
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.2.0-int.119";
1
+ export declare const CANOPYCMS_VERSION = "0.2.0";
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.2.0-int.119';
3
+ export const CANOPYCMS_VERSION = '0.2.0';
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.2.0-int.119",
4
+ "version": "0.2.0",
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",