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.
@@ -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 { getEntityConfig } = useConfig();
15
- // Read the ratios the plugin stashed in the collection's `custom` config.
16
- // Falls back to the shipped defaults so the editor renders even if a
17
- // consumer wired the field without options.
18
- const collectionSlug = typeof data?.collection === 'string' ? data.collection : undefined;
19
- const entityConfig = collectionSlug ? getEntityConfig({
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
- // 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
- }
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
- const [prevUpdatedAtTag, setPrevUpdatedAtTag] = useState(updatedAtTag);
119
- // Re-sync local state from the saved document each time `updatedAt` changes.
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 && updatedAtTag !== prevUpdatedAtTag) {
122
- const nextFocal = {
123
- x: typeof data?.focalX === 'number' ? data.focalX : 50,
124
- y: typeof data?.focalY === 'number' ? data.focalY : 50
125
- };
126
- queueMicrotask(()=>{
127
- setPrevUpdatedAtTag(updatedAtTag);
128
- hasUserEditedRef.current = false;
129
- setFocalPoint(nextFocal);
130
- setCrop(undefined);
131
- });
132
- if (uploadEdits?.crop || uploadEdits?.focalPoint) {
133
- updateUploadEdits({
134
- crop: undefined,
135
- focalPoint: nextFocal,
136
- heightInPixels: undefined,
137
- widthInPixels: undefined
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: cropRelativeFocal,
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
- const clampFocalToCrop = useCallback((x, y)=>{
203
- if (crop && crop.unit === '%' && hasRealCrop) {
204
- x = Math.max(crop.x, Math.min(crop.x + crop.width, x));
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
- let x = (clientX - rect.left) / rect.width * 100;
223
- let y = (clientY - rect.top) / rect.height * 100;
224
- x = Math.max(0, Math.min(100, x));
225
- y = Math.max(0, Math.min(100, y));
226
- const clamped = clampFocalToCrop(x, y);
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(clamped);
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
- clampFocalToCrop
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
- const x = Math.max(0, Math.min(100, Number(e.target.value)));
373
- return clampFocalToCrop(x, prev.y);
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
- const y = Math.max(0, Math.min(100, Number(e.target.value)));
399
- return clampFocalToCrop(prev.x, y);
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: `${focalPoint.x}%`,
632
- top: `${focalPoint.y}%`
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: focalPoint.x,
655
- focalY: focalPoint.y,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-plugin-aspect-preview",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",