canopycms 0.2.0-int.119 → 0.2.0-int.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -46
- package/dist/api/assets.js +20 -10
- package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +15 -9
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-s3.d.ts +7 -3
- package/dist/assets/store-s3.js +25 -6
- package/dist/assets/types.d.ts +7 -2
- package/dist/cli/cli.js +40 -23
- package/dist/config/types.d.ts +3 -2
- package/dist/editor/Editor.js +37 -5
- package/dist/editor/EntryNavigator.d.ts +9 -4
- package/dist/editor/EntryNavigator.js +36 -16
- package/dist/editor/PreviewFrame.d.ts +3 -1
- package/dist/editor/PreviewFrame.js +43 -2
- package/dist/editor/PreviewPane.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.d.ts +7 -8
- package/dist/editor/components/BranchLockBanner.js +25 -2
- package/dist/editor/components/BranchRowLock.d.ts +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -0
- package/dist/editor/components/EditorHeader.js +3 -18
- package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- package/dist/editor/media/AssetCard.js +4 -2
- package/dist/editor/media/AssetListRow.js +4 -2
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- package/dist/editor/media/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- package/dist/editor/preview-bridge.d.ts +7 -0
- package/dist/editor/preview-bridge.js +93 -1
- package/dist/editor/preview-path.d.ts +5 -0
- package/dist/editor/preview-path.js +18 -0
- package/dist/editor/theme.d.ts +1 -0
- package/dist/editor/theme.js +17 -1
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,41 +1,53 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { Button, Group, Modal,
|
|
5
|
-
import
|
|
6
|
-
import { cropAreaPercentToRect,
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
4
|
+
import { Button, Group, Modal, Stack, Text } from '@mantine/core';
|
|
5
|
+
import ReactCrop from 'react-image-crop';
|
|
6
|
+
import { cropAreaPercentToRect, initialCropBox } from './crop-math.js';
|
|
7
|
+
const MIN_BOX_PX = 16;
|
|
8
|
+
const CORNER_LABEL = (corner) => `${corner} corner. Arrow keys resize the box.`;
|
|
9
|
+
const ARIA_LABELS = {
|
|
10
|
+
cropArea: 'Crop box. Arrow keys move it; hold Shift for larger steps.',
|
|
11
|
+
nwDragHandle: CORNER_LABEL('Top-left'),
|
|
12
|
+
neDragHandle: CORNER_LABEL('Top-right'),
|
|
13
|
+
seDragHandle: CORNER_LABEL('Bottom-right'),
|
|
14
|
+
swDragHandle: CORNER_LABEL('Bottom-left'),
|
|
15
|
+
nDragHandle: 'Top edge. Up and down arrow keys resize the box.',
|
|
16
|
+
sDragHandle: 'Bottom edge. Up and down arrow keys resize the box.',
|
|
17
|
+
eDragHandle: 'Right edge. Left and right arrow keys resize the box.',
|
|
18
|
+
wDragHandle: 'Left edge. Left and right arrow keys resize the box.',
|
|
19
|
+
};
|
|
20
|
+
const PREVIEW_MAX_HEIGHT = 360;
|
|
21
|
+
const toPercentCrop = (box) => ({
|
|
22
|
+
unit: '%',
|
|
23
|
+
...box,
|
|
24
|
+
});
|
|
10
25
|
/**
|
|
11
|
-
* react-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
26
|
+
* react-image-crop in a Modal for the ImageField crop step. Box -> normalized-rect
|
|
27
|
+
* conversion is delegated to crop-math.ts's pure, independently-unit-tested
|
|
28
|
+
* `cropAreaPercentToRect`.
|
|
14
29
|
*
|
|
15
|
-
* The
|
|
16
|
-
*
|
|
17
|
-
* starts from fresh state (React remounts on key change) without an effect
|
|
18
|
-
* that would otherwise need to call `setState` synchronously to reset it.
|
|
30
|
+
* The box state lives in `CropStepBody`, mounted only while `opened` and keyed on `imageSrc`,
|
|
31
|
+
* so a fresh crop session always starts from a fresh box (React remounts on key change).
|
|
19
32
|
*/
|
|
20
33
|
export const CropStep = ({ opened, onClose, imageSrc, aspect, initialCrop, onConfirm, }) => {
|
|
21
34
|
return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Crop image", size: "lg", overlayProps: { blur: 2 }, "data-testid": "crop-step-modal", children: opened && (_jsx(CropStepBody, { imageSrc: imageSrc, aspect: aspect, initialCrop: initialCrop, onConfirm: onConfirm, onClose: onClose }, imageSrc)) }));
|
|
22
35
|
};
|
|
23
36
|
const CropStepBody = ({ imageSrc, aspect, initialCrop, onConfirm, onClose, }) => {
|
|
24
|
-
const [
|
|
25
|
-
const [
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (rect)
|
|
32
|
-
onConfirm(rect);
|
|
37
|
+
const [box, setBox] = useState(undefined);
|
|
38
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
39
|
+
// The box can't be seeded until the image's displayed size is known, so Apply stays off until then.
|
|
40
|
+
const rect = box ? cropAreaPercentToRect(box) : null;
|
|
41
|
+
const handleImageLoad = (event) => {
|
|
42
|
+
const { width, height } = event.currentTarget;
|
|
43
|
+
setBox(toPercentCrop(initialCropBox({ imageWidth: width, imageHeight: height, aspect, existing: initialCrop })));
|
|
33
44
|
};
|
|
34
|
-
const initialCroppedAreaPercentages = initialCrop ? cropRectToAreaPercent(initialCrop) : undefined;
|
|
35
45
|
return (_jsxs(Stack, { gap: "md", children: [_jsx("div", { style: {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
height: 360,
|
|
46
|
+
display: 'flex',
|
|
47
|
+
justifyContent: 'center',
|
|
39
48
|
background: 'var(--mantine-color-black)',
|
|
40
|
-
|
|
49
|
+
'--rc-focus-color': 'var(--canopy-focus-ring)',
|
|
50
|
+
}, children: _jsx(ReactCrop, { crop: box, aspect: aspect, minWidth: MIN_BOX_PX, minHeight: MIN_BOX_PX, keepSelection: true, ariaLabels: ARIA_LABELS, onChange: (_pixels, percent) => setBox(percent), children: _jsx("img", { src: imageSrc, alt: "", style: { maxHeight: PREVIEW_MAX_HEIGHT, maxWidth: '100%', display: 'block' }, onLoad: handleImageLoad, onError: () => setLoadFailed(true) }) }) }), loadFailed && (_jsx(Text, { size: "sm", c: "var(--canopy-danger-text)", role: "alert", children: "This image couldn't be loaded, so it can't be cropped." })), _jsx(Text, { size: "sm", c: "dimmed", children: aspect === undefined
|
|
51
|
+
? 'Drag the box to move it, or its edges and corners to resize it. With a keyboard, arrow keys move it; Shift takes larger steps.'
|
|
52
|
+
: 'Drag the box to move it, or a corner to resize it. Its shape is fixed. With a keyboard, arrow keys move it; Shift takes larger steps.' }), _jsxs(Group, { justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: () => rect && onConfirm(rect), disabled: !rect, "data-testid": "crop-step-apply", children: "Apply crop" })] })] }));
|
|
41
53
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why `src` can't be cropped, as the full sentence its disabled Crop button shows; `undefined`
|
|
3
|
+
* when it can. A crop is a directive on a transform URL, which `assetUrl` writes only for a raster
|
|
4
|
+
* in the asset store, so a vector file or a file outside the store never renders one.
|
|
5
|
+
*/
|
|
6
|
+
export declare function cropBlockedReason(src: string): string | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ASSET_PREFIXES } from '../../assets/asset-prefixes.js';
|
|
2
|
+
import { isAssetStoreSrc } from '../../assets/asset-url.js';
|
|
3
|
+
const TRANSFORM_SPACE_PREFIX = `/${ASSET_PREFIXES.transform}/`;
|
|
4
|
+
/**
|
|
5
|
+
* Why `src` can't be cropped, as the full sentence its disabled Crop button shows; `undefined`
|
|
6
|
+
* when it can. A crop is a directive on a transform URL, which `assetUrl` writes only for a raster
|
|
7
|
+
* in the asset store, so a vector file or a file outside the store never renders one.
|
|
8
|
+
*/
|
|
9
|
+
export function cropBlockedReason(src) {
|
|
10
|
+
const path = src.split(/[?#]/)[0] ?? src;
|
|
11
|
+
if (/\.svg$/i.test(path))
|
|
12
|
+
return "SVG images can't be cropped. Replace it with a PNG or JPEG to crop it.";
|
|
13
|
+
if (!isAssetStoreSrc(src)) {
|
|
14
|
+
return 'Crop works on media-library images. Replace this file with a library copy to crop it.';
|
|
15
|
+
}
|
|
16
|
+
if (!src.startsWith(TRANSFORM_SPACE_PREFIX))
|
|
17
|
+
return "This file type can't be cropped. Replace it with a PNG or JPEG to crop it.";
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure math for the ImageField crop step: converting between react-
|
|
3
|
-
* percentage-based
|
|
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-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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-
|
|
3
|
-
* percentage-based
|
|
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-
|
|
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
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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-
|
|
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
|
+
};
|
package/dist/editor/theme.d.ts
CHANGED
|
@@ -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 {
|
package/dist/editor/theme.js
CHANGED
|
@@ -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,8 @@ 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.
|
|
322
324
|
*/
|
|
323
325
|
const CANOPY_STYLES = `
|
|
324
326
|
.mantine-Checkbox-input:not(:checked, :disabled, [data-indeterminate], [data-error]),
|
|
@@ -337,6 +339,20 @@ const CANOPY_STYLES = `
|
|
|
337
339
|
--switch-thumb-bg: var(--canopy-switch-thumb-on, var(--mantine-color-white));
|
|
338
340
|
--switch-thumb-bd: var(--canopy-switch-thumb-on, var(--mantine-color-white));
|
|
339
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
|
+
}
|
|
340
356
|
[data-combobox-selected] {
|
|
341
357
|
--mantine-primary-color-filled: var(--canopy-primary-fill);
|
|
342
358
|
}
|
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.
|
|
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.2.0-int.
|
|
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.2.0-int.
|
|
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-
|
|
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",
|