payload-plugin-aspect-preview 0.1.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/LICENSE.md +21 -0
- package/README.md +94 -0
- package/dist/components/AspectRatioPreviewGrid.d.ts +11 -0
- package/dist/components/AspectRatioPreviewGrid.js +230 -0
- package/dist/components/AspectRatioPreviewGrid.js.map +1 -0
- package/dist/components/CustomUpload.d.ts +4 -0
- package/dist/components/CustomUpload.js +167 -0
- package/dist/components/CustomUpload.js.map +1 -0
- package/dist/components/FocalPointEditor.d.ts +4 -0
- package/dist/components/FocalPointEditor.js +629 -0
- package/dist/components/FocalPointEditor.js.map +1 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +58 -0
- package/dist/defaults.js.map +1 -0
- package/dist/exports/client.d.ts +3 -0
- package/dist/exports/client.js +5 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/types.d.ts +1 -0
- package/dist/exports/types.js +3 -0
- package/dist/exports/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +52 -0
- package/dist/plugin.js.map +1 -0
- package/dist/styles.scss +482 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +82 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Kolbusz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# payload-plugin-aspect-preview
|
|
2
|
+
|
|
3
|
+
Single-screen crop, focal point, and **live multi-aspect-ratio preview** for Payload upload collections. Replaces Payload's separate crop/focal drawers with one editor that shows, in real time, how your image will look at every aspect ratio your frontend uses.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Crop, focal point, and preview on **one screen** — no drawer round-trips.
|
|
10
|
+
- **Live preview grid** across any set of aspect ratios as you drag the focal point or draw a crop.
|
|
11
|
+
- **Zero-config defaults** (six common ratios) — override with your own list.
|
|
12
|
+
- Simulates the frontend's `object-fit: cover` + focal positioning so previews match production.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Payload `^3.85.0`
|
|
17
|
+
- React `^19`
|
|
18
|
+
|
|
19
|
+
> **Compatibility note:** the replacement upload field builds on `@payloadcms/ui` internals (`FileDetails`, `Dropzone`, `PreviewSizes`, …). These are not a stable public API, so a Payload minor upgrade may occasionally require a plugin update. Pin your Payload version and test after upgrades.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add payload-plugin-aspect-preview
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { aspectPreviewPlugin } from 'payload-plugin-aspect-preview'
|
|
31
|
+
|
|
32
|
+
export default buildConfig({
|
|
33
|
+
// ...
|
|
34
|
+
plugins: [
|
|
35
|
+
aspectPreviewPlugin({ collections: ['media'] }), // uses built-in default ratios
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Your `media` collection must be an upload collection (`upload: { ... }`), ideally with `focalPoint: true`.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
| Option | Type | Required | Default | Description |
|
|
45
|
+
|---|---|---|---|---|
|
|
46
|
+
| `collections` | `string[]` | yes | — | Upload collection slugs to enhance. |
|
|
47
|
+
| `aspectRatios` | `AspectRatioConfig[]` | no | `DEFAULT_ASPECT_RATIOS` | Ratios shown in the preview grid. |
|
|
48
|
+
| `disabled` | `boolean` | no | `false` | Keep the schema field but disable the upload override. |
|
|
49
|
+
|
|
50
|
+
### `AspectRatioConfig`
|
|
51
|
+
|
|
52
|
+
| Field | Type | Required | Description |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `name` | `string` | yes | Display label (e.g. `"Social Share"`). |
|
|
55
|
+
| `ratio` | `string` | yes | Display token (e.g. `"16:9"`). |
|
|
56
|
+
| `width` | `number` | yes | Ratio numerator (or absolute px width). |
|
|
57
|
+
| `height` | `number` | yes | Ratio denominator (or absolute px height). |
|
|
58
|
+
| `source` | `'css' \| 'crop'` | no | Badge showing how the frontend renders it. Default `'css'`. |
|
|
59
|
+
| `usage` | `string` | no | Description shown in the card footer. |
|
|
60
|
+
| `category` | `string` | no | Free-form grouping label. |
|
|
61
|
+
|
|
62
|
+
## Examples
|
|
63
|
+
|
|
64
|
+
**Custom aspect ratios**
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
aspectPreviewPlugin({
|
|
68
|
+
collections: ['media'],
|
|
69
|
+
aspectRatios: [
|
|
70
|
+
{ name: 'Hero', ratio: '21:9', width: 21, height: 9, source: 'css', usage: 'Landing hero' },
|
|
71
|
+
{ name: 'Card', ratio: '1:1', width: 1, height: 1, source: 'crop', usage: 'Product cards' },
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Multiple collections**
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
aspectPreviewPlugin({ collections: ['media', 'avatars'] })
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Disable without dropping the schema**
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
aspectPreviewPlugin({ collections: ['media'], disabled: true })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
The plugin wraps each upload collection with a custom admin UI component (`CustomUpload`). It injects a UI field (`FocalPointEditor`) that overlays the live preview grid, replacing Payload's default crop/adjust drawer. The grid simulates `object-fit: cover` with the focal point as the anchor, so you see exactly how each ratio will render on the frontend. Crop and focal coordinates are stored in the document's built-in `focalPoint` field (if present) and in-memory image crop state managed by react-image-crop.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT © Sebastian Kolbusz
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AspectRatioConfig, CropConfig } from '../types';
|
|
3
|
+
interface AspectRatioPreviewGridProps {
|
|
4
|
+
url: string;
|
|
5
|
+
focalX: number;
|
|
6
|
+
focalY: number;
|
|
7
|
+
aspectRatios: AspectRatioConfig[];
|
|
8
|
+
crop?: CropConfig;
|
|
9
|
+
}
|
|
10
|
+
export declare const AspectRatioPreviewGrid: React.FC<AspectRatioPreviewGridProps>;
|
|
11
|
+
export default AspectRatioPreviewGrid;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
function clamp(val, min, max) {
|
|
5
|
+
return Math.max(min, Math.min(max, val));
|
|
6
|
+
}
|
|
7
|
+
function isValidNumber(n) {
|
|
8
|
+
return typeof n === 'number' && !isNaN(n) && isFinite(n) && n > 0;
|
|
9
|
+
}
|
|
10
|
+
function isValidCrop(crop, imgW, imgH) {
|
|
11
|
+
if (!crop) return false;
|
|
12
|
+
let cropW;
|
|
13
|
+
let cropH;
|
|
14
|
+
if (crop.unit === '%') {
|
|
15
|
+
cropW = crop.width;
|
|
16
|
+
cropH = crop.height;
|
|
17
|
+
if (!isValidNumber(cropW) || !isValidNumber(cropH)) return false;
|
|
18
|
+
if (cropW > 100 || cropH > 100) return false;
|
|
19
|
+
if (crop.x < 0 || crop.y < 0) return false;
|
|
20
|
+
if (crop.x + cropW > 100 + 1e-6) return false;
|
|
21
|
+
if (crop.y + cropH > 100 + 1e-6) return false;
|
|
22
|
+
} else {
|
|
23
|
+
if (crop.width > imgW || crop.height > imgH || crop.x > imgW || crop.y > imgH) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (!isValidNumber(crop.width) || !isValidNumber(crop.height)) return false;
|
|
27
|
+
if (crop.x < 0 || crop.y < 0) return false;
|
|
28
|
+
cropW = crop.width;
|
|
29
|
+
cropH = crop.height;
|
|
30
|
+
}
|
|
31
|
+
return cropW > 1e-6 && cropH > 1e-6;
|
|
32
|
+
}
|
|
33
|
+
function resolveCropPixels(crop, imgW, imgH) {
|
|
34
|
+
if (crop.unit === '%') {
|
|
35
|
+
return {
|
|
36
|
+
x: crop.x / 100 * imgW,
|
|
37
|
+
y: crop.y / 100 * imgH,
|
|
38
|
+
w: crop.width / 100 * imgW,
|
|
39
|
+
h: crop.height / 100 * imgH
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
x: crop.x,
|
|
44
|
+
y: crop.y,
|
|
45
|
+
w: crop.width,
|
|
46
|
+
h: crop.height
|
|
47
|
+
};
|
|
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) {
|
|
52
|
+
const relX = (focalX / 100 * imgW - crop.x) / crop.w;
|
|
53
|
+
const relY = (focalY / 100 * imgH - crop.y) / crop.h;
|
|
54
|
+
return {
|
|
55
|
+
x: clamp(relX * 100, 0, 100),
|
|
56
|
+
y: clamp(relY * 100, 0, 100)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Compute a background-image style on the FRAME that simulates Payload's
|
|
60
|
+
// server-side behavior: take the crop region from the original image, then
|
|
61
|
+
// render it with object-fit:cover + object-position:focal% into the frame.
|
|
62
|
+
//
|
|
63
|
+
// Background-image is used instead of <img> because Payload's admin CSS
|
|
64
|
+
// applies aggressive global rules to <img> (max-width:100%, height:auto)
|
|
65
|
+
// that fight inline pixel sizing and cause squashing.
|
|
66
|
+
function computeCroppedBackgroundStyle(frameW, frameH, imgW, imgH, crop, focalX, focalY) {
|
|
67
|
+
// Scale the crop region to cover the frame (same math as object-fit:cover)
|
|
68
|
+
const scale = Math.max(frameW / crop.w, frameH / crop.h);
|
|
69
|
+
// Focal point inside the crop region, expressed as %
|
|
70
|
+
const relFocal = toCropRelativeFocal(focalX, focalY, crop, imgW, imgH);
|
|
71
|
+
// Where the crop region's top-left lands in the frame, per object-position math
|
|
72
|
+
const cropDisplayW = crop.w * scale;
|
|
73
|
+
const cropDisplayH = crop.h * scale;
|
|
74
|
+
const cropLeft = (frameW - cropDisplayW) * (relFocal.x / 100);
|
|
75
|
+
const cropTop = (frameH - cropDisplayH) * (relFocal.y / 100);
|
|
76
|
+
// Position the FULL image so the crop region lands at cropLeft / cropTop
|
|
77
|
+
const bgLeft = cropLeft - crop.x * scale;
|
|
78
|
+
const bgTop = cropTop - crop.y * scale;
|
|
79
|
+
const bgW = imgW * scale;
|
|
80
|
+
const bgH = imgH * scale;
|
|
81
|
+
return {
|
|
82
|
+
backgroundSize: `${bgW}px ${bgH}px`,
|
|
83
|
+
backgroundPosition: `${bgLeft}px ${bgTop}px`
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const PreviewCard = ({ ratio, url, focalX, focalY, crop, imgW, imgH })=>{
|
|
87
|
+
const frameRef = useRef(null);
|
|
88
|
+
const [frameSize, setFrameSize] = useState({
|
|
89
|
+
width: 0,
|
|
90
|
+
height: 0
|
|
91
|
+
});
|
|
92
|
+
useEffect(()=>{
|
|
93
|
+
const el = frameRef.current;
|
|
94
|
+
if (!el) return;
|
|
95
|
+
const observer = new ResizeObserver((entries)=>{
|
|
96
|
+
for (const entry of entries){
|
|
97
|
+
const { width, height } = entry.contentRect;
|
|
98
|
+
setFrameSize({
|
|
99
|
+
width,
|
|
100
|
+
height
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
observer.observe(el);
|
|
105
|
+
return ()=>observer.disconnect();
|
|
106
|
+
}, []);
|
|
107
|
+
const frameStyle = React.useMemo(()=>{
|
|
108
|
+
const base = {
|
|
109
|
+
aspectRatio: `${ratio.width} / ${ratio.height}`,
|
|
110
|
+
backgroundImage: `url("${url}")`,
|
|
111
|
+
backgroundRepeat: 'no-repeat'
|
|
112
|
+
};
|
|
113
|
+
const haveDims = frameSize.width > 0 && frameSize.height > 0 && isValidNumber(imgW) && isValidNumber(imgH);
|
|
114
|
+
// Fallback: no crop OR dimensions not yet known.
|
|
115
|
+
// Use CSS-native cover + position — identical to the frontend's
|
|
116
|
+
// `object-fit:cover; object-position: focalX% focalY%`.
|
|
117
|
+
if (!haveDims || !isValidCrop(crop, imgW, imgH)) {
|
|
118
|
+
return {
|
|
119
|
+
...base,
|
|
120
|
+
backgroundSize: 'cover',
|
|
121
|
+
backgroundPosition: `${focalX}% ${focalY}%`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const cropPx = resolveCropPixels(crop, imgW, imgH);
|
|
125
|
+
const { backgroundSize, backgroundPosition } = computeCroppedBackgroundStyle(frameSize.width, frameSize.height, imgW, imgH, cropPx, focalX, focalY);
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
backgroundSize,
|
|
129
|
+
backgroundPosition
|
|
130
|
+
};
|
|
131
|
+
}, [
|
|
132
|
+
frameSize,
|
|
133
|
+
imgW,
|
|
134
|
+
imgH,
|
|
135
|
+
focalX,
|
|
136
|
+
focalY,
|
|
137
|
+
crop,
|
|
138
|
+
ratio.width,
|
|
139
|
+
ratio.height,
|
|
140
|
+
url
|
|
141
|
+
]);
|
|
142
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
143
|
+
className: "focal-preview__card",
|
|
144
|
+
children: [
|
|
145
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
146
|
+
className: "focal-preview__card-header",
|
|
147
|
+
children: [
|
|
148
|
+
/*#__PURE__*/ _jsx("span", {
|
|
149
|
+
className: "focal-preview__card-name",
|
|
150
|
+
children: ratio.name
|
|
151
|
+
}),
|
|
152
|
+
/*#__PURE__*/ _jsx("span", {
|
|
153
|
+
className: "focal-preview__card-ratio",
|
|
154
|
+
children: ratio.ratio
|
|
155
|
+
})
|
|
156
|
+
]
|
|
157
|
+
}),
|
|
158
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
159
|
+
ref: frameRef,
|
|
160
|
+
className: "focal-preview__card-frame",
|
|
161
|
+
style: frameStyle,
|
|
162
|
+
role: "img",
|
|
163
|
+
"aria-label": `Preview for ${ratio.name}`,
|
|
164
|
+
children: [
|
|
165
|
+
/*#__PURE__*/ _jsx("div", {
|
|
166
|
+
className: "focal-preview__card-overlay"
|
|
167
|
+
}),
|
|
168
|
+
/*#__PURE__*/ _jsx("div", {
|
|
169
|
+
className: "focal-preview__card-crosshair"
|
|
170
|
+
})
|
|
171
|
+
]
|
|
172
|
+
}),
|
|
173
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
174
|
+
className: "focal-preview__card-footer",
|
|
175
|
+
children: [
|
|
176
|
+
/*#__PURE__*/ _jsx("span", {
|
|
177
|
+
className: "focal-preview__card-usage",
|
|
178
|
+
children: ratio.usage
|
|
179
|
+
}),
|
|
180
|
+
/*#__PURE__*/ _jsx("span", {
|
|
181
|
+
className: `focal-preview__card-source focal-preview__card-source--${ratio.source}`,
|
|
182
|
+
children: ratio.source === 'css' ? 'CSS' : 'Crop'
|
|
183
|
+
})
|
|
184
|
+
]
|
|
185
|
+
})
|
|
186
|
+
]
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
export const AspectRatioPreviewGrid = ({ url, focalX, focalY, aspectRatios, crop })=>{
|
|
190
|
+
const [imgDimensions, setImgDimensions] = useState({
|
|
191
|
+
width: 0,
|
|
192
|
+
height: 0
|
|
193
|
+
});
|
|
194
|
+
useEffect(()=>{
|
|
195
|
+
const img = new Image();
|
|
196
|
+
img.onload = ()=>{
|
|
197
|
+
setImgDimensions({
|
|
198
|
+
width: img.naturalWidth,
|
|
199
|
+
height: img.naturalHeight
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
img.onerror = ()=>{
|
|
203
|
+
setImgDimensions({
|
|
204
|
+
width: 0,
|
|
205
|
+
height: 0
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
img.src = url;
|
|
209
|
+
}, [
|
|
210
|
+
url
|
|
211
|
+
]);
|
|
212
|
+
return /*#__PURE__*/ _jsx("div", {
|
|
213
|
+
className: "focal-preview",
|
|
214
|
+
children: /*#__PURE__*/ _jsx("div", {
|
|
215
|
+
className: "focal-preview__grid",
|
|
216
|
+
children: aspectRatios.map((ratio)=>/*#__PURE__*/ _jsx(PreviewCard, {
|
|
217
|
+
ratio: ratio,
|
|
218
|
+
url: url,
|
|
219
|
+
focalX: focalX,
|
|
220
|
+
focalY: focalY,
|
|
221
|
+
crop: crop,
|
|
222
|
+
imgW: imgDimensions.width,
|
|
223
|
+
imgH: imgDimensions.height
|
|
224
|
+
}, ratio.name))
|
|
225
|
+
})
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
export default AspectRatioPreviewGrid;
|
|
229
|
+
|
|
230
|
+
//# sourceMappingURL=AspectRatioPreviewGrid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/AspectRatioPreviewGrid.tsx"],"sourcesContent":["'use client'\n\nimport React, { useEffect, useRef, useState } from 'react'\n\nimport type { AspectRatioConfig, CropConfig } from '../types'\n\ninterface AspectRatioPreviewGridProps {\n url: string\n focalX: number\n focalY: number\n aspectRatios: AspectRatioConfig[]\n crop?: CropConfig\n}\n\nfunction clamp(val: number, min: number, max: number) {\n return Math.max(min, Math.min(max, val))\n}\n\nfunction isValidNumber(n: number) {\n return typeof n === 'number' && !isNaN(n) && isFinite(n) && n > 0\n}\n\nfunction isValidCrop(crop: CropConfig | undefined, imgW: number, imgH: number) {\n if (!crop) return false\n\n let cropW: number\n let cropH: number\n\n if (crop.unit === '%') {\n cropW = crop.width\n cropH = crop.height\n if (!isValidNumber(cropW) || !isValidNumber(cropH)) return false\n if (cropW > 100 || cropH > 100) return false\n if (crop.x < 0 || crop.y < 0) return false\n if (crop.x + cropW > 100 + 1e-6) return false\n if (crop.y + cropH > 100 + 1e-6) return false\n } else {\n if (crop.width > imgW || crop.height > imgH || crop.x > imgW || crop.y > imgH) {\n return false\n }\n if (!isValidNumber(crop.width) || !isValidNumber(crop.height)) return false\n if (crop.x < 0 || crop.y < 0) return false\n cropW = crop.width\n cropH = crop.height\n }\n\n return cropW > 1e-6 && cropH > 1e-6\n}\n\nfunction resolveCropPixels(crop: CropConfig, imgW: number, imgH: number) {\n if (crop.unit === '%') {\n return {\n x: (crop.x / 100) * imgW,\n y: (crop.y / 100) * imgH,\n w: (crop.width / 100) * imgW,\n h: (crop.height / 100) * imgH,\n }\n }\n return { x: crop.x, y: crop.y, w: crop.width, h: crop.height }\n}\n\n// Editor stores focalX/focalY as percentages of the FULL image.\n// Convert to percentages within the crop region.\nfunction toCropRelativeFocal(\n focalX: number,\n focalY: number,\n crop: { x: number; y: number; w: number; h: number },\n imgW: number,\n imgH: number,\n) {\n const relX = ((focalX / 100) * imgW - crop.x) / crop.w\n const relY = ((focalY / 100) * imgH - crop.y) / crop.h\n return {\n x: clamp(relX * 100, 0, 100),\n y: clamp(relY * 100, 0, 100),\n }\n}\n\n// Compute a background-image style on the FRAME that simulates Payload's\n// server-side behavior: take the crop region from the original image, then\n// render it with object-fit:cover + object-position:focal% into the frame.\n//\n// Background-image is used instead of <img> because Payload's admin CSS\n// applies aggressive global rules to <img> (max-width:100%, height:auto)\n// that fight inline pixel sizing and cause squashing.\nfunction computeCroppedBackgroundStyle(\n frameW: number,\n frameH: number,\n imgW: number,\n imgH: number,\n crop: { x: number; y: number; w: number; h: number },\n focalX: number,\n focalY: number,\n): { backgroundSize: string; backgroundPosition: string } {\n // Scale the crop region to cover the frame (same math as object-fit:cover)\n const scale = Math.max(frameW / crop.w, frameH / crop.h)\n\n // Focal point inside the crop region, expressed as %\n const relFocal = toCropRelativeFocal(focalX, focalY, crop, imgW, imgH)\n\n // Where the crop region's top-left lands in the frame, per object-position math\n const cropDisplayW = crop.w * scale\n const cropDisplayH = crop.h * scale\n const cropLeft = (frameW - cropDisplayW) * (relFocal.x / 100)\n const cropTop = (frameH - cropDisplayH) * (relFocal.y / 100)\n\n // Position the FULL image so the crop region lands at cropLeft / cropTop\n const bgLeft = cropLeft - crop.x * scale\n const bgTop = cropTop - crop.y * scale\n\n const bgW = imgW * scale\n const bgH = imgH * scale\n\n return {\n backgroundSize: `${bgW}px ${bgH}px`,\n backgroundPosition: `${bgLeft}px ${bgTop}px`,\n }\n}\n\nconst PreviewCard: React.FC<{\n ratio: AspectRatioConfig\n url: string\n focalX: number\n focalY: number\n crop?: CropConfig\n imgW: number\n imgH: number\n}> = ({ ratio, url, focalX, focalY, crop, imgW, imgH }) => {\n const frameRef = useRef<HTMLDivElement>(null)\n const [frameSize, setFrameSize] = useState({ width: 0, height: 0 })\n\n useEffect(() => {\n const el = frameRef.current\n if (!el) return\n const observer = new ResizeObserver((entries) => {\n for (const entry of entries) {\n const { width, height } = entry.contentRect\n setFrameSize({ width, height })\n }\n })\n observer.observe(el)\n return () => observer.disconnect()\n }, [])\n\n const frameStyle: React.CSSProperties = React.useMemo(() => {\n const base: React.CSSProperties = {\n aspectRatio: `${ratio.width} / ${ratio.height}`,\n backgroundImage: `url(\"${url}\")`,\n backgroundRepeat: 'no-repeat',\n }\n\n const haveDims =\n frameSize.width > 0 && frameSize.height > 0 && isValidNumber(imgW) && isValidNumber(imgH)\n\n // Fallback: no crop OR dimensions not yet known.\n // Use CSS-native cover + position — identical to the frontend's\n // `object-fit:cover; object-position: focalX% focalY%`.\n if (!haveDims || !isValidCrop(crop, imgW, imgH)) {\n return {\n ...base,\n backgroundSize: 'cover',\n backgroundPosition: `${focalX}% ${focalY}%`,\n }\n }\n\n const cropPx = resolveCropPixels(crop!, imgW, imgH)\n const { backgroundSize, backgroundPosition } = computeCroppedBackgroundStyle(\n frameSize.width,\n frameSize.height,\n imgW,\n imgH,\n cropPx,\n focalX,\n focalY,\n )\n return { ...base, backgroundSize, backgroundPosition }\n }, [frameSize, imgW, imgH, focalX, focalY, crop, ratio.width, ratio.height, url])\n\n return (\n <div className=\"focal-preview__card\">\n <div className=\"focal-preview__card-header\">\n <span className=\"focal-preview__card-name\">{ratio.name}</span>\n <span className=\"focal-preview__card-ratio\">{ratio.ratio}</span>\n </div>\n <div\n ref={frameRef}\n className=\"focal-preview__card-frame\"\n style={frameStyle}\n role=\"img\"\n aria-label={`Preview for ${ratio.name}`}\n >\n <div className=\"focal-preview__card-overlay\" />\n <div className=\"focal-preview__card-crosshair\" />\n </div>\n <div className=\"focal-preview__card-footer\">\n <span className=\"focal-preview__card-usage\">{ratio.usage}</span>\n <span className={`focal-preview__card-source focal-preview__card-source--${ratio.source}`}>\n {ratio.source === 'css' ? 'CSS' : 'Crop'}\n </span>\n </div>\n </div>\n )\n}\n\nexport const AspectRatioPreviewGrid: React.FC<AspectRatioPreviewGridProps> = ({\n url,\n focalX,\n focalY,\n aspectRatios,\n crop,\n}) => {\n const [imgDimensions, setImgDimensions] = useState({ width: 0, height: 0 })\n\n useEffect(() => {\n const img = new Image()\n img.onload = () => {\n setImgDimensions({ width: img.naturalWidth, height: img.naturalHeight })\n }\n img.onerror = () => {\n setImgDimensions({ width: 0, height: 0 })\n }\n img.src = url\n }, [url])\n\n return (\n <div className=\"focal-preview\">\n <div className=\"focal-preview__grid\">\n {aspectRatios.map((ratio) => (\n <PreviewCard\n key={ratio.name}\n ratio={ratio}\n url={url}\n focalX={focalX}\n focalY={focalY}\n crop={crop}\n imgW={imgDimensions.width}\n imgH={imgDimensions.height}\n />\n ))}\n </div>\n </div>\n )\n}\n\nexport default AspectRatioPreviewGrid\n"],"names":["React","useEffect","useRef","useState","clamp","val","min","max","Math","isValidNumber","n","isNaN","isFinite","isValidCrop","crop","imgW","imgH","cropW","cropH","unit","width","height","x","y","resolveCropPixels","w","h","toCropRelativeFocal","focalX","focalY","relX","relY","computeCroppedBackgroundStyle","frameW","frameH","scale","relFocal","cropDisplayW","cropDisplayH","cropLeft","cropTop","bgLeft","bgTop","bgW","bgH","backgroundSize","backgroundPosition","PreviewCard","ratio","url","frameRef","frameSize","setFrameSize","el","current","observer","ResizeObserver","entries","entry","contentRect","observe","disconnect","frameStyle","useMemo","base","aspectRatio","backgroundImage","backgroundRepeat","haveDims","cropPx","div","className","span","name","ref","style","role","aria-label","usage","source","AspectRatioPreviewGrid","aspectRatios","imgDimensions","setImgDimensions","img","Image","onload","naturalWidth","naturalHeight","onerror","src","map"],"mappings":"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"}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Drawer, Dropzone, FileDetails, PreviewSizes, Thumbnail, fieldBaseClass, useConfig, useDocumentInfo, useField, useForm, useModal, useTranslation, useUploadEdits } from '@payloadcms/ui';
|
|
4
|
+
import { isImage } from 'payload/shared';
|
|
5
|
+
import React, { useCallback, useState } from 'react';
|
|
6
|
+
import '../styles.scss';
|
|
7
|
+
const baseClass = 'file-field';
|
|
8
|
+
const sizePreviewSlug = 'preview-sizes';
|
|
9
|
+
const validate = (value)=>{
|
|
10
|
+
if (!value && value !== undefined) {
|
|
11
|
+
return 'A file is required.';
|
|
12
|
+
}
|
|
13
|
+
if (value && (!value.name || value.name === '')) {
|
|
14
|
+
return 'A file name is required.';
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
};
|
|
18
|
+
export const CustomUpload = ()=>{
|
|
19
|
+
const { t } = useTranslation();
|
|
20
|
+
const { getEntityConfig } = useConfig();
|
|
21
|
+
const { data, docPermissions } = useDocumentInfo();
|
|
22
|
+
const { setModified } = useForm();
|
|
23
|
+
const { openModal } = useModal();
|
|
24
|
+
const { resetUploadEdits } = useUploadEdits();
|
|
25
|
+
const collectionSlug = data?.collection || 'media';
|
|
26
|
+
const collectionConfig = getEntityConfig({
|
|
27
|
+
collectionSlug
|
|
28
|
+
});
|
|
29
|
+
const uploadConfig = collectionConfig?.upload;
|
|
30
|
+
const { setValue, value } = useField({
|
|
31
|
+
path: 'file',
|
|
32
|
+
validate
|
|
33
|
+
});
|
|
34
|
+
const [fileSrc, setFileSrc] = useState(undefined);
|
|
35
|
+
const [removedFile, setRemovedFile] = useState(false);
|
|
36
|
+
const hasImageSizes = (uploadConfig?.imageSizes?.length || 0) > 0;
|
|
37
|
+
const imageCacheTag = uploadConfig?.cacheTags && data?.updatedAt;
|
|
38
|
+
const handleFileChange = useCallback((file)=>{
|
|
39
|
+
if (file) {
|
|
40
|
+
setFileSrc(URL.createObjectURL(file));
|
|
41
|
+
}
|
|
42
|
+
setValue(file);
|
|
43
|
+
setModified(true);
|
|
44
|
+
}, [
|
|
45
|
+
setValue,
|
|
46
|
+
setModified
|
|
47
|
+
]);
|
|
48
|
+
const handleFileRemoval = useCallback(()=>{
|
|
49
|
+
setRemovedFile(true);
|
|
50
|
+
setValue(null);
|
|
51
|
+
setFileSrc(undefined);
|
|
52
|
+
resetUploadEdits();
|
|
53
|
+
setModified(true);
|
|
54
|
+
}, [
|
|
55
|
+
setValue,
|
|
56
|
+
resetUploadEdits,
|
|
57
|
+
setModified
|
|
58
|
+
]);
|
|
59
|
+
const handleDropzoneChange = useCallback((files)=>{
|
|
60
|
+
if (files && files.length > 0) {
|
|
61
|
+
handleFileChange(files[0]);
|
|
62
|
+
}
|
|
63
|
+
}, [
|
|
64
|
+
handleFileChange
|
|
65
|
+
]);
|
|
66
|
+
const hasExistingFile = data?.filename && !removedFile;
|
|
67
|
+
const hasNewFile = value && fileSrc;
|
|
68
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
69
|
+
className: [
|
|
70
|
+
fieldBaseClass,
|
|
71
|
+
baseClass
|
|
72
|
+
].join(' '),
|
|
73
|
+
children: [
|
|
74
|
+
hasExistingFile && /*#__PURE__*/ _jsx(FileDetails, {
|
|
75
|
+
collectionSlug: collectionSlug,
|
|
76
|
+
doc: data,
|
|
77
|
+
enableAdjustments: false,
|
|
78
|
+
handleRemove: docPermissions?.update ? handleFileRemoval : undefined,
|
|
79
|
+
hasImageSizes: hasImageSizes,
|
|
80
|
+
uploadConfig: uploadConfig,
|
|
81
|
+
imageCacheTag: imageCacheTag
|
|
82
|
+
}),
|
|
83
|
+
(!hasExistingFile || removedFile) && /*#__PURE__*/ _jsx("div", {
|
|
84
|
+
className: `${baseClass}__upload`,
|
|
85
|
+
children: /*#__PURE__*/ _jsx(Dropzone, {
|
|
86
|
+
onChange: handleDropzoneChange,
|
|
87
|
+
children: /*#__PURE__*/ _jsxs("div", {
|
|
88
|
+
className: `${baseClass}__dropzoneContent`,
|
|
89
|
+
children: [
|
|
90
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
91
|
+
buttonStyle: "pill",
|
|
92
|
+
onClick: ()=>document.getElementById('file-input')?.click(),
|
|
93
|
+
size: "small",
|
|
94
|
+
children: t('upload:selectFile')
|
|
95
|
+
}),
|
|
96
|
+
/*#__PURE__*/ _jsx("input", {
|
|
97
|
+
id: "file-input",
|
|
98
|
+
type: "file",
|
|
99
|
+
hidden: true,
|
|
100
|
+
onChange: (e)=>handleFileChange(e.target.files?.[0] || null)
|
|
101
|
+
})
|
|
102
|
+
]
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
}),
|
|
106
|
+
hasNewFile && /*#__PURE__*/ _jsxs("div", {
|
|
107
|
+
className: `${baseClass}__file-selected`,
|
|
108
|
+
children: [
|
|
109
|
+
/*#__PURE__*/ _jsx("div", {
|
|
110
|
+
className: `${baseClass}__thumbnail-wrap`,
|
|
111
|
+
children: /*#__PURE__*/ _jsx(Thumbnail, {
|
|
112
|
+
collectionSlug: collectionSlug,
|
|
113
|
+
fileSrc: isImage(value.type) ? fileSrc : undefined
|
|
114
|
+
})
|
|
115
|
+
}),
|
|
116
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
117
|
+
className: `${baseClass}__file-adjustments`,
|
|
118
|
+
children: [
|
|
119
|
+
/*#__PURE__*/ _jsx("input", {
|
|
120
|
+
type: "text",
|
|
121
|
+
value: value.name,
|
|
122
|
+
onChange: (e)=>{
|
|
123
|
+
const renamed = new File([
|
|
124
|
+
value
|
|
125
|
+
], e.target.value, {
|
|
126
|
+
type: value.type
|
|
127
|
+
});
|
|
128
|
+
handleFileChange(renamed);
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
/*#__PURE__*/ _jsx("div", {
|
|
132
|
+
className: `${baseClass}__upload-actions`,
|
|
133
|
+
children: hasImageSizes && /*#__PURE__*/ _jsx(Button, {
|
|
134
|
+
buttonStyle: "pill",
|
|
135
|
+
size: "small",
|
|
136
|
+
onClick: ()=>openModal(sizePreviewSlug),
|
|
137
|
+
children: t('upload:previewSizes')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
]
|
|
141
|
+
}),
|
|
142
|
+
/*#__PURE__*/ _jsx(Button, {
|
|
143
|
+
buttonStyle: "icon-label",
|
|
144
|
+
icon: "x",
|
|
145
|
+
onClick: handleFileRemoval,
|
|
146
|
+
round: true,
|
|
147
|
+
tooltip: t('general:cancel')
|
|
148
|
+
})
|
|
149
|
+
]
|
|
150
|
+
}),
|
|
151
|
+
data && hasImageSizes && /*#__PURE__*/ _jsx(Drawer, {
|
|
152
|
+
slug: sizePreviewSlug,
|
|
153
|
+
title: t('upload:sizesFor', {
|
|
154
|
+
label: data.filename
|
|
155
|
+
}),
|
|
156
|
+
children: /*#__PURE__*/ _jsx(PreviewSizes, {
|
|
157
|
+
doc: data,
|
|
158
|
+
imageCacheTag: imageCacheTag,
|
|
159
|
+
uploadConfig: uploadConfig
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
export default CustomUpload;
|
|
166
|
+
|
|
167
|
+
//# sourceMappingURL=CustomUpload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/CustomUpload.tsx"],"sourcesContent":["'use client'\n\nimport type { SanitizedUploadConfig } from 'payload'\n\nimport {\n Button,\n Drawer,\n Dropzone,\n FileDetails,\n PreviewSizes,\n Thumbnail,\n fieldBaseClass,\n useConfig,\n useDocumentInfo,\n useField,\n useForm,\n useModal,\n useTranslation,\n useUploadEdits,\n} from '@payloadcms/ui'\nimport { isImage } from 'payload/shared'\nimport React, { useCallback, useState } from 'react'\n\nimport '../styles.scss'\n\nconst baseClass = 'file-field'\nconst sizePreviewSlug = 'preview-sizes'\n\nconst validate = (value: File | undefined) => {\n if (!value && value !== undefined) {\n return 'A file is required.'\n }\n if (value && (!value.name || value.name === '')) {\n return 'A file name is required.'\n }\n return true\n}\n\nexport const CustomUpload: React.FC = () => {\n const { t } = useTranslation()\n const { getEntityConfig } = useConfig()\n const { data, docPermissions } = useDocumentInfo()\n const { setModified } = useForm()\n const { openModal } = useModal()\n const { resetUploadEdits } = useUploadEdits()\n\n const collectionSlug = data?.collection || 'media'\n const collectionConfig = getEntityConfig({ collectionSlug })\n const uploadConfig = collectionConfig?.upload as SanitizedUploadConfig | undefined\n\n const { setValue, value } = useField<File>({ path: 'file', validate })\n\n const [fileSrc, setFileSrc] = useState<string | undefined>(undefined)\n const [removedFile, setRemovedFile] = useState(false)\n\n const hasImageSizes = ((uploadConfig?.imageSizes?.length as number | undefined) || 0) > 0\n const imageCacheTag = uploadConfig?.cacheTags && data?.updatedAt\n\n const handleFileChange = useCallback(\n (file: File | null) => {\n if (file) {\n setFileSrc(URL.createObjectURL(file))\n }\n setValue(file)\n setModified(true)\n },\n [setValue, setModified],\n )\n\n const handleFileRemoval = useCallback(() => {\n setRemovedFile(true)\n setValue(null)\n setFileSrc(undefined)\n resetUploadEdits()\n setModified(true)\n }, [setValue, resetUploadEdits, setModified])\n\n const handleDropzoneChange = useCallback(\n (files: FileList | null) => {\n if (files && files.length > 0) {\n handleFileChange(files[0])\n }\n },\n [handleFileChange],\n )\n\n const hasExistingFile = data?.filename && !removedFile\n const hasNewFile = value && fileSrc\n\n return (\n <div className={[fieldBaseClass, baseClass].join(' ')}>\n {/* Existing file display — no edit button, editor is a separate UI field */}\n {hasExistingFile && (\n <FileDetails\n collectionSlug={collectionSlug}\n doc={data}\n enableAdjustments={false}\n handleRemove={docPermissions?.update ? handleFileRemoval : undefined}\n hasImageSizes={hasImageSizes}\n uploadConfig={uploadConfig!}\n imageCacheTag={imageCacheTag}\n />\n )}\n\n {/* Upload area */}\n {(!hasExistingFile || removedFile) && (\n <div className={`${baseClass}__upload`}>\n <Dropzone onChange={handleDropzoneChange}>\n <div className={`${baseClass}__dropzoneContent`}>\n <Button\n buttonStyle=\"pill\"\n onClick={() => document.getElementById('file-input')?.click()}\n size=\"small\"\n >\n {t('upload:selectFile')}\n </Button>\n <input\n id=\"file-input\"\n type=\"file\"\n hidden\n onChange={(e) => handleFileChange(e.target.files?.[0] || null)}\n />\n </div>\n </Dropzone>\n </div>\n )}\n\n {/* Selected file preview */}\n {hasNewFile && (\n <div className={`${baseClass}__file-selected`}>\n <div className={`${baseClass}__thumbnail-wrap`}>\n <Thumbnail\n collectionSlug={collectionSlug}\n fileSrc={isImage(value.type) ? fileSrc : undefined}\n />\n </div>\n <div className={`${baseClass}__file-adjustments`}>\n <input\n type=\"text\"\n value={value.name}\n onChange={(e) => {\n const renamed = new File([value], e.target.value, { type: value.type })\n handleFileChange(renamed)\n }}\n />\n <div className={`${baseClass}__upload-actions`}>\n {hasImageSizes && (\n <Button buttonStyle=\"pill\" size=\"small\" onClick={() => openModal(sizePreviewSlug)}>\n {t('upload:previewSizes')}\n </Button>\n )}\n </div>\n </div>\n <Button\n buttonStyle=\"icon-label\"\n icon=\"x\"\n onClick={handleFileRemoval}\n round\n tooltip={t('general:cancel')}\n />\n </div>\n )}\n\n {/* Preview Sizes Drawer */}\n {data && hasImageSizes && (\n <Drawer slug={sizePreviewSlug} title={t('upload:sizesFor', { label: data.filename })}>\n <PreviewSizes doc={data} imageCacheTag={imageCacheTag} uploadConfig={uploadConfig!} />\n </Drawer>\n )}\n </div>\n )\n}\n\nexport default CustomUpload\n"],"names":["Button","Drawer","Dropzone","FileDetails","PreviewSizes","Thumbnail","fieldBaseClass","useConfig","useDocumentInfo","useField","useForm","useModal","useTranslation","useUploadEdits","isImage","React","useCallback","useState","baseClass","sizePreviewSlug","validate","value","undefined","name","CustomUpload","t","getEntityConfig","data","docPermissions","setModified","openModal","resetUploadEdits","collectionSlug","collection","collectionConfig","uploadConfig","upload","setValue","path","fileSrc","setFileSrc","removedFile","setRemovedFile","hasImageSizes","imageSizes","length","imageCacheTag","cacheTags","updatedAt","handleFileChange","file","URL","createObjectURL","handleFileRemoval","handleDropzoneChange","files","hasExistingFile","filename","hasNewFile","div","className","join","doc","enableAdjustments","handleRemove","update","onChange","buttonStyle","onClick","document","getElementById","click","size","input","id","type","hidden","e","target","renamed","File","icon","round","tooltip","slug","title","label"],"mappings":"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"}
|