payload-plugin-aspect-preview 0.2.0 → 0.2.1

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.
@@ -65,13 +65,14 @@ export const FocalPointEditor = ()=>{
65
65
  width: uploadEdits.crop.width,
66
66
  height: uploadEdits.crop.height
67
67
  } : undefined);
68
+ // Focal point is stored relative to the active crop region: a 0-100 position
69
+ // inside the crop, or inside the whole image when there is no crop. This is
70
+ // the same space Payload persists (it bakes the crop into the base image and
71
+ // reads focalX/focalY as percentages of the cropped result), so uploadEdits
72
+ // and data.focalX/Y are already in this space — no conversion on load.
73
+ // `typeof` (not `||`) so a valid edge focal of 0 doesn't fall through to 50.
68
74
  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
+ if (uploadEdits?.focalPoint) return uploadEdits.focalPoint;
75
76
  return {
76
77
  x: typeof data?.focalX === 'number' ? data.focalX : 50,
77
78
  y: typeof data?.focalY === 'number' ? data.focalY : 50
@@ -115,39 +116,56 @@ export const FocalPointEditor = ()=>{
115
116
  docWidth,
116
117
  docHeight
117
118
  ]);
118
- const [prevUpdatedAtTag, setPrevUpdatedAtTag] = useState(updatedAtTag);
119
- // Re-sync local state from the saved document each time `updatedAt` changes.
119
+ // Re-sync local state from the saved document once per save. The guard is a
120
+ // ref (not state) so it flips synchronously the effect cannot re-enter when
121
+ // the updateUploadEdits call below changes the upload-edits context. Driving
122
+ // the guard through state (and listing uploadEdits in the deps) is what
123
+ // previously re-entered on every context change and exceeded React's update
124
+ // depth on save.
125
+ const lastSyncedTagRef = useRef(updatedAtTag);
120
126
  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
- }
127
+ if (!updatedAtTag || updatedAtTag === lastSyncedTagRef.current) return;
128
+ lastSyncedTagRef.current = updatedAtTag;
129
+ hasUserEditedRef.current = false;
130
+ const nextFocal = {
131
+ x: typeof data?.focalX === 'number' ? data.focalX : 50,
132
+ y: typeof data?.focalY === 'number' ? data.focalY : 50
133
+ };
134
+ setFocalPoint(nextFocal);
135
+ // The crop is now baked into the saved image, so there is no pending crop
136
+ // and the focal is relative to the whole (already-cropped) image again.
137
+ setCrop(undefined);
138
+ updateUploadEdits({
139
+ crop: undefined,
140
+ focalPoint: nextFocal,
141
+ heightInPixels: undefined,
142
+ widthInPixels: undefined
143
+ });
141
144
  }, [
142
145
  updatedAtTag,
143
- prevUpdatedAtTag,
144
146
  data?.focalX,
145
147
  data?.focalY,
146
- uploadEdits?.crop,
147
- uploadEdits?.focalPoint,
148
148
  updateUploadEdits
149
149
  ]);
150
150
  const hasRealCrop = !!crop && crop.width > 0 && crop.height > 0 && (crop.width !== 100 || crop.height !== 100 || crop.x !== 0 || crop.y !== 0);
151
+ // The rectangle the focal point is measured against: the crop when one exists
152
+ // (in %), otherwise the whole image. Focal state is always a 0-100 position
153
+ // inside this rectangle; converting to/from full-image space happens only at
154
+ // the pointer and rendering boundaries.
155
+ const cropRect = React.useMemo(()=>hasRealCrop && crop && crop.unit === '%' ? {
156
+ x: crop.x,
157
+ y: crop.y,
158
+ width: crop.width,
159
+ height: crop.height
160
+ } : {
161
+ x: 0,
162
+ y: 0,
163
+ width: 100,
164
+ height: 100
165
+ }, [
166
+ hasRealCrop,
167
+ crop
168
+ ]);
151
169
  // Auto-save to uploadEdits whenever state changes
