payload-plugin-aspect-preview 0.2.0 → 0.2.2
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/FocalPointEditor.js +89 -80
- package/dist/plugin.js +21 -0
- package/package.json +1 -1
|
@@ -8,18 +8,15 @@ import { DEFAULT_ASPECT_RATIOS } from '../defaults';
|
|
|
8
8
|
import { toCropRelativeFocal, toFullImageFocal } from '../focal';
|
|
9
9
|
import { AspectRatioPreviewGrid } from './AspectRatioPreviewGrid';
|
|
10
10
|
export const FocalPointEditor = ()=>{
|
|
11
|
-
const { data } = useDocumentInfo();
|
|
11
|
+
const { collectionSlug, data } = useDocumentInfo();
|
|
12
12
|
const { setModified } = useForm();
|
|
13
13
|
const { uploadEdits, updateUploadEdits } = useUploadEdits();
|
|
14
|
-
const {
|
|
15
|
-
// Read the ratios the plugin stashed in the collection's
|
|
16
|
-
// Falls back to the shipped defaults so
|
|
17
|
-
// consumer wired the field without options.
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
collectionSlug
|
|
21
|
-
}) : undefined;
|
|
22
|
-
const aspectRatios = entityConfig?.custom?.aspectPreview?.aspectRatios ?? DEFAULT_ASPECT_RATIOS;
|
|
14
|
+
const { config } = useConfig();
|
|
15
|
+
// Read the ratios the plugin stashed in `admin.custom` (the collection's own
|
|
16
|
+
// `custom` never reaches the client). Falls back to the shipped defaults so
|
|
17
|
+
// the editor renders even if a consumer wired the field without options.
|
|
18
|
+
const stashed = config.admin?.custom?.aspectPreview;
|
|
19
|
+
const aspectRatios = (collectionSlug ? stashed?.[collectionSlug]?.aspectRatios : undefined) ?? DEFAULT_ASPECT_RATIOS;
|
|
23
20
|
const hasUserEditedRef = useRef(false);
|
|
24
21
|
// The freshly-picked File lives in the shared `file` form field (set by the
|
|
25
22
|
// upload component) before the document is saved. Reading it here lets us
|
|
@@ -65,13 +62,14 @@ export const FocalPointEditor = ()=>{
|
|
|
65
62
|
width: uploadEdits.crop.width,
|
|
66
63
|
height: uploadEdits.crop.height
|
|
67
64
|
} : undefined);
|
|
65
|
+
// Focal point is stored relative to the active crop region: a 0-100 position
|
|
66
|
+
// inside the crop, or inside the whole image when there is no crop. This is
|
|
67
|
+
// the same space Payload persists (it bakes the crop into the base image and
|
|
68
|
+
// reads focalX/focalY as percentages of the cropped result), so uploadEdits
|
|
69
|
+
// and data.focalX/Y are already in this space — no conversion on load.
|
|
70
|
+
// `typeof` (not `||`) so a valid edge focal of 0 doesn't fall through to 50.
|
|
68
71
|
const [focalPoint, setFocalPoint] = useState(()=>{
|
|
69
|
-
|
|
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
|
-
}
|
|
72
|
+
if (uploadEdits?.focalPoint) return uploadEdits.focalPoint;
|
|
75
73
|
return {
|
|
76
74
|
x: typeof data?.focalX === 'number' ? data.focalX : 50,
|
|
77
75
|
y: typeof data?.focalY === 'number' ? data.focalY : 50
|
|
@@ -115,39 +113,56 @@ export const FocalPointEditor = ()=>{
|
|
|
115
113
|
docWidth,
|
|
116
114
|
docHeight
|
|
117
115
|
]);
|
|
118
|
-
|
|
119
|
-
//
|
|
116
|
+
// Re-sync local state from the saved document once per save. The guard is a
|
|
117
|
+
// ref (not state) so it flips synchronously — the effect cannot re-enter when
|
|
118
|
+
// the updateUploadEdits call below changes the upload-edits context. Driving
|
|
119
|
+
// the guard through state (and listing uploadEdits in the deps) is what
|
|
120
|
+
// previously re-entered on every context change and exceeded React's update
|
|
121
|
+
// depth on save.
|
|
122
|
+
const lastSyncedTagRef = useRef(updatedAtTag);
|
|
120
123
|
useEffect(()=>{
|
|
121
|
-
if (updatedAtTag
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
124
|
+
if (!updatedAtTag || updatedAtTag === lastSyncedTagRef.current) return;
|
|
125
|
+
lastSyncedTagRef.current = updatedAtTag;
|
|
126
|
+
hasUserEditedRef.current = false;
|
|
127
|
+
const nextFocal = {
|
|
128
|
+
x: typeof data?.focalX === 'number' ? data.focalX : 50,
|
|
129
|
+
y: typeof data?.focalY === 'number' ? data.focalY : 50
|
|
130
|
+
};
|
|
131
|
+
setFocalPoint(nextFocal);
|
|
132
|
+
// The crop is now baked into the saved image, so there is no pending crop
|
|
133
|
+
// and the focal is relative to the whole (already-cropped) image again.
|
|
134
|
+
setCrop(undefined);
|
|
135
|
+
updateUploadEdits({
|
|
136
|
+
crop: undefined,
|
|
137
|
+
focalPoint: nextFocal,
|
|
138
|
+
heightInPixels: undefined,
|
|
139
|
+
widthInPixels: undefined
|
|
140
|
+
});
|
|
141
141
|
}, [
|
|
142
142
|
updatedAtTag,
|
|
143
|
-
prevUpdatedAtTag,
|
|
144
143
|
data?.focalX,
|
|
145
144
|
data?.focalY,
|
|
146
|
-
uploadEdits?.crop,
|
|
147
|
-
uploadEdits?.focalPoint,
|
|
148
145
|
updateUploadEdits
|
|
149
146
|
]);
|
|
150
147
|
const hasRealCrop = !!crop && crop.width > 0 && crop.height > 0 && (crop.width !== 100 || crop.height !== 100 || crop.x !== 0 || crop.y !== 0);
|
|
148
|
+
// The rectangle the focal point is measured against: the crop when one exists
|
|
149
|
+
// (in %), otherwise the whole image. Focal state is always a 0-100 position
|
|
150
|
+
// inside this rectangle; converting to/from full-image space happens only at
|
|
151
|
+
// the pointer and rendering boundaries.
|
|
152
|
+
const cropRect = React.useMemo(()=>hasRealCrop && crop && crop.unit === '%' ? {
|
|
153
|
+
x: crop.x,
|
|
154
|
+
y: crop.y,
|
|
155
|
+
width: crop.width,
|
|
156
|
+
height: crop.height
|
|
157
|
+
} : {
|
|
158
|
+
x: 0,
|
|
159
|
+
y: 0,
|
|
160
|
+
width: 100,
|
|
161
|
+
height: 100
|
|
162
|
+
}, [
|
|
163
|
+
hasRealCrop,
|
|
164
|
+
crop
|
|
165
|
+
]);
|
|
151
166
|
// Auto-save to uploadEdits whenever state changes
|
|
152
167
|
useEffect(()=>{
|
|
153
168
|
if (!hasUserEditedRef.current) return;
|
|
@@ -164,11 +179,6 @@ export const FocalPointEditor = ()=>{
|
|
|
164
179
|
const topPx = Math.floor(crop.y / 100 * naturalSize.height);
|
|
165
180
|
const widthInPixels = Math.max(1, Math.min(Math.floor(crop.width / 100 * naturalSize.width), naturalSize.width - leftPx));
|
|
166
181
|
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);
|
|
172
182
|
updateUploadEdits({
|
|
173
183
|
crop: {
|
|
174
184
|
unit: '%',
|
|
@@ -177,7 +187,9 @@ export const FocalPointEditor = ()=>{
|
|
|
177
187
|
width: crop.width,
|
|
178
188
|
height: crop.height
|
|
179
189
|
},
|
|
180
|
-
focalPoint
|
|
190
|
+
// focalPoint is already relative to the crop region — exactly the
|
|
191
|
+
// space Payload reads it in for the cropped image.
|
|
192
|
+
focalPoint,
|
|
181
193
|
heightInPixels,
|
|
182
194
|
widthInPixels
|
|
183
195
|
});
|
|
@@ -199,35 +211,28 @@ export const FocalPointEditor = ()=>{
|
|
|
199
211
|
setModified,
|
|
200
212
|
updateUploadEdits
|
|
201
213
|
]);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
y = Math.max(crop.y, Math.min(crop.y + crop.height, y));
|
|
206
|
-
}
|
|
207
|
-
return {
|
|
208
|
-
x,
|
|
209
|
-
y
|
|
210
|
-
};
|
|
211
|
-
}, [
|
|
212
|
-
crop,
|
|
213
|
-
hasRealCrop
|
|
214
|
-
]);
|
|
215
|
-
// Focal point drag handlers
|
|
214
|
+
// Focal point drag handler. The pointer lands somewhere on the full image;
|
|
215
|
+
// convert that full-image % into the crop-relative space the focal lives in,
|
|
216
|
+
// then clamp to the crop (0-100 inside cropRect).
|
|
216
217
|
const updateFocalFromEvent = useCallback((e)=>{
|
|
217
218
|
const container = containerRef.current;
|
|
218
219
|
if (!container) return;
|
|
219
220
|
const rect = container.getBoundingClientRect();
|
|
220
221
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
221
222
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
const fullX = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width * 100));
|
|
224
|
+
const fullY = Math.max(0, Math.min(100, (clientY - rect.top) / rect.height * 100));
|
|
225
|
+
const rel = toCropRelativeFocal({
|
|
226
|
+
x: fullX,
|
|
227
|
+
y: fullY
|
|
228
|
+
}, cropRect);
|
|
227
229
|
hasUserEditedRef.current = true;
|
|
228
|
-
setFocalPoint(
|
|
230
|
+
setFocalPoint({
|
|
231
|
+
x: Math.max(0, Math.min(100, rel.x)),
|
|
232
|
+
y: Math.max(0, Math.min(100, rel.y))
|
|
233
|
+
});
|
|
229
234
|
}, [
|
|
230
|
-
|
|
235
|
+
cropRect
|
|
231
236
|
]);
|
|
232
237
|
const handleMouseDown = useCallback((e)=>{
|
|
233
238
|
e.preventDefault();
|
|
@@ -322,6 +327,10 @@ export const FocalPointEditor = ()=>{
|
|
|
322
327
|
height: crop.height
|
|
323
328
|
} : undefined;
|
|
324
329
|
const cropUnitLabel = crop?.unit === 'px' ? 'px' : '%';
|
|
330
|
+
// Full-image position of the focal point (crop-relative → whole image). Used
|
|
331
|
+
// to place the crosshair absolutely and to feed the preview grid, which
|
|
332
|
+
// re-derives the crop-relative position itself.
|
|
333
|
+
const focalFullImage = toFullImageFocal(focalPoint, cropRect);
|
|
325
334
|
return /*#__PURE__*/ _jsx("div", {
|
|
326
335
|
className: "focal-editor",
|
|
327
336
|
children: /*#__PURE__*/ _jsxs("div", {
|
|
@@ -368,10 +377,10 @@ export const FocalPointEditor = ()=>{
|
|
|
368
377
|
value: Math.round(focalPoint.x),
|
|
369
378
|
onChange: (e)=>{
|
|
370
379
|
hasUserEditedRef.current = true;
|
|
371
|
-
setFocalPoint((prev)=>{
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
380
|
+
setFocalPoint((prev)=>({
|
|
381
|
+
...prev,
|
|
382
|
+
x: Math.max(0, Math.min(100, Number(e.target.value)))
|
|
383
|
+
}));
|
|
375
384
|
}
|
|
376
385
|
}),
|
|
377
386
|
/*#__PURE__*/ _jsx("span", {
|
|
@@ -394,10 +403,10 @@ export const FocalPointEditor = ()=>{
|
|
|
394
403
|
value: Math.round(focalPoint.y),
|
|
395
404
|
onChange: (e)=>{
|
|
396
405
|
hasUserEditedRef.current = true;
|
|
397
|
-
setFocalPoint((prev)=>{
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
406
|
+
setFocalPoint((prev)=>({
|
|
407
|
+
...prev,
|
|
408
|
+
y: Math.max(0, Math.min(100, Number(e.target.value)))
|
|
409
|
+
}));
|
|
401
410
|
}
|
|
402
411
|
}),
|
|
403
412
|
/*#__PURE__*/ _jsx("span", {
|
|
@@ -628,8 +637,8 @@ export const FocalPointEditor = ()=>{
|
|
|
628
637
|
children: mode === 'focal' && /*#__PURE__*/ _jsxs("div", {
|
|
629
638
|
className: "focal-editor__crosshair",
|
|
630
639
|
style: {
|
|
631
|
-
left: `${
|
|
632
|
-
top: `${
|
|
640
|
+
left: `${focalFullImage.x}%`,
|
|
641
|
+
top: `${focalFullImage.y}%`
|
|
633
642
|
},
|
|
634
643
|
children: [
|
|
635
644
|
/*#__PURE__*/ _jsx("div", {
|
|
@@ -651,8 +660,8 @@ export const FocalPointEditor = ()=>{
|
|
|
651
660
|
className: "focal-editor__right",
|
|
652
661
|
children: /*#__PURE__*/ _jsx(AspectRatioPreviewGrid, {
|
|
653
662
|
url: imageUrl,
|
|
654
|
-
focalX:
|
|
655
|
-
focalY:
|
|
663
|
+
focalX: focalFullImage.x,
|
|
664
|
+
focalY: focalFullImage.y,
|
|
656
665
|
aspectRatios: aspectRatios,
|
|
657
666
|
crop: cropConfig
|
|
658
667
|
})
|
package/dist/plugin.js
CHANGED
|
@@ -28,8 +28,26 @@ const UPLOAD_COMPONENT = 'payload-plugin-aspect-preview/client#CustomUpload';
|
|
|
28
28
|
*/ export const aspectPreviewPlugin = (options)=>(config)=>{
|
|
29
29
|
const aspectRatios = options.aspectRatios ?? DEFAULT_ASPECT_RATIOS;
|
|
30
30
|
const enabled = new Set(options.collections);
|
|
31
|
+
// Payload strips `collection.custom` from the client config, so the editor
|
|
32
|
+
// could never see it. `admin.custom` is forwarded to the browser.
|
|
33
|
+
const byCollection = Object.fromEntries(options.collections.map((slug)=>[
|
|
34
|
+
slug,
|
|
35
|
+
{
|
|
36
|
+
aspectRatios
|
|
37
|
+
}
|
|
38
|
+
]));
|
|
31
39
|
return {
|
|
32
40
|
...config,
|
|
41
|
+
admin: {
|
|
42
|
+
...config.admin,
|
|
43
|
+
custom: {
|
|
44
|
+
...config.admin?.custom,
|
|
45
|
+
aspectPreview: {
|
|
46
|
+
...config.admin?.custom?.aspectPreview,
|
|
47
|
+
...byCollection
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
33
51
|
collections: config.collections?.map((collection)=>{
|
|
34
52
|
if (!enabled.has(collection.slug)) return collection;
|
|
35
53
|
const next = {
|
|
@@ -40,6 +58,9 @@ const UPLOAD_COMPONENT = 'payload-plugin-aspect-preview/client#CustomUpload';
|
|
|
40
58
|
name: 'aspectPreview',
|
|
41
59
|
type: 'ui',
|
|
42
60
|
admin: {
|
|
61
|
+
// It's an edit-view editor, not data — keep it out of the
|
|
62
|
+
// list view's columns.
|
|
63
|
+
disableListColumn: true,
|
|
43
64
|
components: {
|
|
44
65
|
Field: FIELD_COMPONENT
|
|
45
66
|
}
|
package/package.json
CHANGED