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.
@@ -46,9 +46,11 @@ function resolveCropPixels(crop, imgW, imgH) {
46
46
  h: crop.height
47
47
  };
48
48
  }
49
- // Editor stores focalX/focalY as percentages of the FULL image.
50
- // Convert to percentages within the crop region.
51
- function toCropRelativeFocal(focalX, focalY, crop, imgW, imgH) {
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 = toCropRelativeFocal(focalX, focalY, crop, imgW, imgH);
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
- x: uploadEdits?.focalPoint?.x || data?.focalX || 50,
49
- y: uploadEdits?.focalPoint?.y || data?.focalY || 50
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
- if (docWidth > 0 && docHeight > 0) {
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
  });
@@ -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
- export const DEFAULT_ASPECT_RATIOS = [
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',
@@ -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
- export const aspectPreviewPlugin = (options)=>(config)=>{
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
- export { };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-plugin-aspect-preview",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Single-screen crop, focal point, and live multi-aspect-ratio preview editor for Payload upload collections.",
5
5
  "keywords": [
6
6
  "payload",