152
170
  useEffect(()=>{
153
171
  if (!hasUserEditedRef.current) return;
@@ -164,11 +182,6 @@ export const FocalPointEditor = ()=>{
164
182
  const topPx = Math.floor(crop.y / 100 * naturalSize.height);
165
183
  const widthInPixels = Math.max(1, Math.min(Math.floor(crop.width / 100 * naturalSize.width), naturalSize.width - leftPx));
166
184
  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
185
  updateUploadEdits({
173
186
  crop: {
174
187
  unit: '%',
@@ -177,7 +190,9 @@ export const FocalPointEditor = ()=>{
177
190
  width: crop.width,
178
191
  height: crop.height
179
192
  },
180
- focalPoint: cropRelativeFocal,
193
+ // focalPoint is already relative to the crop region — exactly the
194
+ // space Payload reads it in for the cropped image.
195
+ focalPoint,
181
196
  heightInPixels,
182
197
  widthInPixels
183
198
  });
@@ -199,35 +214,28 @@ export const FocalPointEditor = ()=>{
199
214
  setModified,
200
215
  updateUploadEdits
201
216
  ]);
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
217
+ // Focal point drag handler. The pointer lands somewhere on the full image;
218
+ // convert that full-image % into the crop-relative space the focal lives in,
219
+ // then clamp to the crop (0-100 inside cropRect).
216
220
  const updateFocalFromEvent = useCallback((e)=>{
217
221
  const container = containerRef.current;
218
222
  if (!container) return;
219
223
  const rect = container.getBoundingClientRect();
220
224
  const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
221
225
  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);
226
+ const fullX = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width * 100));
227
+ const fullY = Math.max(0, Math.min(100, (clientY - rect.top) / rect.height * 100));
228
+ const rel = toCropRelativeFocal({
229
+ x: fullX,
230
+ y: fullY
231
+ }, cropRect);
227
232
  hasUserEditedRef.current = true;
228
- setFocalPoint(clamped);
233
+ setFocalPoint({
234
+ x: Math.max(0, Math.min(100, rel.x)),
235
+ y: Math.max(0, Math.min(100, rel.y))
236
+ });
229
237
  }, [
230
- clampFocalToCrop
238
+ cropRect
231
239
  ]);
232
240
  const handleMouseDown = useCallback((e)=>{
233
241
  e.preventDefault();
@@ -322,6 +330,10 @@ export const FocalPointEditor = ()=>{
322
330
  height: crop.height
323
331
  } : undefined;
324
332
  const cropUnitLabel = crop?.unit === 'px' ? 'px' : '%';
333
+ // Full-image position of the focal point (crop-relative → whole image). Used
334
+ // to place the crosshair absolutely and to feed the preview grid, which
335
+ // re-derives the crop-relative position itself.
336
+ const focalFullImage = toFullImageFocal(focalPoint, cropRect);
325
337
  return /*#__PURE__*/ _jsx("div", {
326
338
  className: "focal-editor",
327
339
  children: /*#__PURE__*/ _jsxs("div", {
@@ -368,10 +380,10 @@ export const FocalPointEditor = ()=>{
368
380
  value: Math.round(focalPoint.x),
369
381
  onChange: (e)=>{
370
382
  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
- });
383
+ setFocalPoint((prev)=>({
384
+ ...prev,
385
+ x: Math.max(0, Math.min(100, Number(e.target.value)))
386
+ }));
375
387
  }
376
388
  }),
377
389
  /*#__PURE__*/ _jsx("span", {
@@ -394,10 +406,10 @@ export const FocalPointEditor = ()=>{
394
406
  value: Math.round(focalPoint.y),
395
407
  onChange: (e)=>{
396
408
  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
- });
409
+ setFocalPoint((prev)=>({
410
+ ...prev,
411
+ y: Math.max(0, Math.min(100, Number(e.target.value)))
412
+ }));
401
413
  }
402
414
  }),
403
415
  /*#__PURE__*/ _jsx("span", {
@@ -628,8 +640,8 @@ export const FocalPointEditor = ()=>{
628
640
  children: mode === 'focal' && /*#__PURE__*/ _jsxs("div", {
629
641
  className: "focal-editor__crosshair",
630
642
  style: {
631
- left: `${focalPoint.x}%`,
632
- top: `${focalPoint.y}%`
643
+ left: `${focalFullImage.x}%`,
644
+ top: `${focalFullImage.y}%`
633
645
  },
634
646
  children: [
635
647
  /*#__PURE__*/ _jsx("div", {
@@ -651,8 +663,8 @@ export const FocalPointEditor = ()=>{
651
663
  className: "focal-editor__right",
652
664
  children: /*#__PURE__*/ _jsx(AspectRatioPreviewGrid, {
653
665
  url: imageUrl,
654
- focalX: focalPoint.x,
655
- focalY: focalPoint.y,
666
+ focalX: focalFullImage.x,
667
+ focalY: focalFullImage.y,
656
668
  aspectRatios: aspectRatios,
657
669
  crop: cropConfig
658
670
  })
package/dist/plugin.js CHANGED
@@ -40,6 +40,9 @@ const UPLOAD_COMPONENT = 'payload-plugin-aspect-preview/client#CustomUpload';
40
40
  name: 'aspectPreview',
41
41
  type: 'ui',
42
42
  admin: {
43
+ // It's an edit-view editor, not data — keep it out of the
44
+ // list view's columns.
45
+ disableListColumn: true,
43
46
  components: {
44
47
  Field: FIELD_COMPONENT
45
48
  }
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.1",
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",