@pro-laico/payload-images 0.0.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 +22 -0
- package/README.md +11 -0
- package/dist/access.d.ts +4 -0
- package/dist/access.d.ts.map +1 -0
- package/dist/access.js +4 -0
- package/dist/access.js.map +1 -0
- package/dist/collections/generatedImages.d.ts +26 -0
- package/dist/collections/generatedImages.d.ts.map +1 -0
- package/dist/collections/generatedImages.js +91 -0
- package/dist/collections/generatedImages.js.map +1 -0
- package/dist/collections/images.d.ts +60 -0
- package/dist/collections/images.d.ts.map +1 -0
- package/dist/collections/images.js +223 -0
- package/dist/collections/images.js.map +1 -0
- package/dist/components/admin/focalPreview.d.ts +8 -0
- package/dist/components/admin/focalPreview.d.ts.map +1 -0
- package/dist/components/admin/focalPreview.js +234 -0
- package/dist/components/admin/focalPreview.js.map +1 -0
- package/dist/components/admin/purgeVariants.d.ts +16 -0
- package/dist/components/admin/purgeVariants.d.ts.map +1 -0
- package/dist/components/admin/purgeVariants.js +66 -0
- package/dist/components/admin/purgeVariants.js.map +1 -0
- package/dist/components/image.d.ts +90 -0
- package/dist/components/image.d.ts.map +1 -0
- package/dist/components/image.js +162 -0
- package/dist/components/image.js.map +1 -0
- package/dist/components/inlineLqip.d.ts +36 -0
- package/dist/components/inlineLqip.d.ts.map +1 -0
- package/dist/components/inlineLqip.js +54 -0
- package/dist/components/inlineLqip.js.map +1 -0
- package/dist/endpoints/transform.d.ts +40 -0
- package/dist/endpoints/transform.d.ts.map +1 -0
- package/dist/endpoints/transform.js +181 -0
- package/dist/endpoints/transform.js.map +1 -0
- package/dist/fields/virtualUrls.d.ts +26 -0
- package/dist/fields/virtualUrls.d.ts.map +1 -0
- package/dist/fields/virtualUrls.js +185 -0
- package/dist/fields/virtualUrls.js.map +1 -0
- package/dist/hooks/purge.d.ts +33 -0
- package/dist/hooks/purge.d.ts.map +1 -0
- package/dist/hooks/purge.js +63 -0
- package/dist/hooks/purge.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/configStash.d.ts +6 -0
- package/dist/lib/configStash.d.ts.map +1 -0
- package/dist/lib/configStash.js +10 -0
- package/dist/lib/configStash.js.map +1 -0
- package/dist/lib/getServerSideURL.d.ts +8 -0
- package/dist/lib/getServerSideURL.d.ts.map +1 -0
- package/dist/lib/getServerSideURL.js +12 -0
- package/dist/lib/getServerSideURL.js.map +1 -0
- package/dist/lib/mergeCollection.d.ts +17 -0
- package/dist/lib/mergeCollection.d.ts.map +1 -0
- package/dist/lib/mergeCollection.js +55 -0
- package/dist/lib/mergeCollection.js.map +1 -0
- package/dist/plugin.d.ts +128 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +184 -0
- package/dist/plugin.js.map +1 -0
- package/dist/transform/coalesce.d.ts +16 -0
- package/dist/transform/coalesce.d.ts.map +1 -0
- package/dist/transform/coalesce.js +25 -0
- package/dist/transform/coalesce.js.map +1 -0
- package/dist/transform/geometry.d.ts +46 -0
- package/dist/transform/geometry.d.ts.map +1 -0
- package/dist/transform/geometry.js +64 -0
- package/dist/transform/geometry.js.map +1 -0
- package/dist/transform/getVariantBytes.d.ts +60 -0
- package/dist/transform/getVariantBytes.d.ts.map +1 -0
- package/dist/transform/getVariantBytes.js +163 -0
- package/dist/transform/getVariantBytes.js.map +1 -0
- package/dist/transform/limit.d.ts +5 -0
- package/dist/transform/limit.d.ts.map +1 -0
- package/dist/transform/limit.js +41 -0
- package/dist/transform/limit.js.map +1 -0
- package/dist/transform/lqip.d.ts +21 -0
- package/dist/transform/lqip.d.ts.map +1 -0
- package/dist/transform/lqip.js +32 -0
- package/dist/transform/lqip.js.map +1 -0
- package/dist/transform/params.d.ts +100 -0
- package/dist/transform/params.d.ts.map +1 -0
- package/dist/transform/params.js +144 -0
- package/dist/transform/params.js.map +1 -0
- package/dist/transform/sharp.d.ts +28 -0
- package/dist/transform/sharp.d.ts.map +1 -0
- package/dist/transform/sharp.js +85 -0
- package/dist/transform/sharp.js.map +1 -0
- package/dist/transform/sharpInstance.d.ts +21 -0
- package/dist/transform/sharpInstance.d.ts.map +1 -0
- package/dist/transform/sharpInstance.js +38 -0
- package/dist/transform/sharpInstance.js.map +1 -0
- package/dist/transform/source.d.ts +32 -0
- package/dist/transform/source.d.ts.map +1 -0
- package/dist/transform/source.js +121 -0
- package/dist/transform/source.js.map +1 -0
- package/dist/utils/urls.d.ts +108 -0
- package/dist/utils/urls.d.ts.map +1 -0
- package/dist/utils/urls.js +113 -0
- package/dist/utils/urls.js.map +1 -0
- package/dist/variants/key.d.ts +9 -0
- package/dist/variants/key.d.ts.map +1 -0
- package/dist/variants/key.js +29 -0
- package/dist/variants/key.js.map +1 -0
- package/package.json +98 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useDocumentInfo, useField, useForm, useUploadEdits } from "@payloadcms/ui";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { coverObjectPosition } from "../../transform/geometry.js";
|
|
6
|
+
/**
|
|
7
|
+
* An inline focal-point picker + live ratio preview for the Images upload edit
|
|
8
|
+
* view. It ENHANCES Payload's native focal pipeline rather than competing with it:
|
|
9
|
+
* the displayable source comes from the saved `url` or an object URL of the
|
|
10
|
+
* just-selected `file` (the same way Payload's own Upload element resolves it), and
|
|
11
|
+
* the focal point is written through the shared `UploadEdits` context — exactly what
|
|
12
|
+
* the native "Edit image" selector uses — so the value persists (the server derives
|
|
13
|
+
* `focalX/focalY` from it on save) and the two never conflict.
|
|
14
|
+
*
|
|
15
|
+
* The ratio tiles reproduce the transform endpoint's crop exactly: they share its
|
|
16
|
+
* `coverCropGeometry` (via `coverObjectPosition`) to turn the focal point into the
|
|
17
|
+
* matching `object-position`, rather than panning proportionally with a raw
|
|
18
|
+
* `object-position: <focal>%` (which diverges from the endpoint in the mid-range).
|
|
19
|
+
* No network calls.
|
|
20
|
+
*/ const DEFAULT_RATIOS = [
|
|
21
|
+
'16:9',
|
|
22
|
+
'9:16',
|
|
23
|
+
'1:1',
|
|
24
|
+
'4:3',
|
|
25
|
+
'3:2',
|
|
26
|
+
'21:9'
|
|
27
|
+
];
|
|
28
|
+
const ratioToCss = (r)=>r.replace(/[:/]/, ' / ');
|
|
29
|
+
const clampPct = (n)=>Math.max(0, Math.min(100, n));
|
|
30
|
+
const note = {
|
|
31
|
+
color: 'var(--theme-elevation-500)',
|
|
32
|
+
fontSize: '0.8rem',
|
|
33
|
+
margin: 0
|
|
34
|
+
};
|
|
35
|
+
export const FocalPreview = ({ previewRatios = DEFAULT_RATIOS, readOnly })=>{
|
|
36
|
+
const { data } = useDocumentInfo();
|
|
37
|
+
const { value: file } = useField({
|
|
38
|
+
path: 'file'
|
|
39
|
+
});
|
|
40
|
+
const { uploadEdits, updateUploadEdits } = useUploadEdits();
|
|
41
|
+
const { setModified } = useForm();
|
|
42
|
+
const [objectUrl, setObjectUrl] = useState(null);
|
|
43
|
+
useEffect(()=>{
|
|
44
|
+
if (file instanceof File && file.type.startsWith('image/')) {
|
|
45
|
+
const url = URL.createObjectURL(file);
|
|
46
|
+
setObjectUrl(url);
|
|
47
|
+
return ()=>URL.revokeObjectURL(url);
|
|
48
|
+
}
|
|
49
|
+
setObjectUrl(null);
|
|
50
|
+
return undefined;
|
|
51
|
+
}, [
|
|
52
|
+
file
|
|
53
|
+
]);
|
|
54
|
+
const savedUrl = typeof data?.url === 'string' ? data.url : null;
|
|
55
|
+
const src = savedUrl || objectUrl;
|
|
56
|
+
const focalX = uploadEdits?.focalPoint?.x ?? (typeof data?.focalX === 'number' ? data.focalX : 50);
|
|
57
|
+
const focalY = uploadEdits?.focalPoint?.y ?? (typeof data?.focalY === 'number' ? data.focalY : 50);
|
|
58
|
+
const [srcDims, setSrcDims] = useState(typeof data?.width === 'number' && typeof data?.height === 'number' ? {
|
|
59
|
+
w: data.width,
|
|
60
|
+
h: data.height
|
|
61
|
+
} : null);
|
|
62
|
+
const stageRef = useRef(null);
|
|
63
|
+
const [dragging, setDragging] = useState(false);
|
|
64
|
+
const applyFromEvent = useCallback((clientX, clientY)=>{
|
|
65
|
+
if (readOnly) return;
|
|
66
|
+
const el = stageRef.current;
|
|
67
|
+
if (!el) return;
|
|
68
|
+
const rect = el.getBoundingClientRect();
|
|
69
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
70
|
+
const x = clampPct((clientX - rect.left) / rect.width * 100);
|
|
71
|
+
const y = clampPct((clientY - rect.top) / rect.height * 100);
|
|
72
|
+
updateUploadEdits({
|
|
73
|
+
...uploadEdits,
|
|
74
|
+
focalPoint: {
|
|
75
|
+
x,
|
|
76
|
+
y
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
setModified(true);
|
|
80
|
+
}, [
|
|
81
|
+
readOnly,
|
|
82
|
+
uploadEdits,
|
|
83
|
+
updateUploadEdits,
|
|
84
|
+
setModified
|
|
85
|
+
]);
|
|
86
|
+
if (!src) {
|
|
87
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
88
|
+
style: {
|
|
89
|
+
marginBottom: '1rem'
|
|
90
|
+
},
|
|
91
|
+
children: [
|
|
92
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
93
|
+
style: {
|
|
94
|
+
fontSize: '0.95rem'
|
|
95
|
+
},
|
|
96
|
+
children: "Focus point"
|
|
97
|
+
}),
|
|
98
|
+
/*#__PURE__*/ _jsx("p", {
|
|
99
|
+
style: note,
|
|
100
|
+
children: "Upload an image to set its focus point and preview display ratios."
|
|
101
|
+
})
|
|
102
|
+
]
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
106
|
+
style: {
|
|
107
|
+
marginBottom: '1rem'
|
|
108
|
+
},
|
|
109
|
+
children: [
|
|
110
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
111
|
+
style: {
|
|
112
|
+
fontSize: '0.95rem'
|
|
113
|
+
},
|
|
114
|
+
children: "Focus point"
|
|
115
|
+
}),
|
|
116
|
+
/*#__PURE__*/ _jsx("p", {
|
|
117
|
+
style: {
|
|
118
|
+
...note,
|
|
119
|
+
marginBottom: '0.5rem'
|
|
120
|
+
},
|
|
121
|
+
children: readOnly ? 'Focus point (read-only).' : 'Click or drag to set the focus point used when cropping to a ratio.'
|
|
122
|
+
}),
|
|
123
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
124
|
+
ref: stageRef,
|
|
125
|
+
onPointerDown: (e)=>{
|
|
126
|
+
if (readOnly) return;
|
|
127
|
+
setDragging(true);
|
|
128
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
129
|
+
applyFromEvent(e.clientX, e.clientY);
|
|
130
|
+
},
|
|
131
|
+
onPointerMove: (e)=>{
|
|
132
|
+
if (dragging) applyFromEvent(e.clientX, e.clientY);
|
|
133
|
+
},
|
|
134
|
+
onPointerUp: (e)=>{
|
|
135
|
+
setDragging(false);
|
|
136
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
137
|
+
},
|
|
138
|
+
style: {
|
|
139
|
+
position: 'relative',
|
|
140
|
+
maxWidth: 480,
|
|
141
|
+
borderRadius: 'var(--style-radius-m, 4px)',
|
|
142
|
+
overflow: 'hidden',
|
|
143
|
+
border: '1px solid var(--theme-elevation-150)',
|
|
144
|
+
cursor: readOnly ? 'default' : 'crosshair',
|
|
145
|
+
touchAction: 'none'
|
|
146
|
+
},
|
|
147
|
+
children: [
|
|
148
|
+
/*#__PURE__*/ _jsx("img", {
|
|
149
|
+
src: src,
|
|
150
|
+
alt: "",
|
|
151
|
+
onLoad: (e)=>setSrcDims({
|
|
152
|
+
w: e.currentTarget.naturalWidth,
|
|
153
|
+
h: e.currentTarget.naturalHeight
|
|
154
|
+
}),
|
|
155
|
+
style: {
|
|
156
|
+
display: 'block',
|
|
157
|
+
width: '100%',
|
|
158
|
+
height: 'auto'
|
|
159
|
+
},
|
|
160
|
+
draggable: false
|
|
161
|
+
}),
|
|
162
|
+
/*#__PURE__*/ _jsx("span", {
|
|
163
|
+
"aria-hidden": true,
|
|
164
|
+
style: {
|
|
165
|
+
position: 'absolute',
|
|
166
|
+
left: `${focalX}%`,
|
|
167
|
+
top: `${focalY}%`,
|
|
168
|
+
width: 16,
|
|
169
|
+
height: 16,
|
|
170
|
+
transform: 'translate(-50%, -50%)',
|
|
171
|
+
borderRadius: '50%',
|
|
172
|
+
border: '2px solid var(--theme-success-500, #22c55e)',
|
|
173
|
+
boxShadow: '0 0 0 2px rgba(0,0,0,0.5)',
|
|
174
|
+
pointerEvents: 'none'
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
]
|
|
178
|
+
}),
|
|
179
|
+
/*#__PURE__*/ _jsx("div", {
|
|
180
|
+
style: {
|
|
181
|
+
display: 'grid',
|
|
182
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
|
|
183
|
+
gap: '0.5rem',
|
|
184
|
+
marginTop: '0.75rem'
|
|
185
|
+
},
|
|
186
|
+
children: previewRatios.map((r)=>{
|
|
187
|
+
const [rw, rh] = r.split(/[:/]/).map(Number);
|
|
188
|
+
const pos = srcDims && rw && rh ? coverObjectPosition(srcDims.w, srcDims.h, rw, rh, focalX, focalY) : {
|
|
189
|
+
x: focalX,
|
|
190
|
+
y: focalY
|
|
191
|
+
};
|
|
192
|
+
return /*#__PURE__*/ _jsxs("figure", {
|
|
193
|
+
style: {
|
|
194
|
+
margin: 0
|
|
195
|
+
},
|
|
196
|
+
children: [
|
|
197
|
+
/*#__PURE__*/ _jsx("div", {
|
|
198
|
+
style: {
|
|
199
|
+
aspectRatio: ratioToCss(r),
|
|
200
|
+
overflow: 'hidden',
|
|
201
|
+
borderRadius: 'var(--style-radius-s, 3px)',
|
|
202
|
+
border: '1px solid var(--theme-elevation-100)',
|
|
203
|
+
background: 'var(--theme-elevation-50)'
|
|
204
|
+
},
|
|
205
|
+
children: /*#__PURE__*/ _jsx("img", {
|
|
206
|
+
src: src,
|
|
207
|
+
alt: "",
|
|
208
|
+
draggable: false,
|
|
209
|
+
style: {
|
|
210
|
+
width: '100%',
|
|
211
|
+
height: '100%',
|
|
212
|
+
objectFit: 'cover',
|
|
213
|
+
objectPosition: `${pos.x}% ${pos.y}%`
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
}),
|
|
217
|
+
/*#__PURE__*/ _jsx("figcaption", {
|
|
218
|
+
style: {
|
|
219
|
+
...note,
|
|
220
|
+
textAlign: 'center',
|
|
221
|
+
marginTop: '0.2rem'
|
|
222
|
+
},
|
|
223
|
+
children: r
|
|
224
|
+
})
|
|
225
|
+
]
|
|
226
|
+
}, r);
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
]
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
export default FocalPreview;
|
|
233
|
+
|
|
234
|
+
//# sourceMappingURL=focalPreview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/admin/focalPreview.tsx"],"sourcesContent":["'use client'\n\nimport { useDocumentInfo, useField, useForm, useUploadEdits } from '@payloadcms/ui'\nimport type React from 'react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { coverObjectPosition } from '../../transform/geometry'\n\n/**\n * An inline focal-point picker + live ratio preview for the Images upload edit\n * view. It ENHANCES Payload's native focal pipeline rather than competing with it:\n * the displayable source comes from the saved `url` or an object URL of the\n * just-selected `file` (the same way Payload's own Upload element resolves it), and\n * the focal point is written through the shared `UploadEdits` context — exactly what\n * the native \"Edit image\" selector uses — so the value persists (the server derives\n * `focalX/focalY` from it on save) and the two never conflict.\n *\n * The ratio tiles reproduce the transform endpoint's crop exactly: they share its\n * `coverCropGeometry` (via `coverObjectPosition`) to turn the focal point into the\n * matching `object-position`, rather than panning proportionally with a raw\n * `object-position: <focal>%` (which diverges from the endpoint in the mid-range).\n * No network calls.\n */\n\nconst DEFAULT_RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:2', '21:9']\n\nconst ratioToCss = (r: string): string => r.replace(/[:/]/, ' / ')\nconst clampPct = (n: number): number => Math.max(0, Math.min(100, n))\n\ninterface FocalPreviewProps {\n previewRatios?: string[]\n readOnly?: boolean\n}\n\nconst note: React.CSSProperties = { color: 'var(--theme-elevation-500)', fontSize: '0.8rem', margin: 0 }\n\nexport const FocalPreview: React.FC<FocalPreviewProps> = ({ previewRatios = DEFAULT_RATIOS, readOnly }) => {\n const { data } = useDocumentInfo()\n const { value: file } = useField<File | undefined>({ path: 'file' })\n const { uploadEdits, updateUploadEdits } = useUploadEdits()\n const { setModified } = useForm()\n\n const [objectUrl, setObjectUrl] = useState<string | null>(null)\n useEffect(() => {\n if (file instanceof File && file.type.startsWith('image/')) {\n const url = URL.createObjectURL(file)\n setObjectUrl(url)\n return () => URL.revokeObjectURL(url)\n }\n setObjectUrl(null)\n return undefined\n }, [file])\n\n const savedUrl = typeof data?.url === 'string' ? data.url : null\n const src = savedUrl || objectUrl\n\n const focalX = uploadEdits?.focalPoint?.x ?? (typeof data?.focalX === 'number' ? data.focalX : 50)\n const focalY = uploadEdits?.focalPoint?.y ?? (typeof data?.focalY === 'number' ? data.focalY : 50)\n\n const [srcDims, setSrcDims] = useState<{ w: number; h: number } | null>(\n typeof data?.width === 'number' && typeof data?.height === 'number' ? { w: data.width, h: data.height } : null,\n )\n\n const stageRef = useRef<HTMLDivElement>(null)\n const [dragging, setDragging] = useState(false)\n\n const applyFromEvent = useCallback(\n (clientX: number, clientY: number) => {\n if (readOnly) return\n const el = stageRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return\n const x = clampPct(((clientX - rect.left) / rect.width) * 100)\n const y = clampPct(((clientY - rect.top) / rect.height) * 100)\n updateUploadEdits({ ...uploadEdits, focalPoint: { x, y } })\n setModified(true)\n },\n [readOnly, uploadEdits, updateUploadEdits, setModified],\n )\n\n if (!src) {\n return (\n <div style={{ marginBottom: '1rem' }}>\n <strong style={{ fontSize: '0.95rem' }}>Focus point</strong>\n <p style={note}>Upload an image to set its focus point and preview display ratios.</p>\n </div>\n )\n }\n\n return (\n <div style={{ marginBottom: '1rem' }}>\n <strong style={{ fontSize: '0.95rem' }}>Focus point</strong>\n <p style={{ ...note, marginBottom: '0.5rem' }}>\n {readOnly ? 'Focus point (read-only).' : 'Click or drag to set the focus point used when cropping to a ratio.'}\n </p>\n\n <div\n ref={stageRef}\n onPointerDown={(e) => {\n if (readOnly) return\n setDragging(true)\n e.currentTarget.setPointerCapture(e.pointerId)\n applyFromEvent(e.clientX, e.clientY)\n }}\n onPointerMove={(e) => {\n if (dragging) applyFromEvent(e.clientX, e.clientY)\n }}\n onPointerUp={(e) => {\n setDragging(false)\n e.currentTarget.releasePointerCapture?.(e.pointerId)\n }}\n style={{\n position: 'relative',\n maxWidth: 480,\n borderRadius: 'var(--style-radius-m, 4px)',\n overflow: 'hidden',\n border: '1px solid var(--theme-elevation-150)',\n cursor: readOnly ? 'default' : 'crosshair',\n touchAction: 'none',\n }}\n >\n {/* biome-ignore lint/performance/noImgElement: intentional plain <img> — admin preview that mirrors the frontend's object-position crop */}\n <img\n src={src}\n alt=\"\"\n onLoad={(e) => setSrcDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}\n style={{ display: 'block', width: '100%', height: 'auto' }}\n draggable={false}\n />\n <span\n aria-hidden\n style={{\n position: 'absolute',\n left: `${focalX}%`,\n top: `${focalY}%`,\n width: 16,\n height: 16,\n transform: 'translate(-50%, -50%)',\n borderRadius: '50%',\n border: '2px solid var(--theme-success-500, #22c55e)',\n boxShadow: '0 0 0 2px rgba(0,0,0,0.5)',\n pointerEvents: 'none',\n }}\n />\n </div>\n\n <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '0.5rem', marginTop: '0.75rem' }}>\n {previewRatios.map((r) => {\n const [rw, rh] = r.split(/[:/]/).map(Number)\n const pos = srcDims && rw && rh ? coverObjectPosition(srcDims.w, srcDims.h, rw, rh, focalX, focalY) : { x: focalX, y: focalY }\n return (\n <figure key={r} style={{ margin: 0 }}>\n <div\n style={{\n aspectRatio: ratioToCss(r),\n overflow: 'hidden',\n borderRadius: 'var(--style-radius-s, 3px)',\n border: '1px solid var(--theme-elevation-100)',\n background: 'var(--theme-elevation-50)',\n }}\n >\n {/* biome-ignore lint/performance/noImgElement: intentional plain <img> — admin preview that mirrors the frontend's focal crop */}\n <img\n src={src}\n alt=\"\"\n draggable={false}\n style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: `${pos.x}% ${pos.y}%` }}\n />\n </div>\n <figcaption style={{ ...note, textAlign: 'center', marginTop: '0.2rem' }}>{r}</figcaption>\n </figure>\n )\n })}\n </div>\n </div>\n )\n}\n\nexport default FocalPreview\n"],"names":["useDocumentInfo","useField","useForm","useUploadEdits","useCallback","useEffect","useRef","useState","coverObjectPosition","DEFAULT_RATIOS","ratioToCss","r","replace","clampPct","n","Math","max","min","note","color","fontSize","margin","FocalPreview","previewRatios","readOnly","data","value","file","path","uploadEdits","updateUploadEdits","setModified","objectUrl","setObjectUrl","File","type","startsWith","url","URL","createObjectURL","revokeObjectURL","undefined","savedUrl","src","focalX","focalPoint","x","focalY","y","srcDims","setSrcDims","width","height","w","h","stageRef","dragging","setDragging","applyFromEvent","clientX","clientY","el","current","rect","getBoundingClientRect","left","top","div","style","marginBottom","strong","p","ref","onPointerDown","e","currentTarget","setPointerCapture","pointerId","onPointerMove","onPointerUp","releasePointerCapture","position","maxWidth","borderRadius","overflow","border","cursor","touchAction","img","alt","onLoad","naturalWidth","naturalHeight","display","draggable","span","aria-hidden","transform","boxShadow","pointerEvents","gridTemplateColumns","gap","marginTop","map","rw","rh","split","Number","pos","figure","aspectRatio","background","objectFit","objectPosition","figcaption","textAlign"],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* A button that purges every on-demand variant generated from this source image
|
|
4
|
+
* (POST `<apiRoute><purgePath>/:id`, logged-in users only). Useful after editing the
|
|
5
|
+
* focal point or re-uploading, or to reclaim storage. Only shown on a saved doc.
|
|
6
|
+
*
|
|
7
|
+
* `purgePath` defaults to the endpoint's fixed `/img/purge` route. The POST is
|
|
8
|
+
* authenticated by Payload's session cookie (SameSite-protected).
|
|
9
|
+
*/
|
|
10
|
+
interface PurgeVariantsProps {
|
|
11
|
+
/** Purge route under the API base. Default `/img/purge`. */
|
|
12
|
+
purgePath?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const PurgeVariants: React.FC<PurgeVariantsProps>;
|
|
15
|
+
export default PurgeVariants;
|
|
16
|
+
//# sourceMappingURL=purgeVariants.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"purgeVariants.d.ts","sourceRoot":"","sources":["../../../src/components/admin/purgeVariants.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAG9B;;;;;;;GAOG;AACH,UAAU,kBAAkB;IAC1B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAcD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA+BtD,CAAA;AAED,eAAe,aAAa,CAAA"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { toast, useConfig, useDocumentInfo } from "@payloadcms/ui";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
const button = {
|
|
6
|
+
cursor: 'pointer',
|
|
7
|
+
fontSize: '0.85rem',
|
|
8
|
+
padding: '0.4rem 0.75rem',
|
|
9
|
+
color: 'var(--theme-elevation-800)',
|
|
10
|
+
background: 'var(--theme-elevation-50)',
|
|
11
|
+
borderRadius: 'var(--style-radius-s, 3px)',
|
|
12
|
+
border: '1px solid var(--theme-elevation-150)'
|
|
13
|
+
};
|
|
14
|
+
const note = {
|
|
15
|
+
color: 'var(--theme-elevation-500)',
|
|
16
|
+
fontSize: '0.8rem',
|
|
17
|
+
margin: '0.25rem 0 0'
|
|
18
|
+
};
|
|
19
|
+
export const PurgeVariants = ({ purgePath = '/img/purge' })=>{
|
|
20
|
+
const { config } = useConfig();
|
|
21
|
+
const { id } = useDocumentInfo();
|
|
22
|
+
const apiRoute = config?.routes?.api || '/api';
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
if (id == null) return null;
|
|
25
|
+
const purge = async ()=>{
|
|
26
|
+
setBusy(true);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`${apiRoute}${purgePath}/${id}`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
credentials: 'include'
|
|
31
|
+
});
|
|
32
|
+
const json = await res.json().catch(()=>({}));
|
|
33
|
+
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
|
34
|
+
const n = json?.deleted ?? 0;
|
|
35
|
+
toast.success(`Purged ${n} generated image${n === 1 ? '' : 's'}.`);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
toast.error(err instanceof Error ? err.message : 'Failed to purge variants.');
|
|
38
|
+
} finally{
|
|
39
|
+
setBusy(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
43
|
+
style: {
|
|
44
|
+
marginBottom: '1rem'
|
|
45
|
+
},
|
|
46
|
+
children: [
|
|
47
|
+
/*#__PURE__*/ _jsx("button", {
|
|
48
|
+
type: "button",
|
|
49
|
+
onClick: ()=>void purge(),
|
|
50
|
+
disabled: busy,
|
|
51
|
+
style: {
|
|
52
|
+
...button,
|
|
53
|
+
opacity: busy ? 0.6 : 1
|
|
54
|
+
},
|
|
55
|
+
children: busy ? 'Purging…' : 'Purge generated variants'
|
|
56
|
+
}),
|
|
57
|
+
/*#__PURE__*/ _jsx("p", {
|
|
58
|
+
style: note,
|
|
59
|
+
children: "Deletes every on-demand image generated from this source. They regenerate on next request."
|
|
60
|
+
})
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
export default PurgeVariants;
|
|
65
|
+
|
|
66
|
+
//# sourceMappingURL=purgeVariants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/admin/purgeVariants.tsx"],"sourcesContent":["'use client'\n\nimport { toast, useConfig, useDocumentInfo } from '@payloadcms/ui'\nimport type React from 'react'\nimport { useState } from 'react'\n\n/**\n * A button that purges every on-demand variant generated from this source image\n * (POST `<apiRoute><purgePath>/:id`, logged-in users only). Useful after editing the\n * focal point or re-uploading, or to reclaim storage. Only shown on a saved doc.\n *\n * `purgePath` defaults to the endpoint's fixed `/img/purge` route. The POST is\n * authenticated by Payload's session cookie (SameSite-protected).\n */\ninterface PurgeVariantsProps {\n /** Purge route under the API base. Default `/img/purge`. */\n purgePath?: string\n}\n\nconst button: React.CSSProperties = {\n cursor: 'pointer',\n fontSize: '0.85rem',\n padding: '0.4rem 0.75rem',\n color: 'var(--theme-elevation-800)',\n background: 'var(--theme-elevation-50)',\n borderRadius: 'var(--style-radius-s, 3px)',\n border: '1px solid var(--theme-elevation-150)',\n}\n\nconst note: React.CSSProperties = { color: 'var(--theme-elevation-500)', fontSize: '0.8rem', margin: '0.25rem 0 0' }\n\nexport const PurgeVariants: React.FC<PurgeVariantsProps> = ({ purgePath = '/img/purge' }) => {\n const { config } = useConfig()\n const { id } = useDocumentInfo()\n const apiRoute = config?.routes?.api || '/api'\n const [busy, setBusy] = useState(false)\n\n if (id == null) return null\n\n const purge = async (): Promise<void> => {\n setBusy(true)\n try {\n const res = await fetch(`${apiRoute}${purgePath}/${id}`, { method: 'POST', credentials: 'include' })\n const json = (await res.json().catch(() => ({}))) as { deleted?: number; error?: string }\n if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`)\n const n = json?.deleted ?? 0\n toast.success(`Purged ${n} generated image${n === 1 ? '' : 's'}.`)\n } catch (err) {\n toast.error(err instanceof Error ? err.message : 'Failed to purge variants.')\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ marginBottom: '1rem' }}>\n <button type=\"button\" onClick={() => void purge()} disabled={busy} style={{ ...button, opacity: busy ? 0.6 : 1 }}>\n {busy ? 'Purging…' : 'Purge generated variants'}\n </button>\n <p style={note}>Deletes every on-demand image generated from this source. They regenerate on next request.</p>\n </div>\n )\n}\n\nexport default PurgeVariants\n"],"names":["toast","useConfig","useDocumentInfo","useState","button","cursor","fontSize","padding","color","background","borderRadius","border","note","margin","PurgeVariants","purgePath","config","id","apiRoute","routes","api","busy","setBusy","purge","res","fetch","method","credentials","json","catch","ok","Error","error","status","n","deleted","success","err","message","div","style","marginBottom","type","onClick","disabled","opacity","p"],"mappings":"AAAA;;AAEA,SAASA,KAAK,EAAEC,SAAS,EAAEC,eAAe,QAAQ,iBAAgB;AAElE,SAASC,QAAQ,QAAQ,QAAO;AAehC,MAAMC,SAA8B;IAClCC,QAAQ;IACRC,UAAU;IACVC,SAAS;IACTC,OAAO;IACPC,YAAY;IACZC,cAAc;IACdC,QAAQ;AACV;AAEA,MAAMC,OAA4B;IAAEJ,OAAO;IAA8BF,UAAU;IAAUO,QAAQ;AAAc;AAEnH,OAAO,MAAMC,gBAA8C,CAAC,EAAEC,YAAY,YAAY,EAAE;IACtF,MAAM,EAAEC,MAAM,EAAE,GAAGf;IACnB,MAAM,EAAEgB,EAAE,EAAE,GAAGf;IACf,MAAMgB,WAAWF,QAAQG,QAAQC,OAAO;IACxC,MAAM,CAACC,MAAMC,QAAQ,GAAGnB,SAAS;IAEjC,IAAIc,MAAM,MAAM,OAAO;IAEvB,MAAMM,QAAQ;QACZD,QAAQ;QACR,IAAI;YACF,MAAME,MAAM,MAAMC,MAAM,GAAGP,WAAWH,UAAU,CAAC,EAAEE,IAAI,EAAE;gBAAES,QAAQ;gBAAQC,aAAa;YAAU;YAClG,MAAMC,OAAQ,MAAMJ,IAAII,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;YAC7C,IAAI,CAACL,IAAIM,EAAE,EAAE,MAAM,IAAIC,MAAMH,MAAMI,SAAS,CAAC,KAAK,EAAER,IAAIS,MAAM,EAAE;YAChE,MAAMC,IAAIN,MAAMO,WAAW;YAC3BnC,MAAMoC,OAAO,CAAC,CAAC,OAAO,EAAEF,EAAE,gBAAgB,EAAEA,MAAM,IAAI,KAAK,IAAI,CAAC,CAAC;QACnE,EAAE,OAAOG,KAAK;YACZrC,MAAMgC,KAAK,CAACK,eAAeN,QAAQM,IAAIC,OAAO,GAAG;QACnD,SAAU;YACRhB,QAAQ;QACV;IACF;IAEA,qBACE,MAACiB;QAAIC,OAAO;YAAEC,cAAc;QAAO;;0BACjC,KAACrC;gBAAOsC,MAAK;gBAASC,SAAS,IAAM,KAAKpB;gBAASqB,UAAUvB;gBAAMmB,OAAO;oBAAE,GAAGpC,MAAM;oBAAEyC,SAASxB,OAAO,MAAM;gBAAE;0BAC5GA,OAAO,aAAa;;0BAEvB,KAACyB;gBAAEN,OAAO5B;0BAAM;;;;AAGtB,EAAC;AAED,eAAeE,cAAa"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ResponsiveImage>` — a single plain `<img>` carrying a `srcset` of on-demand transform URLs,
|
|
3
|
+
* with a faithful **inline LQIP** painted as its own `background-image`: a tiny base64 data-URI
|
|
4
|
+
* (generated server-side at the requested aspect-ratio + focal point, zero network) shows
|
|
5
|
+
* instantly, and the real image paints over it on load — the swap is native, no JS, no `<head>`
|
|
6
|
+
* script. An async server component: it resolves the project config (pixel step + placeholder
|
|
7
|
+
* settings), generates the LQIP via the shared variant cache, and renders one `<img>`. Not
|
|
8
|
+
* `next/image`.
|
|
9
|
+
*
|
|
10
|
+
* `className` / `style` / `dataAttributes` all go on the `<img>` itself (size / space / round it
|
|
11
|
+
* there). The LQIP is on by default; pass `blur={false}` (or the plugin's `placeholder: false`)
|
|
12
|
+
* to skip it, and it's skipped automatically when there's no populated doc to generate from.
|
|
13
|
+
*/
|
|
14
|
+
import type { SanitizedConfig } from 'payload';
|
|
15
|
+
import type { CSSProperties, ReactElement } from 'react';
|
|
16
|
+
import { type Fit, type Format } from '../transform/params';
|
|
17
|
+
import { type BuildSrcsetOptions, buildSrcset, type BuildUrlOptions, buildVariantUrl, deriveVersion, type GetImageUrlOptions, getImageUrl, type ImageResource, stepWidths } from '../utils/urls';
|
|
18
|
+
export { buildSrcset, buildVariantUrl, deriveVersion, getImageUrl, stepWidths };
|
|
19
|
+
export type { BuildSrcsetOptions, BuildUrlOptions, Fit, Format, GetImageUrlOptions, ImageResource };
|
|
20
|
+
/** A bare id, or a populated image doc (for natural dims, alt, the version token, and inline-LQIP generation). */
|
|
21
|
+
export type ResponsiveImageInput = string | number | {
|
|
22
|
+
id: string | number;
|
|
23
|
+
width?: number | null;
|
|
24
|
+
height?: number | null;
|
|
25
|
+
alt?: string | null;
|
|
26
|
+
filename?: string | null;
|
|
27
|
+
url?: string | null;
|
|
28
|
+
focalX?: number | null;
|
|
29
|
+
focalY?: number | null;
|
|
30
|
+
};
|
|
31
|
+
type Awaitable<T> = T | Promise<T>;
|
|
32
|
+
/** Placeholder override: `false` off · `true`/unset on (defaults) · a number = LQIP width (px) · `{ width?, quality? }` = tuned. */
|
|
33
|
+
export type PlaceholderProp = boolean | number | {
|
|
34
|
+
width?: number;
|
|
35
|
+
quality?: number;
|
|
36
|
+
};
|
|
37
|
+
export interface ResponsiveImageProps {
|
|
38
|
+
image: ResponsiveImageInput;
|
|
39
|
+
alt?: string;
|
|
40
|
+
/** The `sizes` attribute. Default `100vw`. */
|
|
41
|
+
sizes?: string;
|
|
42
|
+
/** Render aspect ratio (`16/9` | `"16:9"`); falls back to the doc's natural ratio. Ignored when `fill` is set. */
|
|
43
|
+
aspectRatio?: number | string;
|
|
44
|
+
/**
|
|
45
|
+
* Cover-fill a height-driven parent instead of acting as an aspect-ratio box. The `<img>`
|
|
46
|
+
* becomes `position:absolute; inset:0; size:100%` with `object-fit:<fit>` and NO aspect-ratio
|
|
47
|
+
* — so it fills a parent that sets its own height (full-bleed hero, carousel slide, map panel).
|
|
48
|
+
* The parent must be positioned. The placeholder still applies. Default false.
|
|
49
|
+
*/
|
|
50
|
+
fill?: boolean;
|
|
51
|
+
quality?: number;
|
|
52
|
+
fit?: Fit;
|
|
53
|
+
format?: Format;
|
|
54
|
+
/** Override the source intrinsic width used to cap the srcset (else read from a populated doc). */
|
|
55
|
+
sourceWidth?: number;
|
|
56
|
+
/** Native `<img>` `loading`. Default `lazy`; set `eager` for an above-the-fold hero. */
|
|
57
|
+
loading?: 'lazy' | 'eager';
|
|
58
|
+
/** Native `<img>` `fetchpriority`. Default `auto`; set `high` for the LCP image. */
|
|
59
|
+
fetchPriority?: 'high' | 'low' | 'auto';
|
|
60
|
+
/** Native `<img>` `decoding` hint. Default `async`. */
|
|
61
|
+
decoding?: 'async' | 'auto' | 'sync';
|
|
62
|
+
/** Applied to the `<img>` (size / space / round it here). */
|
|
63
|
+
className?: string;
|
|
64
|
+
/** Merged onto the `<img>`'s style. */
|
|
65
|
+
style?: CSSProperties;
|
|
66
|
+
/** Absolute base for the generated URLs (default same-origin). */
|
|
67
|
+
baseUrl?: string;
|
|
68
|
+
/** Transform endpoint base. Default `/api/img`; set it only if your Payload API route or Next.js basePath differs. */
|
|
69
|
+
path?: string;
|
|
70
|
+
/** Payload config, used to resolve the project `pixelStep`. Rarely needed: without it the
|
|
71
|
+
* component uses the config the plugin stashed at init, falling back to the `@payload-config`
|
|
72
|
+
* alias `withPayload` sets up. Pass it explicitly only for a non-standard setup. */
|
|
73
|
+
config?: Awaitable<SanitizedConfig>;
|
|
74
|
+
/** Explicit cache-busting version token (`v=`); overrides the one derived from the doc's filename + focal. */
|
|
75
|
+
version?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Inline LQIP placeholder. `false` disables it; a **number** sets the LQIP width in px; an object
|
|
78
|
+
* tunes `{ width, quality }`. Keep the width small — the LQIP is base64-inlined in **every**
|
|
79
|
+
* response (~0.6 KB at 24px, ~2 KB at 48, ~3–4 KB at 64), so **24–64 is the sweet spot**; larger
|
|
80
|
+
* just bloats the HTML. Defaults to the project `placeholder` config (24px). Supersedes `blur`.
|
|
81
|
+
*/
|
|
82
|
+
placeholder?: PlaceholderProp;
|
|
83
|
+
/** @deprecated Use `placeholder` instead (`placeholder={false}` to disable). */
|
|
84
|
+
blur?: boolean;
|
|
85
|
+
/** Extra attributes (e.g. `data-*`) spread onto the `<img>`. */
|
|
86
|
+
dataAttributes?: Record<string, string>;
|
|
87
|
+
}
|
|
88
|
+
export declare const ResponsiveImage: (props: ResponsiveImageProps) => Promise<ReactElement | null>;
|
|
89
|
+
export default ResponsiveImage;
|
|
90
|
+
//# sourceMappingURL=image.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../../src/components/image.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ResponsiveImage>` — a single plain `<img>` carrying a `srcset` of on-demand transform URLs,
|
|
3
|
+
* with a faithful **inline LQIP** painted as its own `background-image`: a tiny base64 data-URI
|
|
4
|
+
* (generated server-side at the requested aspect-ratio + focal point, zero network) shows
|
|
5
|
+
* instantly, and the real image paints over it on load — the swap is native, no JS, no `<head>`
|
|
6
|
+
* script. An async server component: it resolves the project config (pixel step + placeholder
|
|
7
|
+
* settings), generates the LQIP via the shared variant cache, and renders one `<img>`. Not
|
|
8
|
+
* `next/image`.
|
|
9
|
+
*
|
|
10
|
+
* `className` / `style` / `dataAttributes` all go on the `<img>` itself (size / space / round it
|
|
11
|
+
* there). The LQIP is on by default; pass `blur={false}` (or the plugin's `placeholder: false`)
|
|
12
|
+
* to skip it, and it's skipped automatically when there's no populated doc to generate from.
|
|
13
|
+
*/ import { jsx as _jsx } from "react/jsx-runtime";
|
|
14
|
+
import { stashedConfig } from "../lib/configStash.js";
|
|
15
|
+
import { parseAspectRatio } from "../transform/params.js";
|
|
16
|
+
import { buildSrcset, buildVariantUrl, deriveVersion, getImageUrl, stepWidths } from "../utils/urls.js";
|
|
17
|
+
// Re-export the isomorphic URL builders so this single subpath covers the whole frontend API —
|
|
18
|
+
// `import { ResponsiveImage, getImageUrl } from '@pro-laico/payload-images/components/image'`.
|
|
19
|
+
// (The `utils/urls` subpath stays available for importing the builders without the component.)
|
|
20
|
+
export { buildSrcset, buildVariantUrl, deriveVersion, getImageUrl, stepWidths };
|
|
21
|
+
/** Resolve the `placeholder` prop (falling back to the deprecated `blur`) to on/off + optional width/quality. */ const resolvePlaceholderProp = (placeholder, blur)=>{
|
|
22
|
+
if (placeholder === false) return {
|
|
23
|
+
on: false
|
|
24
|
+
};
|
|
25
|
+
if (placeholder === true) return {
|
|
26
|
+
on: true
|
|
27
|
+
};
|
|
28
|
+
if (typeof placeholder === 'number') return {
|
|
29
|
+
on: true,
|
|
30
|
+
width: placeholder
|
|
31
|
+
};
|
|
32
|
+
if (placeholder && typeof placeholder === 'object') return {
|
|
33
|
+
on: true,
|
|
34
|
+
width: placeholder.width,
|
|
35
|
+
quality: placeholder.quality
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
on: blur
|
|
39
|
+
} // `placeholder` unset → legacy `blur` (default true)
|
|
40
|
+
;
|
|
41
|
+
};
|
|
42
|
+
const CSS_OBJECT_FIT = {
|
|
43
|
+
cover: 'cover',
|
|
44
|
+
contain: 'contain',
|
|
45
|
+
fill: 'fill',
|
|
46
|
+
inside: 'contain',
|
|
47
|
+
outside: 'cover'
|
|
48
|
+
};
|
|
49
|
+
const idOf = (image)=>typeof image === 'object' ? String(image.id ?? '') : String(image ?? '');
|
|
50
|
+
// Once-per-process dev warnings — these failures repeat on every render, so warning each time would flood the console.
|
|
51
|
+
let warnedNoConfig = false;
|
|
52
|
+
let warnedLqipFailed = false;
|
|
53
|
+
export const ResponsiveImage = async (props)=>{
|
|
54
|
+
const { image, alt, sizes = '100vw', aspectRatio, fill = false, quality = 75, fit = 'cover', format = 'auto', sourceWidth, loading = 'lazy', fetchPriority = 'auto', decoding = 'async', className, style, baseUrl, path, version, placeholder, blur = true, dataAttributes, config } = props;
|
|
55
|
+
const id = idOf(image);
|
|
56
|
+
if (!id) return null;
|
|
57
|
+
// Resolve the Payload config once (the srcset's `pixelStep` and the placeholder settings both
|
|
58
|
+
// come off it): the explicit prop, else the globalThis stash the plugin's `onInit` filled (set
|
|
59
|
+
// the moment Payload boots — no bundler involvement), else the `@payload-config` alias — which
|
|
60
|
+
// only resolves from a published package when the consumer transpiles it (`transpilePackages`).
|
|
61
|
+
// `as string` keeps TS from resolving the alias at this package's build. Stays undefined if
|
|
62
|
+
// unreadable (defaults apply).
|
|
63
|
+
let cfg;
|
|
64
|
+
try {
|
|
65
|
+
cfg = await (config ?? stashedConfig() ?? (await import("@payload-config")).default);
|
|
66
|
+
} catch {
|
|
67
|
+
cfg = undefined;
|
|
68
|
+
}
|
|
69
|
+
if (!cfg && process.env.NODE_ENV !== 'production' && !warnedNoConfig) {
|
|
70
|
+
warnedNoConfig = true;
|
|
71
|
+
console.warn("[payload-images] <ResponsiveImage> could not resolve the Payload config — project pixelStep/placeholder settings are skipped and the inline LQIP is disabled. Pass the `config` prop, or add `transpilePackages: ['@pro-laico/payload-images']` to next.config so the `@payload-config` alias resolves.");
|
|
72
|
+
}
|
|
73
|
+
const pixelStep = cfg?.custom?.payloadImages?.pixelStep;
|
|
74
|
+
const doc = typeof image === 'object' ? image : undefined;
|
|
75
|
+
const altText = alt ?? doc?.alt ?? '';
|
|
76
|
+
const naturalW = doc?.width ?? undefined;
|
|
77
|
+
const naturalH = doc?.height ?? undefined;
|
|
78
|
+
// Warn (dev only) when an aspectRatio was passed but doesn't parse — we silently fall back to the
|
|
79
|
+
// natural ratio, which is easy to miss; mirrors how Sanity's reference component surfaces this.
|
|
80
|
+
if (!fill && aspectRatio != null && parseAspectRatio(aspectRatio) === undefined && process.env.NODE_ENV !== 'production') {
|
|
81
|
+
console.warn(`[payload-images] Invalid aspectRatio ${JSON.stringify(aspectRatio)} — expected "w/h", "w:h", or a positive number. Falling back to the image's natural ratio.`);
|
|
82
|
+
}
|
|
83
|
+
const ar = fill ? undefined : parseAspectRatio(aspectRatio) ?? (naturalW && naturalH ? naturalW / naturalH : undefined);
|
|
84
|
+
const opts = {
|
|
85
|
+
fit,
|
|
86
|
+
quality,
|
|
87
|
+
format,
|
|
88
|
+
aspectRatio: ar,
|
|
89
|
+
baseUrl,
|
|
90
|
+
path,
|
|
91
|
+
pixelStep,
|
|
92
|
+
sourceWidth: sourceWidth ?? naturalW,
|
|
93
|
+
version: version ?? deriveVersion(doc)
|
|
94
|
+
};
|
|
95
|
+
const { srcset, src } = buildSrcset(id, opts);
|
|
96
|
+
// The faithful inline LQIP: a tiny base64 variant at this exact ratio/focal, generated
|
|
97
|
+
// server-side (shared variant cache) and painted as the <img>'s own background — instant,
|
|
98
|
+
// zero network, covered when the real image loads. Needs a populated doc + the config;
|
|
99
|
+
// the engine is dynamic-imported so the non-placeholder path never bundles Sharp/getPayload.
|
|
100
|
+
const ph = resolvePlaceholderProp(placeholder, blur);
|
|
101
|
+
let lqip;
|
|
102
|
+
if (ph.on && cfg && doc?.filename) {
|
|
103
|
+
try {
|
|
104
|
+
const { generateInlineLqip } = await import("./inlineLqip.js");
|
|
105
|
+
// Trusted (component) call: the requested width is honored (no `untrusted` clamp).
|
|
106
|
+
lqip = await generateInlineLqip({
|
|
107
|
+
config: cfg,
|
|
108
|
+
source: doc,
|
|
109
|
+
ar,
|
|
110
|
+
fit,
|
|
111
|
+
width: ph.width,
|
|
112
|
+
quality: ph.quality
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
lqip = undefined;
|
|
116
|
+
if (process.env.NODE_ENV !== 'production' && !warnedLqipFailed) {
|
|
117
|
+
warnedLqipFailed = true;
|
|
118
|
+
console.warn(`[payload-images] <ResponsiveImage> inline LQIP generation failed — rendering without a placeholder. ${String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const intrinsicW = naturalW ?? (ar ? 1280 : undefined);
|
|
123
|
+
const intrinsicH = naturalH ?? (ar && intrinsicW ? Math.round(intrinsicW / ar) : undefined);
|
|
124
|
+
const bgSize = fit === 'contain' || fit === 'inside' ? 'contain' : 'cover';
|
|
125
|
+
return(// biome-ignore lint/performance/noImgElement: intentional plain <img> — a hand-built srcset + inline LQIP that next/image would defeat
|
|
126
|
+
/*#__PURE__*/ _jsx("img", {
|
|
127
|
+
src: src,
|
|
128
|
+
srcSet: srcset,
|
|
129
|
+
sizes: sizes,
|
|
130
|
+
alt: altText,
|
|
131
|
+
width: intrinsicW,
|
|
132
|
+
height: intrinsicH,
|
|
133
|
+
loading: loading,
|
|
134
|
+
fetchPriority: fetchPriority,
|
|
135
|
+
decoding: decoding,
|
|
136
|
+
className: className,
|
|
137
|
+
style: {
|
|
138
|
+
display: 'block',
|
|
139
|
+
width: '100%',
|
|
140
|
+
height: fill ? '100%' : 'auto',
|
|
141
|
+
...ar ? {
|
|
142
|
+
aspectRatio: String(ar)
|
|
143
|
+
} : null,
|
|
144
|
+
objectFit: CSS_OBJECT_FIT[fit],
|
|
145
|
+
...fill ? {
|
|
146
|
+
position: 'absolute',
|
|
147
|
+
inset: 0
|
|
148
|
+
} : null,
|
|
149
|
+
...lqip ? {
|
|
150
|
+
backgroundImage: `url(${lqip})`,
|
|
151
|
+
backgroundSize: bgSize,
|
|
152
|
+
backgroundPosition: 'center',
|
|
153
|
+
backgroundRepeat: 'no-repeat'
|
|
154
|
+
} : null,
|
|
155
|
+
...style
|
|
156
|
+
},
|
|
157
|
+
...dataAttributes
|
|
158
|
+
}));
|
|
159
|
+
};
|
|
160
|
+
export default ResponsiveImage;
|
|
161
|
+
|
|
162
|
+
//# sourceMappingURL=image.js.map
|