@object-ui/fields 3.3.2 → 3.4.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/CHANGELOG.md +74 -0
- package/dist/ImageCropperDialog-BYRMqcYm.js +147 -0
- package/dist/ImageCropperDialog-DXLQcwWZ.cjs +1 -0
- package/dist/LookupField-CRKvDPFG.cjs +1 -0
- package/dist/{LookupField-De-GqsfT.js → LookupField-XRxsGgLS.js} +330 -295
- package/dist/dist-BoLAKgtw.cjs +1 -0
- package/dist/dist-qtNk7SzD.js +32 -0
- package/dist/index.cjs +4 -4
- package/dist/index.js +944 -758
- package/dist/packages/fields/src/index.d.ts +2 -0
- package/dist/packages/fields/src/lookup-depends-on.test.d.ts +1 -0
- package/dist/packages/fields/src/widgets/ImageCropperDialog.d.ts +14 -0
- package/dist/packages/fields/src/widgets/TextAreaField.d.ts +6 -1
- package/package.json +9 -8
- package/src/__tests__/cell-renderers.test.tsx +18 -18
- package/src/complex-widgets.test.tsx +46 -0
- package/src/index.tsx +27 -14
- package/src/lookup-depends-on.test.tsx +130 -0
- package/src/remaining-widgets.test.tsx +14 -1
- package/src/widgets/FileField.tsx +100 -17
- package/src/widgets/ImageCropperDialog.tsx +245 -0
- package/src/widgets/ImageField.tsx +121 -26
- package/src/widgets/LookupField.tsx +91 -5
- package/src/widgets/TextAreaField.tsx +88 -6
- package/dist/LookupField-CiIjeL_i.cjs +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useRef, useState, useCallback } from 'react';
|
|
2
2
|
import { Button, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { useUpload } from '@object-ui/providers';
|
|
4
|
+
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
|
|
4
5
|
import { FieldWidgetProps } from './types';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -10,20 +11,37 @@ import { FieldWidgetProps } from './types';
|
|
|
10
11
|
*/
|
|
11
12
|
export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
12
13
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
14
|
+
const cameraRef = useRef<HTMLInputElement>(null);
|
|
13
15
|
const fileField = (field || (props as any).schema) as any;
|
|
14
16
|
const multiple = fileField?.multiple || false;
|
|
15
17
|
const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
|
|
16
18
|
const maxSize = fileField?.maxSize as number | undefined; // bytes
|
|
19
|
+
/**
|
|
20
|
+
* Camera capture mode for mobile devices.
|
|
21
|
+
* - `'environment'` (back camera): photos of receipts, documents, products
|
|
22
|
+
* - `'user'` (front camera): selfies, profile pictures
|
|
23
|
+
* - `false`: disable the camera button entirely
|
|
24
|
+
* @default 'environment' when accept includes image/* on a touch device
|
|
25
|
+
*/
|
|
26
|
+
const captureMode = (fileField?.capture ?? null) as 'environment' | 'user' | false | null;
|
|
27
|
+
const acceptsImages = !accept || accept.split(',').some((t: string) =>
|
|
28
|
+
t.trim().startsWith('image/') || t.trim() === 'image/*' || t.trim().startsWith('.jp') || t.trim().startsWith('.png') || t.trim().startsWith('.gif') || t.trim().startsWith('.webp'),
|
|
29
|
+
);
|
|
30
|
+
// Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
|
|
31
|
+
const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
|
|
32
|
+
const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
|
|
17
33
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
18
34
|
const [errors, setErrors] = useState<string[]>([]);
|
|
35
|
+
const { upload } = useUpload();
|
|
36
|
+
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
|
37
|
+
const [uploading, setUploading] = useState(false);
|
|
19
38
|
|
|
20
39
|
const files = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
21
40
|
|
|
22
|
-
const processFiles = useCallback((selectedFiles: File[]) => {
|
|
41
|
+
const processFiles = useCallback(async (selectedFiles: File[]) => {
|
|
23
42
|
if (selectedFiles.length === 0) return;
|
|
24
43
|
const newErrors: string[] = [];
|
|
25
44
|
|
|
26
|
-
// Validate file sizes
|
|
27
45
|
const validFiles = selectedFiles.filter(file => {
|
|
28
46
|
if (maxSize && file.size > maxSize) {
|
|
29
47
|
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
|
|
@@ -36,21 +54,42 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
36
54
|
|
|
37
55
|
if (validFiles.length === 0) return;
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
setUploading(true);
|
|
58
|
+
try {
|
|
59
|
+
const fileObjects = await Promise.all(
|
|
60
|
+
validFiles.map(async (file) => {
|
|
61
|
+
try {
|
|
62
|
+
const result = await upload(file, {
|
|
63
|
+
onProgress: (ratio) =>
|
|
64
|
+
setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
name: result.name,
|
|
68
|
+
original_name: file.name,
|
|
69
|
+
size: result.size,
|
|
70
|
+
mime_type: result.mimeType,
|
|
71
|
+
url: result.url,
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
newErrors.push(`Failed to upload "${file.name}": ${(err as Error).message}`);
|
|
75
|
+
setErrors([...newErrors]);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const successful = fileObjects.filter(Boolean) as any[];
|
|
81
|
+
if (successful.length === 0) return;
|
|
47
82
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
83
|
+
if (multiple) {
|
|
84
|
+
onChange([...files, ...successful]);
|
|
85
|
+
} else {
|
|
86
|
+
onChange(successful[0]);
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
setUploading(false);
|
|
90
|
+
setUploadProgress({});
|
|
52
91
|
}
|
|
53
|
-
}, [files, multiple, onChange, maxSize]);
|
|
92
|
+
}, [files, multiple, onChange, maxSize, upload]);
|
|
54
93
|
|
|
55
94
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
56
95
|
e.preventDefault();
|
|
@@ -128,6 +167,18 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
128
167
|
onChange={handleFileChange}
|
|
129
168
|
className="hidden"
|
|
130
169
|
/>
|
|
170
|
+
{cameraEnabled && (
|
|
171
|
+
<input
|
|
172
|
+
ref={cameraRef}
|
|
173
|
+
type="file"
|
|
174
|
+
accept="image/*"
|
|
175
|
+
capture={cameraEnabled}
|
|
176
|
+
onChange={handleFileChange}
|
|
177
|
+
className="hidden"
|
|
178
|
+
aria-label="Camera capture"
|
|
179
|
+
data-testid="file-field-camera-input"
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
131
182
|
|
|
132
183
|
<div className="space-y-2">
|
|
133
184
|
{/* Drag-and-drop zone */}
|
|
@@ -159,11 +210,43 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
159
210
|
{isDragOver ? 'Drop files here' : 'Drag & drop files here'}
|
|
160
211
|
</p>
|
|
161
212
|
<p className="text-xs text-muted-foreground mt-1">
|
|
162
|
-
or click to browse
|
|
213
|
+
or click to browse{cameraEnabled ? ' • use the camera button below' : ''}
|
|
163
214
|
</p>
|
|
164
215
|
</div>
|
|
165
216
|
</div>
|
|
166
217
|
|
|
218
|
+
{cameraEnabled && (
|
|
219
|
+
<Button
|
|
220
|
+
type="button"
|
|
221
|
+
variant="outline"
|
|
222
|
+
size="sm"
|
|
223
|
+
className="w-full"
|
|
224
|
+
onClick={(e) => {
|
|
225
|
+
e.stopPropagation();
|
|
226
|
+
cameraRef.current?.click();
|
|
227
|
+
}}
|
|
228
|
+
data-testid="file-field-camera-button"
|
|
229
|
+
>
|
|
230
|
+
<Camera className="size-4 mr-2" />
|
|
231
|
+
{cameraEnabled === 'user' ? 'Take selfie' : 'Take photo'}
|
|
232
|
+
</Button>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Upload progress indicator */}
|
|
236
|
+
{uploading && (
|
|
237
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
|
|
238
|
+
<Loader2 className="size-3 animate-spin" />
|
|
239
|
+
<span>
|
|
240
|
+
Uploading…
|
|
241
|
+
{Object.keys(uploadProgress).length > 0 &&
|
|
242
|
+
` (${Math.round(
|
|
243
|
+
(Object.values(uploadProgress).reduce((s, v) => s + v, 0) /
|
|
244
|
+
Object.keys(uploadProgress).length) * 100,
|
|
245
|
+
)}%)`}
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
167
250
|
{/* Validation errors */}
|
|
168
251
|
{errors.length > 0 && (
|
|
169
252
|
<div className="space-y-0.5">
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageCropperDialog - Lightweight crop/rotate dialog for ImageField.
|
|
3
|
+
*
|
|
4
|
+
* Implementation notes:
|
|
5
|
+
* - Uses a Canvas + native pointer events (no extra dependency) so the bundle
|
|
6
|
+
* stays small and the widget works in all SDUI contexts.
|
|
7
|
+
* - Exposes a rectangular crop selection that can be dragged around the image
|
|
8
|
+
* and a rotate-by-90° button.
|
|
9
|
+
* - On confirm, returns a `Blob` of the cropped image (PNG by default) so callers
|
|
10
|
+
* can wire it into upload providers or `URL.createObjectURL()`.
|
|
11
|
+
*/
|
|
12
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import {
|
|
14
|
+
Button,
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogDescription,
|
|
18
|
+
DialogFooter,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
} from '@object-ui/components';
|
|
22
|
+
import { RotateCw, Crop as CropIcon } from 'lucide-react';
|
|
23
|
+
|
|
24
|
+
export interface ImageCropperDialogProps {
|
|
25
|
+
open: boolean;
|
|
26
|
+
onOpenChange: (open: boolean) => void;
|
|
27
|
+
/** Source image URL or data URL */
|
|
28
|
+
src: string;
|
|
29
|
+
/** MIME type for the cropped output. Defaults to image/png. */
|
|
30
|
+
outputType?: string;
|
|
31
|
+
/** Suggested filename (passed back via onConfirm). Defaults to "cropped.png". */
|
|
32
|
+
outputName?: string;
|
|
33
|
+
/** Called when the user confirms the crop. */
|
|
34
|
+
onConfirm: (blob: Blob, name: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CropRect {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ImageCropperDialog: React.FC<ImageCropperDialogProps> = ({
|
|
45
|
+
open,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
src,
|
|
48
|
+
outputType = 'image/png',
|
|
49
|
+
outputName = 'cropped.png',
|
|
50
|
+
onConfirm,
|
|
51
|
+
}) => {
|
|
52
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
53
|
+
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
54
|
+
const [rotation, setRotation] = useState(0); // 0, 90, 180, 270
|
|
55
|
+
const [crop, setCrop] = useState<CropRect>({ x: 0, y: 0, width: 100, height: 100 });
|
|
56
|
+
const [drag, setDrag] = useState<{ ox: number; oy: number; cx: number; cy: number } | null>(null);
|
|
57
|
+
const [imageReady, setImageReady] = useState(false);
|
|
58
|
+
|
|
59
|
+
// Load the image when the dialog opens
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open || !src) return;
|
|
62
|
+
setImageReady(false);
|
|
63
|
+
const img = new Image();
|
|
64
|
+
img.crossOrigin = 'anonymous';
|
|
65
|
+
img.onload = () => {
|
|
66
|
+
imgRef.current = img;
|
|
67
|
+
// Default crop = centred 80% box
|
|
68
|
+
const w = img.naturalWidth * 0.8;
|
|
69
|
+
const h = img.naturalHeight * 0.8;
|
|
70
|
+
setCrop({
|
|
71
|
+
x: (img.naturalWidth - w) / 2,
|
|
72
|
+
y: (img.naturalHeight - h) / 2,
|
|
73
|
+
width: w,
|
|
74
|
+
height: h,
|
|
75
|
+
});
|
|
76
|
+
setRotation(0);
|
|
77
|
+
setImageReady(true);
|
|
78
|
+
};
|
|
79
|
+
img.onerror = () => setImageReady(false);
|
|
80
|
+
img.src = src;
|
|
81
|
+
}, [open, src]);
|
|
82
|
+
|
|
83
|
+
// Render image + crop overlay
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!imageReady || !imgRef.current || !canvasRef.current) return;
|
|
86
|
+
const img = imgRef.current;
|
|
87
|
+
const canvas = canvasRef.current;
|
|
88
|
+
const ctx = canvas.getContext('2d');
|
|
89
|
+
if (!ctx) return;
|
|
90
|
+
|
|
91
|
+
// Scale image to fit a 480px-wide preview while preserving aspect
|
|
92
|
+
const previewW = 480;
|
|
93
|
+
const scale = previewW / img.naturalWidth;
|
|
94
|
+
const previewH = img.naturalHeight * scale;
|
|
95
|
+
canvas.width = previewW;
|
|
96
|
+
canvas.height = previewH;
|
|
97
|
+
ctx.clearRect(0, 0, previewW, previewH);
|
|
98
|
+
|
|
99
|
+
// Apply rotation to the image (purely visual — actual rotation is baked in on confirm)
|
|
100
|
+
ctx.save();
|
|
101
|
+
ctx.translate(previewW / 2, previewH / 2);
|
|
102
|
+
ctx.rotate((rotation * Math.PI) / 180);
|
|
103
|
+
const drawW = rotation % 180 === 0 ? previewW : previewH;
|
|
104
|
+
const drawH = rotation % 180 === 0 ? previewH : previewW;
|
|
105
|
+
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
|
|
106
|
+
ctx.restore();
|
|
107
|
+
|
|
108
|
+
// Draw crop overlay (in preview coordinates)
|
|
109
|
+
const px = crop.x * scale;
|
|
110
|
+
const py = crop.y * scale;
|
|
111
|
+
const pw = crop.width * scale;
|
|
112
|
+
const ph = crop.height * scale;
|
|
113
|
+
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
|
114
|
+
ctx.fillRect(0, 0, previewW, py);
|
|
115
|
+
ctx.fillRect(0, py + ph, previewW, previewH - py - ph);
|
|
116
|
+
ctx.fillRect(0, py, px, ph);
|
|
117
|
+
ctx.fillRect(px + pw, py, previewW - px - pw, ph);
|
|
118
|
+
ctx.strokeStyle = '#3b82f6';
|
|
119
|
+
ctx.lineWidth = 2;
|
|
120
|
+
ctx.strokeRect(px, py, pw, ph);
|
|
121
|
+
}, [imageReady, crop, rotation]);
|
|
122
|
+
|
|
123
|
+
const handlePointerDown = useCallback(
|
|
124
|
+
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
125
|
+
if (!imgRef.current || !canvasRef.current) return;
|
|
126
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
127
|
+
const scale = imgRef.current.naturalWidth / rect.width;
|
|
128
|
+
setDrag({
|
|
129
|
+
ox: (e.clientX - rect.left) * scale,
|
|
130
|
+
oy: (e.clientY - rect.top) * scale,
|
|
131
|
+
cx: crop.x,
|
|
132
|
+
cy: crop.y,
|
|
133
|
+
});
|
|
134
|
+
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
|
135
|
+
},
|
|
136
|
+
[crop],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const handlePointerMove = useCallback(
|
|
140
|
+
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
141
|
+
if (!drag || !imgRef.current || !canvasRef.current) return;
|
|
142
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
143
|
+
const scale = imgRef.current.naturalWidth / rect.width;
|
|
144
|
+
const dx = (e.clientX - rect.left) * scale - drag.ox;
|
|
145
|
+
const dy = (e.clientY - rect.top) * scale - drag.oy;
|
|
146
|
+
const nextX = Math.max(0, Math.min(drag.cx + dx, imgRef.current.naturalWidth - crop.width));
|
|
147
|
+
const nextY = Math.max(0, Math.min(drag.cy + dy, imgRef.current.naturalHeight - crop.height));
|
|
148
|
+
setCrop((c) => ({ ...c, x: nextX, y: nextY }));
|
|
149
|
+
},
|
|
150
|
+
[drag, crop.width, crop.height],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const handlePointerUp = useCallback(() => setDrag(null), []);
|
|
154
|
+
|
|
155
|
+
const rotate = useCallback(() => setRotation((r) => (r + 90) % 360), []);
|
|
156
|
+
|
|
157
|
+
const handleConfirm = useCallback(async () => {
|
|
158
|
+
if (!imgRef.current) return;
|
|
159
|
+
const img = imgRef.current;
|
|
160
|
+
const out = document.createElement('canvas');
|
|
161
|
+
// After rotation, swap width/height when needed
|
|
162
|
+
const cw = crop.width;
|
|
163
|
+
const ch = crop.height;
|
|
164
|
+
out.width = rotation % 180 === 0 ? cw : ch;
|
|
165
|
+
out.height = rotation % 180 === 0 ? ch : cw;
|
|
166
|
+
const ctx = out.getContext('2d');
|
|
167
|
+
if (!ctx) return;
|
|
168
|
+
ctx.save();
|
|
169
|
+
ctx.translate(out.width / 2, out.height / 2);
|
|
170
|
+
ctx.rotate((rotation * Math.PI) / 180);
|
|
171
|
+
ctx.drawImage(img, crop.x, crop.y, cw, ch, -cw / 2, -ch / 2, cw, ch);
|
|
172
|
+
ctx.restore();
|
|
173
|
+
const blob: Blob | null = await new Promise((resolve) => out.toBlob(resolve, outputType));
|
|
174
|
+
if (blob) {
|
|
175
|
+
onConfirm(blob, outputName);
|
|
176
|
+
onOpenChange(false);
|
|
177
|
+
}
|
|
178
|
+
}, [crop, rotation, outputName, outputType, onConfirm, onOpenChange]);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
182
|
+
<DialogContent className="max-w-xl">
|
|
183
|
+
<DialogHeader>
|
|
184
|
+
<DialogTitle>Crop & rotate image</DialogTitle>
|
|
185
|
+
<DialogDescription>
|
|
186
|
+
Drag the highlighted box to reposition the crop. Use the rotate button to spin the image
|
|
187
|
+
in 90° increments.
|
|
188
|
+
</DialogDescription>
|
|
189
|
+
</DialogHeader>
|
|
190
|
+
|
|
191
|
+
<div className="flex flex-col items-center gap-3">
|
|
192
|
+
{imageReady ? (
|
|
193
|
+
<canvas
|
|
194
|
+
ref={canvasRef}
|
|
195
|
+
data-testid="image-cropper-canvas"
|
|
196
|
+
role="img"
|
|
197
|
+
aria-label="Image crop preview"
|
|
198
|
+
className="border rounded-md cursor-move touch-none max-w-full"
|
|
199
|
+
onPointerDown={handlePointerDown}
|
|
200
|
+
onPointerMove={handlePointerMove}
|
|
201
|
+
onPointerUp={handlePointerUp}
|
|
202
|
+
onPointerCancel={handlePointerUp}
|
|
203
|
+
/>
|
|
204
|
+
) : (
|
|
205
|
+
<div className="flex h-[270px] w-full items-center justify-center text-sm text-muted-foreground">
|
|
206
|
+
Loading image…
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
<div className="flex w-full items-center justify-between text-xs text-muted-foreground">
|
|
210
|
+
<span>
|
|
211
|
+
Crop: {Math.round(crop.width)} × {Math.round(crop.height)} px
|
|
212
|
+
</span>
|
|
213
|
+
<span>Rotation: {rotation}°</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<DialogFooter className="flex justify-between sm:justify-between">
|
|
218
|
+
<Button
|
|
219
|
+
type="button"
|
|
220
|
+
variant="outline"
|
|
221
|
+
onClick={rotate}
|
|
222
|
+
data-testid="image-cropper-rotate"
|
|
223
|
+
>
|
|
224
|
+
<RotateCw className="size-4 mr-2" />
|
|
225
|
+
Rotate 90°
|
|
226
|
+
</Button>
|
|
227
|
+
<div className="flex gap-2">
|
|
228
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
229
|
+
Cancel
|
|
230
|
+
</Button>
|
|
231
|
+
<Button
|
|
232
|
+
type="button"
|
|
233
|
+
onClick={handleConfirm}
|
|
234
|
+
disabled={!imageReady}
|
|
235
|
+
data-testid="image-cropper-confirm"
|
|
236
|
+
>
|
|
237
|
+
<CropIcon className="size-4 mr-2" />
|
|
238
|
+
Apply crop
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
</DialogFooter>
|
|
242
|
+
</DialogContent>
|
|
243
|
+
</Dialog>
|
|
244
|
+
);
|
|
245
|
+
};
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import React, { useRef } from 'react';
|
|
1
|
+
import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
|
|
2
2
|
import { Button, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { useUpload } from '@object-ui/providers';
|
|
4
|
+
import { Upload, X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
|
|
4
5
|
import { FieldWidgetProps } from './types';
|
|
5
6
|
|
|
7
|
+
// Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
|
|
8
|
+
// ImageField bundle. Consumers that never crop pay zero cost.
|
|
9
|
+
const ImageCropperDialog = lazy(() =>
|
|
10
|
+
import('./ImageCropperDialog').then((m) => ({ default: m.ImageCropperDialog })),
|
|
11
|
+
);
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
14
|
* ImageField - Image upload widget with preview thumbnails
|
|
8
15
|
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
@@ -12,6 +19,13 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
|
|
|
12
19
|
const imageField = (field || (props as any).schema) as any;
|
|
13
20
|
const multiple = imageField?.multiple || false;
|
|
14
21
|
const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
|
|
22
|
+
/**
|
|
23
|
+
* Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
|
|
24
|
+
*/
|
|
25
|
+
const cropEnabled = imageField?.crop !== false;
|
|
26
|
+
const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
|
|
27
|
+
const { upload } = useUpload();
|
|
28
|
+
const [uploading, setUploading] = useState(false);
|
|
15
29
|
|
|
16
30
|
if (readonly) {
|
|
17
31
|
if (!value) return <EmptyValue />;
|
|
@@ -33,23 +47,34 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
|
|
|
33
47
|
|
|
34
48
|
const images = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
35
49
|
|
|
36
|
-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
50
|
+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
37
51
|
const selectedFiles = Array.from(e.target.files || []);
|
|
38
52
|
if (selectedFiles.length === 0) return;
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
setUploading(true);
|
|
55
|
+
try {
|
|
56
|
+
const imageObjects = await Promise.all(
|
|
57
|
+
selectedFiles.map(async (file) => {
|
|
58
|
+
const result = await upload(file);
|
|
59
|
+
return {
|
|
60
|
+
name: result.name,
|
|
61
|
+
original_name: file.name,
|
|
62
|
+
size: result.size,
|
|
63
|
+
mime_type: result.mimeType,
|
|
64
|
+
url: result.url,
|
|
65
|
+
};
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
48
68
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
if (multiple) {
|
|
70
|
+
onChange([...images, ...imageObjects]);
|
|
71
|
+
} else {
|
|
72
|
+
onChange(imageObjects[0]);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
setUploading(false);
|
|
76
|
+
// Reset input so picking the same file again still triggers change.
|
|
77
|
+
if (inputRef.current) inputRef.current.value = '';
|
|
53
78
|
}
|
|
54
79
|
};
|
|
55
80
|
|
|
@@ -62,6 +87,43 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
|
|
|
62
87
|
}
|
|
63
88
|
};
|
|
64
89
|
|
|
90
|
+
const handleCropConfirm = useCallback(
|
|
91
|
+
async (blob: Blob, name: string) => {
|
|
92
|
+
if (!cropTarget) return;
|
|
93
|
+
setUploading(true);
|
|
94
|
+
try {
|
|
95
|
+
const result = await upload(blob);
|
|
96
|
+
const next = {
|
|
97
|
+
name: result.name || name,
|
|
98
|
+
original_name: name,
|
|
99
|
+
size: result.size,
|
|
100
|
+
mime_type: result.mimeType,
|
|
101
|
+
url: result.url,
|
|
102
|
+
};
|
|
103
|
+
if (multiple) {
|
|
104
|
+
const updated = [...images];
|
|
105
|
+
updated[cropTarget.index] = next;
|
|
106
|
+
onChange(updated);
|
|
107
|
+
} else {
|
|
108
|
+
onChange(next);
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
setUploading(false);
|
|
112
|
+
setCropTarget(null);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[cropTarget, images, multiple, onChange, upload],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const openCropper = useCallback(
|
|
119
|
+
(index: number) => {
|
|
120
|
+
const img = images[index];
|
|
121
|
+
if (!img?.url) return;
|
|
122
|
+
setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
|
|
123
|
+
},
|
|
124
|
+
[images],
|
|
125
|
+
);
|
|
126
|
+
|
|
65
127
|
return (
|
|
66
128
|
<div className={props.className}>
|
|
67
129
|
<input
|
|
@@ -83,15 +145,30 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
|
|
|
83
145
|
alt={img.name || `Image ${idx + 1}`}
|
|
84
146
|
className="size-20 rounded-md object-cover border border-gray-200"
|
|
85
147
|
/>
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
148
|
+
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
149
|
+
{cropEnabled && (
|
|
150
|
+
<Button
|
|
151
|
+
type="button"
|
|
152
|
+
variant="secondary"
|
|
153
|
+
size="sm"
|
|
154
|
+
onClick={() => openCropper(idx)}
|
|
155
|
+
className="h-6 w-6 p-0"
|
|
156
|
+
aria-label={`Crop image ${idx + 1}`}
|
|
157
|
+
data-testid={`image-field-crop-${idx}`}
|
|
158
|
+
>
|
|
159
|
+
<CropIcon className="size-3" />
|
|
160
|
+
</Button>
|
|
161
|
+
)}
|
|
162
|
+
<Button
|
|
163
|
+
type="button"
|
|
164
|
+
variant="destructive"
|
|
165
|
+
size="sm"
|
|
166
|
+
onClick={() => handleRemove(idx)}
|
|
167
|
+
className="h-6 w-6 p-0"
|
|
168
|
+
>
|
|
169
|
+
<X className="size-3" />
|
|
170
|
+
</Button>
|
|
171
|
+
</div>
|
|
95
172
|
</div>
|
|
96
173
|
))}
|
|
97
174
|
</div>
|
|
@@ -102,11 +179,29 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
|
|
|
102
179
|
variant="outline"
|
|
103
180
|
onClick={() => inputRef.current?.click()}
|
|
104
181
|
className="w-full"
|
|
182
|
+
disabled={uploading}
|
|
183
|
+
data-testid="image-field-upload-button"
|
|
105
184
|
>
|
|
106
|
-
|
|
107
|
-
|
|
185
|
+
{uploading ? (
|
|
186
|
+
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
187
|
+
) : (
|
|
188
|
+
<ImageIcon className="size-4 mr-2" />
|
|
189
|
+
)}
|
|
190
|
+
{uploading ? 'Uploading…' : images.length > 0 ? 'Add More Images' : 'Upload Image'}
|
|
108
191
|
</Button>
|
|
109
192
|
</div>
|
|
193
|
+
|
|
194
|
+
{cropEnabled && cropTarget && (
|
|
195
|
+
<Suspense fallback={null}>
|
|
196
|
+
<ImageCropperDialog
|
|
197
|
+
open
|
|
198
|
+
onOpenChange={(o) => !o && setCropTarget(null)}
|
|
199
|
+
src={cropTarget.src}
|
|
200
|
+
outputName={cropTarget.name}
|
|
201
|
+
onConfirm={handleCropConfirm}
|
|
202
|
+
/>
|
|
203
|
+
</Suspense>
|
|
204
|
+
)}
|
|
110
205
|
</div>
|
|
111
206
|
);
|
|
112
207
|
}
|