From 13dadf08861b86e8f65a8571908d169a4151b4c0 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 16:26:13 +0200 Subject: [PATCH 01/11] chore(image-cropper-web): replace toolbox icons with designer assets --- .../src/ImageCropper.icon.dark.png | Bin 1223 -> 556 bytes .../src/ImageCropper.icon.png | Bin 1272 -> 581 bytes .../src/ImageCropper.tile.dark.png | Bin 5694 -> 1192 bytes .../src/ImageCropper.tile.png | Bin 5897 -> 1168 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png index 1cae9739f5d08cb2853b6ea73c92e4a2734c67ba..3e915a5ef15b10718f5c7abe9330257e4a360ebe 100755 GIT binary patch delta 515 zcmV+e0{s2Q39JN=Bp(TINLh0L01FcU01FcV0GgZ_00001b5ch_0Itp)>5(B5e-3m- zSaefwW^{L9a%BKUX=iO=p0So=0004}Nkl&WB^tGfz!>8A`$?AahYL^k^Rr`sh(e}d zX*!)g6-6;pO6fR`@B96}hDRb;f5`JZF~*GL&nI$wv)OENc*ZIK-EKE2Jxbvjs{phC z1AxFW03ad?SnXP1r`AugWh5xC$3(1C^7Y?jJ4*nH=a9t;LO?BU^MW&V7jf1p)d+`VPj z8V)tL2NZs8bn0M6|F@#>PH6Wcc@xOR#j(C95S_W>~YK!Z6C!0O^MQz%~1P*&sb82bU|#%U0NV;z9wN+*-a zN!Rlgw6u}rq{;-`<{pqrSsT~=sO>|bwg@}>mY6HskGj&HK}1x+tMdUa)*dW{5jX|_ zfnxyRk>zr^D4ko>u;f($w&xa=Qrr4oOZ$}TA&7`9d;?}>i784JjGF)e002ovPDHLk FV1neM=B@w$ delta 1203 zcmZ3(a-4I5NEakt!T2`nV(Bdd5mT85v-2+GOekxd#B0S6Fhk)ii?5o7^aBRw zl=|i+!Y0xx(k=N99NuT^%13VesuFput*f|XiTX?1yVbfUH~EUR#s?^92#9cSv9LBV z{a?+bEBxs5&p*lAZ_kzIYd;;f`l|WIm7Dnu>~hRz&wg3*>(s*pR)tS<{hoi<6bL`D zZu{-G>V7TT9(_5-@^ikr$GnO!nb-CyFA3Se)bJ;~{^;{5XT14x+Fvi&#^3H-c5{W@ z@h#!2uR2Qg-(7!0Qi0X_bdtaW9sSj;ny+mSn3(0w{~Ncyym#|q2D>RrIV;X@NWbC! z?quGf^^;T;&haT!b5CM+oWd8Ras2etPp!W?ep!9?VmWa0pSA*FQ%zm(>F(2%cTejim-GgzDxBXx) zXS(R}b92s>z;AZU4j-obu)MBmTE0nqiTM`aCuUR9yJ|}=?`GJ2H;=7;(Rvx-SC?Cu zCzdm29B$?aw-Q{@`>Q}ncJbZa3@o$C{#?u`*?9l`a)YO(|5!eiE1ciAa^lIyg5RZ8 zcb*I1+2w0+)J(2FSz^kZdd`nCm=?(Vj5C{hh9P}h*J2;L%&yw=EyuVfILDoM9NOZ> zwLtNGY|Amu1c!U8IpSCqK5#NJUaHs9SMo2P{G@NuG3~uFdpH#u%b8Svrh5n#?wTF2 z%85mv=}E(OA;shW*K;)O30T4<=*1ntBDbK|MdMTW$#7LCO~$SR+#BX_xc+l};u>?; zN1)cHI2|bVoM(=;aMvHpPfGjTSS}vbC>L^IpU3f}XO5ubbN?ene~MSvbX+*qt5YA! zl=F<2ZI+scVbC-6c*`oouJY#3KJE3#R91FRwmF;ff6Ju}3ncsJ$!?K}O>?+y22;Im=x`W$|+$gKDH*TCCz zSq)g@Rhm}r=en`|txAJ#MJP*j{rx3RE3Prs?`RTN+VSi3g4$_o<2!e*F=pJ+v%ce@ z_4av;`xvjt>T?8q7hO?Z^V)Vn-T@Q4zUQ|dujO?MW2iY1@%VS#uTb;nHxH!kIrcU6 zJHv$O``?)KF)+Vq%zQqd^~3Tu&9h8;0hezz{^Y%pZxQo`XTkfLFS`7Uk)}HjdTqI^ vyfaYg(Tm?Y9B1ZpR(BdUXCq~?^mPa1IhvTCT!}Vb2g>Q5u6{1-oD!M<7{=`T diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.png index 8c7b266490c19bbe9fcaf0eeaf807cde455ab36c..5f280f2179037ce3de13b643991d4ba61b274c7b 100755 GIT binary patch delta 540 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 literal 5694 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 diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png index f7f7732cc77300c122b3a642e25e836520486d18..250f934c64ec6db1aa10d609e63ef3fc5ba89678 100755 GIT binary patch literal 1168 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 From 1a120c6b5b4436c740a3803ec5871d60a94df5b9 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:30:27 +0200 Subject: [PATCH 02/11] feat(image-cropper-web): add Studio Pro toolbox category --- .../pluggableWidgets/image-cropper-web/src/ImageCropper.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index 50e45d7495..f35f83d856 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -2,6 +2,8 @@ Image Cropper Crop an image attribute + Images, videos & files + Images, Videos & Files https://docs.mendix.com/appstore/widgets/image-cropper From a1f3fa84b6b2d94e1ecf0da0a54ff916eeac7792 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:33:12 +0200 Subject: [PATCH 03/11] fix(image-cropper-web): guard img src with safeImageUri allowlist --- .../src/components/CropArea.tsx | 7 ++++-- .../src/utils/__tests__/safeImageUri.spec.ts | 24 +++++++++++++++++++ .../src/utils/safeImageUri.ts | 10 ++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/safeImageUri.spec.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 68b5d24a6a..820caef8c2 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -9,6 +9,7 @@ import { } from "react-image-crop"; import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; +import { safeImageUri } from "../utils/safeImageUri"; interface CropAreaProps { src: string; @@ -78,7 +79,9 @@ export function CropArea(props: CropAreaProps): ReactElement { [aspect, onImageLoad, boundaryWidth, boundaryHeight] ); - if (loadError) { + const safeSrc = safeImageUri(props.src); + + if (loadError || !safeSrc) { return (
Could not load this image. If it is a remote image, the server must allow cross-origin access. @@ -107,7 +110,7 @@ export function CropArea(props: CropAreaProps): ReactElement { > { + test.each([ + "http://localhost/img.png", + "https://cdn.example.com/a.jpg?x=1", + "blob:http://localhost/abc-123", + "data:image/png;base64,iVBORw0KGgo=" + ])("passes through allowed scheme: %s", uri => { + expect(safeImageUri(uri)).toBe(uri); + }); + + test.each(["javascript:alert(1)", "data:text/html,", "vbscript:msgbox(1)"])( + "returns undefined for disallowed scheme: %s", + uri => { + expect(safeImageUri(uri)).toBeUndefined(); + } + ); + + test("returns undefined for nullish input", () => { + expect(safeImageUri(undefined)).toBeUndefined(); + expect(safeImageUri("")).toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts new file mode 100644 index 0000000000..5456aaa0c5 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts @@ -0,0 +1,10 @@ +// Allow only the URI schemes the Mendix platform legitimately produces for images. +// Blocks javascript:/vbscript:/data:text style payloads as defense-in-depth. +const ALLOWED = /^(https?:|blob:|data:image\/)/i; + +export function safeImageUri(uri: string | undefined): string | undefined { + if (!uri) { + return undefined; + } + return ALLOWED.test(uri.trim()) ? uri : undefined; +} From cea277e0d76ebc6cd2387a1beee2b09c268c4e27 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:33:45 +0200 Subject: [PATCH 04/11] feat(image-cropper-web): redesign structure and design-mode previews --- .../src/ImageCropper.editorConfig.ts | 26 ++++++++----------- .../src/ImageCropper.editorPreview.tsx | 1 + .../src/ui/ImageCropper.scss | 7 +++++ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts index fa0455b645..5b789d78a0 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -37,26 +37,17 @@ export function getPreview(values: ImageCropperPreviewProps, isDarkMode: boolean type: "Container", borders: true, borderRadius: 4, - backgroundColor: palette.background.containerFill, + backgroundColor: palette.background.container, children: [ { type: "RowLayout", columnSize: "grow", - padding: 12, + backgroundColor: palette.background.topbarStandard, + borders: true, + borderWidth: 1, + padding: 8, children: [ - { - type: "Container", - grow: 0, - padding: 4, - children: [ - { - type: "Image", - document: iconDocument, - width: 28, - height: 22 - } - ] - }, + { type: "Image", document: iconDocument, width: 20, height: 16 }, { type: "Container", grow: 1, @@ -77,6 +68,11 @@ export function getPreview(values: ImageCropperPreviewProps, isDarkMode: boolean ] } ] + }, + { + type: "Container", + padding: 12, + children: [{ type: "Image", property: values.image ?? undefined, width: 220, height: 140 }] } ] }; diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index 1732eadd89..53c7f0fc6b 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -8,6 +8,7 @@ export function preview(props: ImageCropperPreviewProps): ReactElement {

Image Cropper

+

Bind an image attribute to crop

); diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 27d52b9c36..c2ed4bea83 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -86,5 +86,12 @@ $image-cropper-icon: url(../assets/crop-icon.svg); font-size: 11px; color: var(--gray-light, $image-cropper-gray-light); } + + .widget-image-cropper__hint { + margin: 0; + font-size: 10px; + color: var(--gray-light, $image-cropper-gray-light); + opacity: 0.8; + } } } From b2b6f9a47c48725ec9deda622bb89dfa5fc9b0fa Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:34:13 +0200 Subject: [PATCH 05/11] feat(image-cropper-web): add rotation, black & white, and reset controls --- .../image-cropper-web/src/ImageCropper.xml | 14 ++ .../src/__tests__/ImageCropper.spec.tsx | 31 +++- .../__tests__/ImageCropperRotation.spec.tsx | 167 ++++++++++++++++++ .../src/components/CropArea.tsx | 9 +- .../src/components/CropToolbar.tsx | 67 +++++++ .../src/components/ImageCropperContainer.tsx | 66 ++++++- .../src/components/PreviewPane.tsx | 46 +++-- .../components/__tests__/CropArea.spec.tsx | 43 +++++ .../components/__tests__/CropToolbar.spec.tsx | 48 +++++ .../components/__tests__/PreviewPane.spec.tsx | 31 ++++ .../__tests__/useImageCropperState.spec.ts | 21 +++ .../hooks/__tests__/useOriginalImage.spec.ts | 29 +++ .../src/hooks/useImageCropperState.ts | 20 ++- .../src/hooks/useOriginalImage.ts | 52 ++++++ .../src/ui/ImageCropper.scss | 23 ++- .../src/utils/__tests__/cropImage.spec.ts | 69 +++++++- .../src/utils/__tests__/cropMapping.spec.ts | 28 +++ .../image-cropper-web/src/utils/cropImage.ts | 43 +++-- .../src/utils/cropMapping.ts | 12 ++ .../typings/ImageCropperProps.d.ts | 8 +- 20 files changed, 785 insertions(+), 42 deletions(-) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index f35f83d856..e9c0d3728f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -83,6 +83,20 @@ Let the user resize the selection by dragging its corners. + + + Enable rotation + Show rotate-left / rotate-right buttons. Rotation is baked into the saved image. + + + Enable black & white + Show a grayscale toggle. When on, the saved image is converted to black and white. + + + Show reset button + Show a Reset button that restores the original image and clears zoom, rotation, and crop. + + Enable zoom 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 758321d875..32f6d8c313 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { Big } from "big.js"; import { ValueStatus } from "mendix"; import { Ref } from "react"; @@ -97,6 +97,9 @@ function makeProps(overrides: Partial = {}): ImageCr outputFormat: "png", outputQuality: new Big(0.92), outputSize: "original", + enableRotation: true, + enableGrayscale: false, + showResetButton: true, onCropAction: actionValue(), ...overrides }; @@ -114,6 +117,7 @@ async function flushApply(): Promise { describe("", () => { beforeEach(() => { jest.useFakeTimers(); + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; }); afterEach(() => { jest.runOnlyPendingTimers(); @@ -223,4 +227,29 @@ describe("", () => { render(); expect(captured.wheelZoomMode).toBe("off"); }); + + test("reset restores the captured original via setValue", 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(); + }); + (image.setValue as jest.Mock).mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + await flushApply(); + expect((image.setValue as jest.Mock).mock.calls[0]?.[0]).toBeInstanceOf(File); + }); + + test("reset button disabled when original capture failed", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); + }); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx new file mode 100644 index 0000000000..c67b7619b4 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -0,0 +1,167 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { Big } from "big.js"; +import { ValueStatus } from "mendix"; +import { Ref } from "react"; +import type { Crop, PixelCrop } from "react-image-crop"; +import { actionValue } from "@mendix/widget-plugin-test-utils"; +import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; + +// Minimal shape of the cropImage options we assert on (avoids importing from the mocked module). +interface CapturedCropOptions { + rotation: number; + grayscale: boolean; +} + +// Integration test: proves the rotation/grayscale state set via the toolbar buttons +// actually reaches cropImage when a crop is applied — the end-to-end ref plumbing, +// not just "the button fired a callback". We mock cropImage to capture its options. + +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; +} +let captured: CapturedCropArea; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + }) => { + captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete }; + return ( + { + if (node) { + Object.defineProperty(node, "naturalWidth", { value: 400, configurable: true }); + Object.defineProperty(node, "naturalHeight", { value: 300, configurable: true }); + Object.defineProperty(node, "width", { value: 400, configurable: true }); + Object.defineProperty(node, "height", { value: 300, configurable: true }); + } + if (typeof props.imageRef === "function") { + props.imageRef(node); + } else if (props.imageRef) { + (props.imageRef as { current: HTMLImageElement | null }).current = node; + } + }} + /> + ); + } +})); + +const cropImageOptions: CapturedCropOptions[] = []; +jest.mock("../utils/cropImage", () => ({ + CropError: class CropError extends Error {}, + cropImage: jest.fn((options: CapturedCropOptions) => { + cropImageOptions.push(options); + return Promise.resolve(new File(["x"], "crop.png", { type: "image/png" })); + }) +})); + +import { ImageCropper } from "../ImageCropper"; + +type ImageProp = ImageCropperContainerProps["image"]; +type WebImage = NonNullable; + +const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; +const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 }; + +function makeImageProp(): ImageProp { + return { + status: ValueStatus.Available, + value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage, + readOnly: false, + validation: undefined, + setValidator: jest.fn(), + setValue: jest.fn(), + setThumbnailSize: jest.fn() + } as ImageProp; +} + +function makeProps(overrides: Partial = {}): ImageCropperContainerProps { + return { + name: "imageCrop", + class: "", + style: undefined, + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "free", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + showPreview: false, + previewWidth: 100, + previewHeight: 100, + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + onCropAction: actionValue(), + ...overrides + }; +} + +async function flushApply(): Promise { + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe(" rotation/grayscale integration", () => { + beforeEach(() => { + jest.useFakeTimers(); + cropImageOptions.length = 0; + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("rotate-right then crop-complete passes rotation=90 to cropImage", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // Click rotate-right: sets rotation state to 90, which the next render writes into rotationRef. + act(() => { + fireEvent.click(screen.getByLabelText("Rotate right")); + }); + // A user crop release applies immediately, reading the now-current rotationRef. + act(() => { + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(cropImageOptions.length).toBeGreaterThan(0); + expect(cropImageOptions[cropImageOptions.length - 1].rotation).toBe(90); + }); + + test("black & white toggle then crop-complete passes grayscale=true to cropImage", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Black and white")); + }); + act(() => { + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(cropImageOptions[cropImageOptions.length - 1].grayscale).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 820caef8c2..fadda25676 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -11,7 +11,7 @@ import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; import { safeImageUri } from "../utils/safeImageUri"; -interface CropAreaProps { +export interface CropAreaProps { src: string; crop: Crop | undefined; onCropChange: (crop: Crop) => void; @@ -27,6 +27,8 @@ interface CropAreaProps { maxZoom: number; setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; + rotation: number; + grayscale: boolean; imageRef: Ref; } @@ -118,8 +120,9 @@ export function CropArea(props: CropAreaProps): ReactElement { height: displaySize?.height, maxWidth: displaySize ? undefined : props.boundaryWidth, maxHeight: displaySize ? undefined : props.boundaryHeight, - transform: `scale(${props.zoom})`, - transformOrigin: "center" + transform: `scale(${props.zoom}) rotate(${props.rotation}deg)`, + transformOrigin: "center", + filter: props.grayscale ? "grayscale(1)" : undefined }} onLoad={handleImageLoad} onError={() => setLoadError(true)} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx new file mode 100644 index 0000000000..d46ec3cae0 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -0,0 +1,67 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; + +interface CropToolbarProps { + showRotation: boolean; + showGrayscale: boolean; + showReset: boolean; + grayscale: boolean; + canReset: boolean; + onRotateLeft: () => void; + onRotateRight: () => void; + onToggleGrayscale: () => void; + onReset: () => void; +} + +export function CropToolbar(props: CropToolbarProps): ReactElement | null { + if (!props.showRotation && !props.showGrayscale && !props.showReset) { + return null; + } + return ( +
+ {props.showRotation && ( + <> + + + + )} + {props.showGrayscale && ( + + )} + {props.showReset && ( + + )} +
+ ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 462b418129..96850be33b 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -3,23 +3,30 @@ import { ValueStatus } from "mendix"; import { ReactElement, SetStateAction, useCallback, useEffect, useRef } from "react"; import { type Crop, type PixelCrop } from "react-image-crop"; import { CropArea } from "./CropArea"; +import { CropToolbar } from "./CropToolbar"; import { PreviewPane } from "./PreviewPane"; import { ZoomSlider } from "./ZoomSlider"; import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop"; import { useImageCropperState } from "../hooks/useImageCropperState"; +import { useOriginalImage } from "../hooks/useOriginalImage"; import { resolveAspectRatio } from "../utils/aspectRatio"; import { cropImage, CropError } from "../utils/cropImage"; +import { normalizeRotation } from "../utils/cropMapping"; export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { const state = useImageCropperState(Number(props.minZoom)); - const { setZoom, setLiveCrop, setCommittedCrop } = state; + const { setZoom, setLiveCrop, setCommittedCrop, setRotation, setGrayscale } = state; const committedCropRef = useRef(undefined); committedCropRef.current = state.committedCrop; const zoomRef = useRef(state.zoom); zoomRef.current = state.zoom; + const rotationRef = useRef(state.rotation); + rotationRef.current = state.rotation; + const grayscaleRef = useRef(state.grayscale); + grayscaleRef.current = state.grayscale; const applyCrop = useCallback(async () => { const img = state.imageRef.current; @@ -44,6 +51,8 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE cropShape: props.cropShape, viewportWidth: props.boundaryWidth, viewportHeight: props.boundaryHeight, + rotation: rotationRef.current, + grayscale: grayscaleRef.current, originalName: props.image.value.name }); if (props.outputSize === "viewport") { @@ -87,6 +96,10 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE ); const uri = props.image.status === ValueStatus.Available ? props.image.value?.uri : undefined; + const original = useOriginalImage( + uri, + props.image.status === ValueStatus.Available ? props.image.value?.name : undefined + ); useEffect(() => { setLiveCrop(undefined); setCommittedCrop(undefined); @@ -110,6 +123,42 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE [setZoom, auto] ); + const handleRotate = useCallback( + (deltaDeg: number) => { + setRotation(prev => normalizeRotation(prev + deltaDeg)); + auto.applyDebounced(); + }, + [setRotation, auto] + ); + + const handleToggleGrayscale = useCallback(() => { + setGrayscale(prev => !prev); + auto.applyDebounced(); + }, [setGrayscale, auto]); + + const handleReset = useCallback(() => { + setZoom(Number(props.minZoom)); + setRotation(0); + setGrayscale(false); + setLiveCrop(undefined); + setCommittedCrop(undefined); + armed(); // do not auto-apply the reset itself + const file = original.getOriginal(); + if (file && !props.image.readOnly && props.image.status === ValueStatus.Available) { + props.image.setValue(file); + } + }, [ + setZoom, + props.minZoom, + props.image, + setRotation, + setGrayscale, + setLiveCrop, + setCommittedCrop, + armed, + original + ]); + if (props.image.status === ValueStatus.Loading) { return (
{props.zoomEnabled && props.showZoomSlider ? ( @@ -162,6 +213,17 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE onChange={handleZoomChange} /> ) : null} + handleRotate(-90)} + onRotateRight={() => handleRotate(90)} + onToggleGrayscale={handleToggleGrayscale} + onReset={handleReset} + /> {props.showPreview ? ( ) : null}
diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx index c58ed85094..515900756c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx @@ -1,5 +1,6 @@ import { ReactElement, useEffect, useRef } from "react"; import type { PixelCrop } from "react-image-crop"; +import { normalizeRotation } from "../utils/cropMapping"; interface PreviewPaneProps { image: HTMLImageElement | null; @@ -8,9 +9,20 @@ interface PreviewPaneProps { width: number; height: number; circle: boolean; + rotation: number; + grayscale: boolean; } -export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: PreviewPaneProps): ReactElement { +export function PreviewPane({ + image, + pixelCrop, + zoom, + width, + height, + circle, + rotation, + grayscale +}: PreviewPaneProps): ReactElement { const canvasRef = useRef(null); useEffect(() => { @@ -34,30 +46,36 @@ export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: P // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. return; } + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + const rot = normalizeRotation(rotation); + if (grayscale) { + ctx.filter = "grayscale(1)"; + } + ctx.save(); + ctx.translate(width / 2, height / 2); + ctx.rotate((rot * Math.PI) / 180); + const drawW = rot === 90 || rot === 270 ? height : width; + const drawH = rot === 90 || rot === 270 ? width : height; if (circle) { - ctx.save(); ctx.beginPath(); - ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + ctx.ellipse(0, 0, drawW / 2, drawH / 2, 0, 0, Math.PI * 2); ctx.clip(); } - const scaleX = image.naturalWidth / image.width; - const scaleY = image.naturalHeight / image.height; - const z = zoom > 0 ? zoom : 1; ctx.drawImage( image, (pixelCrop.x / z) * scaleX, (pixelCrop.y / z) * scaleY, (pixelCrop.width / z) * scaleX, (pixelCrop.height / z) * scaleY, - 0, - 0, - width, - height + -drawW / 2, + -drawH / 2, + drawW, + drawH ); - if (circle) { - ctx.restore(); - } - }, [image, pixelCrop, zoom, width, height, circle]); + ctx.restore(); + }, [image, pixelCrop, zoom, width, height, circle, rotation, grayscale]); return ; } 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 new file mode 100644 index 0000000000..860f7d817a --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx @@ -0,0 +1,43 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { CropArea, type CropAreaProps } from "../CropArea"; + +function baseProps(overrides: Partial = {}): CropAreaProps { + return { + src: "http://localhost/img.png", + crop: undefined, + onCropChange: jest.fn(), + onCropComplete: jest.fn(), + aspect: undefined, + circular: false, + resizable: true, + boundaryWidth: 300, + boundaryHeight: 300, + onImageLoad: jest.fn(), + zoom: 1, + minZoom: 1, + maxZoom: 4, + setZoom: jest.fn(), + wheelZoomMode: "off" as const, + rotation: 90, + grayscale: true, + imageRef: createRef(), + ...overrides + }; +} + +describe("", () => { + test("applies rotation transform and grayscale filter to the image", () => { + const { container } = render(); + const img = container.querySelector("img")!; + expect(img.style.transform).toContain("rotate(90deg)"); + expect(img.style.transform).toContain("scale(1)"); + expect(img.style.filter).toContain("grayscale(1)"); + }); + + test("no grayscale filter when grayscale is false", () => { + const { container } = render(); + const img = container.querySelector("img")!; + expect(img.style.filter === "" || img.style.filter === "none").toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx new file mode 100644 index 0000000000..7d34226302 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -0,0 +1,48 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { type ComponentProps } from "react"; +import { CropToolbar } from "../CropToolbar"; + +function props(overrides = {}): ComponentProps { + return { + showRotation: true, + showGrayscale: true, + showReset: true, + grayscale: false, + canReset: true, + onRotateLeft: jest.fn(), + onRotateRight: jest.fn(), + onToggleGrayscale: jest.fn(), + onReset: jest.fn(), + ...overrides + }; +} + +describe("", () => { + test("fires rotate and reset callbacks", () => { + const p = props(); + render(); + fireEvent.click(screen.getByLabelText("Rotate left")); + fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + expect(p.onRotateLeft).toHaveBeenCalledTimes(1); + expect(p.onRotateRight).toHaveBeenCalledTimes(1); + expect(p.onReset).toHaveBeenCalledTimes(1); + }); + + test("grayscale toggle reflects aria-pressed", () => { + render(); + expect(screen.getByLabelText("Black and white")).toHaveAttribute("aria-pressed", "true"); + }); + + test("hides controls when their flags are false", () => { + render(); + expect(screen.queryByLabelText("Rotate left")).toBeNull(); + expect(screen.queryByLabelText("Black and white")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reset" })).toBeNull(); + }); + + test("reset button disabled when canReset is false", () => { + render(); + expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx new file mode 100644 index 0000000000..ae63ccc595 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx @@ -0,0 +1,31 @@ +import { render } from "@testing-library/react"; +import type { PixelCrop } from "react-image-crop"; +import { PreviewPane } from "../PreviewPane"; + +function makeImage(): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: 1000, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 800, configurable: true }); + Object.defineProperty(img, "width", { value: 400, configurable: true }); + Object.defineProperty(img, "height", { value: 320, configurable: true }); + return img; +} +const crop: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 80 }; + +describe("", () => { + test("renders without throwing when rotated and grayscale (canvas mock)", () => { + const { container } = render( + + ); + expect(container.querySelector("canvas")).not.toBeNull(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts new file mode 100644 index 0000000000..5bbcd01354 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts @@ -0,0 +1,21 @@ +import { renderHook, act } from "@testing-library/react"; +import { useImageCropperState } from "../useImageCropperState"; + +describe("useImageCropperState", () => { + test("initializes zoom from arg, rotation 0, grayscale false", () => { + const { result } = renderHook(() => useImageCropperState(1)); + expect(result.current.zoom).toBe(1); + expect(result.current.rotation).toBe(0); + expect(result.current.grayscale).toBe(false); + }); + + test("setRotation and setGrayscale update state", () => { + const { result } = renderHook(() => useImageCropperState(1)); + act(() => { + result.current.setRotation(90); + result.current.setGrayscale(true); + }); + expect(result.current.rotation).toBe(90); + expect(result.current.grayscale).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts new file mode 100644 index 0000000000..06bdfef1ff --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts @@ -0,0 +1,29 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { useOriginalImage } from "../useOriginalImage"; + +describe("useOriginalImage", () => { + afterEach(() => jest.restoreAllMocks()); + + test("captures a File from the uri and reports canRestore", async () => { + const blob = new Blob(["x"], { type: "image/png" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) as jest.Mock; + const { result } = renderHook(() => useOriginalImage("http://localhost/img.png", "img.png")); + await waitFor(() => expect(result.current.canRestore).toBe(true)); + const file = result.current.getOriginal(); + expect(file).toBeInstanceOf(File); + expect(file!.name).toBe("img.png"); + }); + + test("canRestore false when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + const { result } = renderHook(() => useOriginalImage("http://x/y.png", "y.png")); + await waitFor(() => expect(result.current.canRestore).toBe(false)); + expect(result.current.getOriginal()).toBeUndefined(); + }); + + test("no fetch when uri is undefined", () => { + global.fetch = jest.fn() as jest.Mock; + renderHook(() => useOriginalImage(undefined, undefined)); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts index 0dff232ae2..ebb79c67b1 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts @@ -10,6 +10,10 @@ interface ImageCropperState { setCommittedCrop: Dispatch>; zoom: number; setZoom: Dispatch>; + rotation: number; // degrees, multiples of 90 + setRotation: Dispatch>; + grayscale: boolean; + setGrayscale: Dispatch>; imageRef: RefObject; } @@ -17,6 +21,20 @@ export function useImageCropperState(initialZoom: number): ImageCropperState { const [liveCrop, setLiveCrop] = useState(undefined); const [committedCrop, setCommittedCrop] = useState(undefined); const [zoom, setZoom] = useState(initialZoom); + const [rotation, setRotation] = useState(0); + const [grayscale, setGrayscale] = useState(false); const imageRef = useRef(null); - return { liveCrop, setLiveCrop, committedCrop, setCommittedCrop, zoom, setZoom, imageRef }; + return { + liveCrop, + setLiveCrop, + committedCrop, + setCommittedCrop, + zoom, + setZoom, + rotation, + setRotation, + grayscale, + setGrayscale, + imageRef + }; } diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts new file mode 100644 index 0000000000..a0e9d67652 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from "react"; + +interface OriginalImage { + getOriginal: () => File | undefined; + canRestore: boolean; +} + +// Capture the original image bytes on first load so Reset can restore them +// after auto-apply has overwritten the bound attribute. Eager fetch is the +// accepted cost for robustness against blob: URL revocation. +export function useOriginalImage(uri: string | undefined, name: string | undefined): OriginalImage { + const fileRef = useRef(undefined); + const [canRestore, setCanRestore] = useState(false); + const capturedUri = useRef(undefined); + + useEffect(() => { + if (!uri || capturedUri.current === uri) { + return; + } + capturedUri.current = uri; + fileRef.current = undefined; + setCanRestore(false); + let cancelled = false; + (async () => { + try { + const res = await fetch(uri); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + const blob = await res.blob(); + if (cancelled) { + return; + } + fileRef.current = new File([blob], name ?? "original", { type: blob.type || "image/png" }); + setCanRestore(true); + } catch { + // fetch failed: degrade to no-restore + if (!cancelled) { + setCanRestore(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [uri, name]); + + return { + getOriginal: () => fileRef.current, + canRestore + }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index c2ed4bea83..16a5ef29f8 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -42,8 +42,27 @@ $image-cropper-icon: url(../assets/crop-icon.svg); background: #fff; } - &__button { - align-self: flex-start; + &__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + } + + &__tool { + min-width: 36px; + padding: 4px 8px; + line-height: 1; + + &.active { + background-color: var(--brand-primary, #264ae5); + color: #fff; + } + } + + &__reset { + margin-left: auto; } &__error, diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts index 2d5a2fb578..3bb43ea8c6 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts @@ -25,7 +25,9 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + rotation: 0, + grayscale: false }) ).rejects.toBeInstanceOf(CropError); }); @@ -41,7 +43,9 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + rotation: 0, + grayscale: false }); expect(file.name.endsWith(".png")).toBe(true); expect(file.type).toBe("image/png"); @@ -58,7 +62,9 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + rotation: 0, + grayscale: false }); expect(file.name.endsWith(".jpg")).toBe(true); expect(file.type).toBe("image/jpeg"); @@ -76,7 +82,9 @@ describe("cropImage", () => { outputSize: "viewport", cropShape: "rect", viewportWidth: 50, - viewportHeight: 40 + viewportHeight: 40, + rotation: 0, + grayscale: false }) ); const ctx = calls[0].ctx as CanvasRenderingContext2D; @@ -96,7 +104,9 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + rotation: 0, + grayscale: false }) ); const [, sx, sy, sw, sh] = calls[0]; @@ -117,7 +127,9 @@ describe("cropImage", () => { outputSize: "original", cropShape: "circle", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + rotation: 0, + grayscale: false }); expect(file).toBeInstanceOf(File); expect(file.name.endsWith(".png")).toBe(true); @@ -140,13 +152,56 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + rotation: 0, + grayscale: false }) ).rejects.toBeInstanceOf(CropError); } finally { HTMLCanvasElement.prototype.toBlob = originalToBlob; } }); + + test("swaps canvas dimensions for 90° rotation (viewport output)", async () => { + const img = makeImg(1000, 800); + const spy = jest.spyOn(document, "createElement"); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "viewport", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 200, + rotation: 90, + grayscale: false + }); + const canvas = spy.mock.results.map(r => r.value).find(el => el?.tagName === "CANVAS") as HTMLCanvasElement; + expect(canvas.width).toBe(200); // swapped from 300x200 + expect(canvas.height).toBe(300); + expect(file).toBeInstanceOf(File); + spy.mockRestore(); + }); + + test("grayscale option produces a File without throwing under canvas mock", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300, + rotation: 0, + grayscale: true + }); + expect(file).toBeInstanceOf(File); + }); }); async function captureDrawImageCalls( diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts new file mode 100644 index 0000000000..d5cf3509ee --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts @@ -0,0 +1,28 @@ +import { normalizeRotation, rotatedCanvasSize } from "../cropMapping"; + +describe("normalizeRotation", () => { + test.each([ + [0, 0], + [90, 90], + [180, 180], + [270, 270], + [360, 0], + [-90, 270], + [450, 90], + [44, 0], + [46, 90] + ])("snaps %i° to %i°", (input, expected) => { + expect(normalizeRotation(input)).toBe(expected); + }); +}); + +describe("rotatedCanvasSize", () => { + test("keeps dimensions for 0/180", () => { + expect(rotatedCanvasSize(100, 60, 0)).toEqual({ width: 100, height: 60 }); + expect(rotatedCanvasSize(100, 60, 180)).toEqual({ width: 100, height: 60 }); + }); + test("swaps dimensions for 90/270", () => { + expect(rotatedCanvasSize(100, 60, 90)).toEqual({ width: 60, height: 100 }); + expect(rotatedCanvasSize(100, 60, 270)).toEqual({ width: 60, height: 100 }); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts index e5aa835cde..ef0884428b 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -1,4 +1,5 @@ import type { PixelCrop } from "react-image-crop"; +import { normalizeRotation, rotatedCanvasSize } from "./cropMapping"; import type { CropShapeEnum, OutputFormatEnum, OutputSizeEnum } from "../../typings/ImageCropperProps"; export class CropError extends Error { @@ -18,6 +19,8 @@ export interface CropImageOptions { cropShape: CropShapeEnum; viewportWidth: number; viewportHeight: number; + rotation: number; // degrees; snapped to 90° multiples + grayscale: boolean; originalName?: string; } @@ -32,6 +35,8 @@ export async function cropImage(options: CropImageOptions): Promise { cropShape, viewportWidth, viewportHeight, + rotation, + grayscale, originalName } = options; @@ -48,12 +53,18 @@ export async function cropImage(options: CropImageOptions): Promise { const sw = (pixelCrop.width / z) * scaleX; const sh = (pixelCrop.height / z) * scaleY; - const destW = outputSize === "viewport" ? viewportWidth : sw; - const destH = outputSize === "viewport" ? viewportHeight : sh; + const rot = normalizeRotation(rotation); + + // Unrotated destination size (existing behavior). + const baseW = outputSize === "viewport" ? viewportWidth : sw; + const baseH = outputSize === "viewport" ? viewportHeight : sh; + + // After rotation the visible canvas swaps for quarter turns. + const dest = rotatedCanvasSize(Math.max(1, Math.round(baseW)), Math.max(1, Math.round(baseH)), rot); const canvas = document.createElement("canvas"); - canvas.width = Math.max(1, Math.round(destW)); - canvas.height = Math.max(1, Math.round(destH)); + canvas.width = dest.width; + canvas.height = dest.height; const ctx = canvas.getContext("2d"); if (!ctx) { @@ -65,20 +76,28 @@ export async function cropImage(options: CropImageOptions): Promise { ctx.fillRect(0, 0, canvas.width, canvas.height); } - if (cropShape === "circle") { - ctx.save(); - ctx.beginPath(); - ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2); - ctx.closePath(); - ctx.clip(); + if (grayscale) { + // Inert under jest-canvas-mock (no throw); applied by evergreen browsers. + ctx.filter = "grayscale(1)"; } - ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + // Rotate around the canvas center, then draw the cropped source rect centered. + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate((rot * Math.PI) / 180); + // When rotated 90/270 the drawn box uses the pre-swap width/height. + const drawW = rot === 90 || rot === 270 ? canvas.height : canvas.width; + const drawH = rot === 90 || rot === 270 ? canvas.width : canvas.height; if (cropShape === "circle") { - ctx.restore(); + ctx.beginPath(); + ctx.ellipse(0, 0, drawW / 2, drawH / 2, 0, 0, Math.PI * 2); + ctx.clip(); } + ctx.drawImage(image, sx, sy, sw, sh, -drawW / 2, -drawH / 2, drawW, drawH); + ctx.restore(); + const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; const ext = outputFormat === "jpeg" ? "jpg" : "png"; const quality = outputFormat === "jpeg" ? Math.min(1, Math.max(0, outputQuality)) : undefined; diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts new file mode 100644 index 0000000000..08185b4c26 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts @@ -0,0 +1,12 @@ +// Rotation is supported only at 90° multiples; arbitrary angles are snapped. +export function normalizeRotation(deg: number): 0 | 90 | 180 | 270 { + const snapped = Math.round(deg / 90) * 90; + const mod = ((snapped % 360) + 360) % 360; + return mod as 0 | 90 | 180 | 270; +} + +// Destination canvas size after a quarter-turn rotation: 90/270 swap w/h. +export function rotatedCanvasSize(width: number, height: number, rotation: number): { width: number; height: number } { + const r = normalizeRotation(rotation); + return r === 90 || r === 270 ? { width: height, height: width } : { width, height }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index e03d303554..9ca354fff7 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -3,9 +3,9 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { CSSProperties } from "react"; import { ActionValue, EditableImageValue, WebImage } from "mendix"; import { Big } from "big.js"; +import { CSSProperties } from "react"; export type CropShapeEnum = "rect" | "circle"; @@ -34,6 +34,9 @@ export interface ImageCropperContainerProps { previewWidth: number; previewHeight: number; resizableEnabled: boolean; + enableRotation: boolean; + enableGrayscale: boolean; + showResetButton: boolean; zoomEnabled: boolean; showZoomSlider: boolean; wheelZoomMode: WheelZoomModeEnum; @@ -67,6 +70,9 @@ export interface ImageCropperPreviewProps { previewWidth: number | null; previewHeight: number | null; resizableEnabled: boolean; + enableRotation: boolean; + enableGrayscale: boolean; + showResetButton: boolean; zoomEnabled: boolean; showZoomSlider: boolean; wheelZoomMode: WheelZoomModeEnum; From ce8d3f9b92a399997fbb44e764ec3fe78c63373d Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:34:21 +0200 Subject: [PATCH 06/11] docs(image-cropper-web): keep changelog at initial release --- packages/pluggableWidgets/image-cropper-web/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index bc5fa0bfdd..28906d2306 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -10,4 +10,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Initial release. Crops a bound `EditableImageValue` attribute with rectangular or circular viewport, optional zoom (slider + wheel), live preview pane, and PNG/JPEG output. Replaces the legacy ImageCrop widget. +- Initial release. From d65ad07ddbbb55da34b8b166f410d06459f7b5bc Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 10:24:03 +0200 Subject: [PATCH 07/11] fix(image-cropper-web): rotate image pixels with live blob preview to fix crop alignment Replace CSS-transform rotation with a pixel re-bake so the crop selection maps to the visible image, and add a blob-URL live preview so the rotation is visible before the deferred Mendix commit (Save). Reset now restores the true first-load original via an internal-change gate in useOriginalImage. --- .../__tests__/ImageCropperRotation.spec.tsx | 67 ++++++++-- .../src/components/CropArea.tsx | 3 +- .../src/components/ImageCropperContainer.tsx | 67 ++++++++-- .../src/components/PreviewPane.tsx | 22 +--- .../components/__tests__/CropArea.spec.tsx | 5 +- .../components/__tests__/PreviewPane.spec.tsx | 3 +- .../hooks/__tests__/useOriginalImage.spec.ts | 32 ++++- .../src/hooks/__tests__/usePreviewSrc.spec.ts | 63 ++++++++++ .../src/hooks/useOriginalImage.ts | 19 ++- .../src/hooks/usePreviewSrc.ts | 49 ++++++++ .../src/utils/__tests__/cropImage.spec.ts | 53 ++++---- .../src/utils/__tests__/rotateImage.spec.ts | 116 ++++++++++++++++++ .../image-cropper-web/src/utils/cropImage.ts | 33 ++--- .../src/utils/rotateImage.ts | 55 +++++++++ 14 files changed, 480 insertions(+), 107 deletions(-) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts 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 c67b7619b4..919fb9e6ef 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -6,15 +6,7 @@ import type { Crop, PixelCrop } from "react-image-crop"; import { actionValue } from "@mendix/widget-plugin-test-utils"; import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; -// Minimal shape of the cropImage options we assert on (avoids importing from the mocked module). -interface CapturedCropOptions { - rotation: number; - grayscale: boolean; -} - -// Integration test: proves the rotation/grayscale state set via the toolbar buttons -// actually reaches cropImage when a crop is applied — the end-to-end ref plumbing, -// not just "the button fired a callback". We mock cropImage to capture its options. +// Integration test: proves the rotate/grayscale actions reach the right util with the right args. interface CapturedCropArea { onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; @@ -50,6 +42,21 @@ jest.mock("../components/CropArea", () => ({ } })); +interface CapturedRotateOptions { + rotation: number; + outputFormat: string; +} +const rotateImageOptions: CapturedRotateOptions[] = []; +jest.mock("../utils/rotateImage", () => ({ + rotateImage: jest.fn((options: CapturedRotateOptions) => { + rotateImageOptions.push(options); + return Promise.resolve(new File(["x"], "rotate.png", { type: "image/png" })); + }) +})); + +interface CapturedCropOptions { + grayscale: boolean; +} const cropImageOptions: CapturedCropOptions[] = []; jest.mock("../utils/cropImage", () => ({ CropError: class CropError extends Error {}, @@ -123,8 +130,12 @@ async function flushApply(): Promise { describe(" rotation/grayscale integration", () => { beforeEach(() => { jest.useFakeTimers(); + rotateImageOptions.length = 0; cropImageOptions.length = 0; global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + // jsdom lacks blob URL APIs used by the live-preview hook. + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test"; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; }); afterEach(() => { jest.runOnlyPendingTimers(); @@ -132,22 +143,52 @@ describe(" rotation/grayscale integration", () => { jest.clearAllMocks(); }); - test("rotate-right then crop-complete passes rotation=90 to cropImage", async () => { + test("rotate-right calls rotateImage with rotation=90 and writes the result via setValue", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions.length).toBeGreaterThan(0); + expect(rotateImageOptions[rotateImageOptions.length - 1].rotation).toBe(90); + expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); + }); + + test("rotate-left calls rotateImage with rotation=-90", async () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); - // Click rotate-right: sets rotation state to 90, which the next render writes into rotationRef. + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate left")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions[rotateImageOptions.length - 1].rotation).toBe(-90); + }); + + test("subsequent crop-complete after rotate calls cropImage without a rotation field", async () => { + render(); act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + await act(async () => { fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); }); - // A user crop release applies immediately, reading the now-current rotationRef. + cropImageOptions.length = 0; act(() => { captured.onCropComplete(PIXEL_CROP); }); await flushApply(); expect(cropImageOptions.length).toBeGreaterThan(0); - expect(cropImageOptions[cropImageOptions.length - 1].rotation).toBe(90); + expect(cropImageOptions[cropImageOptions.length - 1]).not.toHaveProperty("rotation"); }); test("black & white toggle then crop-complete passes grayscale=true to cropImage", async () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index fadda25676..f9008bd9e7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -27,7 +27,6 @@ export interface CropAreaProps { maxZoom: number; setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; - rotation: number; grayscale: boolean; imageRef: Ref; } @@ -120,7 +119,7 @@ export function CropArea(props: CropAreaProps): ReactElement { height: displaySize?.height, maxWidth: displaySize ? undefined : props.boundaryWidth, maxHeight: displaySize ? undefined : props.boundaryHeight, - transform: `scale(${props.zoom}) rotate(${props.rotation}deg)`, + transform: `scale(${props.zoom})`, transformOrigin: "center", filter: props.grayscale ? "grayscale(1)" : undefined }} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 96850be33b..d11ca0c3b8 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -10,9 +10,10 @@ import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop"; import { useImageCropperState } from "../hooks/useImageCropperState"; import { useOriginalImage } from "../hooks/useOriginalImage"; +import { usePreviewSrc } from "../hooks/usePreviewSrc"; import { resolveAspectRatio } from "../utils/aspectRatio"; import { cropImage, CropError } from "../utils/cropImage"; -import { normalizeRotation } from "../utils/cropMapping"; +import { rotateImage } from "../utils/rotateImage"; export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { const state = useImageCropperState(Number(props.minZoom)); @@ -23,8 +24,6 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE committedCropRef.current = state.committedCrop; const zoomRef = useRef(state.zoom); zoomRef.current = state.zoom; - const rotationRef = useRef(state.rotation); - rotationRef.current = state.rotation; const grayscaleRef = useRef(state.grayscale); grayscaleRef.current = state.grayscale; @@ -51,13 +50,13 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE cropShape: props.cropShape, viewportWidth: props.boundaryWidth, viewportHeight: props.boundaryHeight, - rotation: rotationRef.current, grayscale: grayscaleRef.current, originalName: props.image.value.name }); if (props.outputSize === "viewport") { props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); } + markInternalRef.current(); props.image.setValue(file); if (props.onCropAction?.canExecute) { props.onCropAction.execute(); @@ -100,6 +99,17 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE uri, props.image.status === ValueStatus.Available ? props.image.value?.name : undefined ); + + // Ref mirror so applyCrop's stable identity is untouched (same reason zoomRef exists). + const markInternalRef = useRef(original.markInternalChange); + markInternalRef.current = original.markInternalChange; + + // Live preview for baked rotations: setValue defers the commit, so show a local + // blob URL until the bound uri catches up on Save. + const { previewSrc, showPreview } = usePreviewSrc(uri); + const showPreviewRef = useRef(showPreview); + showPreviewRef.current = showPreview; + useEffect(() => { setLiveCrop(undefined); setCommittedCrop(undefined); @@ -124,11 +134,47 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE ); const handleRotate = useCallback( - (deltaDeg: number) => { - setRotation(prev => normalizeRotation(prev + deltaDeg)); - auto.applyDebounced(); + async (deltaDeg: number) => { + const img = state.imageRef.current; + if (!img || props.image.readOnly || props.image.status !== ValueStatus.Available || !props.image.value) { + return; + } + try { + const file = await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: props.outputFormat, + outputQuality: Number(props.outputQuality), + originalName: props.image.value.name + }); + setRotation(0); + setLiveCrop(undefined); + setCommittedCrop(undefined); + committedCropRef.current = undefined; + armed(); + // Show the rotated pixels immediately; CropArea reloads from this blob and + // rebuilds a fresh crop against the swapped dimensions on its onLoad. + showPreviewRef.current(file); + markInternalRef.current(); + props.image.setValue(file); + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + throw err; + } + } }, - [setRotation, auto] + [ + state.imageRef, + props.image, + props.outputFormat, + props.outputQuality, + setRotation, + setLiveCrop, + setCommittedCrop, + armed + ] ); const handleToggleGrayscale = useCallback(() => { @@ -145,6 +191,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE armed(); // do not auto-apply the reset itself const file = original.getOriginal(); if (file && !props.image.readOnly && props.image.status === ValueStatus.Available) { + markInternalRef.current(); props.image.setValue(file); } }, [ @@ -186,7 +233,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE return (
@@ -232,7 +278,6 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE width={props.previewWidth} height={props.previewHeight} circle={props.cropShape === "circle"} - rotation={state.rotation} grayscale={state.grayscale} /> ) : null} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx index 515900756c..4fa65e9e36 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx @@ -1,6 +1,5 @@ import { ReactElement, useEffect, useRef } from "react"; import type { PixelCrop } from "react-image-crop"; -import { normalizeRotation } from "../utils/cropMapping"; interface PreviewPaneProps { image: HTMLImageElement | null; @@ -9,7 +8,6 @@ interface PreviewPaneProps { width: number; height: number; circle: boolean; - rotation: number; grayscale: boolean; } @@ -20,7 +18,6 @@ export function PreviewPane({ width, height, circle, - rotation, grayscale }: PreviewPaneProps): ReactElement { const canvasRef = useRef(null); @@ -49,18 +46,12 @@ export function PreviewPane({ const scaleX = image.naturalWidth / image.width; const scaleY = image.naturalHeight / image.height; const z = zoom > 0 ? zoom : 1; - const rot = normalizeRotation(rotation); if (grayscale) { ctx.filter = "grayscale(1)"; } - ctx.save(); - ctx.translate(width / 2, height / 2); - ctx.rotate((rot * Math.PI) / 180); - const drawW = rot === 90 || rot === 270 ? height : width; - const drawH = rot === 90 || rot === 270 ? width : height; if (circle) { ctx.beginPath(); - ctx.ellipse(0, 0, drawW / 2, drawH / 2, 0, 0, Math.PI * 2); + ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); ctx.clip(); } ctx.drawImage( @@ -69,13 +60,12 @@ export function PreviewPane({ (pixelCrop.y / z) * scaleY, (pixelCrop.width / z) * scaleX, (pixelCrop.height / z) * scaleY, - -drawW / 2, - -drawH / 2, - drawW, - drawH + 0, + 0, + width, + height ); - ctx.restore(); - }, [image, pixelCrop, zoom, width, height, circle, rotation, grayscale]); + }, [image, pixelCrop, zoom, width, height, circle, grayscale]); return ; } 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 860f7d817a..289e246e8b 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 @@ -19,7 +19,6 @@ function baseProps(overrides: Partial = {}): CropAreaProps { maxZoom: 4, setZoom: jest.fn(), wheelZoomMode: "off" as const, - rotation: 90, grayscale: true, imageRef: createRef(), ...overrides @@ -27,11 +26,11 @@ function baseProps(overrides: Partial = {}): CropAreaProps { } describe("", () => { - test("applies rotation transform and grayscale filter to the image", () => { + test("applies zoom scale and grayscale filter to the image (no CSS rotation)", () => { const { container } = render(); const img = container.querySelector("img")!; - expect(img.style.transform).toContain("rotate(90deg)"); expect(img.style.transform).toContain("scale(1)"); + expect(img.style.transform).not.toContain("rotate("); expect(img.style.filter).toContain("grayscale(1)"); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx index ae63ccc595..e396be9b86 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx @@ -13,7 +13,7 @@ function makeImage(): HTMLImageElement { const crop: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 80 }; describe("", () => { - test("renders without throwing when rotated and grayscale (canvas mock)", () => { + test("renders without throwing when grayscale (canvas mock)", () => { const { container } = render( ", () => { width={100} height={100} circle={false} - rotation={90} grayscale /> ); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts index 06bdfef1ff..ddfe47475f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; import { useOriginalImage } from "../useOriginalImage"; describe("useOriginalImage", () => { @@ -26,4 +26,34 @@ describe("useOriginalImage", () => { renderHook(() => useOriginalImage(undefined, undefined)); expect(global.fetch).not.toHaveBeenCalled(); }); + + test("markInternalChange skips recapture on next uri change and preserves original File", async () => { + const blob1 = new Blob(["original"], { type: "image/png" }); + const blob2 = new Blob(["baked"], { type: "image/png" }); + const fetchMock = jest.fn().mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(blob1) }); + global.fetch = fetchMock as jest.Mock; + + const { result, rerender } = renderHook(({ uri }) => useOriginalImage(uri, "img.png"), { + initialProps: { uri: "http://localhost/original.png" } + }); + await waitFor(() => expect(result.current.canRestore).toBe(true)); + const originalFile = result.current.getOriginal(); + expect(originalFile).toBeInstanceOf(File); + + // Simulate an internal bake: mark then change uri + fetchMock.mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(blob2) }); + act(() => { + result.current.markInternalChange(); + }); + rerender({ uri: "http://localhost/baked.png" }); + + // fetch must NOT have been called a second time + await waitFor(() => { + // give the effect a tick to potentially run + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + // original File is preserved + expect(result.current.getOriginal()).toBe(originalFile); + expect(result.current.canRestore).toBe(true); + }); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts new file mode 100644 index 0000000000..3d735d8495 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts @@ -0,0 +1,63 @@ +import { renderHook, act } from "@testing-library/react"; +import { usePreviewSrc } from "../usePreviewSrc"; + +describe("usePreviewSrc", () => { + // jsdom doesn't implement these; define no-op stubs so we can spy on them. + if (!URL.createObjectURL) { + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => ""; + } + if (!URL.revokeObjectURL) { + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + } + const createSpy = jest.spyOn(URL, "createObjectURL"); + const revokeSpy = jest.spyOn(URL, "revokeObjectURL"); + + beforeEach(() => { + let n = 0; + createSpy.mockImplementation(() => `blob:mock-${++n}`); + revokeSpy.mockImplementation(() => undefined); + }); + afterEach(() => jest.clearAllMocks()); + + const file = (): File => new File(["x"], "r.png", { type: "image/png" }); + + test("previewSrc is undefined initially", () => { + const { result } = renderHook(({ uri }) => usePreviewSrc(uri), { initialProps: { uri: "http://x/a.png" } }); + expect(result.current.previewSrc).toBeUndefined(); + }); + + test("showPreview creates a blob URL and exposes it", () => { + const { result } = renderHook(({ uri }) => usePreviewSrc(uri), { initialProps: { uri: "http://x/a.png" } }); + act(() => result.current.showPreview(file())); + expect(result.current.previewSrc).toBe("blob:mock-1"); + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + test("showPreview revokes the prior blob before creating a new one", () => { + const { result } = renderHook(({ uri }) => usePreviewSrc(uri), { initialProps: { uri: "http://x/a.png" } }); + act(() => result.current.showPreview(file())); + act(() => result.current.showPreview(file())); + expect(revokeSpy).toHaveBeenCalledWith("blob:mock-1"); + expect(result.current.previewSrc).toBe("blob:mock-2"); + }); + + test("changing committed uri drops the preview and revokes the blob", () => { + const { result, rerender } = renderHook(({ uri }) => usePreviewSrc(uri), { + initialProps: { uri: "http://x/a.png" } + }); + act(() => result.current.showPreview(file())); + expect(result.current.previewSrc).toBe("blob:mock-1"); + rerender({ uri: "http://x/b.png" }); + expect(revokeSpy).toHaveBeenCalledWith("blob:mock-1"); + expect(result.current.previewSrc).toBeUndefined(); + }); + + test("revokes the blob on unmount", () => { + const { result, unmount } = renderHook(({ uri }) => usePreviewSrc(uri), { + initialProps: { uri: "http://x/a.png" } + }); + act(() => result.current.showPreview(file())); + unmount(); + expect(revokeSpy).toHaveBeenCalledWith("blob:mock-1"); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts index a0e9d67652..7d99926908 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; interface OriginalImage { getOriginal: () => File | undefined; canRestore: boolean; + markInternalChange: () => void; } // Capture the original image bytes on first load so Reset can restore them @@ -12,11 +13,24 @@ export function useOriginalImage(uri: string | undefined, name: string | undefin const fileRef = useRef(undefined); const [canRestore, setCanRestore] = useState(false); const capturedUri = useRef(undefined); + const internalChange = useRef(false); + + // Stable setter: called by the container before every internal setValue so + // the next uri change is skipped without recapturing our own baked output. + const markInternalChange = useCallback(() => { + internalChange.current = true; + }, []); useEffect(() => { if (!uri || capturedUri.current === uri) { return; } + // Our own bake produced this new uri — adopt it, keep the original, skip fetch. + if (internalChange.current) { + capturedUri.current = uri; + internalChange.current = false; + return; + } capturedUri.current = uri; fileRef.current = undefined; setCanRestore(false); @@ -47,6 +61,7 @@ export function useOriginalImage(uri: string | undefined, name: string | undefin return { getOriginal: () => fileRef.current, - canRestore + canRestore, + markInternalChange }; } diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts new file mode 100644 index 0000000000..b0a41fc07b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface PreviewSrc { + // A local blob: URL to display instead of the bound uri, or undefined to use the bound uri. + previewSrc: string | undefined; + // Show a baked File immediately (before the deferred commit changes the bound uri). + showPreview: (file: File) => void; +} + +// Bridges the gap between an in-memory baked File (e.g. a rotation) and the Mendix +// deferred-commit model: setValue stages the file but the bound uri only changes on +// Save. We mint a blob: URL so the edit is visible right away, then drop it once the +// real commit produces a new uri. +export function usePreviewSrc(committedUri: string | undefined): PreviewSrc { + const [previewSrc, setPreviewSrc] = useState(undefined); + const blobRef = useRef(undefined); + const prevUri = useRef(committedUri); + + const revoke = useCallback(() => { + if (blobRef.current) { + URL.revokeObjectURL(blobRef.current); + blobRef.current = undefined; + } + }, []); + + const showPreview = useCallback( + (file: File) => { + revoke(); + const url = URL.createObjectURL(file); + blobRef.current = url; + setPreviewSrc(url); + }, + [revoke] + ); + + // A new committed uri means the bound value caught up (or was replaced externally): + // discard the local preview and fall back to the bound uri. + if (prevUri.current !== committedUri) { + prevUri.current = committedUri; + if (blobRef.current) { + revoke(); + setPreviewSrc(undefined); + } + } + + useEffect(() => revoke, [revoke]); + + return { previewSrc, showPreview }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts index 3bb43ea8c6..e5fc2a9897 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts @@ -26,7 +26,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: false }) ).rejects.toBeInstanceOf(CropError); @@ -44,7 +43,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: false }); expect(file.name.endsWith(".png")).toBe(true); @@ -63,7 +61,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: false }); expect(file.name.endsWith(".jpg")).toBe(true); @@ -83,7 +80,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 50, viewportHeight: 40, - rotation: 0, grayscale: false }) ); @@ -92,6 +88,28 @@ describe("cropImage", () => { expect(ctx.canvas.height).toBe(40); }); + test("drawImage dest starts at top-left (no center-translate)", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300, + grayscale: false + }) + ); + // dest top-left must be (0, 0) — no rotation translate + const [, , , , , dx, dy] = calls[0]; + expect(dx).toBe(0); + expect(dy).toBe(0); + }); + test("divides source rect by zoom factor when zoom > 1", async () => { const img = makeImg(1000, 800, 1000, 800); const calls = await captureDrawImageCalls(() => @@ -105,7 +123,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: false }) ); @@ -128,7 +145,6 @@ describe("cropImage", () => { cropShape: "circle", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: false }); expect(file).toBeInstanceOf(File); @@ -153,7 +169,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: false }) ).rejects.toBeInstanceOf(CropError); @@ -162,29 +177,6 @@ describe("cropImage", () => { } }); - test("swaps canvas dimensions for 90° rotation (viewport output)", async () => { - const img = makeImg(1000, 800); - const spy = jest.spyOn(document, "createElement"); - const file = await cropImage({ - image: img, - pixelCrop: baseCrop, - zoom: 1, - outputFormat: "png", - outputQuality: 1, - outputSize: "viewport", - cropShape: "rect", - viewportWidth: 300, - viewportHeight: 200, - rotation: 90, - grayscale: false - }); - const canvas = spy.mock.results.map(r => r.value).find(el => el?.tagName === "CANVAS") as HTMLCanvasElement; - expect(canvas.width).toBe(200); // swapped from 300x200 - expect(canvas.height).toBe(300); - expect(file).toBeInstanceOf(File); - spy.mockRestore(); - }); - test("grayscale option produces a File without throwing under canvas mock", async () => { const img = makeImg(1000, 800); const file = await cropImage({ @@ -197,7 +189,6 @@ describe("cropImage", () => { cropShape: "rect", viewportWidth: 300, viewportHeight: 300, - rotation: 0, grayscale: true }); expect(file).toBeInstanceOf(File); diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts new file mode 100644 index 0000000000..ca0d00cfb9 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts @@ -0,0 +1,116 @@ +import { CropError } from "../cropImage"; +import { rotateImage, RotateImageOptions } from "../rotateImage"; + +function makeImg(naturalW: number, naturalH: number): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: naturalW }); + Object.defineProperty(img, "naturalHeight", { value: naturalH }); + Object.defineProperty(img, "width", { value: naturalW }); + Object.defineProperty(img, "height", { value: naturalH }); + return img; +} + +const baseOpts: Omit = { + outputFormat: "png", + outputQuality: 1, + originalName: "photo.png" +}; + +describe("rotateImage", () => { + test("rejects with CropError when image has zero natural width", async () => { + const img = makeImg(0, 0); + await expect(rotateImage({ ...baseOpts, image: img, rotation: 90 })).rejects.toBeInstanceOf(CropError); + }); + + test("swaps canvas dimensions for 90° rotation (1000x800 → 800x1000)", async () => { + const img = makeImg(1000, 800); + const spy = jest.spyOn(document, "createElement"); + const file = await rotateImage({ ...baseOpts, image: img, rotation: 90 }); + const canvas = spy.mock.results.map(r => r.value).find(el => el?.tagName === "CANVAS") as HTMLCanvasElement; + expect(canvas.width).toBe(800); + expect(canvas.height).toBe(1000); + expect(file).toBeInstanceOf(File); + spy.mockRestore(); + }); + + test("keeps canvas dimensions for 180° rotation (1000x800 → 1000x800)", async () => { + const img = makeImg(1000, 800); + const spy = jest.spyOn(document, "createElement"); + await rotateImage({ ...baseOpts, image: img, rotation: 180 }); + const canvas = spy.mock.results.map(r => r.value).find(el => el?.tagName === "CANVAS") as HTMLCanvasElement; + expect(canvas.width).toBe(1000); + expect(canvas.height).toBe(800); + spy.mockRestore(); + }); + + test("drawImage receives full source rect centered (1000x800, rotation 90)", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => rotateImage({ ...baseOpts, image: img, rotation: 90 })); + // centered: dest top-left at (-nw/2, -nh/2) = (-500, -400); size = natural (1000, 800) + const [drawImg, sx, sy, sw, sh, dx, dy, dw, dh] = calls[0]; + expect(drawImg).toBe(img); + expect(sx).toBe(0); + expect(sy).toBe(0); + expect(sw).toBe(1000); + expect(sh).toBe(800); + expect(dx).toBe(-500); + expect(dy).toBe(-400); + expect(dw).toBe(1000); + expect(dh).toBe(800); + }); + + test("returns a .png File for png outputFormat", async () => { + const img = makeImg(1000, 800); + const file = await rotateImage({ ...baseOpts, image: img, rotation: 90, outputFormat: "png" }); + expect(file.name.endsWith(".png")).toBe(true); + expect(file.type).toBe("image/png"); + }); + + test("returns a .jpg File for jpeg outputFormat", async () => { + const img = makeImg(1000, 800); + const file = await rotateImage({ + ...baseOpts, + image: img, + rotation: 90, + outputFormat: "jpeg", + outputQuality: 0.8, + originalName: "photo.jpg" + }); + expect(file.name.endsWith(".jpg")).toBe(true); + expect(file.type).toBe("image/jpeg"); + }); + + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { + const img = makeImg(1000, 800); + const originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function (cb: (b: Blob | null) => void) { + cb(null); + }; + try { + await expect(rotateImage({ ...baseOpts, image: img, rotation: 90 })).rejects.toBeInstanceOf(CropError); + } finally { + HTMLCanvasElement.prototype.toBlob = originalToBlob; + } + }); +}); + +async function captureDrawImageCalls( + fn: () => Promise +): Promise> { + const calls: any[] = []; + const proto = CanvasRenderingContext2D.prototype as any; + const original = proto.drawImage; + proto.drawImage = function (this: CanvasRenderingContext2D, ...args: any[]) { + const entry: any = [...args]; + entry.ctx = this; + entry.args = args; + calls.push(entry); + return original?.apply(this, args); + }; + try { + await fn(); + } finally { + proto.drawImage = original; + } + return calls; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts index ef0884428b..c872f658ee 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -1,5 +1,4 @@ import type { PixelCrop } from "react-image-crop"; -import { normalizeRotation, rotatedCanvasSize } from "./cropMapping"; import type { CropShapeEnum, OutputFormatEnum, OutputSizeEnum } from "../../typings/ImageCropperProps"; export class CropError extends Error { @@ -19,7 +18,6 @@ export interface CropImageOptions { cropShape: CropShapeEnum; viewportWidth: number; viewportHeight: number; - rotation: number; // degrees; snapped to 90° multiples grayscale: boolean; originalName?: string; } @@ -35,7 +33,6 @@ export async function cropImage(options: CropImageOptions): Promise { cropShape, viewportWidth, viewportHeight, - rotation, grayscale, originalName } = options; @@ -53,18 +50,12 @@ export async function cropImage(options: CropImageOptions): Promise { const sw = (pixelCrop.width / z) * scaleX; const sh = (pixelCrop.height / z) * scaleY; - const rot = normalizeRotation(rotation); - - // Unrotated destination size (existing behavior). - const baseW = outputSize === "viewport" ? viewportWidth : sw; - const baseH = outputSize === "viewport" ? viewportHeight : sh; - - // After rotation the visible canvas swaps for quarter turns. - const dest = rotatedCanvasSize(Math.max(1, Math.round(baseW)), Math.max(1, Math.round(baseH)), rot); + const destW = Math.max(1, Math.round(outputSize === "viewport" ? viewportWidth : sw)); + const destH = Math.max(1, Math.round(outputSize === "viewport" ? viewportHeight : sh)); const canvas = document.createElement("canvas"); - canvas.width = dest.width; - canvas.height = dest.height; + canvas.width = destW; + canvas.height = destH; const ctx = canvas.getContext("2d"); if (!ctx) { @@ -73,30 +64,20 @@ export async function cropImage(options: CropImageOptions): Promise { if (outputFormat === "jpeg") { ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillRect(0, 0, destW, destH); } if (grayscale) { - // Inert under jest-canvas-mock (no throw); applied by evergreen browsers. ctx.filter = "grayscale(1)"; } - // Rotate around the canvas center, then draw the cropped source rect centered. - ctx.save(); - ctx.translate(canvas.width / 2, canvas.height / 2); - ctx.rotate((rot * Math.PI) / 180); - // When rotated 90/270 the drawn box uses the pre-swap width/height. - const drawW = rot === 90 || rot === 270 ? canvas.height : canvas.width; - const drawH = rot === 90 || rot === 270 ? canvas.width : canvas.height; - if (cropShape === "circle") { ctx.beginPath(); - ctx.ellipse(0, 0, drawW / 2, drawH / 2, 0, 0, Math.PI * 2); + ctx.ellipse(destW / 2, destH / 2, destW / 2, destH / 2, 0, 0, Math.PI * 2); ctx.clip(); } - ctx.drawImage(image, sx, sy, sw, sh, -drawW / 2, -drawH / 2, drawW, drawH); - ctx.restore(); + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, destW, destH); const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; const ext = outputFormat === "jpeg" ? "jpg" : "png"; diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts new file mode 100644 index 0000000000..54dcf889a2 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts @@ -0,0 +1,55 @@ +import { CropError } from "./cropImage"; +import { normalizeRotation, rotatedCanvasSize } from "./cropMapping"; +import type { OutputFormatEnum } from "../../typings/ImageCropperProps"; + +export interface RotateImageOptions { + image: HTMLImageElement; + rotation: number; // delta degrees; snapped to 90° multiples + outputFormat: OutputFormatEnum; + outputQuality: number; + originalName?: string; +} + +export async function rotateImage(options: RotateImageOptions): Promise { + const { image, rotation, outputFormat, outputQuality, originalName } = options; + const nw = image.naturalWidth; + const nh = image.naturalHeight; + if (!nw || !nh) { + throw new CropError("Image not loaded."); + } + const rot = normalizeRotation(rotation); + const dest = rotatedCanvasSize(nw, nh, rot); + + const canvas = document.createElement("canvas"); + canvas.width = dest.width; + canvas.height = dest.height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new CropError("Canvas 2D context unavailable."); + } + if (outputFormat === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate((rot * Math.PI) / 180); + ctx.drawImage(image, 0, 0, nw, nh, -nw / 2, -nh / 2, nw, nh); + ctx.restore(); + + const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; + const ext = outputFormat === "jpeg" ? "jpg" : "png"; + const quality = outputFormat === "jpeg" ? Math.min(1, Math.max(0, outputQuality)) : undefined; + const blob = await new Promise(resolve => { + try { + canvas.toBlob(resolve, mime, quality); + } catch { + resolve(null); + } + }); + if (!blob) { + throw new CropError("Could not export the rotated image (canvas may be tainted)."); + } + const baseName = originalName ? originalName.replace(/\.[^.]+$/, "") : `rotate-${Date.now()}`; + return new File([blob], `${baseName}.${ext}`, { type: mime }); +} From 4b1efa3927d9cb49824fea79a4a869057856dd0f Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 10:24:16 +0200 Subject: [PATCH 08/11] fix(image-cropper-web): use plain ASCII in black and white caption --- .../pluggableWidgets/image-cropper-web/src/ImageCropper.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index e9c0d3728f..eda2c231e7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -89,7 +89,7 @@ Show rotate-left / rotate-right buttons. Rotation is baked into the saved image. - Enable black & white + Enable black and white Show a grayscale toggle. When on, the saved image is converted to black and white. From f75a6507acf15f557401a1231bd1a27b5b30d3cb Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 11:27:25 +0200 Subject: [PATCH 09/11] fix(image-cropper-web): bake grayscale into rotated image and drop unused rotation state --- .../__tests__/ImageCropperRotation.spec.tsx | 17 +++++++++++ .../src/components/ImageCropperContainer.tsx | 28 +++---------------- .../__tests__/useImageCropperState.spec.ts | 7 ++--- .../src/hooks/useImageCropperState.ts | 5 ---- .../src/utils/__tests__/rotateImage.spec.ts | 17 +++++++++++ .../src/utils/rotateImage.ts | 8 +++++- 6 files changed, 47 insertions(+), 35 deletions(-) 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 919fb9e6ef..818b85cf61 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -45,6 +45,7 @@ jest.mock("../components/CropArea", () => ({ interface CapturedRotateOptions { rotation: number; outputFormat: string; + grayscale: boolean; } const rotateImageOptions: CapturedRotateOptions[] = []; jest.mock("../utils/rotateImage", () => ({ @@ -159,6 +160,22 @@ describe(" rotation/grayscale integration", () => { expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); }); + test("black & white on then rotate bakes grayscale into the rotated file", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Black and white")); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions[rotateImageOptions.length - 1].grayscale).toBe(true); + }); + test("rotate-left calls rotateImage with rotation=-90", async () => { render(); act(() => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index d11ca0c3b8..9d78cf83b4 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -18,7 +18,7 @@ import { rotateImage } from "../utils/rotateImage"; export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { const state = useImageCropperState(Number(props.minZoom)); - const { setZoom, setLiveCrop, setCommittedCrop, setRotation, setGrayscale } = state; + const { setZoom, setLiveCrop, setCommittedCrop, setGrayscale } = state; const committedCropRef = useRef(undefined); committedCropRef.current = state.committedCrop; @@ -145,9 +145,9 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE rotation: deltaDeg, outputFormat: props.outputFormat, outputQuality: Number(props.outputQuality), + grayscale: grayscaleRef.current, originalName: props.image.value.name }); - setRotation(0); setLiveCrop(undefined); setCommittedCrop(undefined); committedCropRef.current = undefined; @@ -165,16 +165,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE } } }, - [ - state.imageRef, - props.image, - props.outputFormat, - props.outputQuality, - setRotation, - setLiveCrop, - setCommittedCrop, - armed - ] + [state.imageRef, props.image, props.outputFormat, props.outputQuality, setLiveCrop, setCommittedCrop, armed] ); const handleToggleGrayscale = useCallback(() => { @@ -184,7 +175,6 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE const handleReset = useCallback(() => { setZoom(Number(props.minZoom)); - setRotation(0); setGrayscale(false); setLiveCrop(undefined); setCommittedCrop(undefined); @@ -194,17 +184,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE markInternalRef.current(); props.image.setValue(file); } - }, [ - setZoom, - props.minZoom, - props.image, - setRotation, - setGrayscale, - setLiveCrop, - setCommittedCrop, - armed, - original - ]); + }, [setZoom, props.minZoom, props.image, setGrayscale, setLiveCrop, setCommittedCrop, armed, original]); if (props.image.status === ValueStatus.Loading) { return ( diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts index 5bbcd01354..fd34122a17 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts @@ -2,20 +2,17 @@ import { renderHook, act } from "@testing-library/react"; import { useImageCropperState } from "../useImageCropperState"; describe("useImageCropperState", () => { - test("initializes zoom from arg, rotation 0, grayscale false", () => { + test("initializes zoom from arg, grayscale false", () => { const { result } = renderHook(() => useImageCropperState(1)); expect(result.current.zoom).toBe(1); - expect(result.current.rotation).toBe(0); expect(result.current.grayscale).toBe(false); }); - test("setRotation and setGrayscale update state", () => { + test("setGrayscale updates state", () => { const { result } = renderHook(() => useImageCropperState(1)); act(() => { - result.current.setRotation(90); result.current.setGrayscale(true); }); - expect(result.current.rotation).toBe(90); expect(result.current.grayscale).toBe(true); }); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts index ebb79c67b1..f899e713ec 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts @@ -10,8 +10,6 @@ interface ImageCropperState { setCommittedCrop: Dispatch>; zoom: number; setZoom: Dispatch>; - rotation: number; // degrees, multiples of 90 - setRotation: Dispatch>; grayscale: boolean; setGrayscale: Dispatch>; imageRef: RefObject; @@ -21,7 +19,6 @@ export function useImageCropperState(initialZoom: number): ImageCropperState { const [liveCrop, setLiveCrop] = useState(undefined); const [committedCrop, setCommittedCrop] = useState(undefined); const [zoom, setZoom] = useState(initialZoom); - const [rotation, setRotation] = useState(0); const [grayscale, setGrayscale] = useState(false); const imageRef = useRef(null); return { @@ -31,8 +28,6 @@ export function useImageCropperState(initialZoom: number): ImageCropperState { setCommittedCrop, zoom, setZoom, - rotation, - setRotation, grayscale, setGrayscale, imageRef diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts index ca0d00cfb9..29ef30e455 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts @@ -13,6 +13,7 @@ function makeImg(naturalW: number, naturalH: number): HTMLImageElement { const baseOpts: Omit = { outputFormat: "png", outputQuality: 1, + grayscale: false, originalName: "photo.png" }; @@ -80,6 +81,22 @@ describe("rotateImage", () => { expect(file.type).toBe("image/jpeg"); }); + test("applies grayscale filter to the canvas when grayscale is true", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + rotateImage({ ...baseOpts, image: img, rotation: 90, grayscale: true }) + ); + expect(calls[0].ctx.filter).toBe("grayscale(1)"); + }); + + test("leaves the canvas filter unset when grayscale is false", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + rotateImage({ ...baseOpts, image: img, rotation: 90, grayscale: false }) + ); + expect(calls[0].ctx.filter === "none" || calls[0].ctx.filter === "").toBe(true); + }); + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { const img = makeImg(1000, 800); const originalToBlob = HTMLCanvasElement.prototype.toBlob; diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts index 54dcf889a2..6d12b4fcd4 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts @@ -7,11 +7,12 @@ export interface RotateImageOptions { rotation: number; // delta degrees; snapped to 90° multiples outputFormat: OutputFormatEnum; outputQuality: number; + grayscale: boolean; originalName?: string; } export async function rotateImage(options: RotateImageOptions): Promise { - const { image, rotation, outputFormat, outputQuality, originalName } = options; + const { image, rotation, outputFormat, outputQuality, grayscale, originalName } = options; const nw = image.naturalWidth; const nh = image.naturalHeight; if (!nw || !nh) { @@ -31,6 +32,11 @@ export async function rotateImage(options: RotateImageOptions): Promise { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); } + if (grayscale) { + // Bake B&W here too: rotate replaces the staged file, so without this a + // grayscale-then-rotate-then-Save would persist a color image. + ctx.filter = "grayscale(1)"; + } ctx.save(); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate((rot * Math.PI) / 180); From 43f6d2cd03f7d835cf22bbe88f82885d6c4a25ed Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 19 Jun 2026 09:47:44 +0200 Subject: [PATCH 10/11] feat(image-cropper-web): redesign editor previews for structure and design mode Simplify structure mode to a title row plus a single status/config row ([No attribute selected] vs config summary), dropping the icon and large image render. Rewrite design mode into a three-state preview driven by the bound image: static images render the real CropArea (non-interactive) with a config caption, while dynamic/unbound images show a placeholder glyph with [No image selected yet]. Extract shared describeConfig/aspectLabel into utils, add the cropper placeholder asset, declare the png module for TypeScript, and cover both editor surfaces with new specs. --- .../src/ImageCropper.editorConfig.ts | 65 +++-------- .../src/ImageCropper.editorPreview.tsx | 79 ++++++++++++- .../__tests__/ImageCropper.editor.spec.tsx | 106 ++++++++++++++++++ .../src/assets/cropper-placeholder.png | Bin 0 -> 1678 bytes .../src/ui/ImageCropper.scss | 62 +++++----- .../src/utils/describeConfig.ts | 32 ++++++ .../image-cropper-web/typings/modules.d.ts | 4 + 7 files changed, 260 insertions(+), 88 deletions(-) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png create mode 100644 packages/pluggableWidgets/image-cropper-web/src/utils/describeConfig.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts index 5b789d78a0..0a3d0b1cc7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -4,7 +4,7 @@ import { structurePreviewPalette } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; -import CropIconSvg from "./assets/crop-icon.svg"; +import { describeConfig } from "./utils/describeConfig"; export function getProperties(values: ImageCropperPreviewProps, defaultProperties: Properties): Properties { const propsToHide: Array = []; @@ -31,7 +31,6 @@ export function getProperties(values: ImageCropperPreviewProps, defaultPropertie export function getPreview(values: ImageCropperPreviewProps, isDarkMode: boolean): StructurePreviewProps { const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"]; - const iconDocument = decodeURIComponent(CropIconSvg.replace("data:image/svg+xml,", "")); return { type: "Container", @@ -47,32 +46,25 @@ export function getPreview(values: ImageCropperPreviewProps, isDarkMode: boolean borderWidth: 1, padding: 8, children: [ - { type: "Image", document: iconDocument, width: 20, height: 16 }, { - type: "Container", - grow: 1, - children: [ - { - type: "Text", - content: "Image Cropper", - bold: true, - fontColor: palette.text.primary, - fontSize: 10 - }, - { - type: "Text", - content: describeConfig(values), - fontColor: palette.text.secondary, - fontSize: 8 - } - ] + type: "Text", + content: "Image cropper", + fontColor: palette.text.primary, + fontSize: 10 } ] }, { type: "Container", - padding: 12, - children: [{ type: "Image", property: values.image ?? undefined, width: 220, height: 140 }] + padding: 8, + children: [ + { + type: "Text", + content: values.image ? describeConfig(values) : "[No attribute selected]", + fontColor: palette.text.secondary, + fontSize: 9 + } + ] } ] }; @@ -82,32 +74,3 @@ export function getCustomCaption(values: ImageCropperPreviewProps): string { const shape = values.cropShape === "circle" ? "Circle" : "Rectangle"; return `Image Cropper (${shape})`; } - -function describeConfig(values: ImageCropperPreviewProps): string { - const parts: string[] = []; - parts.push(values.cropShape === "circle" ? "Circle" : "Rectangle"); - parts.push(aspectLabel(values)); - parts.push(`${values.outputFormat.toUpperCase()} · ${values.outputSize === "viewport" ? "Viewport" : "Original"}`); - return parts.join(" · "); -} - -function aspectLabel(values: ImageCropperPreviewProps): string { - switch (values.aspectRatio) { - case "free": - return "Free aspect"; - case "square": - return "1:1"; - case "landscape16x9": - return "16:9"; - case "landscape4x3": - return "4:3"; - case "portrait3x4": - return "3:4"; - case "custom": - return `${values.customAspectWidth}:${values.customAspectHeight}`; - default: { - const _exhaustive: never = values.aspectRatio; - return _exhaustive; - } - } -} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index 53c7f0fc6b..a7eaca0725 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -1,15 +1,82 @@ +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import classNames from "classnames"; -import { ReactElement } from "react"; +import { ReactElement, createRef, useState } from "react"; +import { type Crop } from "react-image-crop"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; +import { CropArea } from "./components/CropArea"; +import { resolveAspectRatio } from "./utils/aspectRatio"; +import { describeConfig } from "./utils/describeConfig"; +import CropperPlaceholderIcon from "./assets/cropper-placeholder.png"; + +declare function require(name: string): string; + +// Defaults used when boundary props are blank in the editor — keep the preview box compact. +const PREVIEW_BOUNDARY_WIDTH = 260; +const PREVIEW_BOUNDARY_HEIGHT = 170; + +// Renders the real CropArea against a static (design-time) image URL with all interaction +// disabled, so design mode shows a faithful, non-clickable crop preview. +function StaticCropPreview(props: { imageUrl: string; values: ImageCropperPreviewProps }): ReactElement { + const { imageUrl, values } = props; + const [crop, setCrop] = useState(undefined); + const imageRef = createRef(); + + const aspect = resolveAspectRatio( + values.aspectRatio, + values.customAspectWidth ?? 0, + values.customAspectHeight ?? 0 + ); + + const handleImageLoad = (percentCrop: Crop): void => { + // Display-only preview: just draw the centered selection CropArea computed for us. + // No zoom/commit/auto-apply machinery — that's runtime-only. + setCrop(percentCrop); + }; + + return ( +
+ undefined} + aspect={aspect} + circular={values.cropShape === "circle"} + resizable={false} + boundaryWidth={values.boundaryWidth ?? PREVIEW_BOUNDARY_WIDTH} + boundaryHeight={values.boundaryHeight ?? PREVIEW_BOUNDARY_HEIGHT} + onImageLoad={handleImageLoad} + zoom={values.minZoom ?? 1} + minZoom={values.minZoom ?? 1} + maxZoom={values.maxZoom ?? 1} + setZoom={() => undefined} + wheelZoomMode="off" + grayscale={false} + imageRef={imageRef} + /> +
+ ); +} export function preview(props: ImageCropperPreviewProps): ReactElement { + const staticImage = props.image?.type === "static" ? props.image : undefined; + return ( -
-
-
-

Image Cropper

-

Bind an image attribute to crop

+
+

Image cropper

+
+ {staticImage ? ( + + ) : ( + + )}
+

+ {staticImage ? describeConfig(props) : "[No image selected yet]"} +

); } 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 new file mode 100644 index 0000000000..b220d1828a --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -0,0 +1,106 @@ +import { render } from "@testing-library/react"; +import { ImageCropperPreviewProps } from "../../typings/ImageCropperProps"; +import { getPreview } from "../ImageCropper.editorConfig"; +import { preview } from "../ImageCropper.editorPreview"; + +function makePreviewProps(overrides: Partial = {}): ImageCropperPreviewProps { + return { + className: "", + class: "", + style: "", + styleObject: undefined, + readOnly: false, + renderMode: "design", + translate: (t: string) => t, + image: null, + cropShape: "rect", + aspectRatio: "free", + customAspectWidth: null, + customAspectHeight: null, + onCropAction: null, + boundaryWidth: null, + boundaryHeight: null, + showPreview: false, + previewWidth: null, + previewHeight: null, + resizableEnabled: true, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: null, + maxZoom: null, + outputFormat: "png", + outputSize: "original", + outputQuality: null, + ...overrides + }; +} + +// Walk the StructurePreviewProps tree and collect every Text node's content. +function collectText(node: any): string[] { + if (!node || typeof node !== "object") { + return []; + } + const here = node.type === "Text" && typeof node.content === "string" ? [node.content] : []; + const kids = Array.isArray(node.children) ? node.children.flatMap(collectText) : []; + return [...here, ...kids]; +} + +describe("ImageCropper structure mode (getPreview)", () => { + test("shows the widget title", () => { + const texts = collectText(getPreview(makePreviewProps(), false)); + expect(texts).toContain("Image cropper"); + }); + + test("shows placeholder body when no image is bound", () => { + const texts = collectText(getPreview(makePreviewProps({ image: null }), false)); + expect(texts).toContain("[No attribute selected]"); + }); + + test("shows config summary in body when an image is bound", () => { + const props = makePreviewProps({ + image: { type: "dynamic", entity: "MyModule.Photo" }, + cropShape: "circle", + aspectRatio: "square", + outputFormat: "jpeg", + outputSize: "viewport" + }); + const texts = collectText(getPreview(props, false)); + expect(texts).toContain("Circle · 1:1 · JPEG · Viewport"); + expect(texts).not.toContain("[No attribute selected]"); + }); +}); + +describe("ImageCropper design mode (preview)", () => { + test("renders the placeholder glyph and empty caption when nothing is bound", () => { + const { container, getByText } = render(preview(makePreviewProps({ image: null }))); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); + expect(getByText("[No image selected yet]")).toBeInTheDocument(); + }); + + test("treats a dynamic image as not previewable (glyph + empty caption)", () => { + const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" } }); + const { container, getByText } = render(preview(props)); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); + expect(getByText("[No image selected yet]")).toBeInTheDocument(); + }); + + test("renders the real image and config caption for a static image", () => { + const props = makePreviewProps({ + image: { type: "static", imageUrl: "http://localhost/photo.png" }, + cropShape: "rect", + aspectRatio: "free", + outputFormat: "png", + outputSize: "original" + }); + const { container, getByText } = render(preview(props)); + const img = container.querySelector("img") as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.getAttribute("src")).toBe("http://localhost/photo.png"); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).toBeNull(); + expect(getByText("Rectangle · Free aspect · PNG · Original")).toBeInTheDocument(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..564ddea0d21d63b23d982c4eb85b39ccf6636df1 GIT binary patch literal 1678 zcmV;9266d`P)KX4o1j7Wn zzQxj6o#%a`DO`N8xuoD;n(x z%S$V>5YY#Ou<*BU6-!`{d#-DxDHW>##r_}ZbF8^qJ>|au!}uzw$Ztp8&sTaK40G3U zYABW!0RpU}>?|&>7TDhl{1nrCg3TT__xM3eeHfCO+`IS4YCXPt_s1Ia2_DUIL6FV^ z(lqx#g?+)F-MaNtLjl6l(u%DC*4PdgOq=1SLV0Pq#O*$>0A@sZIj3O687lxroUsC6 z#2I-7q#+?zVJDaMzA9p4MrpwyK#=&PqB3e>yYfv95|G#evR-zJ=Uk=O@Ca<} z>8;7aI|~w!NCA^q-q^gEg5hBWUN8#@NaO;9YMN(uhsU6oQ`xlfghm@)kk^QAXax}F z5T4BjxWIg7^(|QUVE31 z3ZNuSKlwwl0H;?z-98}gTQC4Z7g%yvY$;fTIeSi9-{R%}$J3gri@ z(@Ls8k>6rPNKPFRaQ4c(`Bop7%Fo%$f3;P9UOqYXP-%6BcNaK)?PP#1yncFW9Sk8d zMAZcw4@CLCjRIo&JXxDdn_s*e5)x5)B=BKV}d7f{FGL?b{K)9=KJbA=cs7EkI9B8$i< zvV|vDRyp?3zmcdk#`E%nmn)Rgmk2WFNe9PDh{*T0e?MJq``s&VccNTK8tLVeZ!bxd zsFmJno`3c9JA@wSYiTUdXb}Y^I3}I0(5?RvWuLqJ)@ahjm;w+E(^SXgz!iGwKRBYC zHyI|I90g9Cnre;;J@y|t(y28t#L%b1O*E+uRz*)rG?{|`bY_E;EV+47#MWc%A?q^c=Zclxnx zuI#S3EcMY;n!=JwDTrQ9LNs;Xb&bCQSJ!)maB&nzjcp$;(fK-MgJEQ`M5OS7rrGY1 zASs(G$3~aU>@S;(yvBLOi+n%B>lC{tT5??#fP_U&V3^Nn*_Mj$`zJ_KOqI3g8*uoZ Date: Fri, 19 Jun 2026 11:53:54 +0200 Subject: [PATCH 11/11] feat(image-cropper-web): replace icon --- .../image-cropper-web/CHANGELOG.md | 2 -- .../src/assets/crop-icon.svg | 8 -------- .../src/assets/cropper-placeholder.png | Bin 1678 -> 5459 bytes 3 files changed, 10 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index 28906d2306..c34e6e44bd 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -6,8 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] -## [1.0.0] - 2026-05-21 - ### Added - Initial release. diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg deleted file mode 100644 index 534cf020b2..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png index 564ddea0d21d63b23d982c4eb85b39ccf6636df1..0afd4095b5e85963c572a5e2de7b048f797217b5 100644 GIT binary patch literal 5459 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 akU)-JnrRiF1QGJg6VTH(*1~AOqW%Looanp& delta 1642 zcmV-w29^2KDvk{{iBL{Q4GJ0x0000DNk~Le0000$0000#2nGNE08kyy$^ZZW32;bR za{vGf6951U69E94oEVWdIe!KTNkl&$I?(^Rd}x!LY6Zb|UH5C#59I4Tn>YE+^9WS%b?O=K zJ_N%Ay1vEIS)J#7qA6T_u)0MZt#SBJY8{n+1_mLG*IC(x==)&>6>Wk+w1ymS+_>xM zZeJ$QHfgZIDAeT&6@U1C1sEV7Q2@6H=s&?Es4hZ`#Q|cKO~k0hAwrG_i*eVxDnJoD zo|`Kg?Fh?DE3**M2ZXTjw{I0oV32#RYo#d_s{qCRAL(9ya&*K}&rYl7E`qyZ6XyJ-&PQ#~Snr z9?fz=kj?|rH1|M-eZimIy7f~-0m9PKimd_G*bWy=o8hNId1<-C?LMynW<+>7r(nbx zD*#5Eu>xSk8F>YyAt6>_CztiUDq>?sX~7^skocscGHPMF@=XmAkk|sUUUrM;T%~~t zFA&f02yE@?t$)eFI|~w!NCA^q-q^gEg5hBWUN8#@NaO;9YMN(uhsU6oQ`xlfghm@) zkk^QAXax}F5TEYUm1>@Ft#j#1J+sM2;`P z02rzZOkR7Jj|!k9O+WcVvH+)7KHWYb?OQMaLKj$aS8OR*ggJXoTi@sOScY?gCaOXb zs**=8A?mReoMRIrUI!b%u8rIDPG8 zfG)g#dTJdEAu>eO1se}U`M!+;V){H=D;>Fjsed&d`l_uhy!}Twr7gh_=vyZ!rXO=j z#{GHdk)+ezkI@$ebeeuAJj~5bUi&)_AN`$wp0gG0$QJs9iY*&)sY=R9a7-F47Ge4w zXsyH*VrO!qL^#YfOtBJ5Z~_{ov7@E95X2X%j{Xd{$nt?A_@ulSP{-gzBS06^@5G98 zg?|_&7EkI9B8$i$FvQTO!%Z})4OT@@N;H{*|8!=9 zlq|VDT3zXDg+dxdau6i1D1A1=}P zI%R`lWU)k~@Pek*Jyt-i{DnJ?WAuUcixbfb;2GYW;q^+= z2XNn#hls4Tl+09J`HoavG#9fxCDctMuN4RYC>j4EtZGbq;8CL>lg1}isuDk=4Y oa``aUAX2;kWxQHHm+8gvKhKxd6(3;k2LJ#707*qoM6N<$f(DTah5!Hn