diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index 35335abcca..4266b4afd6 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added an "Initial crop size (%)" property to control how much of the image the crop box covers when it first appears and after Reset. Previously this was hardcoded to 80%; the default is now 100% (the full image). + ## [1.1.0] - 2026-08-11 ### Added diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index 074763aec1..549166a0c3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -47,6 +47,7 @@ function StaticCropPreview(props: { imageUrl: string; values: ImageCropperPrevie onCropChange={setCrop} onCropComplete={() => undefined} aspect={aspect} + initialCropSize={values.initialCropSize ?? 100} circular={values.cropShape === "circle"} resizable={false} boundaryWidth={values.boundaryWidth ?? PREVIEW_BOUNDARY_WIDTH} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index 96491e96d0..c3aa9d39da 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -44,6 +44,10 @@ Height side of the ratio (e.g. 2 in 3:2). Used when Aspect ratio is Custom. Can be bound to an attribute or expression. + + Initial crop size (%) + How much of the image the crop box covers when it first appears and after Reset, as a percentage of the image (1-100). 100 selects the full image; a lower value centers a smaller box that the user can resize afterwards. + diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 2254399bac..a2d65eb96c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -17,6 +17,7 @@ function makePreviewProps(overrides: Partial = {}): Im aspectRatio: "free", customAspectWidth: "1", customAspectHeight: "1", + initialCropSize: null, onCropAction: null, boundaryWidth: null, boundaryHeight: null, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx index 381897fed0..5c1e89dc7e 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -91,6 +91,7 @@ function makeProps(overrides: Partial = {}): ImageCr customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, + initialCropSize: 100, resizableEnabled: true, zoomEnabled: true, showZoomSlider: true, @@ -267,12 +268,33 @@ describe("", () => { }); fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await flushApply(); - // Box is re-seeded (not undefined) to the default 80%-centered percent crop. + // Box is re-seeded (not undefined) to the default 100%-centered percent crop. expect(captured.crop).toBeDefined(); expect(captured.crop!.unit).toBe("%"); - expect(captured.crop!.width).toBeCloseTo(80, 5); - // centered horizontally: x = (100 - 80) / 2 = 10 - expect(captured.crop!.x).toBeCloseTo(10, 5); + expect(captured.crop!.width).toBeCloseTo(100, 5); + // centered horizontally: x = (100 - 100) / 2 = 0 + expect(captured.crop!.x).toBeCloseTo(0, 5); + }); + + test("reset re-seeds the cropbox at a configured initial crop size", async () => { + const blob = new Blob(["x"], { type: "image/png" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) as jest.Mock; + const image = makeImageProp(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); + await flushApply(); + expect(captured.crop).toBeDefined(); + expect(captured.crop!.unit).toBe("%"); + expect(captured.crop!.width).toBeCloseTo(50, 5); + // centered horizontally: x = (100 - 50) / 2 = 25 + expect(captured.crop!.x).toBeCloseTo(25, 5); }); test("reset button disabled when original capture failed", async () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx index a80237e6be..5459c72463 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -100,6 +100,7 @@ function makeProps(overrides: Partial = {}): ImageCr customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, + initialCropSize: 100, resizableEnabled: true, enableRotation: true, enableGrayscale: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx index f42d4b068a..f7a5abf1e7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -92,6 +92,7 @@ function makeProps(overrides: Partial = {}): ImageCr customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, + initialCropSize: 100, resizableEnabled: true, enableRotation: true, enableGrayscale: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx index 9094eeb222..537ffc8e00 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -109,6 +109,7 @@ function makeProps(overrides: Partial = {}): ImageCr customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, + initialCropSize: 100, resizableEnabled: true, enableRotation: true, enableGrayscale: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 4c5a9e27f3..e38f29b20c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -14,6 +14,7 @@ export interface CropAreaProps { onCropComplete: (pixelCrop: PixelCrop) => void; onUserInteractStart?: () => void; aspect: number | undefined; + initialCropSize: number; circular: boolean; resizable: boolean; boundaryWidth: number; @@ -49,7 +50,7 @@ export function CropArea(props: CropAreaProps): ReactElement { const [loadError, setLoadError] = useState(false); const [displaySize, setDisplaySize] = useState<{ width: number; height: number } | null>(null); - const { aspect, onImageLoad, boundaryWidth, boundaryHeight, src } = props; + const { aspect, initialCropSize, onImageLoad, boundaryWidth, boundaryHeight, src } = props; const [prevSrc, setPrevSrc] = useState(src); if (prevSrc !== src) { @@ -61,10 +62,10 @@ export function CropArea(props: CropAreaProps): ReactElement { (e: SyntheticEvent) => { const img = e.currentTarget; setDisplaySize(fitToBoundary(img.naturalWidth, img.naturalHeight, boundaryWidth, boundaryHeight)); - const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect); + const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect, initialCropSize); onImageLoad(percentCrop, pixelCrop); }, - [aspect, onImageLoad, boundaryWidth, boundaryHeight] + [aspect, initialCropSize, onImageLoad, boundaryWidth, boundaryHeight] ); const { onCropChange, onCropComplete } = props; diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index e08de6040d..c3634b3a29 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -85,6 +85,7 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( onCropComplete={pixelCrop => store.commitCrop(pixelCrop)} onUserInteractStart={() => store.markUserDragged()} aspect={store.cropAspect} + initialCropSize={props.initialCropSize} circular={props.cropShape === "circle"} resizable={props.resizableEnabled} boundaryWidth={props.boundaryWidth} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx index b13d84f744..7377af9541 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx @@ -9,6 +9,7 @@ function baseProps(overrides: Partial = {}): CropAreaProps { onCropChange: jest.fn(), onCropComplete: jest.fn(), aspect: undefined, + initialCropSize: 100, circular: false, resizable: true, boundaryWidth: 300, diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index 1915f2be70..23def11b01 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -416,7 +416,7 @@ export class ImageCropperStore implements SetupComponent { // what puts the box back. const img = this.deps.getImage(); if (img && img.naturalWidth) { - const { percentCrop, pixelCrop } = buildInitialCrop(img, this.cropAspect); + const { percentCrop, pixelCrop } = buildInitialCrop(img, this.cropAspect, this.props.initialCropSize); this.liveCrop = percentCrop; this.committedCrop = pixelCrop; } else { @@ -458,7 +458,7 @@ export class ImageCropperStore implements SetupComponent { // No on-screen image yet; CropArea's onLoad will seed with the now-ready ratio. return; } - const { percentCrop, pixelCrop } = buildInitialCrop(img, this.cropAspect); + const { percentCrop, pixelCrop } = buildInitialCrop(img, this.cropAspect, this.props.initialCropSize); this.liveCrop = percentCrop; this.committedCrop = pixelCrop; this.armed(); // programmatic re-seed must not auto-commit diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts index 1b3d34638d..6b22098b41 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -58,6 +58,7 @@ function makeProps(overrides: Partial = {}): ImageCr customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, + initialCropSize: 100, resizableEnabled: true, zoomEnabled: true, showZoomSlider: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/initialCrop.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/initialCrop.ts index 1843efb77f..c5fff39a86 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/initialCrop.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/initialCrop.ts @@ -1,15 +1,20 @@ import { centerCrop, convertToPixelCrop, makeAspectCrop, type Crop, type PixelCrop } from "react-image-crop"; -// Default selection covers 80% of the image, centered, at the resolved aspect ratio. -// Shared by CropArea's onLoad (initial box) and Reset (re-seed the box to its initial state). +// Default selection covers `sizePercent`% of the image (widget property, default 100), centered, +// at the resolved aspect ratio. Shared by CropArea's onLoad (initial box) and Reset (re-seed the +// box to its initial state). export function buildInitialCrop( img: HTMLImageElement, - aspect: number | undefined + aspect: number | undefined, + sizePercent: number ): { percentCrop: Crop; pixelCrop: PixelCrop } { const { naturalWidth, naturalHeight, width, height } = img; const safeAspect = aspect ?? naturalWidth / naturalHeight; + // Guard against an out-of-range or non-finite configured value (e.g. 0, a negative number, + // or NaN from a still-loading expression) rather than feeding it straight into the crop math. + const safeSizePercent = Number.isFinite(sizePercent) ? Math.min(100, Math.max(1, sizePercent)) : 100; const percentCrop = centerCrop( - makeAspectCrop({ unit: "%", width: 80 }, safeAspect, naturalWidth, naturalHeight), + makeAspectCrop({ unit: "%", width: safeSizePercent }, safeAspect, naturalWidth, naturalHeight), naturalWidth, naturalHeight ); diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 52e40b17be..8955ff43c3 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -27,6 +27,7 @@ export interface ImageCropperContainerProps { aspectRatio: AspectRatioEnum; customAspectWidth: DynamicValue; customAspectHeight: DynamicValue; + initialCropSize: number; onCropAction?: ActionValue; boundaryWidth: number; boundaryHeight: number; @@ -69,6 +70,7 @@ export interface ImageCropperPreviewProps { aspectRatio: AspectRatioEnum; customAspectWidth: string; customAspectHeight: string; + initialCropSize: number | null; onCropAction: {} | null; boundaryWidth: number | null; boundaryHeight: number | null;