@object-ui/fields 17.3.0 → 17.6.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 +1655 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +23 -3
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +35 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +651 -0
- package/dist/index.d.ts +474 -85
- package/dist/index.js +5418 -5023
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/BooleanField.d.ts +1 -1
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/DateField.d.ts +1 -1
- package/dist/widgets/DateTimeField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +28 -1
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +68 -6
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +21 -1
- package/dist/widgets/PasswordField.d.ts +1 -1
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +34 -10
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +26 -6
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +1 -1
- package/dist/widgets/TimeField.d.ts +1 -1
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +88 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/richTextDisplay.d.ts +80 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +189 -0
- package/dist/widgets/types.d.ts +8 -3
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -14
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -191
- package/src/__tests__/DateCellRenderer.test.tsx +0 -73
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -908
- package/src/currency.ts +0 -14
- package/src/datetime-widgets.test.tsx +0 -93
- package/src/field-type-alias.ts +0 -100
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.tsx +0 -2503
- package/src/standard-widgets.test.tsx +0 -357
- package/src/widgets/AddressField.tsx +0 -132
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -52
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -136
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -95
- package/src/widgets/DateField.tsx +0 -31
- package/src/widgets/DateTimeField.tsx +0 -37
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -494
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -33
- package/src/widgets/FullscreenFieldEditor.tsx +0 -271
- package/src/widgets/GeolocationField.tsx +0 -167
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -406
- package/src/widgets/GridField.tsx +0 -966
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.tsx +0 -255
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1291
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -142
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -83
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -111
- package/src/widgets/RatingField.tsx +0 -81
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1316
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -162
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -58
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -175
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -150
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -264
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,245 +0,0 @@
|
|
|
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,255 +0,0 @@
|
|
|
1
|
-
import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
|
|
2
|
-
import { Button, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { useUpload } from '@object-ui/providers';
|
|
4
|
-
import { useObjectTranslation } from '@object-ui/i18n';
|
|
5
|
-
import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
|
|
6
|
-
import { FieldWidgetComponentProps } from './types';
|
|
7
|
-
import { toDomProps } from './toDomProps';
|
|
8
|
-
import { ImageLightbox } from './ImageLightbox';
|
|
9
|
-
import { useUploadingSignal } from './useUploadingSignal';
|
|
10
|
-
import {
|
|
11
|
-
fileValueForSubmit,
|
|
12
|
-
readFileValues,
|
|
13
|
-
uploadResultView,
|
|
14
|
-
withRecentUploads,
|
|
15
|
-
type FileValueView,
|
|
16
|
-
} from './file-value';
|
|
17
|
-
|
|
18
|
-
// Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
|
|
19
|
-
// ImageField bundle. Consumers that never crop pay zero cost.
|
|
20
|
-
const ImageCropperDialog = lazy(() =>
|
|
21
|
-
import('./ImageCropperDialog').then((m) => ({ default: m.ImageCropperDialog })),
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* ImageField - Image upload widget with preview thumbnails
|
|
26
|
-
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
27
|
-
*/
|
|
28
|
-
export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
29
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
30
|
-
const imageField = field as any;
|
|
31
|
-
const multiple = imageField?.multiple || false;
|
|
32
|
-
const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
|
|
33
|
-
/**
|
|
34
|
-
* Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
|
|
35
|
-
*/
|
|
36
|
-
const cropEnabled = imageField?.crop !== false;
|
|
37
|
-
const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
|
|
38
|
-
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
39
|
-
const { upload } = useUpload();
|
|
40
|
-
const { t } = useObjectTranslation();
|
|
41
|
-
const [uploading, setUploading] = useState(false);
|
|
42
|
-
// Display details of just-uploaded images, keyed by their new `sys_file` id.
|
|
43
|
-
// Submitting the reference form means the field value no longer carries the
|
|
44
|
-
// URL a thumbnail needs; these keep the preview visible until the next read
|
|
45
|
-
// returns the expanded form. See `file-value`.
|
|
46
|
-
const [recent, setRecent] = useState<Record<string, FileValueView>>({});
|
|
47
|
-
useUploadingSignal(uploading, onUploadingChange);
|
|
48
|
-
|
|
49
|
-
// Derived value + memoized handlers must run before the readonly early return
|
|
50
|
-
// so hook order stays stable across renders.
|
|
51
|
-
const images = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
52
|
-
const views = withRecentUploads(readFileValues(value, 'Image'), recent);
|
|
53
|
-
|
|
54
|
-
const remember = useCallback((result: any, originalName: string) => {
|
|
55
|
-
const view = uploadResultView(result, originalName);
|
|
56
|
-
if (view.id) setRecent((prev) => ({ ...prev, [view.id as string]: view }));
|
|
57
|
-
}, []);
|
|
58
|
-
|
|
59
|
-
const handleCropConfirm = useCallback(
|
|
60
|
-
async (blob: Blob, name: string) => {
|
|
61
|
-
if (!cropTarget) return;
|
|
62
|
-
setUploading(true);
|
|
63
|
-
try {
|
|
64
|
-
const result = await upload(blob);
|
|
65
|
-
remember(result, name);
|
|
66
|
-
const next = fileValueForSubmit(result, name);
|
|
67
|
-
if (multiple) {
|
|
68
|
-
const updated = [...images];
|
|
69
|
-
updated[cropTarget.index] = next;
|
|
70
|
-
onChange(updated);
|
|
71
|
-
} else {
|
|
72
|
-
onChange(next);
|
|
73
|
-
}
|
|
74
|
-
} finally {
|
|
75
|
-
setUploading(false);
|
|
76
|
-
setCropTarget(null);
|
|
77
|
-
}
|
|
78
|
-
},
|
|
79
|
-
[cropTarget, images, multiple, onChange, upload, remember],
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const openCropper = useCallback(
|
|
83
|
-
(index: number) => {
|
|
84
|
-
const img = views[index];
|
|
85
|
-
if (!img?.url) return;
|
|
86
|
-
setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
|
|
87
|
-
},
|
|
88
|
-
[views],
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
const lightboxImages = views.filter((v) => v.url).map((v) => ({ url: v.url as string, name: v.name }));
|
|
92
|
-
|
|
93
|
-
if (readonly) {
|
|
94
|
-
if (!value || lightboxImages.length === 0) return <EmptyValue />;
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
<>
|
|
98
|
-
<div className="flex flex-wrap gap-2">
|
|
99
|
-
{lightboxImages.map((img, idx) => (
|
|
100
|
-
<button
|
|
101
|
-
key={idx}
|
|
102
|
-
type="button"
|
|
103
|
-
onClick={() => setLightboxIndex(idx)}
|
|
104
|
-
className="group relative overflow-hidden rounded-md border border-border focus:outline-none focus:ring-2 focus:ring-ring"
|
|
105
|
-
aria-label={t('fields.image.enlarge', { name: img.name || t('fields.image.imageAlt', { index: idx + 1 }) })}
|
|
106
|
-
>
|
|
107
|
-
<img
|
|
108
|
-
src={img.url}
|
|
109
|
-
alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
|
|
110
|
-
className="size-20 object-cover transition-transform duration-150 group-hover:scale-105"
|
|
111
|
-
/>
|
|
112
|
-
<span className="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
|
|
113
|
-
</button>
|
|
114
|
-
))}
|
|
115
|
-
</div>
|
|
116
|
-
{lightboxIndex !== null && (
|
|
117
|
-
<ImageLightbox
|
|
118
|
-
images={lightboxImages}
|
|
119
|
-
index={lightboxIndex}
|
|
120
|
-
open
|
|
121
|
-
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
122
|
-
onIndexChange={setLightboxIndex}
|
|
123
|
-
/>
|
|
124
|
-
)}
|
|
125
|
-
</>
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
130
|
-
const selectedFiles = Array.from(e.target.files || []);
|
|
131
|
-
if (selectedFiles.length === 0) return;
|
|
132
|
-
|
|
133
|
-
setUploading(true);
|
|
134
|
-
try {
|
|
135
|
-
const imageObjects = await Promise.all(
|
|
136
|
-
selectedFiles.map(async (file) => {
|
|
137
|
-
const result = await upload(file);
|
|
138
|
-
remember(result, file.name);
|
|
139
|
-
return fileValueForSubmit(result, file.name);
|
|
140
|
-
}),
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
if (multiple) {
|
|
144
|
-
onChange([...images, ...imageObjects]);
|
|
145
|
-
} else {
|
|
146
|
-
onChange(imageObjects[0]);
|
|
147
|
-
}
|
|
148
|
-
} finally {
|
|
149
|
-
setUploading(false);
|
|
150
|
-
// Reset input so picking the same file again still triggers change.
|
|
151
|
-
if (inputRef.current) inputRef.current.value = '';
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const handleRemove = (index: number) => {
|
|
156
|
-
if (multiple) {
|
|
157
|
-
const newImages = images.filter((_: any, i: number) => i !== index);
|
|
158
|
-
onChange(newImages.length > 0 ? newImages : null);
|
|
159
|
-
} else {
|
|
160
|
-
onChange(null);
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
return (
|
|
165
|
-
<div className={props.className}>
|
|
166
|
-
<input
|
|
167
|
-
ref={inputRef}
|
|
168
|
-
type="file"
|
|
169
|
-
multiple={multiple}
|
|
170
|
-
accept={accept}
|
|
171
|
-
onChange={handleFileChange}
|
|
172
|
-
className="hidden"
|
|
173
|
-
/>
|
|
174
|
-
|
|
175
|
-
<div className="space-y-2">
|
|
176
|
-
{views.length > 0 && (
|
|
177
|
-
<div className="grid grid-cols-4 gap-2">
|
|
178
|
-
{views.map((img, idx) => (
|
|
179
|
-
<div key={idx} className="relative group">
|
|
180
|
-
<img
|
|
181
|
-
src={img.url || ''}
|
|
182
|
-
alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
|
|
183
|
-
className="size-20 rounded-md object-cover border border-gray-200"
|
|
184
|
-
/>
|
|
185
|
-
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
186
|
-
{cropEnabled && (
|
|
187
|
-
<Button
|
|
188
|
-
type="button"
|
|
189
|
-
variant="secondary"
|
|
190
|
-
size="sm"
|
|
191
|
-
onClick={() => openCropper(idx)}
|
|
192
|
-
className="h-6 w-6 p-0"
|
|
193
|
-
aria-label={t('fields.image.crop', { index: idx + 1 })}
|
|
194
|
-
data-testid={`image-field-crop-${idx}`}
|
|
195
|
-
>
|
|
196
|
-
<CropIcon className="size-3" />
|
|
197
|
-
</Button>
|
|
198
|
-
)}
|
|
199
|
-
<Button
|
|
200
|
-
type="button"
|
|
201
|
-
variant="destructive"
|
|
202
|
-
size="sm"
|
|
203
|
-
onClick={() => handleRemove(idx)}
|
|
204
|
-
className="h-6 w-6 p-0"
|
|
205
|
-
aria-label={t('fields.image.remove', { index: idx + 1 })}
|
|
206
|
-
>
|
|
207
|
-
<X className="size-3" />
|
|
208
|
-
</Button>
|
|
209
|
-
</div>
|
|
210
|
-
</div>
|
|
211
|
-
))}
|
|
212
|
-
</div>
|
|
213
|
-
)}
|
|
214
|
-
|
|
215
|
-
<Button
|
|
216
|
-
// DOM pass-through onto the widget's real focusable control — the
|
|
217
|
-
// upload button is the keyboard path to the hidden file input
|
|
218
|
-
// (objectui#3318).
|
|
219
|
-
{...toDomProps(props)}
|
|
220
|
-
type="button"
|
|
221
|
-
variant="outline"
|
|
222
|
-
onClick={() => inputRef.current?.click()}
|
|
223
|
-
className="w-full"
|
|
224
|
-
disabled={uploading}
|
|
225
|
-
data-testid="image-field-upload-button"
|
|
226
|
-
// AFTER the spread so this widget's own computation wins (#3222).
|
|
227
|
-
aria-invalid={!!error}
|
|
228
|
-
>
|
|
229
|
-
{uploading ? (
|
|
230
|
-
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
231
|
-
) : (
|
|
232
|
-
<ImageIcon className="size-4 mr-2" />
|
|
233
|
-
)}
|
|
234
|
-
{uploading
|
|
235
|
-
? t('fields.image.uploading')
|
|
236
|
-
: images.length > 0
|
|
237
|
-
? t('fields.image.addMore')
|
|
238
|
-
: t('fields.image.upload')}
|
|
239
|
-
</Button>
|
|
240
|
-
</div>
|
|
241
|
-
|
|
242
|
-
{cropEnabled && cropTarget && (
|
|
243
|
-
<Suspense fallback={null}>
|
|
244
|
-
<ImageCropperDialog
|
|
245
|
-
open
|
|
246
|
-
onOpenChange={(o) => !o && setCropTarget(null)}
|
|
247
|
-
src={cropTarget.src}
|
|
248
|
-
outputName={cropTarget.name}
|
|
249
|
-
onConfirm={handleCropConfirm}
|
|
250
|
-
/>
|
|
251
|
-
</Suspense>
|
|
252
|
-
)}
|
|
253
|
-
</div>
|
|
254
|
-
);
|
|
255
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
-
import { describe, it, expect } from 'vitest';
|
|
3
|
-
import { I18nProvider } from '@object-ui/i18n';
|
|
4
|
-
import { ImageLightbox, type ImageLightboxProps } from './ImageLightbox';
|
|
5
|
-
|
|
6
|
-
const imgs = [
|
|
7
|
-
{ url: '/api/v1/storage/files/a', name: 'a.png' },
|
|
8
|
-
{ url: '/api/v1/storage/files/b', name: 'b.png' },
|
|
9
|
-
{ url: '/api/v1/storage/files/c', name: 'c.png' },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const renderLightbox = (props: ImageLightboxProps) =>
|
|
13
|
-
render(
|
|
14
|
-
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
|
|
15
|
-
<ImageLightbox {...props} />
|
|
16
|
-
</I18nProvider>,
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
describe('ImageLightbox', () => {
|
|
20
|
-
it('renders the image at the requested index', () => {
|
|
21
|
-
renderLightbox({ images: imgs, index: 1, open: true, onOpenChange: () => {} });
|
|
22
|
-
const img = screen.getByRole('img') as HTMLImageElement;
|
|
23
|
-
expect(img.src).toContain('/api/v1/storage/files/b');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('shows navigation and a position counter for multiple images', () => {
|
|
27
|
-
renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {} });
|
|
28
|
-
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
|
29
|
-
expect(screen.getByLabelText('Next image')).toBeInTheDocument();
|
|
30
|
-
expect(screen.getByLabelText('Previous image')).toBeInTheDocument();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('omits navigation for a single image', () => {
|
|
34
|
-
renderLightbox({ images: [imgs[0]], index: 0, open: true, onOpenChange: () => {} });
|
|
35
|
-
expect(screen.queryByLabelText('Next image')).not.toBeInTheDocument();
|
|
36
|
-
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('advances to the next image and wraps around', () => {
|
|
40
|
-
const seen: number[] = [];
|
|
41
|
-
renderLightbox({ images: imgs, index: 2, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
|
|
42
|
-
fireEvent.click(screen.getByLabelText('Next image'));
|
|
43
|
-
expect(seen).toEqual([0]); // wrapped 2 -> 0
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('steps to the previous image and wraps around', () => {
|
|
47
|
-
const seen: number[] = [];
|
|
48
|
-
renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
|
|
49
|
-
fireEvent.click(screen.getByLabelText('Previous image'));
|
|
50
|
-
expect(seen).toEqual([2]); // wrapped 0 -> 2
|
|
51
|
-
});
|
|
52
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as React from 'react';
|
|
10
|
-
import { Dialog, DialogContent, DialogTitle } from '@object-ui/components';
|
|
11
|
-
import { useObjectTranslation } from '@object-ui/i18n';
|
|
12
|
-
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
13
|
-
|
|
14
|
-
export interface LightboxImage {
|
|
15
|
-
url: string;
|
|
16
|
-
name?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface ImageLightboxProps {
|
|
20
|
-
images: LightboxImage[];
|
|
21
|
-
/** Index of the image shown when the lightbox opens. */
|
|
22
|
-
index: number;
|
|
23
|
-
open: boolean;
|
|
24
|
-
onOpenChange: (open: boolean) => void;
|
|
25
|
-
/** Called when the user navigates to another image (prev/next, arrow keys). */
|
|
26
|
-
onIndexChange?: (index: number) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Full-screen image preview with prev/next navigation for a set of images.
|
|
31
|
-
*
|
|
32
|
-
* Controlled: the caller owns `open` and the current `index`. Built on the
|
|
33
|
-
* shared `Dialog`, so backdrop-click and Escape close it for free; this adds
|
|
34
|
-
* left/right arrow-key navigation and a position counter for the multi-image
|
|
35
|
-
* case. A single image simply omits the arrows and counter.
|
|
36
|
-
*/
|
|
37
|
-
export function ImageLightbox({ images, index, open, onOpenChange, onIndexChange }: ImageLightboxProps) {
|
|
38
|
-
const { t } = useObjectTranslation();
|
|
39
|
-
const count = images.length;
|
|
40
|
-
const current = images[index];
|
|
41
|
-
|
|
42
|
-
const go = React.useCallback(
|
|
43
|
-
(delta: number) => {
|
|
44
|
-
if (count <= 1) return;
|
|
45
|
-
const next = (index + delta + count) % count;
|
|
46
|
-
onIndexChange?.(next);
|
|
47
|
-
},
|
|
48
|
-
[count, index, onIndexChange],
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const handleKeyDown = React.useCallback(
|
|
52
|
-
(e: React.KeyboardEvent) => {
|
|
53
|
-
if (e.key === 'ArrowRight') {
|
|
54
|
-
e.preventDefault();
|
|
55
|
-
go(1);
|
|
56
|
-
} else if (e.key === 'ArrowLeft') {
|
|
57
|
-
e.preventDefault();
|
|
58
|
-
go(-1);
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
[go],
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
if (!current) return null;
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
68
|
-
<DialogContent
|
|
69
|
-
className="max-w-[92vw] sm:max-w-4xl p-0 bg-transparent border-0 shadow-none"
|
|
70
|
-
onKeyDown={handleKeyDown}
|
|
71
|
-
>
|
|
72
|
-
{/* Title is required for a labelled dialog but visually redundant here. */}
|
|
73
|
-
<DialogTitle className="sr-only">{current.name || t('fields.image.preview')}</DialogTitle>
|
|
74
|
-
<div className="relative flex items-center justify-center">
|
|
75
|
-
<img
|
|
76
|
-
src={current.url}
|
|
77
|
-
alt={current.name || t('fields.image.imageAlt', { index: index + 1 })}
|
|
78
|
-
className="max-h-[85vh] max-w-full rounded-md object-contain"
|
|
79
|
-
/>
|
|
80
|
-
|
|
81
|
-
{count > 1 && (
|
|
82
|
-
<>
|
|
83
|
-
<button
|
|
84
|
-
type="button"
|
|
85
|
-
onClick={() => go(-1)}
|
|
86
|
-
aria-label={t('fields.image.previous')}
|
|
87
|
-
className="absolute left-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
88
|
-
>
|
|
89
|
-
<ChevronLeft className="size-5" />
|
|
90
|
-
</button>
|
|
91
|
-
<button
|
|
92
|
-
type="button"
|
|
93
|
-
onClick={() => go(1)}
|
|
94
|
-
aria-label={t('fields.image.next')}
|
|
95
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
96
|
-
>
|
|
97
|
-
<ChevronRight className="size-5" />
|
|
98
|
-
</button>
|
|
99
|
-
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white">
|
|
100
|
-
{t('fields.image.counter', { current: index + 1, total: count })}
|
|
101
|
-
</div>
|
|
102
|
-
</>
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
105
|
-
</DialogContent>
|
|
106
|
-
</Dialog>
|
|
107
|
-
);
|
|
108
|
-
}
|