@object-ui/fields 17.5.0 → 17.7.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 +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- 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 +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- 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 +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- 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 +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- 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 +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- 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 -357
- 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 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- 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 -1295
- 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 -196
- 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 -99
- 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 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- 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 -59
- 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 -166
- 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/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- 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 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- 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,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ImageField — a declared `maxSize` is enforced BEFORE any upload starts
|
|
3
|
-
* (objectui#4141).
|
|
4
|
-
*
|
|
5
|
-
* The defect this pins: `paramToField` copies `maxSize` onto the field config
|
|
6
|
-
* for every param type, so the image widget RECEIVES the constraint — it just
|
|
7
|
-
* never read it. An oversized pick therefore fired the whole
|
|
8
|
-
* `presigned → PUT → complete` chain and rendered a preview, while the sibling
|
|
9
|
-
* file param rejected the same pick with zero uploads.
|
|
10
|
-
*
|
|
11
|
-
* So the assertion that matters is **zero upload attempts**, not "no preview":
|
|
12
|
-
* a widget that uploads the bytes and then hides the thumbnail has still
|
|
13
|
-
* stored an over-limit file. The spy sits on the `UploadProvider` adapter —
|
|
14
|
-
* the layer that performs the presigned request — so a call there IS the
|
|
15
|
-
* network attempt.
|
|
16
|
-
*
|
|
17
|
-
* Two upload entry points exist on this widget and both are covered here:
|
|
18
|
-
* 1. the native picker (`handleFileChange`, single and `multiple`);
|
|
19
|
-
* 2. the crop dialog's confirm (`handleCropConfirm`), which uploads a
|
|
20
|
-
* freshly re-encoded PNG blob — canvas re-encoding can push a crop of an
|
|
21
|
-
* in-limit JPEG *over* the limit, so it is a real second door.
|
|
22
|
-
*/
|
|
23
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
24
|
-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
25
|
-
import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
|
|
26
|
-
import { ImageField } from './ImageField';
|
|
27
|
-
// Imported at module scope (not awaited in a hook) so the widget's
|
|
28
|
-
// `React.lazy(() => import('./ImageCropperDialog'))` resolves from the ESM
|
|
29
|
-
// cache instead of racing RTL's 1000ms `findBy` budget — AGENTS.md §测试纪律.
|
|
30
|
-
import './ImageCropperDialog';
|
|
31
|
-
|
|
32
|
-
const MB = 1024 * 1024;
|
|
33
|
-
|
|
34
|
-
/** A File whose reported `size` is the declared one (constructor bytes are not). */
|
|
35
|
-
function imageOfSize(name: string, bytes: number): File {
|
|
36
|
-
const file = new File(['x'], name, { type: 'image/png' });
|
|
37
|
-
Object.defineProperty(file, 'size', { value: bytes });
|
|
38
|
-
return file;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function blobOfSize(bytes: number): Blob {
|
|
42
|
-
const blob = new Blob(['x'], { type: 'image/png' });
|
|
43
|
-
Object.defineProperty(blob, 'size', { value: bytes });
|
|
44
|
-
return blob;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function renderField(field: Record<string, unknown>, value: unknown = undefined) {
|
|
48
|
-
const upload = vi.fn(async (f: File | Blob) => ({
|
|
49
|
-
url: 'https://cdn.example/uploaded.png',
|
|
50
|
-
name: (f as File).name ?? 'cropped.png',
|
|
51
|
-
size: f.size,
|
|
52
|
-
mimeType: f.type || 'image/png',
|
|
53
|
-
}));
|
|
54
|
-
const adapter: UploadAdapter = { name: 'spy', upload };
|
|
55
|
-
const onChange = vi.fn();
|
|
56
|
-
render(
|
|
57
|
-
<UploadProvider adapter={adapter}>
|
|
58
|
-
<ImageField field={field as any} value={value} onChange={onChange} />
|
|
59
|
-
</UploadProvider>,
|
|
60
|
-
);
|
|
61
|
-
return { upload, onChange, input: document.querySelector('input[type="file"]') as HTMLInputElement };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
describe('ImageField — client-side maxSize guard (#4141)', () => {
|
|
65
|
-
it('never reaches the upload layer for a pick over the declared maxSize', async () => {
|
|
66
|
-
const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
|
|
67
|
-
|
|
68
|
-
fireEvent.change(input, { target: { files: [imageOfSize('huge.png', 6.3 * MB)] } });
|
|
69
|
-
|
|
70
|
-
// The message is the proof the guard ran; the spy is the proof nothing was sent.
|
|
71
|
-
await screen.findByText(/"huge\.png" exceeds max size \(5\.0 MB\)/);
|
|
72
|
-
expect(upload).not.toHaveBeenCalled();
|
|
73
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('still uploads a pick inside the declared maxSize', async () => {
|
|
77
|
-
const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
|
|
78
|
-
|
|
79
|
-
fireEvent.change(input, { target: { files: [imageOfSize('ok.png', 1 * MB)] } });
|
|
80
|
-
|
|
81
|
-
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
82
|
-
expect(upload).toHaveBeenCalledTimes(1);
|
|
83
|
-
expect(screen.queryByText(/exceeds max size/)).toBeNull();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('uploads unrestricted when the param declares no maxSize', async () => {
|
|
87
|
-
const { upload, onChange, input } = renderField({ name: 'p_cover' });
|
|
88
|
-
|
|
89
|
-
fireEvent.change(input, { target: { files: [imageOfSize('enormous.png', 500 * MB)] } });
|
|
90
|
-
|
|
91
|
-
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
92
|
-
expect(upload).toHaveBeenCalledTimes(1);
|
|
93
|
-
expect(screen.queryByText(/exceeds max size/)).toBeNull();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('rejects only the oversized picks of a multi-select and uploads the rest', async () => {
|
|
97
|
-
const { upload, onChange, input } = renderField({ name: 'p_gallery', multiple: true, maxSize: 5 * MB });
|
|
98
|
-
|
|
99
|
-
fireEvent.change(input, {
|
|
100
|
-
target: { files: [imageOfSize('small.png', 1 * MB), imageOfSize('huge.png', 6.3 * MB)] },
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
104
|
-
expect(upload).toHaveBeenCalledTimes(1);
|
|
105
|
-
expect((upload.mock.calls[0][0] as File).name).toBe('small.png');
|
|
106
|
-
expect(screen.getByText(/"huge\.png" exceeds max size \(5\.0 MB\)/)).toBeTruthy();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('guards the crop dialog too — an oversized re-encode is never uploaded', async () => {
|
|
110
|
-
const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
|
|
111
|
-
const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
|
|
112
|
-
|
|
113
|
-
fireEvent.click(screen.getByTestId('image-field-crop-0'));
|
|
114
|
-
const confirm = await screen.findByTestId('fake-crop-confirm');
|
|
115
|
-
fireEvent.click(confirm);
|
|
116
|
-
|
|
117
|
-
await screen.findByText(/exceeds max size \(5\.0 MB\)/);
|
|
118
|
-
expect(upload).not.toHaveBeenCalled();
|
|
119
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('still uploads a crop that fits inside the declared maxSize', async () => {
|
|
123
|
-
const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
|
|
124
|
-
const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
|
|
125
|
-
|
|
126
|
-
fireEvent.click(screen.getByTestId('image-field-crop-0'));
|
|
127
|
-
fireEvent.click(await screen.findByTestId('fake-crop-confirm-small'));
|
|
128
|
-
|
|
129
|
-
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
130
|
-
expect(upload).toHaveBeenCalledTimes(1);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The real cropper is a canvas + `toBlob` pipeline that happy-dom cannot run;
|
|
136
|
-
* this stand-in drives the ONE contract ImageField has with it — `onConfirm`
|
|
137
|
-
* hands back a blob and a name — with a blob size on either side of the limit.
|
|
138
|
-
*/
|
|
139
|
-
vi.mock('./ImageCropperDialog', () => ({
|
|
140
|
-
ImageCropperDialog: ({ onConfirm }: { onConfirm: (blob: Blob, name: string) => void }) => (
|
|
141
|
-
<div>
|
|
142
|
-
<button type="button" data-testid="fake-crop-confirm" onClick={() => onConfirm(blobOfSize(6.3 * MB), 'cover.png')}>
|
|
143
|
-
confirm big
|
|
144
|
-
</button>
|
|
145
|
-
<button
|
|
146
|
-
type="button"
|
|
147
|
-
data-testid="fake-crop-confirm-small"
|
|
148
|
-
onClick={() => onConfirm(blobOfSize(0.5 * MB), 'cover.png')}
|
|
149
|
-
>
|
|
150
|
-
confirm small
|
|
151
|
-
</button>
|
|
152
|
-
</div>
|
|
153
|
-
),
|
|
154
|
-
}));
|