payload-plugin-aspect-preview 0.1.1 → 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.
- package/dist/components/AspectRatioPreviewGrid.js +6 -4
- package/dist/components/FocalPointEditor.js +43 -6
- package/dist/defaults.d.ts +4 -0
- package/dist/defaults.js +4 -1
- package/dist/focal.d.ts +17 -0
- package/dist/focal.js +21 -0
- package/dist/plugin.d.ts +25 -0
- package/dist/plugin.js +25 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.js +5 -1
- package/package.json +1 -1
|
@@ -46,9 +46,11 @@ function resolveCropPixels(crop, imgW, imgH) {
|
|
|
46
46
|
h: crop.height
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
|
-
// Editor
|
|
50
|
-
//
|
|
51
|
-
|
|
49
|
+
// Editor passes focalX/focalY as percentages of the FULL image; convert to
|
|
50
|
+
// percentages within the crop region. Pixel-based sibling of focal.ts'
|
|
51
|
+
// `toCropRelativeFocal` — identical result, but expressed in pixels here
|
|
52
|
+
// because this component already resolves the crop to pixels.
|
|
53
|
+
function focalWithinCropPixels(focalX, focalY, crop, imgW, imgH) {
|
|
52
54
|
const relX = (focalX / 100 * imgW - crop.x) / crop.w;
|
|
53
55
|
const relY = (focalY / 100 * imgH - crop.y) / crop.h;
|
|
54
56
|
return {
|
|
@@ -67,7 +69,7 @@ function computeCroppedBackgroundStyle(frameW, frameH, imgW, imgH, crop, focalX,
|
|
|
67
69
|
// Scale the crop region to cover the frame (same math as object-fit:cover)
|
|
68
70
|
const scale = Math.max(frameW / crop.w, frameH / crop.h);
|
|
69
71
|
// Focal point inside the crop region, expressed as %
|
|
70
|
-
const relFocal =
|
|
72
|
+
const relFocal = focalWithinCropPixels(focalX, focalY, crop, imgW, imgH);
|
|
71
73
|
// Where the crop region's top-left lands in the frame, per object-position math
|
|
72
74
|
const cropDisplayW = crop.w * scale;
|
|
73
75
|
const cropDisplayH = crop.h * scale;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useConfig, useDocumentInfo, useForm, useUploadEdits } from '@payloadcms/ui';
|
|
3
|
+
import { useConfig, useDocumentInfo, useField, useForm, useUploadEdits } from '@payloadcms/ui';
|
|
4
4
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
5
|
import 'react-image-crop/dist/ReactCrop.css';
|
|
6
6
|
import ReactCrop from 'react-image-crop';
|
|
7
7
|
import { DEFAULT_ASPECT_RATIOS } from '../defaults';
|
|
8
|
+
import { toCropRelativeFocal, toFullImageFocal } from '../focal';
|
|
8
9
|
import { AspectRatioPreviewGrid } from './AspectRatioPreviewGrid';
|
|
9
10
|
export const FocalPointEditor = ()=>{
|
|
10
11
|
const { data } = useDocumentInfo();
|
|
@@ -20,16 +21,36 @@ export const FocalPointEditor = ()=>{
|
|
|
20
21
|
}) : undefined;
|
|
21
22
|
const aspectRatios = entityConfig?.custom?.aspectPreview?.aspectRatios ?? DEFAULT_ASPECT_RATIOS;
|
|
22
23
|
const hasUserEditedRef = useRef(false);
|
|
24
|
+
// The freshly-picked File lives in the shared `file` form field (set by the
|
|
25
|
+
// upload component) before the document is saved. Reading it here lets us
|
|
26
|
+
// show the editor + preview immediately on selection, saving a save-round-trip.
|
|
27
|
+
const { value: pendingFile } = useField({
|
|
28
|
+
path: 'file'
|
|
29
|
+
});
|
|
30
|
+
const pendingUrl = React.useMemo(()=>pendingFile instanceof File && pendingFile.type.startsWith('image/') ? URL.createObjectURL(pendingFile) : undefined, [
|
|
31
|
+
pendingFile
|
|
32
|
+
]);
|
|
33
|
+
// Revoke the object URL when it changes or the editor unmounts.
|
|
34
|
+
useEffect(()=>{
|
|
35
|
+
if (!pendingUrl) return;
|
|
36
|
+
return ()=>URL.revokeObjectURL(pendingUrl);
|
|
37
|
+
}, [
|
|
38
|
+
pendingUrl
|
|
39
|
+
]);
|
|
23
40
|
// Bust the browser cache when the document is re-saved so the regenerated
|
|
24
41
|
// cropped image (same URL, new bytes) shows up in the preview grid.
|
|
25
42
|
const rawUrl = data?.url;
|
|
26
43
|
const updatedAtTag = data?.updatedAt ? String(data.updatedAt) : undefined;
|
|
27
44
|
const imageUrl = React.useMemo(()=>{
|
|
45
|
+
// A pending selection wins over the saved doc — it's what the user is
|
|
46
|
+
// about to save. Blob URLs take no query string, so skip cache-busting.
|
|
47
|
+
if (pendingUrl) return pendingUrl;
|
|
28
48
|
if (!rawUrl) return undefined;
|
|
29
49
|
if (!updatedAtTag) return rawUrl;
|
|
30
50
|
const sep = rawUrl.includes('?') ? '&' : '?';
|
|
31
51
|
return `${rawUrl}${sep}v=${encodeURIComponent(updatedAtTag)}`;
|
|
32
52
|
}, [
|
|
53
|
+
pendingUrl,
|
|
33
54
|
rawUrl,
|
|
34
55
|
updatedAtTag
|
|
35
56
|
]);
|
|
@@ -44,9 +65,17 @@ export const FocalPointEditor = ()=>{
|
|
|
44
65
|
width: uploadEdits.crop.width,
|
|
45
66
|
height: uploadEdits.crop.height
|
|
46
67
|
} : undefined);
|
|
47
|
-
const [focalPoint, setFocalPoint] = useState({
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const [focalPoint, setFocalPoint] = useState(()=>{
|
|
69
|
+
// uploadEdits stores the focal crop-relative (see the save effect below);
|
|
70
|
+
// lift it back into the full-image space the editor works in. `??` — a
|
|
71
|
+
// focal of 0 (an edge) is valid and must not fall through to the centre.
|
|
72
|
+
if (uploadEdits?.focalPoint) {
|
|
73
|
+
return uploadEdits.crop && uploadEdits.crop.unit === '%' ? toFullImageFocal(uploadEdits.focalPoint, uploadEdits.crop) : uploadEdits.focalPoint;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
x: typeof data?.focalX === 'number' ? data.focalX : 50,
|
|
77
|
+
y: typeof data?.focalY === 'number' ? data.focalY : 50
|
|
78
|
+
};
|
|
50
79
|
});
|
|
51
80
|
const containerRef = useRef(null);
|
|
52
81
|
const [isDragging, setIsDragging] = useState(false);
|
|
@@ -62,7 +91,9 @@ export const FocalPointEditor = ()=>{
|
|
|
62
91
|
const docWidth = typeof data?.width === 'number' ? data.width : 0;
|
|
63
92
|
const docHeight = typeof data?.height === 'number' ? data.height : 0;
|
|
64
93
|
useEffect(()=>{
|
|
65
|
-
|
|
94
|
+
// For a pending selection the doc dimensions are stale/absent — always
|
|
95
|
+
// decode the picked image so crop pixel math uses the real size.
|
|
96
|
+
if (!pendingUrl && docWidth > 0 && docHeight > 0) {
|
|
66
97
|
queueMicrotask(()=>setNaturalSize({
|
|
67
98
|
width: docWidth,
|
|
68
99
|
height: docHeight
|
|
@@ -80,6 +111,7 @@ export const FocalPointEditor = ()=>{
|
|
|
80
111
|
img.src = imageUrl;
|
|
81
112
|
}, [
|
|
82
113
|
imageUrl,
|
|
114
|
+
pendingUrl,
|
|
83
115
|
docWidth,
|
|
84
116
|
docHeight
|
|
85
117
|
]);
|
|
@@ -132,6 +164,11 @@ export const FocalPointEditor = ()=>{
|
|
|
132
164
|
const topPx = Math.floor(crop.y / 100 * naturalSize.height);
|
|
133
165
|
const widthInPixels = Math.max(1, Math.min(Math.floor(crop.width / 100 * naturalSize.width), naturalSize.width - leftPx));
|
|
134
166
|
const heightInPixels = Math.max(1, Math.min(Math.floor(crop.height / 100 * naturalSize.height), naturalSize.height - topPx));
|
|
167
|
+
// Payload interprets the saved focal point relative to the CROPPED
|
|
168
|
+
// image (it swaps the base image for the cropped bytes before sizing),
|
|
169
|
+
// so re-express our full-image focal within the crop region. clampFocal-
|
|
170
|
+
// ToCrop already keeps the focal inside the crop, so this stays 0-100.
|
|
171
|
+
const cropRelativeFocal = toCropRelativeFocal(focalPoint, crop);
|
|
135
172
|
updateUploadEdits({
|
|
136
173
|
crop: {
|
|
137
174
|
unit: '%',
|
|
@@ -140,7 +177,7 @@ export const FocalPointEditor = ()=>{
|
|
|
140
177
|
width: crop.width,
|
|
141
178
|
height: crop.height
|
|
142
179
|
},
|
|
143
|
-
focalPoint,
|
|
180
|
+
focalPoint: cropRelativeFocal,
|
|
144
181
|
heightInPixels,
|
|
145
182
|
widthInPixels
|
|
146
183
|
});
|
package/dist/defaults.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { AspectRatioConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The aspect ratios previewed when {@link AspectPreviewPluginOptions.aspectRatios}
|
|
4
|
+
* is omitted: square, 4:3, 3:2, 16:9, 9:16, and a 1200×630 social-share target.
|
|
5
|
+
*/
|
|
2
6
|
export declare const DEFAULT_ASPECT_RATIOS: AspectRatioConfig[];
|
package/dist/defaults.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The aspect ratios previewed when {@link AspectPreviewPluginOptions.aspectRatios}
|
|
3
|
+
* is omitted: square, 4:3, 3:2, 16:9, 9:16, and a 1200×630 social-share target.
|
|
4
|
+
*/ export const DEFAULT_ASPECT_RATIOS = [
|
|
2
5
|
{
|
|
3
6
|
name: 'Square',
|
|
4
7
|
ratio: '1:1',
|
package/dist/focal.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CropConfig, FocalPointState } from './types.js';
|
|
2
|
+
/** The crop rectangle in percentage units — all this math needs. */
|
|
3
|
+
type PercentCrop = Pick<CropConfig, 'x' | 'y' | 'width' | 'height'>;
|
|
4
|
+
/**
|
|
5
|
+
* Payload stores the focal point relative to the CROP region: on save it bakes
|
|
6
|
+
* the crop into the base image, then interprets `focalX/focalY` as percentages
|
|
7
|
+
* of that cropped image. The editor, by contrast, works in full-image
|
|
8
|
+
* coordinates because the crosshair is drawn over the whole picture.
|
|
9
|
+
*
|
|
10
|
+
* These two pure inverses convert between the spaces. The math is percentage
|
|
11
|
+
* only — the image's pixel dimensions cancel out, so just the crop rectangle
|
|
12
|
+
* (in %) is required. Callers must guarantee a non-zero crop width/height.
|
|
13
|
+
*/
|
|
14
|
+
export declare function toCropRelativeFocal(focal: FocalPointState, crop: PercentCrop): FocalPointState;
|
|
15
|
+
/** Inverse of {@link toCropRelativeFocal}: crop-relative % back to full-image %. */
|
|
16
|
+
export declare function toFullImageFocal(focal: FocalPointState, crop: PercentCrop): FocalPointState;
|
|
17
|
+
export {};
|
package/dist/focal.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload stores the focal point relative to the CROP region: on save it bakes
|
|
3
|
+
* the crop into the base image, then interprets `focalX/focalY` as percentages
|
|
4
|
+
* of that cropped image. The editor, by contrast, works in full-image
|
|
5
|
+
* coordinates because the crosshair is drawn over the whole picture.
|
|
6
|
+
*
|
|
7
|
+
* These two pure inverses convert between the spaces. The math is percentage
|
|
8
|
+
* only — the image's pixel dimensions cancel out, so just the crop rectangle
|
|
9
|
+
* (in %) is required. Callers must guarantee a non-zero crop width/height.
|
|
10
|
+
*/ export function toCropRelativeFocal(focal, crop) {
|
|
11
|
+
return {
|
|
12
|
+
x: (focal.x - crop.x) / crop.width * 100,
|
|
13
|
+
y: (focal.y - crop.y) / crop.height * 100
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Inverse of {@link toCropRelativeFocal}: crop-relative % back to full-image %. */ export function toFullImageFocal(focal, crop) {
|
|
17
|
+
return {
|
|
18
|
+
x: crop.x + focal.x / 100 * crop.width,
|
|
19
|
+
y: crop.y + focal.y / 100 * crop.height
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
1
|
import type { Plugin } from 'payload';
|
|
2
2
|
import type { AspectPreviewPluginOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adds an inline focal-point + crop editor with a live aspect-ratio preview
|
|
5
|
+
* grid to Payload upload collections.
|
|
6
|
+
*
|
|
7
|
+
* For every collection listed in {@link AspectPreviewPluginOptions.collections}
|
|
8
|
+
* it appends an `aspectPreview` UI field that renders the editor and (unless
|
|
9
|
+
* {@link AspectPreviewPluginOptions.disabled}) overrides the collection's upload
|
|
10
|
+
* component to replace Payload's default crop drawer.
|
|
11
|
+
*
|
|
12
|
+
* @param options - see {@link AspectPreviewPluginOptions}.
|
|
13
|
+
* @returns a Payload {@link Plugin}.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* // payload.config.ts
|
|
18
|
+
* import { aspectPreviewPlugin } from 'payload-plugin-aspect-preview'
|
|
19
|
+
*
|
|
20
|
+
* export default buildConfig({
|
|
21
|
+
* plugins: [
|
|
22
|
+
* // aspectRatios defaults to DEFAULT_ASPECT_RATIOS when omitted
|
|
23
|
+
* aspectPreviewPlugin({ collections: ['media'] }),
|
|
24
|
+
* ],
|
|
25
|
+
* })
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
3
28
|
export declare const aspectPreviewPlugin: (options: AspectPreviewPluginOptions) => Plugin;
|
package/dist/plugin.js
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import { DEFAULT_ASPECT_RATIOS } from './defaults.js';
|
|
2
2
|
const FIELD_COMPONENT = 'payload-plugin-aspect-preview/client#FocalPointEditor';
|
|
3
3
|
const UPLOAD_COMPONENT = 'payload-plugin-aspect-preview/client#CustomUpload';
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Adds an inline focal-point + crop editor with a live aspect-ratio preview
|
|
6
|
+
* grid to Payload upload collections.
|
|
7
|
+
*
|
|
8
|
+
* For every collection listed in {@link AspectPreviewPluginOptions.collections}
|
|
9
|
+
* it appends an `aspectPreview` UI field that renders the editor and (unless
|
|
10
|
+
* {@link AspectPreviewPluginOptions.disabled}) overrides the collection's upload
|
|
11
|
+
* component to replace Payload's default crop drawer.
|
|
12
|
+
*
|
|
13
|
+
* @param options - see {@link AspectPreviewPluginOptions}.
|
|
14
|
+
* @returns a Payload {@link Plugin}.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* // payload.config.ts
|
|
19
|
+
* import { aspectPreviewPlugin } from 'payload-plugin-aspect-preview'
|
|
20
|
+
*
|
|
21
|
+
* export default buildConfig({
|
|
22
|
+
* plugins: [
|
|
23
|
+
* // aspectRatios defaults to DEFAULT_ASPECT_RATIOS when omitted
|
|
24
|
+
* aspectPreviewPlugin({ collections: ['media'] }),
|
|
25
|
+
* ],
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*/ export const aspectPreviewPlugin = (options)=>(config)=>{
|
|
5
29
|
const aspectRatios = options.aspectRatios ?? DEFAULT_ASPECT_RATIOS;
|
|
6
30
|
const enabled = new Set(options.collections);
|
|
7
31
|
return {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single aspect ratio shown in the editor's live preview grid. Pass an array
|
|
3
|
+
* of these as {@link AspectPreviewPluginOptions.aspectRatios} to tailor the
|
|
4
|
+
* ratios to your frontend; see {@link DEFAULT_ASPECT_RATIOS} for the shipped set.
|
|
5
|
+
*/
|
|
1
6
|
export interface AspectRatioConfig {
|
|
2
7
|
/** Display label, e.g. "Social Share". */
|
|
3
8
|
name: string;
|
|
@@ -14,6 +19,7 @@ export interface AspectRatioConfig {
|
|
|
14
19
|
/** Optional free-form grouping label. */
|
|
15
20
|
category?: string;
|
|
16
21
|
}
|
|
22
|
+
/** Options for {@link aspectPreviewPlugin}. */
|
|
17
23
|
export interface AspectPreviewPluginOptions {
|
|
18
24
|
/** Upload collections (by slug) to enhance with the inline editor. */
|
|
19
25
|
collections: string[];
|
|
@@ -22,6 +28,7 @@ export interface AspectPreviewPluginOptions {
|
|
|
22
28
|
/** Disable the plugin while keeping the schema field for DB consistency. */
|
|
23
29
|
disabled?: boolean;
|
|
24
30
|
}
|
|
31
|
+
/** A crop rectangle. `x`/`y`/`width`/`height` are in `unit` (percent or pixels). */
|
|
25
32
|
export interface CropConfig {
|
|
26
33
|
unit: 'px' | '%';
|
|
27
34
|
x: number;
|
|
@@ -29,10 +36,12 @@ export interface CropConfig {
|
|
|
29
36
|
width: number;
|
|
30
37
|
height: number;
|
|
31
38
|
}
|
|
39
|
+
/** Focal point as percentages (0-100) of its reference image. */
|
|
32
40
|
export interface FocalPointState {
|
|
33
41
|
x: number;
|
|
34
42
|
y: number;
|
|
35
43
|
}
|
|
44
|
+
/** The editor's pending edits, mirrored into Payload's upload-edits context. */
|
|
36
45
|
export interface UploadEditsState {
|
|
37
46
|
crop?: CropConfig;
|
|
38
47
|
focalPoint?: FocalPointState;
|
package/dist/types.js
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A single aspect ratio shown in the editor's live preview grid. Pass an array
|
|
3
|
+
* of these as {@link AspectPreviewPluginOptions.aspectRatios} to tailor the
|
|
4
|
+
* ratios to your frontend; see {@link DEFAULT_ASPECT_RATIOS} for the shipped set.
|
|
5
|
+
*/ /** The editor's pending edits, mirrored into Payload's upload-edits context. */ export { };
|
package/package.json
CHANGED