From 5dc5e56446d97f0b8eadf6ec4731d69ebe063eaf Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 16:26:13 +0200 Subject: [PATCH 01/67] 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 2b59ec70dc57e2bb658bb3ba5bf9ce7314f5b137 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:30:27 +0200 Subject: [PATCH 02/67] 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 e6f68e137f373ffb42191c5a13ebf6b24d3d116c Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:33:12 +0200 Subject: [PATCH 03/67] 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 17d307dbafd229f3ee56cea2eebf1d03866284ea Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:33:45 +0200 Subject: [PATCH 04/67] 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 b0d38fddfc9e6c0aaff502e6d9d550f0d1d89a08 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:34:13 +0200 Subject: [PATCH 05/67] 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 a511eed396d449694b6a8d23b5f9b2b3fae19966 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:34:21 +0200 Subject: [PATCH 06/67] 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 1b2805fd9dab8ad488ed19c846e3225ca419e2ba Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 10:24:03 +0200 Subject: [PATCH 07/67] 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 32063fb2a84edec730966c95ab7429c452d94926 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 10:24:16 +0200 Subject: [PATCH 08/67] 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 73e5e3810bd335216dff216b0a86244466941d4c Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 11:27:25 +0200 Subject: [PATCH 09/67] 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 6173565b8160930857864f817259ddc75a1cb89c Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 19 Jun 2026 09:47:44 +0200 Subject: [PATCH 10/67] 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/67] 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 From fc6ec0cd9a820e352ee0927563b642e6a7f916a1 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 24 Jun 2026 17:15:47 +0200 Subject: [PATCH 12/67] fix(image-cropper-web): swap placeholder to SVG and fix editor preview captions --- .../src/ImageCropper.editorPreview.tsx | 14 +++++++++----- .../src/__tests__/ImageCropper.editor.spec.tsx | 7 ++++--- .../src/assets/cropper-placeholder.png | Bin 5459 -> 0 bytes .../src/assets/cropper-placeholder.svg | 5 +++++ .../src/components/CropArea.tsx | 1 - 5 files changed, 18 insertions(+), 9 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index a7eaca0725..3741fe40c2 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -1,12 +1,12 @@ -import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import classNames from "classnames"; import { ReactElement, createRef, useState } from "react"; import { type Crop } from "react-image-crop"; +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; +import CropperPlaceholderIcon from "./assets/cropper-placeholder.svg"; 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; @@ -59,7 +59,13 @@ function StaticCropPreview(props: { imageUrl: string; values: ImageCropperPrevie } export function preview(props: ImageCropperPreviewProps): ReactElement { + // Narrow on the object (not a derived boolean) so TS keeps .imageUrl / .entity typed. const staticImage = props.image?.type === "static" ? props.image : undefined; + const dynamicEntity = props.image?.type === "dynamic" ? props.image.entity : undefined; + + // Dynamic bindings carry only the entity name — no design-time pixels — so they still show the + // placeholder, but the caption must reflect that an attribute IS bound (not "nothing selected"). + const caption = staticImage ? describeConfig(props) : dynamicEntity || "[No image selected yet]"; return (
)}
-

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

+

{caption}

); } diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index b220d1828a..78cc9f802e 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -81,11 +81,12 @@ describe("ImageCropper design mode (preview)", () => { expect(getByText("[No image selected yet]")).toBeInTheDocument(); }); - test("treats a dynamic image as not previewable (glyph + empty caption)", () => { + test("shows the bound entity for a dynamic image (placeholder glyph, not previewable)", () => { const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" } }); - const { container, getByText } = render(preview(props)); + const { container, getByText, queryByText } = render(preview(props)); expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); - expect(getByText("[No image selected yet]")).toBeInTheDocument(); + expect(getByText("MyModule.Photo")).toBeInTheDocument(); + expect(queryByText("[No image selected yet]")).toBeNull(); }); test("renders the real image and config caption for a static image", () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png deleted file mode 100644 index 0afd4095b5e85963c572a5e2de7b048f797217b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5459 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 akU)-JnrRiF1QGJg6VTH(*1~AOqW%Looanp& diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg new file mode 100644 index 0000000000..51a1a51c51 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index f9008bd9e7..fc769e76c7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -113,7 +113,6 @@ export function CropArea(props: CropAreaProps): ReactElement { ref={props.imageRef} src={safeSrc} alt="" - crossOrigin="anonymous" style={{ width: displaySize?.width, height: displaySize?.height, From 94f90e82f5bfae1ccc6b8d927b5ed23fc9e10185 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 25 Jun 2026 10:10:30 +0200 Subject: [PATCH 13/67] feat(image-cropper-web): flip/grayscale toolbar polish and crop-commit fixes Terminology: - rename user-facing "Rotate" to "Flip" (toolbar, tooltips, enableFlip property) - rename "Black and white"/"B&W" toggle to "Grayscale" Toolbar: - single-row layout: flip-left, flip-right, grayscale, zoom slider, reset - flip buttons use SVG icons; square, icon-only styling - always-on native tooltips on every toolbar button Layout: - size widget to its image and render block-level so sibling widgets stack below - editor design-mode preview fills available width - remove redundant "Image cropper" label above the design-mode preview Behavior: - keep a color working image on flip so grayscale stays reversible; bake grayscale only into the committed file when the toggle is on - gate crop auto-commit on a genuine user drag so editing one cropper does not commit sibling instances in a list Tests: rename specs for flip/grayscale; add grayscale-reversibility and multi-instance commit regression specs. --- .../image-cropper-web/jest.config.js | 16 +- .../src/ImageCropper.editorPreview.tsx | 1 - .../image-cropper-web/src/ImageCropper.xml | 12 +- .../__tests__/ImageCropper.editor.spec.tsx | 2 +- .../src/__tests__/ImageCropper.spec.tsx | 8 +- .../__tests__/ImageCropperGrayscale.spec.tsx | 193 +++++++++++++++++ .../ImageCropperMultiInstance.spec.tsx | 197 ++++++++++++++++++ .../__tests__/ImageCropperRotation.spec.tsx | 32 ++- .../src/assets/flip-left.svg | 3 + .../src/assets/flip-right.svg | 3 + .../src/components/CropArea.tsx | 2 + .../src/components/CropToolbar.tsx | 50 +++-- .../src/components/ImageCropperContainer.tsx | 57 +++-- .../components/__tests__/CropToolbar.spec.tsx | 42 ++-- .../src/ui/ImageCropper.scss | 47 ++++- .../src/utils/rotateImage.ts | 5 +- .../typings/ImageCropperProps.d.ts | 4 +- 17 files changed, 593 insertions(+), 81 deletions(-) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg diff --git a/packages/pluggableWidgets/image-cropper-web/jest.config.js b/packages/pluggableWidgets/image-cropper-web/jest.config.js index 8ee98da701..1340950f89 100644 --- a/packages/pluggableWidgets/image-cropper-web/jest.config.js +++ b/packages/pluggableWidgets/image-cropper-web/jest.config.js @@ -1,6 +1,20 @@ const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); +const { join } = require("path"); + +// Override the SVG transform: the base config returns a React component for *.svg imports, +// but we import SVGs as URL strings (declare module "*.svg" { const content: string }). +// Using assetsTransformer returns the filename as a plain string, matching the runtime behaviour +// and avoiding the React "Invalid value for prop `src`" warning in tests. +const assetsTransformer = join( + require.resolve("@mendix/pluggable-widgets-tools/test-config/jest.config.js"), + "../assetsTransformer.js" +); module.exports = { ...base, - setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")] + transform: { + ...base.transform, + "^.+\\.svg$": assetsTransformer + }, + setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), join(__dirname, "jest.setup.ts")] }; diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index 3741fe40c2..5f3075bfd3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -72,7 +72,6 @@ export function preview(props: ImageCropperPreviewProps): ReactElement { className={classNames(props.class, "widget-image-cropper", "widget-image-cropper--preview")} style={parseStyle(props.style)} > -

Image cropper

{staticImage ? ( diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index eda2c231e7..ae02851601 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -84,17 +84,17 @@ - - Enable rotation - Show rotate-left / rotate-right buttons. Rotation is baked into the saved image. + + Enable flip + Show flip-left / flip-right buttons. The flip is baked into the saved image. - Enable black and white - Show a grayscale toggle. When on, the saved image is converted to black and white. + Enable grayscale + Show a grayscale toggle. When on, the saved image is converted to grayscale (black and white). Show reset button - Show a Reset button that restores the original image and clears zoom, rotation, and crop. + Show a Reset button that restores the original image and clears zoom, flip, and crop. diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 78cc9f802e..e5b903f174 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -24,7 +24,7 @@ function makePreviewProps(overrides: Partial = {}): Im previewWidth: null, previewHeight: null, resizableEnabled: true, - enableRotation: true, + enableFlip: true, enableGrayscale: true, showResetButton: true, zoomEnabled: true, 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 32f6d8c313..80eeb9585a 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -11,6 +11,7 @@ import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps interface CapturedCropArea { onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; setZoom: (next: number) => void; wheelZoomMode: string; } @@ -21,12 +22,14 @@ jest.mock("../components/CropArea", () => ({ imageRef: Ref; onImageLoad: CapturedCropArea["onImageLoad"]; onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; setZoom: CapturedCropArea["setZoom"]; wheelZoomMode: string; }) => { captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart, setZoom: props.setZoom, wheelZoomMode: props.wheelZoomMode }; @@ -97,7 +100,7 @@ function makeProps(overrides: Partial = {}): ImageCr outputFormat: "png", outputQuality: new Big(0.92), outputSize: "original", - enableRotation: true, + enableFlip: true, enableGrayscale: false, showResetButton: true, onCropAction: actionValue(), @@ -152,6 +155,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -164,6 +168,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -189,6 +194,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx new file mode 100644 index 0000000000..5b889f151d --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -0,0 +1,193 @@ +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"; + +// Integration test: proves grayscale reversibility after flip. + +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; + } + }} + /> + ); + } +})); + +interface CapturedRotateOptions { + rotation: number; + outputFormat: string; + grayscale: boolean; +} +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 {}, + 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, + enableFlip: 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 + }; +} + +describe(" grayscale reversibility after flip", () => { + 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(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("flip with grayscale ON produces a COLOR working image (grayscale:false) so toggling off is reversible", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); // ON + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Flip right")); + await Promise.resolve(); + await Promise.resolve(); + }); + // The WORKING image (the one shown + reloaded into imageRef) must be COLOR. + // With approach B, handleFlip calls rotateImage twice: once color (working), + // once baked (commit). The color call is the one whose result feeds the preview. + const colorWorkingCall = rotateImageOptions.some(o => o.grayscale === false); + expect(colorWorkingCall).toBe(true); + }); + + test("flip with grayscale ON still bakes a B&W file for the committed setValue", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); // ON + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Flip right")); + await Promise.resolve(); + await Promise.resolve(); + }); + // committed file must be the baked one + expect(rotateImageOptions.some(o => o.grayscale === true)).toBe(true); + expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); + }); + + test("flip with grayscale OFF produces only a color file (no baked B&W)", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Flip right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions.every(o => o.grayscale === false)).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx new file mode 100644 index 0000000000..1ea48bef65 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -0,0 +1,197 @@ +import { act, render } 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"; + +// Multi-instance integration test: verifies that auto-commit only fires for the +// cropper instance where the user actually dragged, not for layout-driven completes +// or sibling instances. + +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; +} +const captures: CapturedCropArea[] = []; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; + }) => { + captures.push({ + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart + }); + 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; + } + }} + /> + ); + } +})); + +jest.mock("../utils/cropImage", () => ({ + CropError: class CropError extends Error {}, + cropImage: jest.fn(() => Promise.resolve(new File(["x"], "crop.png", { type: "image/png" }))) +})); + +jest.mock("../utils/rotateImage", () => ({ + rotateImage: jest.fn(() => Promise.resolve(new File(["x"], "rotate.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, + enableFlip: 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(" multi-instance crop gating", () => { + beforeEach(() => { + jest.useFakeTimers(); + captures.length = 0; + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test"; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("layout-driven onCropComplete without a user drag does not commit", async () => { + const image = makeImageProp(); + render(); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // layout-driven complete, NO preceding onUserInteractStart + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).not.toHaveBeenCalled(); + }); + + test("a genuine user drag then onCropComplete DOES commit", async () => { + const image = makeImageProp(); + render(); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + captures[0].onUserInteractStart?.(); + }); // user grabs the crop + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).toHaveBeenCalled(); + }); + + test("editing one cropper does not commit its sibling", async () => { + const imageA = makeImageProp(); + const imageB = makeImageProp(); + render( + <> + + + + ); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + captures[1].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // only A receives a genuine user drag + act(() => { + captures[0].onUserInteractStart?.(); + }); + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + // B receives a layout-driven complete with NO drag signal — must NOT commit + act(() => { + captures[1].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(imageA.setValue).toHaveBeenCalled(); + expect(imageB.setValue).not.toHaveBeenCalled(); + }); +}); 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 818b85cf61..b2033acce7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -11,6 +11,7 @@ import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps interface CapturedCropArea { onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; } let captured: CapturedCropArea; @@ -19,8 +20,13 @@ jest.mock("../components/CropArea", () => ({ imageRef: Ref; onImageLoad: CapturedCropArea["onImageLoad"]; onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; }) => { - captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete }; + captured = { + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart + }; return ( = {}): ImageCr boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, - enableRotation: true, + enableFlip: true, enableGrayscale: true, showResetButton: true, zoomEnabled: true, @@ -151,7 +157,7 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByLabelText("Flip right")); await Promise.resolve(); await Promise.resolve(); }); @@ -160,16 +166,18 @@ describe(" rotation/grayscale integration", () => { expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); }); - test("black & white on then rotate bakes grayscale into the rotated file", async () => { + test("flip with grayscale on bakes grayscale into the COMMITTED file", async () => { + // handleFlip also produces a color working image (first call, grayscale:false); + // the last call is the baked commit (grayscale:true) used for setValue. render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); act(() => { - fireEvent.click(screen.getByLabelText("Black and white")); + fireEvent.click(screen.getByLabelText("Grayscale")); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByLabelText("Flip right")); await Promise.resolve(); await Promise.resolve(); }); @@ -182,7 +190,7 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate left")); + fireEvent.click(screen.getByLabelText("Flip left")); await Promise.resolve(); await Promise.resolve(); }); @@ -195,11 +203,14 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByLabelText("Flip right")); await Promise.resolve(); await Promise.resolve(); }); cropImageOptions.length = 0; + act(() => { + captured.onUserInteractStart?.(); + }); act(() => { captured.onCropComplete(PIXEL_CROP); }); @@ -214,7 +225,10 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); act(() => { - fireEvent.click(screen.getByLabelText("Black and white")); + fireEvent.click(screen.getByLabelText("Grayscale")); + }); + act(() => { + captured.onUserInteractStart?.(); }); act(() => { captured.onCropComplete(PIXEL_CROP); diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg new file mode 100644 index 0000000000..6e056262b3 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg new file mode 100644 index 0000000000..17063fa892 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index fc769e76c7..8f2080dfbf 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -16,6 +16,7 @@ export interface CropAreaProps { crop: Crop | undefined; onCropChange: (crop: Crop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; aspect: number | undefined; circular: boolean; resizable: boolean; @@ -104,6 +105,7 @@ export function CropArea(props: CropAreaProps): ReactElement { crop={props.crop} onChange={(_pixel, percent) => props.onCropChange(percent)} onComplete={pixel => props.onCropComplete(pixel)} + onDragStart={() => props.onUserInteractStart?.()} aspect={props.aspect} circularCrop={props.circular} disabled={!props.resizable} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx index d46ec3cae0..c53d49e3c2 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -1,41 +1,51 @@ import classNames from "classnames"; import { ReactElement } from "react"; +import { ZoomSlider } from "./ZoomSlider"; +import FlipLeftIcon from "../assets/flip-left.svg"; +import FlipRightIcon from "../assets/flip-right.svg"; interface CropToolbarProps { - showRotation: boolean; + showFlip: boolean; showGrayscale: boolean; + showZoom: boolean; showReset: boolean; grayscale: boolean; canReset: boolean; - onRotateLeft: () => void; - onRotateRight: () => void; + zoom: number; + minZoom: number; + maxZoom: number; + onFlipLeft: () => void; + onFlipRight: () => void; onToggleGrayscale: () => void; + onZoomChange: (zoom: number) => void; onReset: () => void; } export function CropToolbar(props: CropToolbarProps): ReactElement | null { - if (!props.showRotation && !props.showGrayscale && !props.showReset) { + if (!props.showFlip && !props.showGrayscale && !props.showZoom && !props.showReset) { return null; } return (
- {props.showRotation && ( + {props.showFlip && ( <> )} @@ -45,17 +55,27 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { className={classNames("btn btn-default widget-image-cropper__tool", { active: props.grayscale })} - aria-label="Black and white" + aria-label="Grayscale" + title="Grayscale" aria-pressed={props.grayscale} onClick={props.onToggleGrayscale} > - B&W + Grayscale )} + {props.showZoom && ( + + )} {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 c4f37371bc..774b8f02a5 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -155,7 +155,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE [setZoom, auto] ); - const handleFlip = useCallback( + const handleRotate = useCallback( async (deltaDeg: number) => { const img = state.imageRef.current; if (!img || props.image.readOnly || props.image.status !== ValueStatus.Available || !props.image.value) { @@ -171,7 +171,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE grayscale: false, originalName: props.image.value.name }); - // Commit a baked B&W file only while the toggle is ON, so a flip-then-Save + // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save // with no further crop still persists grayscale. const committed = grayscaleRef.current ? await rotateImage({ @@ -216,8 +216,8 @@ 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) { - // Mirror handleFlip: setValue defers the commit, so drive the live preview with the - // original bytes too — otherwise a stale rotated/flipped blob keeps rendering after Reset. + // Mirror handleRotate: setValue defers the commit, so drive the live preview with the + // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. showPreviewRef.current(file); markInternalRef.current(); props.image.setValue(file); @@ -301,7 +301,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE imageRef={state.imageRef} /> handleFlip(-90)} - onFlipRight={() => handleFlip(90)} + onRotateLeft={() => handleRotate(-90)} + onRotateRight={() => handleRotate(90)} onToggleGrayscale={handleToggleGrayscale} onReset={handleReset} /> 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 index 8f491064a7..8843334f8c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -4,7 +4,7 @@ import { CropToolbar } from "../CropToolbar"; function props(overrides = {}): ComponentProps { return { - showFlip: true, + showRotation: true, showGrayscale: true, showZoom: true, showReset: true, @@ -13,8 +13,8 @@ function props(overrides = {}): ComponentProps { zoom: 1, minZoom: 1, maxZoom: 4, - onFlipLeft: jest.fn(), - onFlipRight: jest.fn(), + onRotateLeft: jest.fn(), + onRotateRight: jest.fn(), onToggleGrayscale: jest.fn(), onZoomChange: jest.fn(), onReset: jest.fn(), @@ -23,14 +23,14 @@ function props(overrides = {}): ComponentProps { } describe("", () => { - test("fires flip and reset callbacks", () => { + test("fires rotate and reset callbacks", () => { const p = props(); render(); - fireEvent.click(screen.getByLabelText("Flip left")); - fireEvent.click(screen.getByLabelText("Flip right")); + fireEvent.click(screen.getByLabelText("Rotate left")); + fireEvent.click(screen.getByLabelText("Rotate right")); fireEvent.click(screen.getByRole("button", { name: "Reset" })); - expect(p.onFlipLeft).toHaveBeenCalledTimes(1); - expect(p.onFlipRight).toHaveBeenCalledTimes(1); + expect(p.onRotateLeft).toHaveBeenCalledTimes(1); + expect(p.onRotateRight).toHaveBeenCalledTimes(1); expect(p.onReset).toHaveBeenCalledTimes(1); }); @@ -40,8 +40,8 @@ describe("", () => { }); test("hides controls when their flags are false", () => { - render(); - expect(screen.queryByLabelText("Flip left")).toBeNull(); + render(); + expect(screen.queryByLabelText("Rotate left")).toBeNull(); expect(screen.queryByLabelText("Grayscale")).toBeNull(); expect(screen.queryByRole("button", { name: "Reset" })).toBeNull(); }); @@ -58,8 +58,8 @@ describe("", () => { test("every toolbar button exposes a native title tooltip", () => { render(); - expect(screen.getByRole("button", { name: "Flip left" })).toHaveAttribute("title", "Flip left"); - expect(screen.getByRole("button", { name: "Flip right" })).toHaveAttribute("title", "Flip right"); + expect(screen.getByRole("button", { name: "Rotate left" })).toHaveAttribute("title", "Rotate left"); + expect(screen.getByRole("button", { name: "Rotate right" })).toHaveAttribute("title", "Rotate right"); expect(screen.getByRole("button", { name: "Grayscale" })).toHaveAttribute("title", "Grayscale"); expect(screen.getByRole("button", { name: "Reset" })).toHaveAttribute("title", "Reset"); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 990ab66612..96665cc8c1 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -44,6 +44,12 @@ $image-cropper-gray-light: #6c757d; } } + // The "Zoom" text sits in a ; Atlas styles labels bold, so set the weight on the + // span itself (class specificity) to render it regular like the other toolbar labels. + &__zoom-label { + font-weight: normal; + } + &__preview { border: 1px solid #ddd; background: #fff; @@ -68,7 +74,7 @@ $image-cropper-gray-light: #6c757d; } } - // Icon-only buttons (flip left/right). Atlas .btn owns the vertical box + // Icon-only buttons (rotate left/right). Atlas .btn owns the vertical box // (line-height + padding-y + border) → height auto-matches Grayscale/Reset; // we do NOT touch it (Atlas sizes win — frontend-guidelines). We only neutralise // the text-button min-width and equalise horizontal padding to the .btn vertical @@ -83,7 +89,7 @@ $image-cropper-gray-light: #6c757d; // Atlas's `.btn img` rule styles this icon for the "icon + text" case: // height: calc(var(--font-size-default) + 4px); margin-right: 4px; // vertical-align: text-top; - // Our flip buttons are icon-ONLY, so that right margin pushed the icon + // Our rotate buttons are icon-ONLY, so that right margin pushed the icon // off-centre (widening the box) and text-top shoved it up. We KEEP Atlas's // icon height (convention: Atlas sizes win) and only neutralise the two // text-affordance properties — which centres the icon and tightens the box. diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts index b9f0cb2bcc..3b9de97f74 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts @@ -33,7 +33,7 @@ export async function rotateImage(options: RotateImageOptions): Promise { ctx.fillRect(0, 0, canvas.width, canvas.height); } if (grayscale) { - // Bake B&W for the COMMITTED file only. handleFlip keeps a separate color + // Bake B&W for the COMMITTED file only. handleRotate keeps a separate color // working image so toggling grayscale off stays reversible; this baked file // is what gets persisted via setValue while the toggle is on. ctx.filter = "grayscale(1)"; diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 3b16b48341..9ca354fff7 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -34,7 +34,7 @@ export interface ImageCropperContainerProps { previewWidth: number; previewHeight: number; resizableEnabled: boolean; - enableFlip: boolean; + enableRotation: boolean; enableGrayscale: boolean; showResetButton: boolean; zoomEnabled: boolean; @@ -70,7 +70,7 @@ export interface ImageCropperPreviewProps { previewWidth: number | null; previewHeight: number | null; resizableEnabled: boolean; - enableFlip: boolean; + enableRotation: boolean; enableGrayscale: boolean; showResetButton: boolean; zoomEnabled: boolean; From 90077d748abd3e2c1e5ada1f4ac9237c7af44347 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 3 Jul 2026 15:35:15 +0200 Subject: [PATCH 20/67] docs(image-cropper-web): add openspec --- .../image-cropper-web/openspec/config.yaml | 20 ++ .../openspec/specs/image-cropper/spec.md | 249 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/pluggableWidgets/image-cropper-web/openspec/config.yaml create mode 100644 packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml b/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml new file mode 100644 index 0000000000..392946c67c --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md new file mode 100644 index 0000000000..3cdcbb96f9 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -0,0 +1,249 @@ +# image-cropper Specification + +## Purpose + +The Image Cropper widget lets a Mendix end user crop, rotate, and recolor an image bound to +an image attribute, writing the edited result back to that same attribute. It is a client-side +widget: all image transforms happen on an HTML canvas in the browser, and the edited bytes are +pushed to the Mendix runtime via `EditableValue.setValue`. This spec captures the behavior of +the shipped widget (v1.0.0) as a baseline. + +## Requirements + +### Requirement: Image source binding and lifecycle states + +The widget SHALL bind to a single required image attribute and SHALL render distinct states for +the Mendix value lifecycle. All edits (crop, rotate, grayscale, reset) SHALL write back to that +same attribute via `setValue`. + +#### Scenario: Loading state + +- **WHEN** the bound image value status is `Loading` +- **THEN** the widget SHALL render a placeholder container marked `aria-busy="true"` +- **AND** SHALL NOT render the crop area or toolbar + +#### Scenario: No image available + +- **WHEN** the bound image status is not `Available` or has no value +- **THEN** the widget SHALL render an empty state reading "No image" + +#### Scenario: Read-only attribute + +- **WHEN** the bound image is `readOnly` +- **THEN** editing operations SHALL NOT call `setValue` and the attribute SHALL be left unchanged + +### Requirement: Crop shape + +The widget SHALL support a rectangular and a circular crop shape, selected by the `cropShape` +property. + +#### Scenario: Circle shape masks corners + +- **WHEN** `cropShape` is `circle` +- **THEN** the on-screen selection SHALL be rendered as a circle +- **AND** the exported image SHALL be clipped to an ellipse inscribed in the crop rectangle so the corners are transparent (PNG) or filled (JPEG) + +#### Scenario: Rectangle shape + +- **WHEN** `cropShape` is `rect` +- **THEN** the full crop rectangle SHALL be exported with no corner masking + +### Requirement: Aspect ratio + +The widget SHALL constrain the crop selection to the ratio chosen in `aspectRatio`, supporting +free-form, preset ratios, and a custom ratio built from `customAspectWidth` / `customAspectHeight`. + +#### Scenario: Preset ratio locks proportions + +- **WHEN** `aspectRatio` is a preset (`square` = 1:1, `landscape16x9` = 16:9, `landscape4x3` = 4:3, `portrait3x4` = 3:4) +- **THEN** the crop selection SHALL keep that width-to-height proportion while being resized + +#### Scenario: Free ratio + +- **WHEN** `aspectRatio` is `free` +- **THEN** the crop selection SHALL be resizable to any proportion + +#### Scenario: Custom ratio + +- **WHEN** `aspectRatio` is `custom` and both `customAspectWidth` and `customAspectHeight` are greater than 0 +- **THEN** the crop selection SHALL be locked to `customAspectWidth / customAspectHeight` +- **AND** if either value is not greater than 0, the crop SHALL fall back to free-form + +### Requirement: Default crop selection + +On image load, the widget SHALL seed a default crop box centered on the image at the resolved +aspect ratio. + +#### Scenario: Initial box covers 80% centered + +- **WHEN** an image finishes loading +- **THEN** the default selection SHALL cover 80% of the image, centered, at the resolved aspect ratio (falling back to the image's own ratio when free) + +### Requirement: Resizable handles + +The widget SHALL show or hide the selection's resize handles based on `resizableEnabled`. + +#### Scenario: Handles disabled + +- **WHEN** `resizableEnabled` is `false` +- **THEN** the user SHALL NOT be able to resize the selection by dragging its corners + +### Requirement: Crop persistence (auto-apply) + +The widget SHALL persist the edited image to the bound attribute automatically, with no manual +"apply" button. Direct crop-box edits SHALL be written back immediately on pointer release, while +zoom and grayscale changes SHALL be written back on a 400 ms debounce so rapid changes collapse +into a single write. Programmatic changes to the crop box (image load, source change, reset) SHALL +NOT trigger an auto-apply. + +#### Scenario: Crop box edit applies immediately + +- **WHEN** the user moves or resizes the crop box and releases the pointer +- **THEN** the cropped result SHALL be written back to the bound attribute immediately, cancelling any pending debounced write + +#### Scenario: Zoom and grayscale apply on a debounce + +- **WHEN** the user changes the zoom level or toggles grayscale +- **THEN** the edited result SHALL be written back after a 400 ms debounce +- **AND** further zoom or grayscale changes within that window SHALL reset the timer so only one write occurs + +#### Scenario: Programmatic re-seed does not auto-save + +- **WHEN** the crop box is re-seeded programmatically (image load, bound source change, or reset) +- **THEN** the widget SHALL NOT auto-apply a crop, because auto-apply only fires after a real user interaction + +### Requirement: Zoom + +The widget SHALL let the user zoom the image between `minZoom` and `maxZoom` via a slider and/or +the mouse wheel, gated by `zoomEnabled`, `showZoomSlider`, and `wheelZoomMode`. + +#### Scenario: Zoom master switch off + +- **WHEN** `zoomEnabled` is `false` +- **THEN** the slider and mouse-wheel zoom SHALL be disabled and the image SHALL stay at 1× + +#### Scenario: Slider hidden but wheel active + +- **WHEN** `zoomEnabled` is `true` and `showZoomSlider` is `false` +- **THEN** the zoom slider SHALL be hidden while mouse-wheel zoom remains available per `wheelZoomMode` + +#### Scenario: Wheel zoom modes + +- **WHEN** `wheelZoomMode` is `onWithCtrl` +- **THEN** the wheel SHALL zoom only while Ctrl is held, leaving normal page scroll otherwise +- **AND** when `wheelZoomMode` is `on` the wheel SHALL always zoom, and when `off` the wheel SHALL never zoom + +#### Scenario: Zoom anchored on the crop-box center + +- **WHEN** the zoom level changes +- **THEN** the zoom SHALL be anchored at the crop box's current center so the framed region stays put on screen +- **AND** the exported pixels SHALL use the same anchor so the saved image matches the on-screen framing + +### Requirement: Rotation + +When `enableRotation` is true, the widget SHALL show rotate-left and rotate-right buttons that +rotate the image in 90° steps and bake the rotation into the saved image. + +#### Scenario: Rotate buttons hidden + +- **WHEN** `enableRotation` is `false` +- **THEN** the rotate-left / rotate-right buttons SHALL NOT be shown + +#### Scenario: Rotate bakes into the image + +- **WHEN** the user clicks rotate-left (−90°) or rotate-right (+90°) +- **THEN** the image SHALL be redrawn on a canvas sized to the rotated dimensions and written back to the attribute +- **AND** a live preview of the rotated (color) pixels SHALL be shown immediately, before the Mendix commit lands + +#### Scenario: Rotation preserves grayscale reversibility + +- **WHEN** the grayscale toggle is on and the user rotates +- **THEN** the committed file SHALL be baked black-and-white, while the working image kept in memory SHALL remain color so toggling grayscale off stays reversible + +### Requirement: Grayscale + +When `enableGrayscale` is true, the widget SHALL show a grayscale toggle; enabling it SHALL render +the image gray on screen and convert the saved image to black-and-white. + +#### Scenario: Grayscale toggle hidden + +- **WHEN** `enableGrayscale` is `false` +- **THEN** the grayscale toggle SHALL NOT be shown + +#### Scenario: Grayscale on + +- **WHEN** the grayscale toggle is on +- **THEN** the crop area SHALL render the image with a grayscale CSS filter +- **AND** the exported image SHALL be drawn with `grayscale(1)` so the saved bytes are black-and-white + +### Requirement: Reset + +When `showResetButton` is true, the widget SHALL show a Reset button that restores the original +image and clears zoom, rotation, and grayscale, re-seeding the default crop box. + +#### Scenario: Reset button availability + +- **WHEN** `showResetButton` is `true` and the original image bytes were captured +- **THEN** the Reset button SHALL be enabled; if the original could not be captured it SHALL be disabled + +#### Scenario: Reset restores original state + +- **WHEN** the user clicks Reset +- **THEN** zoom SHALL return to `minZoom`, grayscale SHALL turn off, the original image bytes SHALL be written back, and the default 80%-centered crop box SHALL be re-seeded +- **AND** the reset itself SHALL NOT trigger an auto-apply write of a crop + +### Requirement: Live preview pane + +When `showPreview` is true, the widget SHALL render a live thumbnail of the current crop next to +the crop area at the configured `previewWidth` × `previewHeight`. + +#### Scenario: Preview reflects current crop + +- **WHEN** `showPreview` is `true` +- **THEN** a thumbnail SHALL be shown reflecting the current crop, zoom, grayscale, and crop shape (circular mask when `cropShape` is `circle`) + +#### Scenario: Preview hidden + +- **WHEN** `showPreview` is `false` +- **THEN** no preview thumbnail SHALL be rendered + +### Requirement: Output encoding + +The widget SHALL encode the saved image according to `outputFormat`, `outputQuality`, and +`outputSize`. + +#### Scenario: PNG output + +- **WHEN** `outputFormat` is `png` +- **THEN** the file SHALL be encoded as `image/png` with a `.png` extension and transparency preserved, ignoring `outputQuality` + +#### Scenario: JPEG output + +- **WHEN** `outputFormat` is `jpeg` +- **THEN** the file SHALL be encoded as `image/jpeg` with a `.jpg` extension, a white background filled behind the image, and `outputQuality` clamped to 0.0–1.0 + +#### Scenario: Output resolution + +- **WHEN** `outputSize` is `original` +- **THEN** the crop SHALL be exported at the source (natural) resolution of the cropped region +- **AND** when `outputSize` is `viewport` the crop SHALL be exported at the canvas dimensions (`boundaryWidth` × `boundaryHeight`) and the attribute thumbnail size SHALL be set to match + +### Requirement: On crop event + +When configured, the widget SHALL run the `onCropAction` each time a crop is auto-applied. + +#### Scenario: Action runs on apply + +- **WHEN** a crop is written back to the attribute and `onCropAction.canExecute` is true +- **THEN** the widget SHALL call `onCropAction.execute()` + +### Requirement: Canvas sizing + +The widget SHALL scale the on-screen crop area to fit within `boundaryWidth` × `boundaryHeight` +without changing the saved image resolution. + +#### Scenario: Image scales to fit the canvas + +- **WHEN** the source image is larger than `boundaryWidth` × `boundaryHeight` +- **THEN** the rendered image SHALL scale down to fit, and the canvas SHALL wrap the rendered image so smaller crops produce a smaller canvas with no blank gaps +- **AND** the on-screen scaling SHALL NOT change the exported resolution From f4ecce1da4c8374ff23041ad8ffd584edb177b6d Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 6 Jul 2026 12:49:19 +0200 Subject: [PATCH 21/67] test(image-cropper-web): align editor spec with structure preview refactor --- .../image-cropper-web/CHANGELOG.md | 4 +++- .../src/__tests__/ImageCropper.editor.spec.tsx | 15 +++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index c34e6e44bd..bc5fa0bfdd 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -6,6 +6,8 @@ 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. +- 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. diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 78cc9f802e..2ad1a9a6f9 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -50,17 +50,12 @@ function collectText(node: any): string[] { } describe("ImageCropper structure mode (getPreview)", () => { - test("shows the widget title", () => { + test("shows the configure placeholder when nothing is bound", () => { const texts = collectText(getPreview(makePreviewProps(), false)); - expect(texts).toContain("Image cropper"); + expect(texts).toContain("[Configure 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", () => { + test("shows config summary caption when an image is bound", () => { const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" }, cropShape: "circle", @@ -69,8 +64,8 @@ describe("ImageCropper structure mode (getPreview)", () => { outputSize: "viewport" }); const texts = collectText(getPreview(props, false)); - expect(texts).toContain("Circle · 1:1 · JPEG · Viewport"); - expect(texts).not.toContain("[No attribute selected]"); + expect(texts).toContain("[Circle · 1:1 · JPEG · Viewport] Image Cropper"); + expect(texts).not.toContain("[Configure Image Cropper]"); }); }); From 062095da1cd9bf44abaa22231134f45bd80d38cd Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 8 Jul 2026 17:23:12 +0200 Subject: [PATCH 22/67] feat(image-cropper-web): remove live preview pane Remove the showPreview / previewWidth / previewHeight properties and the PreviewPane component that rendered a live crop thumbnail next to the canvas. Drops the XML property group, editor hide-logic, SCSS rule, spec requirement, and the related test mock props. The usePreviewSrc hook (rotate/reset live feedback) and the Studio Pro editor preview machinery are unrelated and left intact. --- .../openspec/specs/image-cropper/spec.md | 15 ----- .../src/ImageCropper.editorConfig.ts | 4 -- .../image-cropper-web/src/ImageCropper.xml | 14 ---- .../__tests__/ImageCropper.editor.spec.tsx | 3 - .../src/__tests__/ImageCropper.spec.tsx | 3 - .../__tests__/ImageCropperGrayscale.spec.tsx | 3 - .../ImageCropperMultiInstance.spec.tsx | 3 - .../__tests__/ImageCropperRotation.spec.tsx | 3 - .../src/components/ImageCropperContainer.tsx | 13 ---- .../src/components/PreviewPane.tsx | 62 ------------------ .../components/__tests__/PreviewPane.spec.tsx | 65 ------------------- .../src/hooks/useImageCropperState.ts | 2 +- .../src/ui/ImageCropper.scss | 5 -- .../image-cropper-web/src/utils/cropImage.ts | 2 +- .../typings/ImageCropperProps.d.ts | 6 -- 15 files changed, 2 insertions(+), 201 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md index 3cdcbb96f9..236052138e 100644 --- a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -192,21 +192,6 @@ image and clears zoom, rotation, and grayscale, re-seeding the default crop box. - **THEN** zoom SHALL return to `minZoom`, grayscale SHALL turn off, the original image bytes SHALL be written back, and the default 80%-centered crop box SHALL be re-seeded - **AND** the reset itself SHALL NOT trigger an auto-apply write of a crop -### Requirement: Live preview pane - -When `showPreview` is true, the widget SHALL render a live thumbnail of the current crop next to -the crop area at the configured `previewWidth` × `previewHeight`. - -#### Scenario: Preview reflects current crop - -- **WHEN** `showPreview` is `true` -- **THEN** a thumbnail SHALL be shown reflecting the current crop, zoom, grayscale, and crop shape (circular mask when `cropShape` is `circle`) - -#### Scenario: Preview hidden - -- **WHEN** `showPreview` is `false` -- **THEN** no preview thumbnail SHALL be rendered - ### Requirement: Output encoding The widget SHALL encode the saved image according to `outputFormat`, `outputQuality`, and diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts index 57eb5e3565..726400ea8d 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -20,10 +20,6 @@ export function getProperties(values: ImageCropperPreviewProps, defaultPropertie propsToHide.push("showZoomSlider", "wheelZoomMode", "minZoom", "maxZoom"); } - if (!values.showPreview) { - propsToHide.push("previewWidth", "previewHeight"); - } - if (values.outputFormat !== "jpeg") { propsToHide.push("outputQuality"); } diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index a5163f5cd8..ea35211211 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -61,20 +61,6 @@ Maximum on-screen height of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. - - - Show preview - Show a live thumbnail of the current crop next to the canvas. - - - Preview width (px) - Width of the preview thumbnail. - - - Preview height (px) - Height of the preview thumbnail. - - diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 2ad1a9a6f9..40352bc211 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -20,9 +20,6 @@ function makePreviewProps(overrides: Partial = {}): Im onCropAction: null, boundaryWidth: null, boundaryHeight: null, - showPreview: false, - previewWidth: null, - previewHeight: null, resizableEnabled: true, enableRotation: true, enableGrayscale: true, 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 75e58a8433..7fd8872a79 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -97,9 +97,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx index ca2b6f3259..e43fe42904 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -109,9 +109,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx index 0dd34e3fce..49af30088f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -101,9 +101,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", 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 4e76caad7d..ca2bd62a74 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -118,9 +118,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 774b8f02a5..ba8092e70f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -4,7 +4,6 @@ import { ReactElement, SetStateAction, useCallback, useEffect, useRef, useState import { type Crop, type PixelCrop } from "react-image-crop"; import { CropArea } from "./CropArea"; import { CropToolbar } from "./CropToolbar"; -import { PreviewPane } from "./PreviewPane"; import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop"; import { useImageCropperState } from "../hooks/useImageCropperState"; @@ -316,18 +315,6 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE 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 deleted file mode 100644 index 2c5383967c..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ReactElement, useEffect, useRef } from "react"; -import type { PixelCrop } from "react-image-crop"; -import { computeSourceRect, type ZoomAnchor } from "../utils/cropImage"; - -interface PreviewPaneProps { - image: HTMLImageElement | null; - pixelCrop: PixelCrop | undefined; - zoom: number; - zoomAnchor?: ZoomAnchor; - width: number; - height: number; - circle: boolean; - grayscale: boolean; -} - -export function PreviewPane({ - image, - pixelCrop, - zoom, - zoomAnchor, - width, - height, - circle, - grayscale -}: PreviewPaneProps): ReactElement { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !image || !pixelCrop || !image.naturalWidth) { - return; - } - - const dpr = window.devicePixelRatio || 1; - canvas.width = width * dpr; - canvas.height = height * dpr; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - ctx.scale(dpr, dpr); - ctx.clearRect(0, 0, width, height); - if (pixelCrop.width === 0 || pixelCrop.height === 0) { - // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. - return; - } - if (grayscale) { - ctx.filter = "grayscale(1)"; - } - if (circle) { - ctx.beginPath(); - ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); - ctx.clip(); - } - const { sx, sy, sw, sh } = computeSourceRect(pixelCrop, image, zoom, zoomAnchor); - ctx.drawImage(image, sx, sy, sw, sh, 0, 0, width, height); - }, [image, pixelCrop, zoom, zoomAnchor, width, height, circle, grayscale]); - - return ; -} 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 deleted file mode 100644 index b7ba8ead09..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 grayscale (canvas mock)", () => { - const { container } = render( - - ); - expect(container.querySelector("canvas")).not.toBeNull(); - }); - - test("draws with the box-center source rect (matches cropImage) at zoom > 1", () => { - const proto = CanvasRenderingContext2D.prototype as unknown as { - drawImage: (...args: unknown[]) => void; - }; - const original = proto.drawImage; - const calls: unknown[][] = []; - proto.drawImage = function (...args: unknown[]) { - calls.push(args); - }; - try { - // natural 1000x800, rendered 400x320 → scaleX=2.5, scaleY=2.5. - // anchor == box center: (100+100/2)/400=0.375, (100+100/2)/320=0.46875 → natural (375,375). - // z=2 → sw=sh=(100/2)*2.5=125; sx=sy=375-125/2=312.5. - render( - - ); - const [, sx, sy, sw, sh] = calls[0] as number[]; - expect(sw).toBeCloseTo(125, 5); - expect(sh).toBeCloseTo(125, 5); - expect(sx).toBeCloseTo(312.5, 5); - expect(sy).toBeCloseTo(312.5, 5); - } finally { - proto.drawImage = original; - } - }); -}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts index f899e713ec..b000864e64 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts @@ -3,7 +3,7 @@ import type { Crop, PixelCrop } from "react-image-crop"; interface ImageCropperState { // liveCrop: % units, updated per pointer move (onChange) — survives container resize. - // committedCrop: px units, set on release (onComplete) — consumed by cropImage/PreviewPane. + // committedCrop: px units, set on release (onComplete) — consumed by cropImage. liveCrop: Crop | undefined; setLiveCrop: Dispatch>; committedCrop: PixelCrop | undefined; diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 96665cc8c1..6b2af98af0 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -50,11 +50,6 @@ $image-cropper-gray-light: #6c757d; font-weight: normal; } - &__preview { - border: 1px solid #ddd; - background: #fff; - } - &__toolbar { display: flex; flex-wrap: nowrap; diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts index ede30f0ea7..462f11a593 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -29,7 +29,7 @@ export const CENTER_ANCHOR: ZoomAnchor = { x: 0.5, y: 0.5 }; * Maps the crop box (in the displayed image's unscaled layout px) to a source rectangle on the * natural image. Zoom is a CSS `transform: scale(z)` anchored on `anchor` (a fixed point that * stays put on screen while scaling), so the visible region shrinks by 1/z about that point. - * Shared by cropImage (export) and PreviewPane so exported pixels match the on-screen framing. + * Used by cropImage (export) so exported pixels match the on-screen framing. * Result is clamped to the image. When anchor == the crop-box center this reduces to the plain * "zoom into the box" mapping. */ diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 9ca354fff7..e208662799 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -30,9 +30,6 @@ export interface ImageCropperContainerProps { onCropAction?: ActionValue; boundaryWidth: number; boundaryHeight: number; - showPreview: boolean; - previewWidth: number; - previewHeight: number; resizableEnabled: boolean; enableRotation: boolean; enableGrayscale: boolean; @@ -66,9 +63,6 @@ export interface ImageCropperPreviewProps { onCropAction: {} | null; boundaryWidth: number | null; boundaryHeight: number | null; - showPreview: boolean; - previewWidth: number | null; - previewHeight: number | null; resizableEnabled: boolean; enableRotation: boolean; enableGrayscale: boolean; From 42f063be3ebdcbc5e73d2bcd461f865239cc9933 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 9 Jul 2026 14:36:09 +0200 Subject: [PATCH 23/67] fix(image-cropper-web): add aria-label to reset button --- .../image-cropper-web/src/__tests__/ImageCropper.spec.tsx | 6 +++--- .../src/__tests__/ImageCropperRotation.spec.tsx | 2 +- .../image-cropper-web/src/components/CropToolbar.tsx | 1 + .../src/components/__tests__/CropToolbar.spec.tsx | 8 ++++---- 4 files changed, 9 insertions(+), 8 deletions(-) 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 7fd8872a79..1a01746126 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -247,7 +247,7 @@ describe("", () => { await Promise.resolve(); }); (image.setValue as jest.Mock).mockClear(); - fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await flushApply(); expect((image.setValue as jest.Mock).mock.calls[0]?.[0]).toBeInstanceOf(File); }); @@ -265,7 +265,7 @@ describe("", () => { act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); - fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await flushApply(); // Box is re-seeded (not undefined) to the default 80%-centered percent crop. expect(captured.crop).toBeDefined(); @@ -282,6 +282,6 @@ describe("", () => { await Promise.resolve(); await Promise.resolve(); }); - expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Reset crop" })).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 index ca2bd62a74..b3806b507e 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -245,7 +245,7 @@ describe(" rotation/grayscale integration", () => { // Reset should stop showing the rotated preview (revert to original bytes / bound uri). await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await Promise.resolve(); await Promise.resolve(); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx index 1f66f2a40c..3cc9a727cd 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -75,6 +75,7 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { )} {props.showZoom && ( @@ -68,6 +76,8 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { zoom={props.zoom} minZoom={props.minZoom} maxZoom={props.maxZoom} + label={props.zoomCaption} + ariaLabel={props.zoomAriaLabel} onChange={props.onZoomChange} /> )} @@ -75,12 +85,12 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { )}
diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index ba8092e70f..486a3289ae 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -309,6 +309,14 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE zoom={state.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} + rotateLeftLabel={props.rotateLeftLabel?.value ?? "Rotate left"} + rotateRightLabel={props.rotateRightLabel?.value ?? "Rotate right"} + grayscaleCaption={props.grayscaleCaption?.value ?? "Grayscale"} + grayscaleAriaLabel={props.grayscaleAriaLabel?.value ?? "Grayscale"} + resetCaption={props.resetCaption?.value ?? "Reset"} + resetAriaLabel={props.resetAriaLabel?.value ?? "Reset crop"} + zoomCaption={props.zoomCaption?.value ?? "Zoom"} + zoomAriaLabel={props.zoomAriaLabel?.value ?? "Zoom"} onZoomChange={handleZoomChange} onRotateLeft={() => handleRotate(-90)} onRotateRight={() => handleRotate(90)} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx index 2edc53abe7..d0ab6d0f26 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx @@ -4,20 +4,22 @@ interface ZoomSliderProps { zoom: number; minZoom: number; maxZoom: number; + label: string; + ariaLabel: string; onChange: (zoom: number) => void; } -export function ZoomSlider({ zoom, minZoom, maxZoom, onChange }: ZoomSliderProps): ReactElement { +export function ZoomSlider({ zoom, minZoom, maxZoom, label, ariaLabel, onChange }: ZoomSliderProps): ReactElement { return ( 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 index 3032fc7e06..49c9e2b4f7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -13,6 +13,14 @@ function props(overrides = {}): ComponentProps { zoom: 1, minZoom: 1, maxZoom: 4, + rotateLeftLabel: "Rotate left", + rotateRightLabel: "Rotate right", + grayscaleCaption: "Grayscale", + grayscaleAriaLabel: "Grayscale", + resetCaption: "Reset", + resetAriaLabel: "Reset crop", + zoomCaption: "Zoom", + zoomAriaLabel: "Zoom", onRotateLeft: jest.fn(), onRotateRight: jest.fn(), onToggleGrayscale: jest.fn(), @@ -56,6 +64,25 @@ describe("", () => { expect(screen.queryByLabelText("Zoom")).toBeNull(); }); + test("renders custom labels and keeps caption independent from aria-label", () => { + render( + + ); + // aria-label drives the accessible name; caption is the visible text/title. + const resetBtn = screen.getByRole("button", { name: "Undo all crop changes" }); + expect(resetBtn).toHaveTextContent("Undo"); + expect(resetBtn).toHaveAttribute("title", "Undo"); + const grayscaleBtn = screen.getByRole("button", { name: "Black and white toggle" }); + expect(grayscaleBtn).toHaveTextContent("B&W"); + }); + test("every toolbar button exposes a native title tooltip", () => { render(); expect(screen.getByRole("button", { name: "Rotate left" })).toHaveAttribute("title", "Rotate left"); diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index e208662799..4d99c310d0 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -3,7 +3,7 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ActionValue, EditableImageValue, WebImage } from "mendix"; +import { ActionValue, DynamicValue, EditableImageValue, WebImage } from "mendix"; import { Big } from "big.js"; import { CSSProperties } from "react"; @@ -39,6 +39,14 @@ export interface ImageCropperContainerProps { wheelZoomMode: WheelZoomModeEnum; minZoom: Big; maxZoom: Big; + grayscaleCaption?: DynamicValue; + resetCaption?: DynamicValue; + zoomCaption?: DynamicValue; + rotateLeftLabel?: DynamicValue; + rotateRightLabel?: DynamicValue; + grayscaleAriaLabel?: DynamicValue; + resetAriaLabel?: DynamicValue; + zoomAriaLabel?: DynamicValue; outputFormat: OutputFormatEnum; outputQuality: Big; outputSize: OutputSizeEnum; @@ -72,6 +80,14 @@ export interface ImageCropperPreviewProps { wheelZoomMode: WheelZoomModeEnum; minZoom: number | null; maxZoom: number | null; + grayscaleCaption: string; + resetCaption: string; + zoomCaption: string; + rotateLeftLabel: string; + rotateRightLabel: string; + grayscaleAriaLabel: string; + resetAriaLabel: string; + zoomAriaLabel: string; outputFormat: OutputFormatEnum; outputQuality: number | null; outputSize: OutputSizeEnum; From 179610b78383dd37bc7011e9d48000f207e69ee0 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 9 Jul 2026 17:23:02 +0200 Subject: [PATCH 25/67] feat(image-cropper-web): style empty state with info color and icon Replace the plain red 'No image' text with an informational placeholder: an inline info-circle icon plus 'No uploaded image to crop', laid out on a single row and colored with Atlas's semantic --brand-info token instead of the error red. The icon inherits the text color via currentColor. --- .../src/__tests__/ImageCropper.spec.tsx | 2 +- .../src/components/ImageCropperContainer.tsx | 21 +++++++++++++++- .../src/ui/ImageCropper.scss | 24 +++++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) 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 eb5f8356c4..df3db0adc4 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -140,7 +140,7 @@ describe("", () => { test("renders empty state when image has no value", () => { const props = makeProps({ image: makeImageProp({ value: undefined }) }); render(); - expect(screen.getByText("No image")).toBeInTheDocument(); + expect(screen.getByText("No uploaded image to crop")).toBeInTheDocument(); }); test("does NOT auto-apply on initial image load (no data mutation without user intent)", async () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 486a3289ae..648a26e3a9 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -267,7 +267,26 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE style={props.style} tabIndex={props.tabIndex} > - No image + + No uploaded image 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 6b2af98af0..3cc522d5f7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -94,12 +94,32 @@ $image-cropper-gray-light: #6c757d; vertical-align: middle; } - &__error, - &--empty { + &__error { padding: 8px; color: #b00; } + // Informational empty state — no image bound yet. Uses Atlas's semantic + // "info" color (not the error red) so it reads as guidance, not a failure. + &--empty { + display: flex; + flex-direction: row; // override the base column so icon + text share one line + align-items: center; + gap: 8px; + padding: 12px; + color: var(--brand-info, #0086d9); + } + + &__empty-icon { + flex: 0 0 auto; + width: 1.5em; // scales with the text via font-size / em + height: 1.5em; + } + + &__empty-text { + line-height: 1.2; + } + &--loading { min-height: 200px; } From 9e31cb40ea456566417887d124703ecb5a32b8f1 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 12:36:56 +0200 Subject: [PATCH 26/67] feat(image-cropper-web): make no-image message editable, tidy aria captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a noImageCaption textTemplate (default "No uploaded image to crop") so the empty-state message shown when no image is bound can be edited and translated. Drop the redundant "aria label" wording from the grayscale/reset/zoom caption titles — the Aria labels group already makes their purpose clear. --- .../image-cropper-web/src/ImageCropper.xml | 14 +++++++++++--- .../src/__tests__/ImageCropper.editor.spec.tsx | 1 + .../src/components/ImageCropperContainer.tsx | 4 +++- .../typings/ImageCropperProps.d.ts | 2 ++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index ed39b0543f..c7268821d7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -137,6 +137,14 @@ Zoomen + + No image message + Shown when no image is bound to the image attribute. + + No uploaded image to crop + Geen geüploade afbeelding om bij te snijden + + @@ -156,7 +164,7 @@ - Grayscale aria label + Grayscale Accessible name for the grayscale toggle (announced by screen readers). Grayscale @@ -164,7 +172,7 @@ - Reset aria label + Reset Accessible name for the reset button (announced by screen readers). Reset crop @@ -172,7 +180,7 @@ - Zoom aria label + Zoom Accessible name for the zoom slider (announced by screen readers). Zoom diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index b9f8e05f27..8121eaca81 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -32,6 +32,7 @@ function makePreviewProps(overrides: Partial = {}): Im grayscaleCaption: "Grayscale", resetCaption: "Reset", zoomCaption: "Zoom", + noImageCaption: "No uploaded image to crop", rotateLeftLabel: "Rotate left", rotateRightLabel: "Rotate right", grayscaleAriaLabel: "Grayscale", diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 648a26e3a9..20b9c721af 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -286,7 +286,9 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE /> - No uploaded image to crop + + {props.noImageCaption?.value ?? "No uploaded image to crop"} +
); } diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 4d99c310d0..515e799308 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -42,6 +42,7 @@ export interface ImageCropperContainerProps { grayscaleCaption?: DynamicValue; resetCaption?: DynamicValue; zoomCaption?: DynamicValue; + noImageCaption?: DynamicValue; rotateLeftLabel?: DynamicValue; rotateRightLabel?: DynamicValue; grayscaleAriaLabel?: DynamicValue; @@ -83,6 +84,7 @@ export interface ImageCropperPreviewProps { grayscaleCaption: string; resetCaption: string; zoomCaption: string; + noImageCaption: string; rotateLeftLabel: string; rotateRightLabel: string; grayscaleAriaLabel: string; From 033313d6b39e1a845f91c6a009d269e378d099cb Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 12:37:03 +0200 Subject: [PATCH 27/67] docs(image-cropper-web): point README to Mendix documentation --- packages/pluggableWidgets/image-cropper-web/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/README.md b/packages/pluggableWidgets/image-cropper-web/README.md index 0eeb43c514..318c1dcb35 100644 --- a/packages/pluggableWidgets/image-cropper-web/README.md +++ b/packages/pluggableWidgets/image-cropper-web/README.md @@ -1,5 +1 @@ -# Image Cropper - -Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute. - -See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs. +Please see [Image Cropper](https://docs.mendix.com/appstore/widgets/image-cropper) in the Mendix documentation for details. From b90a9d56fe3c9d5bb356acff85809e642231ac18 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:06:42 +0200 Subject: [PATCH 28/67] build(image-cropper-web): add mobx, mobx-react-lite, widget-plugin-mobx-kit Dependencies for the MobX crop store: mobx 6.12.3 and mobx-react-lite 4.0.7 (pinned to match the rest of the monorepo) plus the shared widget-plugin-mobx-kit gate/setup utilities. --- .../pluggableWidgets/image-cropper-web/package.json | 3 +++ pnpm-lock.yaml | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/package.json b/packages/pluggableWidgets/image-cropper-web/package.json index 097d544871..6be4bfb431 100644 --- a/packages/pluggableWidgets/image-cropper-web/package.json +++ b/packages/pluggableWidgets/image-cropper-web/package.json @@ -41,7 +41,10 @@ "verify": "rui-verify-package-format" }, "dependencies": { + "@mendix/widget-plugin-mobx-kit": "workspace:*", "classnames": "^2.5.1", + "mobx": "6.12.3", + "mobx-react-lite": "4.0.7", "react-image-crop": "^11.0.10" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf32de8c14..5be7daead9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,7 +388,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -413,7 +413,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1696,9 +1696,18 @@ importers: packages/pluggableWidgets/image-cropper-web: dependencies: + '@mendix/widget-plugin-mobx-kit': + specifier: workspace:* + version: link:../../shared/widget-plugin-mobx-kit classnames: specifier: ^2.5.1 version: 2.5.1 + mobx: + specifier: 6.12.3 + version: 6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9) + mobx-react-lite: + specifier: 4.0.7 + version: 4.0.7(patch_hash=47fd2d1b5c35554ddd4fa32fcaa928a16fda9f82dca0ff68bcdc1f7c3e5f9d1a)(mobx@6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9))(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@18.3.1) From 594898682cbf4673fe2e023c8bf43640f72d622a Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:07:11 +0200 Subject: [PATCH 29/67] feat(image-cropper-web): add ImageCropperStore for crop state Centralize crop-domain working state (live/committed crop, zoom, grayscale, zoom anchor) and its operations in a MobX store. Store methods read observable state directly, so the debounced auto-apply no longer needs a stable-identity callback reading state-mirroring refs. The store owns the debounce + armed/user-interacted apply gate (previously useAutoApplyCrop) and exposes crop operations as actions: setZoom, commitCrop, toggleGrayscale, rotate, reset, initFromImageLoad, onImageChanged. aspect is a computed derived from gated props. Imperative React-owned dependencies (live img element, blob-URL preview, original-image capture, internal-change flag) are injected via setDeps and not owned by the store. Preview generation stays an async action, not a computed, because cropImage/rotateImage are async canvas operations with side effects. Add headless unit tests covering the commit gate, debounce coalescing, no-stale-reads, rotate/reset/grayscale flows, inbound image sync, and read-only/no-crop guards. --- .../src/stores/ImageCropperStore.ts | 306 +++++++++++++++++ .../__tests__/ImageCropperStore.spec.ts | 312 ++++++++++++++++++ 2 files changed, 618 insertions(+) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts new file mode 100644 index 0000000000..b235b78b2b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -0,0 +1,306 @@ +import { ValueStatus } from "mendix"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { type Crop, type PixelCrop } from "react-image-crop"; +import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; +import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; +import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { CENTER_ANCHOR, CropError, cropImage, type ZoomAnchor } from "../utils/cropImage"; +import { buildInitialCrop } from "../utils/initialCrop"; +import { rotateImage } from "../utils/rotateImage"; + +const DEBOUNCE_MS = 400; + +/** + * Imperative, React-owned dependencies the store needs but must not own itself: + * a live DOM , the blob-URL preview lifecycle, the original-image capture, and + * the internal-change flag. All are stable except getImage/getOriginal, which read + * refs — so they are re-injected each render via {@link ImageCropperStore.setDeps}. + */ +export interface CropperDeps { + getImage: () => HTMLImageElement | null; + showPreview: (file: File) => void; + markInternalChange: () => void; + getOriginal: () => File | undefined; +} + +const NOOP_DEPS: CropperDeps = { + getImage: () => null, + showPreview: () => undefined, + markInternalChange: () => undefined, + getOriginal: () => undefined +}; + +/** + * Owns the crop-domain working state (crop box, zoom, grayscale, zoom anchor) and the + * operations over it. Replaces the state-mirroring refs the React container needed: a + * store method reads `this.zoom` directly, so it never goes stale inside the debounced + * apply the way a stable-identity useCallback did. + * + * Controlled/uncontrolled: the ONLY externally controlled value is `props.image` + * (an EditableValue). It is read live through the gate and written back via setValue. + * Zoom/grayscale/crop are uncontrolled internal state. Config props (minZoom, cropShape, + * outputFormat, …) are read live through the gate and never copied into observables. + */ +export class ImageCropperStore implements SetupComponent { + // liveCrop: % units, updated per pointer move — survives container resize. + liveCrop: Crop | undefined = undefined; + // committedCrop: px units, set on release — consumed by cropImage on export. + committedCrop: PixelCrop | undefined = undefined; + zoom: number; + grayscale = false; + // Fixed point the CSS zoom pivots on; frozen at the box center when zoom changes so + // the image stays put while the box moves. Drives CropArea transformOrigin AND export. + zoomAnchor: ZoomAnchor = CENTER_ANCHOR; + + private gate: DerivedPropsGate; + private deps: CropperDeps = NOOP_DEPS; + + // Imperative interaction state — never drives render, so deliberately non-observable. + private userDragged = false; + private userInteracted = false; + private applyDebouncedFn: (() => void) | undefined = undefined; + private abort: (() => void) | undefined = undefined; + + constructor(gate: DerivedPropsGate) { + this.gate = gate; + this.zoom = Number(gate.props.minZoom); + // Explicit annotations (not makeAutoObservable): only the crop-domain working state is + // observable; gate/deps and the imperative apply gate stay plain. Reference-typed crop + // values use observable.ref (we always replace, never mutate in place). + makeObservable(this, { + liveCrop: observable.ref, + committedCrop: observable.ref, + zoom: observable, + grayscale: observable, + zoomAnchor: observable.ref, + props: computed, + aspect: computed, + setLiveCrop: action, + markUserDragged: action, + commitCrop: action, + setZoom: action, + toggleGrayscale: action, + rotate: action, + reset: action, + initFromImageLoad: action, + onImageChanged: action, + // applyCrop/applyNow/applyDebounced/armed read or write only the non-observable + // apply gate (userInteracted); they are intentionally NOT actions. + applyCrop: false, + applyNow: false, + applyDebounced: false, + armed: false + }); + } + + private get props(): ImageCropperContainerProps { + return this.gate.props; + } + + get aspect(): number | undefined { + return resolveAspectRatio(this.props.aspectRatio, this.props.customAspectWidth, this.props.customAspectHeight); + } + + setup(): () => void { + const [debounced, abort] = debounce(() => { + if (this.userInteracted) { + // Fire-and-forget: applyCrop swallows CropError itself; an unexpected throw + // surfaces as an unhandled rejection (logged), same as the old hook. + this.applyCrop(); + } + }, DEBOUNCE_MS); + this.applyDebouncedFn = debounced; + this.abort = abort; + return abort; + } + + // Re-injected every render (getImage/getOriginal close over React refs). + setDeps(deps: CropperDeps): void { + this.deps = deps; + } + + setLiveCrop(crop: Crop | undefined): void { + this.liveCrop = crop; + } + + markUserDragged(): void { + this.userDragged = true; + } + + commitCrop(pixelCrop: PixelCrop): void { + this.committedCrop = pixelCrop; + // A genuine user drag commits immediately and disarms; a programmatic onComplete + // (e.g. the box the library seeds right after image load) only updates committedCrop. + if (this.userDragged) { + this.userDragged = false; + this.applyNow(); + } + } + + setZoom(next: number): void { + // Freeze the anchor at the current box center. Recomputing it only here (not while the + // box moves) keeps the image stable during drags but still zooms into the box. + const live = this.liveCrop; + if (live && live.unit === "%") { + this.zoomAnchor = { x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }; + } + this.zoom = next; + this.applyDebounced(); + } + + toggleGrayscale(): void { + this.grayscale = !this.grayscale; + this.applyDebounced(); + } + + async rotate(deltaDeg: number): Promise { + const img = this.deps.getImage(); + const image = this.props.image; + if (!img || image.readOnly || image.status !== ValueStatus.Available || !image.value) { + return; + } + try { + // Working image is ALWAYS color so toggling grayscale OFF stays reversible. + const working = await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + grayscale: false, + originalName: image.value.name + }); + // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save + // with no further crop still persists grayscale. + const committed = this.grayscale + ? await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + grayscale: true, + originalName: image.value.name + }) + : working; + runInAction(() => { + this.liveCrop = undefined; + this.committedCrop = undefined; + this.armed(); + }); + // Show COLOR working pixels; CropArea reloads from this blob and rebuilds a fresh + // crop against the swapped dimensions on its onLoad. The CSS grayscale filter from + // this.grayscale still renders gray on screen. + this.deps.showPreview(working); + this.deps.markInternalChange(); + image.setValue(committed); + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + throw err; + } + } + } + + reset(): void { + this.zoom = Number(this.props.minZoom); + this.zoomAnchor = CENTER_ANCHOR; + this.grayscale = false; + this.armed(); // do not auto-apply the reset itself + const image = this.props.image; + const file = this.deps.getOriginal(); + if (file && !image.readOnly && image.status === ValueStatus.Available) { + // Mirror rotate: setValue defers the commit, so drive the live preview with the + // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. + this.deps.showPreview(file); + this.deps.markInternalChange(); + image.setValue(file); + } + // Re-seed the default cropbox. If restoring original bytes changed the uri (e.g. after a + // rotation), CropArea's onLoad re-seeds again against the correct dimensions; when no + // edit occurred the uri is unchanged and onLoad won't refire, so this direct re-seed is + // what puts the box back. + const img = this.deps.getImage(); + if (img && img.naturalWidth) { + const { percentCrop, pixelCrop } = buildInitialCrop(img, this.aspect); + this.liveCrop = percentCrop; + this.committedCrop = pixelCrop; + } else { + this.liveCrop = undefined; + this.committedCrop = undefined; + } + } + + initFromImageLoad(percentCrop: Crop, pixelCrop: PixelCrop): void { + this.zoom = Number(this.props.minZoom); + this.zoomAnchor = CENTER_ANCHOR; + this.liveCrop = percentCrop; + this.committedCrop = pixelCrop; + this.armed(); // programmatic load must not auto-commit + } + + // Inbound sync: called from a React effect when the bound image uri changes. + onImageChanged(): void { + this.liveCrop = undefined; + this.committedCrop = undefined; + this.armed(); + } + + // --- apply gate (was useAutoApplyCrop) --------------------------------------------------- + + private armed(): void { + this.userInteracted = false; + } + + private applyNow(): void { + this.userInteracted = true; + this.abort?.(); + // Fire-and-forget (see setup): applyCrop handles its own errors. + this.applyCrop(); + } + + private applyDebounced(): void { + this.userInteracted = true; + this.applyDebouncedFn?.(); + } + + private async applyCrop(): Promise { + const img = this.deps.getImage(); + const committedCrop = this.committedCrop; + const image = this.props.image; + if (!img || !committedCrop || image.readOnly || image.status !== ValueStatus.Available || !image.value) { + return; + } + try { + const file = await cropImage({ + image: img, + pixelCrop: committedCrop, + zoom: this.zoom, + zoomAnchor: this.zoomAnchor, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + outputSize: this.props.outputSize, + cropShape: this.props.cropShape, + viewportWidth: this.props.boundaryWidth, + viewportHeight: this.props.boundaryHeight, + grayscale: this.grayscale, + originalName: image.value.name + }); + if (this.props.outputSize === "viewport") { + image.setThumbnailSize(this.props.boundaryWidth, this.props.boundaryHeight); + } + this.deps.markInternalChange(); + image.setValue(file); + if (this.props.onCropAction?.canExecute) { + this.props.onCropAction.execute(); + } + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + console.error("[image-cropper-web] unexpected error:", err); + throw err; + } + } + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts new file mode 100644 index 0000000000..03eb965b25 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -0,0 +1,312 @@ +import { Big } from "big.js"; +import { ValueStatus } from "mendix"; +import type { Crop, PixelCrop } from "react-image-crop"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; +import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; + +// The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts +// orchestration (when/what is committed) without a real canvas. +jest.mock("../../utils/cropImage", () => { + const actual = jest.requireActual("../../utils/cropImage"); + return { + ...actual, + cropImage: jest.fn(async () => new File(["cropped"], "cropped.png", { type: "image/png" })) + }; +}); +jest.mock("../../utils/rotateImage", () => ({ + rotateImage: jest.fn( + async (opts: { grayscale: boolean }) => + new File([opts.grayscale ? "bw" : "color"], "rotated.png", { type: "image/png" }) + ) +})); + +import { cropImage } from "../../utils/cropImage"; +import { rotateImage } from "../../utils/rotateImage"; + +const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; +const PERCENT_CROP: Crop = { unit: "%", x: 20, y: 20, width: 40, height: 40 }; + +type ImageProp = ImageCropperContainerProps["image"]; +type WebImage = NonNullable; + +function makeImageProp(overrides: Partial = {}): 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(), + ...overrides + } as unknown as ImageProp; +} + +function makeProps(overrides: Partial = {}): ImageCropperContainerProps { + return { + name: "imageCrop", + class: "", + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "square", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + ...overrides + } as ImageCropperContainerProps; +} + +// A mutable gate whose props can be swapped, mimicking GateProvider.setProps. +class FakeGate implements DerivedPropsGate { + props: ImageCropperContainerProps; + constructor(props: ImageCropperContainerProps) { + this.props = props; + } +} + +// Minimal stand-in for the on-screen the store reads for export/rotation math. +function fakeImage(): HTMLImageElement { + return { naturalWidth: 400, naturalHeight: 300, width: 400, height: 300 } as HTMLImageElement; +} + +function makeDeps(overrides: Partial = {}): jest.Mocked { + return { + getImage: jest.fn(() => fakeImage()), + showPreview: jest.fn(), + markInternalChange: jest.fn(), + getOriginal: jest.fn(() => undefined), + ...overrides + } as jest.Mocked; +} + +// Build a store already wired up: gate + setup() (debounce armed) + deps injected. +function makeStore(propsOverride: Partial = {}): { + store: ImageCropperStore; + gate: FakeGate; + deps: jest.Mocked; + dispose: () => void; +} { + const gate = new FakeGate(makeProps(propsOverride)); + const store = new ImageCropperStore(gate); + const dispose = store.setup(); + const deps = makeDeps(); + store.setDeps(deps); + return { store, gate, deps, dispose }; +} + +async function flush(): Promise { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("ImageCropperStore", () => { + beforeEach(() => { + jest.useFakeTimers(); + (cropImage as jest.Mock).mockClear(); + (rotateImage as jest.Mock).mockClear(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + describe("initial state", () => { + it("seeds zoom from minZoom prop and defaults", () => { + const { store, dispose } = makeStore({ minZoom: new Big(2) }); + expect(store.zoom).toBe(2); + expect(store.grayscale).toBe(false); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + }); + + describe("aspect computed", () => { + it("derives from gate props and recomputes when props change", () => { + const { store, gate, dispose } = makeStore({ aspectRatio: "square" }); + expect(store.aspect).toBe(1); + gate.props = makeProps({ aspectRatio: "landscape16x9" }); + expect(store.aspect).toBeCloseTo(16 / 9); + dispose(); + }); + }); + + describe("commitCrop gate (user-drag vs programmatic)", () => { + it("does NOT auto-commit a programmatic complete (no preceding drag)", async () => { + const { store, deps, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(store.committedCrop).toEqual(PIXEL_CROP); + expect(deps.markInternalChange).not.toHaveBeenCalled(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + + it("auto-commits immediately after a user drag, then disarms", async () => { + const { store, gate, deps, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + + // A subsequent programmatic complete must NOT commit (flag was reset). + (cropImage as jest.Mock).mockClear(); + store.commitCrop({ ...PIXEL_CROP, x: 50 }); + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + }); + + describe("setZoom", () => { + it("freezes the anchor at the live-crop center and debounces the apply", async () => { + const { store, deps, dispose } = makeStore(); + store.setLiveCrop(PERCENT_CROP); + store.commitCrop(PIXEL_CROP); // programmatic — no commit yet + store.setZoom(2); + // anchor = box center: (x + w/2)/100, (y + h/2)/100 = (40/100, 40/100) + expect(store.zoomAnchor).toEqual({ x: 0.4, y: 0.4 }); + expect(store.zoom).toBe(2); + expect(cropImage).not.toHaveBeenCalled(); // still within debounce window + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + dispose(); + }); + + it("coalesces rapid zoom changes into a single apply", async () => { + const { store, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + store.setZoom(2); + store.setZoom(3); + store.setZoom(4); + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + describe("no stale reads", () => { + it("applies with the LATEST zoom/grayscale, not the value at trigger time", async () => { + const { store, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + store.setZoom(2); + store.toggleGrayscale(); // grayscale -> true, also schedules apply + store.setZoom(3.5); // change again before the debounce fires + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + const opts = (cropImage as jest.Mock).mock.calls[0][0]; + expect(opts.zoom).toBe(3.5); + expect(opts.grayscale).toBe(true); + dispose(); + }); + }); + + describe("rotate", () => { + it("commits a color file when grayscale is off and shows the working preview", async () => { + const { store, gate, deps, dispose } = makeStore(); + await store.rotate(90); + expect(rotateImage).toHaveBeenCalledTimes(1); + expect((rotateImage as jest.Mock).mock.calls[0][0].grayscale).toBe(false); + expect(deps.showPreview).toHaveBeenCalledTimes(1); + expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + + it("bakes a separate B&W committed file when grayscale is on", async () => { + const { store, dispose } = makeStore(); + store.toggleGrayscale(); + await store.rotate(-90); + // Two rotateImage calls: color working + B&W committed. + expect(rotateImage).toHaveBeenCalledTimes(2); + const grayscales = (rotateImage as jest.Mock).mock.calls.map(c => c[0].grayscale); + expect(grayscales).toEqual([false, true]); + dispose(); + }); + }); + + describe("reset", () => { + it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", () => { + const original = new File(["orig"], "img.png", { type: "image/png" }); + const gate = new FakeGate(makeProps({ minZoom: new Big(1) })); + const store = new ImageCropperStore(gate); + const dispose = store.setup(); + const deps = makeDeps({ getOriginal: jest.fn(() => original) }); + store.setDeps(deps); + + store.toggleGrayscale(); + store.setZoom(3); + store.reset(); + + expect(store.zoom).toBe(1); + expect(store.grayscale).toBe(false); + expect(store.zoomAnchor).toEqual({ x: 0.5, y: 0.5 }); + expect(deps.showPreview).toHaveBeenCalledWith(original); + expect(deps.markInternalChange).toHaveBeenCalled(); + expect(gate.props.image.setValue).toHaveBeenCalledWith(original); + expect(store.liveCrop).toBeDefined(); // re-seeded from the fake image + dispose(); + }); + }); + + describe("onImageChanged (inbound sync)", () => { + it("clears the crop and disarms so a queued apply cannot fire", async () => { + const { store, deps, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + (cropImage as jest.Mock).mockClear(); + (deps.markInternalChange as jest.Mock).mockClear(); + + store.setZoom(2); // arms + schedules a debounced apply + store.onImageChanged(); // new image arrived: disarm + clear + await flush(); + + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + expect(cropImage).not.toHaveBeenCalled(); // disarmed before the debounce fired + dispose(); + }); + }); + + describe("guards / no feedback loop", () => { + it("does not write back when the image is read-only", async () => { + const { store, gate, dispose } = makeStore(); + gate.props = makeProps({ image: makeImageProp({ readOnly: true } as Partial) }); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + expect(gate.props.image.setValue).not.toHaveBeenCalled(); + dispose(); + }); + + it("does nothing when no committed crop exists", async () => { + const { store, dispose } = makeStore(); + store.setZoom(2); // arms + debounces, but committedCrop is undefined + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + }); +}); From b70fb844fbfa375d9b784c7e74e65662e071ff8d Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:07:32 +0200 Subject: [PATCH 30/67] refactor(image-cropper-web): drive container from ImageCropperStore Wire ImageCropperContainer to the MobX store via the mobx-kit gate pattern (GateProvider + useConst + useSetup) and wrap it in observer. Crop operations become plain store method calls. Remove the five state-mirroring refs (committedCropRef, zoomRef, grayscaleRef, liveCropRef, zoomAnchorRef) and the two callback mirrors (markInternalRef, showPreviewRef); the store reads its own observable state, so stable-identity closures no longer need them. Config and the controlled image value are read live through the gate; the bound uri is synced inbound via a single effect calling store.onImageChanged. usePreviewSrc, useOriginalImage and the DOM image ref stay in React and are injected into the store through setDeps. Delete useAutoApplyCrop and useImageCropperState, now subsumed by the store. --- .../src/components/CropArea.tsx | 2 +- .../src/components/ImageCropperContainer.tsx | 304 +++--------------- .../__tests__/useImageCropperState.spec.ts | 18 -- .../src/hooks/useAutoApplyCrop.ts | 47 --- .../src/hooks/useImageCropperState.ts | 35 -- 5 files changed, 51 insertions(+), 355 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 2fad22010e..4c5a9e27f3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -2,8 +2,8 @@ import { Dispatch, ReactElement, Ref, SetStateAction, SyntheticEvent, useCallbac import { default as ReactCrop, type Crop, type PixelCrop } from "react-image-crop"; import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; -import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; import { isStrayCrop, MIN_CROP_PX } from "../utils/cropGuard"; +import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; import { buildInitialCrop } from "../utils/initialCrop"; import { safeImageUri } from "../utils/safeImageUri"; diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 20b9c721af..9e39418e67 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -1,254 +1,54 @@ import classNames from "classnames"; import { ValueStatus } from "mendix"; -import { ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; -import { type Crop, type PixelCrop } from "react-image-crop"; +import { observer } from "mobx-react-lite"; +import { ReactElement, useEffect, useRef } from "react"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CropArea } from "./CropArea"; import { CropToolbar } from "./CropToolbar"; 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, CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; -import { buildInitialCrop } from "../utils/initialCrop"; -import { rotateImage } from "../utils/rotateImage"; +import { ImageCropperStore } from "../stores/ImageCropperStore"; -export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { - const state = useImageCropperState(Number(props.minZoom)); +export const ImageCropperContainer = observer(function ImageCropperContainer( + props: ImageCropperContainerProps +): ReactElement | null { + const gateProvider = useConst(() => new GateProvider(props)); + const store = useSetup(() => new ImageCropperStore(gateProvider.gate)); - const { setZoom, setLiveCrop, setCommittedCrop, setGrayscale } = state; - - const committedCropRef = useRef(undefined); - committedCropRef.current = state.committedCrop; - const zoomRef = useRef(state.zoom); - zoomRef.current = state.zoom; - const grayscaleRef = useRef(state.grayscale); - grayscaleRef.current = state.grayscale; - const userDraggedRef = useRef(false); - // Live crop mirror so handleZoomChange (stable identity) can read the box's current center. - const liveCropRef = useRef(undefined); - liveCropRef.current = state.liveCrop; - - // Fixed point of the zoom, captured from the box center when zoom changes and frozen while the - // box moves/draws (so the image stays stable). State drives CropArea's transformOrigin; the ref - // mirror lets applyCrop's stable closure read it for the matching export math. - const [zoomAnchor, setZoomAnchor] = useState(CENTER_ANCHOR); - const zoomAnchorRef = useRef(zoomAnchor); - zoomAnchorRef.current = zoomAnchor; - - const applyCrop = useCallback(async () => { - const img = state.imageRef.current; - const committedCrop = committedCropRef.current; - if ( - !img || - !committedCrop || - props.image.readOnly || - props.image.status !== ValueStatus.Available || - !props.image.value - ) { - return; - } - try { - const file = await cropImage({ - image: img, - pixelCrop: committedCrop, - zoom: zoomRef.current, - zoomAnchor: zoomAnchorRef.current, - outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality), - outputSize: props.outputSize, - cropShape: props.cropShape, - viewportWidth: props.boundaryWidth, - viewportHeight: props.boundaryHeight, - 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(); - } - } catch (err) { - if (err instanceof CropError) { - console.error("[image-cropper-web] CropError:", err.message); - } else { - console.error("[image-cropper-web] unexpected error:", err); - throw err; - } - } - }, [ - state.imageRef, - props.image, - props.outputFormat, - props.outputQuality, - props.outputSize, - props.cropShape, - props.boundaryWidth, - props.boundaryHeight, - props.onCropAction - ]); + // Push fresh Mendix props into the gate every render (config + image read live off it). + useEffect(() => { + gateProvider.setProps(props); + }); - const auto = useAutoApplyCrop(applyCrop); - const { armed } = auto; - - const handleImageLoad = useCallback( - (percentCrop: Crop, pixelCrop: PixelCrop) => { - setZoom(Number(props.minZoom)); - setZoomAnchor(CENTER_ANCHOR); - setLiveCrop(percentCrop); - setCommittedCrop(pixelCrop); - armed(); - }, - [setZoom, setLiveCrop, setCommittedCrop, props.minZoom, armed] - ); + const imageRef = useRef(null); 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 ); - - // 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 + // Live preview for baked rotations/reset: 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; + // Feed the store its imperative React-owned deps. getImage/getOriginal close over refs, so + // re-inject every render; assigning plain (non-observable) fields triggers no reactions. useEffect(() => { - setLiveCrop(undefined); - setCommittedCrop(undefined); - armed(); - }, [uri, setLiveCrop, setCommittedCrop, armed]); - - const handleCropComplete = useCallback( - (pixelCrop: PixelCrop) => { - committedCropRef.current = pixelCrop; - setCommittedCrop(pixelCrop); - if (userDraggedRef.current) { - userDraggedRef.current = false; - auto.applyNow(); - } - }, - [setCommittedCrop, auto] - ); - - const handleZoomChange = useCallback( - (next: SetStateAction) => { - // Freeze the zoom anchor at the current box center. Recomputing it only here (not while - // the box moves) keeps the image stable during drags/draws but still zooms into the box. - const live = liveCropRef.current; - if (live && live.unit === "%") { - setZoomAnchor({ x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }); - } - setZoom(next); - auto.applyDebounced(); - }, - [setZoom, auto] - ); - - const handleRotate = useCallback( - async (deltaDeg: number) => { - const img = state.imageRef.current; - if (!img || props.image.readOnly || props.image.status !== ValueStatus.Available || !props.image.value) { - return; - } - try { - // Working image is ALWAYS color so toggling grayscale OFF stays reversible. - const working = await rotateImage({ - image: img, - rotation: deltaDeg, - outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality), - grayscale: false, - originalName: props.image.value.name - }); - // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save - // with no further crop still persists grayscale. - const committed = grayscaleRef.current - ? await rotateImage({ - image: img, - rotation: deltaDeg, - outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality), - grayscale: true, - originalName: props.image.value.name - }) - : working; - setLiveCrop(undefined); - setCommittedCrop(undefined); - committedCropRef.current = undefined; - armed(); - // Show COLOR working pixels; CropArea reloads from this blob and rebuilds - // a fresh crop against the swapped dimensions on its onLoad. - // The CSS grayscale filter from state.grayscale still renders gray on screen. - showPreviewRef.current(working); - markInternalRef.current(); - props.image.setValue(committed); - } catch (err) { - if (err instanceof CropError) { - console.error("[image-cropper-web] CropError:", err.message); - } else { - throw err; - } - } - }, - [state.imageRef, props.image, props.outputFormat, props.outputQuality, setLiveCrop, setCommittedCrop, armed] - ); - - const handleToggleGrayscale = useCallback(() => { - setGrayscale(prev => !prev); - auto.applyDebounced(); - }, [setGrayscale, auto]); - - const handleReset = useCallback(() => { - setZoom(Number(props.minZoom)); - setZoomAnchor(CENTER_ANCHOR); - setGrayscale(false); - armed(); // do not auto-apply the reset itself - const file = original.getOriginal(); - if (file && !props.image.readOnly && props.image.status === ValueStatus.Available) { - // Mirror handleRotate: setValue defers the commit, so drive the live preview with the - // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. - showPreviewRef.current(file); - markInternalRef.current(); - props.image.setValue(file); - } - // Re-seed the default cropbox to its initial state. If restoring original bytes changed - // the uri (e.g. after a rotation), CropArea's onLoad re-seeds again against the correct - // dimensions; when no edit occurred the uri is unchanged and onLoad won't refire, so this - // direct re-seed is what puts the box back. - const img = state.imageRef.current; - if (img && img.naturalWidth) { - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); - const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect); - setLiveCrop(percentCrop); - setCommittedCrop(pixelCrop); - } else { - setLiveCrop(undefined); - setCommittedCrop(undefined); - } - }, [ - setZoom, - props.minZoom, - props.image, - props.aspectRatio, - props.customAspectWidth, - props.customAspectHeight, - setGrayscale, - setLiveCrop, - setCommittedCrop, - armed, - original, - state.imageRef - ]); + store.setDeps({ + getImage: () => imageRef.current, + showPreview, + markInternalChange: original.markInternalChange, + getOriginal: original.getOriginal + }); + }); + + // Inbound image sync: a new bound uri means the source changed — clear the crop and disarm. + useEffect(() => { + store.onImageChanged(); + }, [uri, store]); if (props.image.status === ValueStatus.Loading) { return ( @@ -293,41 +93,37 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE ); } - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); - return (
{ - userDraggedRef.current = true; - }} - aspect={aspect} + crop={store.liveCrop} + onCropChange={crop => store.setLiveCrop(crop)} + onCropComplete={pixelCrop => store.commitCrop(pixelCrop)} + onUserInteractStart={() => store.markUserDragged()} + aspect={store.aspect} circular={props.cropShape === "circle"} resizable={props.resizableEnabled} boundaryWidth={props.boundaryWidth} boundaryHeight={props.boundaryHeight} - onImageLoad={handleImageLoad} - zoom={state.zoom} + onImageLoad={(percentCrop, pixelCrop) => store.initFromImageLoad(percentCrop, pixelCrop)} + zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} - setZoom={handleZoomChange} - zoomAnchor={zoomAnchor} + setZoom={next => store.setZoom(typeof next === "function" ? next(store.zoom) : next)} + zoomAnchor={store.zoomAnchor} wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"} - grayscale={state.grayscale} - imageRef={state.imageRef} + grayscale={store.grayscale} + imageRef={imageRef} /> handleRotate(-90)} - onRotateRight={() => handleRotate(90)} - onToggleGrayscale={handleToggleGrayscale} - onReset={handleReset} + onZoomChange={zoom => store.setZoom(zoom)} + onRotateLeft={() => store.rotate(-90)} + onRotateRight={() => store.rotate(90)} + onToggleGrayscale={() => store.toggleGrayscale()} + onReset={() => store.reset()} />
); -} +}); 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 deleted file mode 100644 index fd34122a17..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { useImageCropperState } from "../useImageCropperState"; - -describe("useImageCropperState", () => { - test("initializes zoom from arg, grayscale false", () => { - const { result } = renderHook(() => useImageCropperState(1)); - expect(result.current.zoom).toBe(1); - expect(result.current.grayscale).toBe(false); - }); - - test("setGrayscale updates state", () => { - const { result } = renderHook(() => useImageCropperState(1)); - act(() => { - result.current.setGrayscale(true); - }); - expect(result.current.grayscale).toBe(true); - }); -}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts deleted file mode 100644 index 6b217f04ae..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; - -const DEBOUNCE_MS = 400; - -export interface AutoApplyCrop { - armed: () => void; - applyNow: () => void; - applyDebounced: () => void; -} - -export function useAutoApplyCrop(applyCrop: () => void | Promise): AutoApplyCrop { - const latest = useRef(applyCrop); - latest.current = applyCrop; - - const userInteracted = useRef(false); - - const [applyDebounced, abort] = useMemo( - () => - debounce(() => { - if (userInteracted.current) { - latest.current(); - } - }, DEBOUNCE_MS), - [] - ); - - useEffect(() => abort, [abort]); - - return useMemo( - () => ({ - armed: () => { - userInteracted.current = false; - }, - applyNow: () => { - userInteracted.current = true; - abort(); - latest.current(); - }, - applyDebounced: () => { - userInteracted.current = true; - applyDebounced(); - } - }), - [abort, applyDebounced] - ); -} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts deleted file mode 100644 index b000864e64..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react"; -import type { Crop, PixelCrop } from "react-image-crop"; - -interface ImageCropperState { - // liveCrop: % units, updated per pointer move (onChange) — survives container resize. - // committedCrop: px units, set on release (onComplete) — consumed by cropImage. - liveCrop: Crop | undefined; - setLiveCrop: Dispatch>; - committedCrop: PixelCrop | undefined; - setCommittedCrop: Dispatch>; - zoom: number; - setZoom: Dispatch>; - grayscale: boolean; - setGrayscale: Dispatch>; - imageRef: RefObject; -} - -export function useImageCropperState(initialZoom: number): ImageCropperState { - const [liveCrop, setLiveCrop] = useState(undefined); - const [committedCrop, setCommittedCrop] = useState(undefined); - const [zoom, setZoom] = useState(initialZoom); - const [grayscale, setGrayscale] = useState(false); - const imageRef = useRef(null); - return { - liveCrop, - setLiveCrop, - committedCrop, - setCommittedCrop, - zoom, - setZoom, - grayscale, - setGrayscale, - imageRef - }; -} From bba65e78161380773cd2d9570e5cda31a37f2357 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 17:01:14 +0200 Subject: [PATCH 31/67] refactor(image-cropper-web): own image fetch and preview in ImageCropperStore --- .../src/components/ImageCropperContainer.tsx | 31 +-- .../hooks/__tests__/useOriginalImage.spec.ts | 59 ----- .../src/hooks/__tests__/usePreviewSrc.spec.ts | 63 ------ .../src/hooks/useOriginalImage.ts | 67 ------ .../src/hooks/usePreviewSrc.ts | 49 ----- .../src/stores/ImageCropperStore.ts | 172 +++++++++++++-- .../__tests__/ImageCropperStore.spec.ts | 208 +++++++++++++++--- 7 files changed, 334 insertions(+), 315 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 9e39418e67..ceebebf28a 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -8,8 +8,6 @@ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CropArea } from "./CropArea"; import { CropToolbar } from "./CropToolbar"; import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; -import { useOriginalImage } from "../hooks/useOriginalImage"; -import { usePreviewSrc } from "../hooks/usePreviewSrc"; import { ImageCropperStore } from "../stores/ImageCropperStore"; export const ImageCropperContainer = observer(function ImageCropperContainer( @@ -25,31 +23,16 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( const imageRef = useRef(null); - 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 - ); - // Live preview for baked rotations/reset: setValue defers the commit, so show a local - // blob URL until the bound uri catches up on Save. - const { previewSrc, showPreview } = usePreviewSrc(uri); - - // Feed the store its imperative React-owned deps. getImage/getOriginal close over refs, so - // re-inject every render; assigning plain (non-observable) fields triggers no reactions. + // Feed the store the one React-owned dep it still needs: the live on-screen used as + // the canvas draw source. It closes over a ref, so re-inject every render; assigning a + // plain (non-observable) field triggers no reactions. Fetch/preview/capture now live in the + // store, driven by its own reaction on the bound uri. useEffect(() => { store.setDeps({ - getImage: () => imageRef.current, - showPreview, - markInternalChange: original.markInternalChange, - getOriginal: original.getOriginal + getImage: () => imageRef.current }); }); - // Inbound image sync: a new bound uri means the source changed — clear the crop and disarm. - useEffect(() => { - store.onImageChanged(); - }, [uri, store]); - if (props.image.status === ValueStatus.Loading) { return (
store.setLiveCrop(crop)} onCropComplete={pixelCrop => store.commitCrop(pixelCrop)} @@ -122,7 +105,7 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( showZoom={props.zoomEnabled && props.showZoomSlider} showReset={props.showResetButton} grayscale={store.grayscale} - canReset={original.canRestore} + canReset={store.canRestore} zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} 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 deleted file mode 100644 index ddfe47475f..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { renderHook, act, 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(); - }); - - 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 deleted file mode 100644 index 3d735d8495..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 7d99926908..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 -// 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); - 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); - 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, - markInternalChange - }; -} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts deleted file mode 100644 index b0a41fc07b..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts +++ /dev/null @@ -1,49 +0,0 @@ -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/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index b235b78b2b..b97019646c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -1,5 +1,5 @@ import { ValueStatus } from "mendix"; -import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; import { type Crop, type PixelCrop } from "react-image-crop"; import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; @@ -12,23 +12,18 @@ import { rotateImage } from "../utils/rotateImage"; const DEBOUNCE_MS = 400; /** - * Imperative, React-owned dependencies the store needs but must not own itself: - * a live DOM , the blob-URL preview lifecycle, the original-image capture, and - * the internal-change flag. All are stable except getImage/getOriginal, which read - * refs — so they are re-injected each render via {@link ImageCropperStore.setDeps}. + * The one imperative, React-owned dependency the store still needs but must not own itself: + * the live on-screen DOM , used as the canvas draw source for crop/rotate export. It + * reads a ref, so it is re-injected each render via {@link ImageCropperStore.setDeps}. The + * blob-URL preview, the original-image capture, and the internal-change flag were absorbed + * into the store (they are pure browser resources, not React-lifecycle-bound in practice). */ export interface CropperDeps { getImage: () => HTMLImageElement | null; - showPreview: (file: File) => void; - markInternalChange: () => void; - getOriginal: () => File | undefined; } const NOOP_DEPS: CropperDeps = { - getImage: () => null, - showPreview: () => undefined, - markInternalChange: () => undefined, - getOriginal: () => undefined + getImage: () => null }; /** @@ -53,6 +48,13 @@ export class ImageCropperStore implements SetupComponent { // the image stays put while the box moves. Drives CropArea transformOrigin AND export. zoomAnchor: ZoomAnchor = CENTER_ANCHOR; + // Local blob: URL shown instead of the bound uri until the deferred setValue commits on + // Save, or undefined to use the bound uri. Read by the container JSX. (was usePreviewSrc) + previewSrc: string | undefined = undefined; + // Whether the original bytes were captured, so Reset can restore them. Drives the Reset + // button's enabled state. (was useOriginalImage's canRestore) + canRestore = false; + private gate: DerivedPropsGate; private deps: CropperDeps = NOOP_DEPS; @@ -62,18 +64,35 @@ export class ImageCropperStore implements SetupComponent { private applyDebouncedFn: (() => void) | undefined = undefined; private abort: (() => void) | undefined = undefined; + // Image-capture / preview plumbing (was useOriginalImage + usePreviewSrc). Plain, not + // observable: blobUrl/originalFile are browser resources; internalChange/capturedUri are + // the fetch-dedupe gate that must not drive render. + private originalFile: File | undefined = undefined; + private blobUrl: string | undefined = undefined; + private internalChange = false; + // Bumped on every uri change so an in-flight fetch superseded by a newer uri is dropped + // when it resolves (the reactive analog of useOriginalImage's `cancelled` guard). + private fetchGeneration = 0; + // Disposer for the uri reaction, torn down alongside the debounce in setup(). + private disposeUriEffect: (() => void) | undefined = undefined; + constructor(gate: DerivedPropsGate) { this.gate = gate; this.zoom = Number(gate.props.minZoom); // Explicit annotations (not makeAutoObservable): only the crop-domain working state is // observable; gate/deps and the imperative apply gate stay plain. Reference-typed crop // values use observable.ref (we always replace, never mutate in place). - makeObservable(this, { + makeObservable< + this, + "props" | "applyCrop" | "applyNow" | "applyDebounced" | "armed" | "onUriChanged" | "revokePreview" + >(this, { liveCrop: observable.ref, committedCrop: observable.ref, zoom: observable, grayscale: observable, zoomAnchor: observable.ref, + previewSrc: observable, + canRestore: observable, props: computed, aspect: computed, setLiveCrop: action, @@ -85,12 +104,17 @@ export class ImageCropperStore implements SetupComponent { reset: action, initFromImageLoad: action, onImageChanged: action, + showPreview: action, + // onUriChanged writes crop/preview/canRestore observables (fired by the reaction). + onUriChanged: action, // applyCrop/applyNow/applyDebounced/armed read or write only the non-observable - // apply gate (userInteracted); they are intentionally NOT actions. + // apply gate (userInteracted); revokePreview touches only the plain blobUrl. + // captureOriginal/markInternalChange wrap their own runInAction / touch plain state. applyCrop: false, applyNow: false, applyDebounced: false, - armed: false + armed: false, + revokePreview: false }); } @@ -112,14 +136,113 @@ export class ImageCropperStore implements SetupComponent { }, DEBOUNCE_MS); this.applyDebouncedFn = debounced; this.abort = abort; - return abort; + + // React to the bound image's uri changing (was useOriginalImage's [uri,name] effect + + // the container's onImageChanged effect). The data fn reads the observable gate props, + // so it re-tracks each render; the effect only fires when the derived uri value changes. + this.disposeUriEffect = reaction( + () => { + const image = this.props.image; + const value = image.status === ValueStatus.Available ? image.value : undefined; + return { uri: value?.uri, name: value?.name }; + }, + ({ uri, name }) => this.onUriChanged(uri, name), + { fireImmediately: true } + ); + + // Combined teardown: stop the debounce, dispose the uri reaction, revoke any live blob. + return () => { + abort(); + this.disposeUriEffect?.(); + this.revokePreview(); + }; } - // Re-injected every render (getImage/getOriginal close over React refs). + // Handles an image-uri change. Two branches: + // - internal bake (our own setValue): adopt the uri, don't refetch, keep the crop box; + // - external swap (a genuinely new image): capture original bytes for Reset, clear the + // crop + disarm, and drop any stale preview blob so the bound uri shows through. + // Called by the setup() reaction (also fires immediately for the initial image). + private onUriChanged(uri: string | undefined, name: string | undefined): void { + // A fresh committed uri means the previous local preview is superseded — drop it + // unconditionally (usePreviewSrc did this on ANY committed-uri change). + this.revokePreview(); + this.previewSrc = undefined; + + if (!uri) { + return; + } + + if (this.internalChange) { + // Our own bake produced this uri — adopt it, keep the original + crop, skip fetch. + this.internalChange = false; + return; + } + + // A genuinely new external image arrived. Invalidate any in-flight fetch, reset the + // capture state, clear the crop + disarm, then recapture the original bytes for Reset. + const generation = ++this.fetchGeneration; + this.originalFile = undefined; + this.canRestore = false; + this.onImageChanged(); + void this.captureOriginal(uri, name, generation); + } + + // Async original-bytes capture for Reset. Guarded by the generation token so a fetch + // superseded by a newer uri is discarded when it resolves. (was useOriginalImage's fetch) + private async captureOriginal(uri: string, name: string | undefined, generation: number): Promise { + try { + const res = await fetch(uri); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + const blob = await res.blob(); + if (generation !== this.fetchGeneration) { + return; // superseded by a newer uri — drop this result + } + runInAction(() => { + this.originalFile = new File([blob], name ?? "original", { type: blob.type || "image/png" }); + this.canRestore = true; + }); + } catch { + if (generation === this.fetchGeneration) { + runInAction(() => { + this.canRestore = false; + }); + } + } + } + + // Re-injected every render (getImage closes over a React ref). setDeps(deps: CropperDeps): void { this.deps = deps; } + // Show a baked File immediately via a local blob: URL, before the deferred setValue commit + // changes the bound uri. Revokes any prior URL first. (was usePreviewSrc.showPreview) + showPreview(file: File): void { + this.revokePreview(); + const url = URL.createObjectURL(file); + this.blobUrl = url; + this.previewSrc = url; + } + + // Revoke the live blob URL (browser resource cleanup). Called before the next showPreview, + // when the bound uri catches up, and on teardown. Does not touch previewSrc — callers that + // need the fallback-to-bound-uri behavior clear previewSrc themselves. + private revokePreview(): void { + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = undefined; + } + } + + // Flag the next uri change as our own bake so the uri effect adopts it without refetching. + // Called synchronously before every internal setValue. (was useOriginalImage.markInternalChange) + private markInternalChange(): void { + this.internalChange = true; + } + setLiveCrop(crop: Crop | undefined): void { this.liveCrop = crop; } @@ -190,8 +313,8 @@ export class ImageCropperStore implements SetupComponent { // Show COLOR working pixels; CropArea reloads from this blob and rebuilds a fresh // crop against the swapped dimensions on its onLoad. The CSS grayscale filter from // this.grayscale still renders gray on screen. - this.deps.showPreview(working); - this.deps.markInternalChange(); + this.showPreview(working); + this.markInternalChange(); image.setValue(committed); } catch (err) { if (err instanceof CropError) { @@ -208,12 +331,12 @@ export class ImageCropperStore implements SetupComponent { this.grayscale = false; this.armed(); // do not auto-apply the reset itself const image = this.props.image; - const file = this.deps.getOriginal(); + const file = this.originalFile; if (file && !image.readOnly && image.status === ValueStatus.Available) { // Mirror rotate: setValue defers the commit, so drive the live preview with the // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. - this.deps.showPreview(file); - this.deps.markInternalChange(); + this.showPreview(file); + this.markInternalChange(); image.setValue(file); } // Re-seed the default cropbox. If restoring original bytes changed the uri (e.g. after a @@ -239,7 +362,8 @@ export class ImageCropperStore implements SetupComponent { this.armed(); // programmatic load must not auto-commit } - // Inbound sync: called from a React effect when the bound image uri changes. + // Inbound sync: clear the crop box and disarm the apply gate when the bound image changes. + // Called by onUriChanged's external-swap branch (was a React effect on the uri). onImageChanged(): void { this.liveCrop = undefined; this.committedCrop = undefined; @@ -289,7 +413,7 @@ export class ImageCropperStore implements SetupComponent { if (this.props.outputSize === "viewport") { image.setThumbnailSize(this.props.boundaryWidth, this.props.boundaryHeight); } - this.deps.markInternalChange(); + this.markInternalChange(); image.setValue(file); if (this.props.onCropAction?.canExecute) { this.props.onCropAction.execute(); diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts index 03eb965b25..7a93bab0e6 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -1,5 +1,6 @@ import { Big } from "big.js"; import { ValueStatus } from "mendix"; +import { action, makeObservable, observable } from "mobx"; import type { Crop, PixelCrop } from "react-image-crop"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; @@ -71,11 +72,17 @@ function makeProps(overrides: Partial = {}): ImageCr } as ImageCropperContainerProps; } -// A mutable gate whose props can be swapped, mimicking GateProvider.setProps. +// A mutable gate whose props can be swapped, mimicking GateProvider. props is observable.ref + +// setProps is an action, matching the real gate — the store's uri reaction and `aspect`/`props` +// computeds only re-evaluate when props is swapped through the observable, not a plain write. class FakeGate implements DerivedPropsGate { props: ImageCropperContainerProps; constructor(props: ImageCropperContainerProps) { this.props = props; + makeObservable(this, { props: observable.ref, setProps: action }); + } + setProps(props: ImageCropperContainerProps): void { + this.props = props; } } @@ -87,14 +94,13 @@ function fakeImage(): HTMLImageElement { function makeDeps(overrides: Partial = {}): jest.Mocked { return { getImage: jest.fn(() => fakeImage()), - showPreview: jest.fn(), - markInternalChange: jest.fn(), - getOriginal: jest.fn(() => undefined), ...overrides } as jest.Mocked; } -// Build a store already wired up: gate + setup() (debounce armed) + deps injected. +// Build a store already wired up: gate + setup() (debounce armed + uri reaction) + deps injected. +// setup() runs the reaction with fireImmediately, so the initial image is captured right away; +// callers that assert on the fetch should `await flush()` first. function makeStore(propsOverride: Partial = {}): { store: ImageCropperStore; gate: FakeGate; @@ -103,9 +109,9 @@ function makeStore(propsOverride: Partial = {}): { } { const gate = new FakeGate(makeProps(propsOverride)); const store = new ImageCropperStore(gate); - const dispose = store.setup(); const deps = makeDeps(); store.setDeps(deps); + const dispose = store.setup(); return { store, gate, deps, dispose }; } @@ -113,16 +119,41 @@ async function flush(): Promise { jest.runOnlyPendingTimers(); await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); +} + +// jsdom implements neither blob URLs nor fetch; stub both so the store's preview + capture +// paths can run and be spied on. +if (!URL.createObjectURL) { + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => ""; +} +if (!URL.revokeObjectURL) { + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; } describe("ImageCropperStore", () => { + let createSpy: jest.SpyInstance; + let revokeSpy: jest.SpyInstance; + beforeEach(() => { jest.useFakeTimers(); (cropImage as jest.Mock).mockClear(); (rotateImage as jest.Mock).mockClear(); + + let n = 0; + createSpy = jest.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:mock-${++n}`); + revokeSpy = jest.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + + // Default: fetch succeeds with a tiny png blob (the initial-image capture the reaction + // fires on setup). Individual tests override global.fetch as needed. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["orig"], { type: "image/png" })) + }) as jest.Mock; }); afterEach(() => { jest.useRealTimers(); + jest.restoreAllMocks(); }); describe("initial state", () => { @@ -140,7 +171,7 @@ describe("ImageCropperStore", () => { it("derives from gate props and recomputes when props change", () => { const { store, gate, dispose } = makeStore({ aspectRatio: "square" }); expect(store.aspect).toBe(1); - gate.props = makeProps({ aspectRatio: "landscape16x9" }); + gate.setProps(makeProps({ aspectRatio: "landscape16x9" })); expect(store.aspect).toBeCloseTo(16 / 9); dispose(); }); @@ -148,22 +179,21 @@ describe("ImageCropperStore", () => { describe("commitCrop gate (user-drag vs programmatic)", () => { it("does NOT auto-commit a programmatic complete (no preceding drag)", async () => { - const { store, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); store.commitCrop(PIXEL_CROP); await flush(); expect(store.committedCrop).toEqual(PIXEL_CROP); - expect(deps.markInternalChange).not.toHaveBeenCalled(); + expect(gate.props.image.setValue).not.toHaveBeenCalled(); expect(cropImage).not.toHaveBeenCalled(); dispose(); }); it("auto-commits immediately after a user drag, then disarms", async () => { - const { store, gate, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); store.markUserDragged(); store.commitCrop(PIXEL_CROP); await flush(); expect(cropImage).toHaveBeenCalledTimes(1); - expect(deps.markInternalChange).toHaveBeenCalledTimes(1); expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); // A subsequent programmatic complete must NOT commit (flag was reset). @@ -177,7 +207,7 @@ describe("ImageCropperStore", () => { describe("setZoom", () => { it("freezes the anchor at the live-crop center and debounces the apply", async () => { - const { store, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); store.setLiveCrop(PERCENT_CROP); store.commitCrop(PIXEL_CROP); // programmatic — no commit yet store.setZoom(2); @@ -187,7 +217,7 @@ describe("ImageCropperStore", () => { expect(cropImage).not.toHaveBeenCalled(); // still within debounce window await flush(); expect(cropImage).toHaveBeenCalledTimes(1); - expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); dispose(); }); @@ -221,12 +251,15 @@ describe("ImageCropperStore", () => { describe("rotate", () => { it("commits a color file when grayscale is off and shows the working preview", async () => { - const { store, gate, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); + await flush(); // let the initial-image capture settle before asserting on preview + createSpy.mockClear(); await store.rotate(90); expect(rotateImage).toHaveBeenCalledTimes(1); expect((rotateImage as jest.Mock).mock.calls[0][0].grayscale).toBe(false); - expect(deps.showPreview).toHaveBeenCalledTimes(1); - expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + // showPreview minted a blob URL for the working file and exposed it. + expect(createSpy).toHaveBeenCalledTimes(1); + expect(store.previewSrc).toBe("blob:mock-1"); expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); expect(store.liveCrop).toBeUndefined(); expect(store.committedCrop).toBeUndefined(); @@ -246,13 +279,11 @@ describe("ImageCropperStore", () => { }); describe("reset", () => { - it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", () => { - const original = new File(["orig"], "img.png", { type: "image/png" }); - const gate = new FakeGate(makeProps({ minZoom: new Big(1) })); - const store = new ImageCropperStore(gate); - const dispose = store.setup(); - const deps = makeDeps({ getOriginal: jest.fn(() => original) }); - store.setDeps(deps); + it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", async () => { + const { store, gate, dispose } = makeStore({ minZoom: new Big(1) }); + // The initial-image reaction fetches + captures the original bytes for Reset. + await flush(); + expect(store.canRestore).toBe(true); store.toggleGrayscale(); store.setZoom(3); @@ -261,9 +292,11 @@ describe("ImageCropperStore", () => { expect(store.zoom).toBe(1); expect(store.grayscale).toBe(false); expect(store.zoomAnchor).toEqual({ x: 0.5, y: 0.5 }); - expect(deps.showPreview).toHaveBeenCalledWith(original); - expect(deps.markInternalChange).toHaveBeenCalled(); - expect(gate.props.image.setValue).toHaveBeenCalledWith(original); + // Reset drives the live preview with the captured original and writes it back. + expect(store.previewSrc).toBeDefined(); + const restored = (gate.props.image.setValue as jest.Mock).mock.calls.at(-1)?.[0]; + expect(restored).toBeInstanceOf(File); + expect(restored.name).toBe("img.png"); // the captured original's name expect(store.liveCrop).toBeDefined(); // re-seeded from the fake image dispose(); }); @@ -271,12 +304,11 @@ describe("ImageCropperStore", () => { describe("onImageChanged (inbound sync)", () => { it("clears the crop and disarms so a queued apply cannot fire", async () => { - const { store, deps, dispose } = makeStore(); + const { store, dispose } = makeStore(); store.markUserDragged(); store.commitCrop(PIXEL_CROP); await flush(); (cropImage as jest.Mock).mockClear(); - (deps.markInternalChange as jest.Mock).mockClear(); store.setZoom(2); // arms + schedules a debounced apply store.onImageChanged(); // new image arrived: disarm + clear @@ -292,7 +324,7 @@ describe("ImageCropperStore", () => { describe("guards / no feedback loop", () => { it("does not write back when the image is read-only", async () => { const { store, gate, dispose } = makeStore(); - gate.props = makeProps({ image: makeImageProp({ readOnly: true } as Partial) }); + gate.setProps(makeProps({ image: makeImageProp({ readOnly: true } as Partial) })); store.markUserDragged(); store.commitCrop(PIXEL_CROP); await flush(); @@ -309,4 +341,122 @@ describe("ImageCropperStore", () => { dispose(); }); }); + + describe("original-image capture (was useOriginalImage)", () => { + it("fetches the bound image and reports canRestore", async () => { + const { store, dispose } = makeStore(); + await flush(); + expect(global.fetch).toHaveBeenCalledWith("http://localhost/img.png"); + expect(store.canRestore).toBe(true); + dispose(); + }); + + it("reports canRestore=false when the fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + const { store, dispose } = makeStore(); + await flush(); + expect(store.canRestore).toBe(false); + dispose(); + }); + + it("re-captures and clears the crop when a new external uri arrives", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); + (global.fetch as jest.Mock).mockClear(); + store.setLiveCrop(PERCENT_CROP); + store.commitCrop(PIXEL_CROP); + + // A genuinely new external image (no preceding internal bake). + gate.setProps( + makeProps({ image: makeImageProp({ value: { uri: "http://x/new.png", name: "new.png" } as WebImage }) }) + ); + await flush(); + + expect(global.fetch).toHaveBeenCalledWith("http://x/new.png"); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + + it("adopts our own bake's uri without refetching or clearing the crop", async () => { + const { store, gate, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); // applyCrop -> markInternalChange -> setValue + expect(store.committedCrop).toEqual(PIXEL_CROP); + (global.fetch as jest.Mock).mockClear(); + + // Simulate the deferred commit landing: the bound uri changes to our baked output. + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/baked.png", name: "baked.png" } as WebImage }) + }) + ); + await flush(); + + expect(global.fetch).not.toHaveBeenCalled(); // adopted, not recaptured + expect(store.committedCrop).toEqual(PIXEL_CROP); // crop preserved + dispose(); + }); + + it("drops a superseded in-flight fetch (stale-fetch cancellation)", async () => { + // First fetch never resolves within the window; a second uri supersedes it. + let resolveFirst: (r: unknown) => void = () => undefined; + const slowBlob = new Blob(["slow"], { type: "image/png" }); + global.fetch = jest + .fn() + .mockImplementationOnce(() => new Promise(res => (resolveFirst = res))) + .mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(slowBlob) }) as jest.Mock; + + const { store, gate, dispose } = makeStore(); + // Supersede before the first fetch resolves. + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/second.png", name: "second.png" } as WebImage }) + }) + ); + await flush(); + // Now resolve the STALE first fetch — it must be discarded (generation mismatch). + resolveFirst({ ok: true, blob: () => Promise.resolve(new Blob(["stale"], { type: "image/png" })) }); + await flush(); + + // canRestore reflects the SECOND (winning) fetch, not the stale first. + expect(store.canRestore).toBe(true); + dispose(); + }); + }); + + describe("preview blob lifecycle (was usePreviewSrc)", () => { + it("drops and revokes the preview when the committed uri catches up", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); + await store.rotate(90); // mints a preview blob (internal bake -> markInternalChange) + expect(store.previewSrc).toBeDefined(); + const minted = store.previewSrc; + revokeSpy.mockClear(); + + // The deferred commit lands: bound uri becomes our baked output. onUriChanged drops + // the local preview and revokes the blob (adopt branch, previewSrc cleared first). + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/baked.png", name: "baked.png" } as WebImage }) + }) + ); + await flush(); + + expect(store.previewSrc).toBeUndefined(); + expect(revokeSpy).toHaveBeenCalledWith(minted); + dispose(); + }); + + it("revokes the live blob URL on teardown", async () => { + const { store, dispose } = makeStore(); + await flush(); + await store.rotate(90); + const minted = store.previewSrc; + revokeSpy.mockClear(); + dispose(); + expect(revokeSpy).toHaveBeenCalledWith(minted); + }); + }); }); From 067fecc5c202ffb3b266cff7dfee02e6263215ae Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 14 Jul 2026 15:03:40 +0200 Subject: [PATCH 32/67] refactor(image-cropper-web): resolve zoom updater in store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the SetStateAction resolution out of the container and into ImageCropperStore.setZoom, which now accepts a value or updater and resolves the functional form against its own zoom. The container drops to setZoom={next => store.setZoom(next)}, making the store the single owner of current zoom. The functional-updater contract stays on the CropArea/ZoomContainer props (the wheel-zoom hook drives setZoom(prev => …) and the editor preview passes a no-op). Behavior unchanged. Also sync the image-cropper openspec: the "No image available" scenario now reflects the editable noImageCaption empty-state text. --- .../openspec/specs/image-cropper/spec.md | 2 +- .../src/components/ImageCropperContainer.tsx | 2 +- .../image-cropper-web/src/stores/ImageCropperStore.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md index 236052138e..52d188cfcb 100644 --- a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -25,7 +25,7 @@ same attribute via `setValue`. #### Scenario: No image available - **WHEN** the bound image status is not `Available` or has no value -- **THEN** the widget SHALL render an empty state reading "No image" +- **THEN** the widget SHALL render an empty state showing the configurable `noImageCaption` (default "No uploaded image to crop") #### Scenario: Read-only attribute diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index ceebebf28a..f6a80ffe8f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -93,7 +93,7 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} - setZoom={next => store.setZoom(typeof next === "function" ? next(store.zoom) : next)} + setZoom={next => store.setZoom(next)} zoomAnchor={store.zoomAnchor} wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"} grayscale={store.grayscale} diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index b97019646c..b59d52eb2d 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -1,5 +1,6 @@ import { ValueStatus } from "mendix"; import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; +import { type SetStateAction } from "react"; import { type Crop, type PixelCrop } from "react-image-crop"; import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; @@ -261,14 +262,18 @@ export class ImageCropperStore implements SetupComponent { } } - setZoom(next: number): void { + setZoom(next: SetStateAction): void { + // Accept the React setState contract (value or updater). The wheel-zoom hook drives + // zoom with setZoom(prev => …), so the store — the owner of `zoom` — resolves the + // updater against its own value instead of leaking that into the container. + const value = typeof next === "function" ? next(this.zoom) : next; // Freeze the anchor at the current box center. Recomputing it only here (not while the // box moves) keeps the image stable during drags but still zooms into the box. const live = this.liveCrop; if (live && live.unit === "%") { this.zoomAnchor = { x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }; } - this.zoom = next; + this.zoom = value; this.applyDebounced(); } From 7c9e435d2e54b8e56dd9cb39b03b1f047c082372 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 14 Jul 2026 16:05:12 +0200 Subject: [PATCH 33/67] style(image-cropper-web): lint --- .../image-cropper-web/src/stores/ImageCropperStore.ts | 4 +++- .../src/stores/__tests__/ImageCropperStore.spec.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index b59d52eb2d..9b2e986c17 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -186,7 +186,9 @@ export class ImageCropperStore implements SetupComponent { this.originalFile = undefined; this.canRestore = false; this.onImageChanged(); - void this.captureOriginal(uri, name, generation); + // Fire-and-forget: captureOriginal never rejects (it handles its own errors below), so + // there's no floating-promise to await. Bare call keeps eslint's no-void rule happy. + this.captureOriginal(uri, name, generation); } // Async original-bytes capture for Reset. Guarded by the generation token so a fetch diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts index 7a93bab0e6..f8df72000c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -4,7 +4,6 @@ import { action, makeObservable, observable } from "mobx"; import type { Crop, PixelCrop } from "react-image-crop"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; -import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; // The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts // orchestration (when/what is committed) without a real canvas. @@ -24,6 +23,7 @@ jest.mock("../../utils/rotateImage", () => ({ import { cropImage } from "../../utils/cropImage"; import { rotateImage } from "../../utils/rotateImage"; +import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; const PERCENT_CROP: Crop = { unit: "%", x: 20, y: 20, width: 40, height: 40 }; From 4601496bb0b72efc1ea1c95ca520b6b07e56e0e7 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 16:26:13 +0200 Subject: [PATCH 34/67] 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 zcmc&&c|26__rEi)j3s7}>@${Rr-ke@_N}6YWNC<^vW5_5v1Cn@P}WkBWM4CwWM864 zgsdf7qp2{N#_#%km)Gyl-+#aRdfoe+`u$pV1={yDEs=|POpOAeNoPn-Z0AsPXIFi~Jv!|po_U}FEJ&0z8X+duPg0EoK+ zfd7^`35~mlD|GHY^RJPu0QPTbXs!VMA88m+!1f;+phD8Xg8)VsG;sP`oDT#5-uB%S zc3+8C3;_12TbdZ24TY__INg}k6iOF3Fiq!O*k{taM>>pce&HBjJ%wyBPI_{lq~b@t zioJ`!rir|wiBwSXALJ^fX;nV6;uvZ)i5|F|%||LVDB?3j?sXnnQ`AF5UAy)N0h7Xj zF(f1sZiGYpUv7*IXFea^r>(C)#hgo0JL%`^TlamcslKw`FWn4*AQO;)`5^1hN_lj1 zbMyK8?Dyd~dU<&{K5@&=cKH?3f{VPuhc@s&mq7KjlZ$2!ny?hpgIo}@anwEGYj*Z_ zTtN3uv7&&>ez+0F609pJbu70+Uf7w9Lo3GZo%IV+1!Hi>5R|Z=Upv#!YU9t1v#CE& zkOyO^RAef^)BmU>1z8+T0tcv@rR>IzYN zzS`QEBSs8ya9a+Qr~BY+#yGgTdR&Q)Q2{TUpQPiZ@DeF346of}6L$Dp{rv69$MO(^ z?bYgX*LB_&Cp4BYxvs0>R^EBdIa`WwYYQ7d+4|9iR`VY*M<|=%;1$R`g4#uF=WVrC zCfzgjX1)ip+;cg2wC{}0kBW{SXt0RHjDo0R%mes$KroW^ndc}JsSTS49jzG zD(*V*<$1&{G6}>;VWSELhvxE06@lT6QY_JFg|fBTrrRp{v_PU6LFBUdEw1pb7LQLA zjtwmd4GMjT@}&q0!V8f~qPb-5s2m^X9DqIW%$m`K_24fmW6iyvh5R^&pzC&otbKTK zP9T*vgvvj~s=jlfllOvnrBg<}P4WCXDix5OaZer|bnDE}h<&Nm9iBYS6cB6m-YUu` zJd|D&G@^2vaJ{~@qeA~|^guAj1zj|+g&IN@c(yjFhFP#gdR)>b|HPQ3kHj3l zdxjIomvVWV}C4m#0z4%!D;GXl(PDIBVJ>;$+CCe(qzP*0uwbokMF~{uA3qhGZ z81&KB+8NJN(&L3JmDqk@^PB9Q{cP-74NGkx?4aRs#K|{}z^75mSw&e9NMfq#VwVsn zf8O}dvbHSlnfG%$V|2`Mrw!-XSs4uM+vhI)xU}Ib?(ol-gac7-SMn zP{p}(#K_J&P}~K+Cnlw#O~$SbdzUPRb>{n&OU?^kGYbqbf4 zUpxy{ZO|&GGE6m}Y3TI`C_LrR`oQ2ES-C_vp9Usu?J(`iE@v zbRz+BljB$A&%p#0A&<$`>wX3;5iyq>#axFg_0NThLyB*%SBLYL4{?)3;+0X|KLWD0rC`Cq zgAEsOk0u6uwT1RH`xt{+2l0FOZ~P4)JQ-~*!WzOmc2hcPBD-8&5LfQ{VkwX0$2j#t z#mOc8H|5S$eCb}U%_PWDJoBUfiVs@O+2P2TRbg$5_r;4t%|2U)y&0!I-&dLt(qC3z zC-l>@a6?Tnfpty%7_2S-jo^e)U=E9C`&U3GtN5h#Z6P9?aY9$t>V&z{;QrK@T8irf zw}q@hi62Gnz-+Bf=wkB3Bvz)BhuTmX06e1~(`;_*lL4xJewcZu0rPEJ=lQ6%{`a0( zg1;oWQMb)cx`F*YF?wF+$sn6AJDO$l( zvve7bev=s!u}F${@6ps9ZF)qid(x=jp2`Rdf7dggdgnjA8#2Ygk=vfX#JeW0BcnJw zm-yttEt$8=4`ZhNCur^q0r@Xm(5fQ?p2bTNj5{UE$AVBpvaTPn1zjuOS`U3N#o~OA$GkK^fxX>)lN+f_(T3~t!AUB_?j>d%E;}Nca`Z^NVZ+6{Z^*9`NVk9)+rWjYIOV3Q7CW3WTyWmJ@BC6Og4fwP(q5L(~*_ zQ+Y-#GtdE^TD|G9eHt-xpmi`kP@N(pkO+L`kTXWXI7ypzDbXgbcJy#{p#*^bHQFiU zUM%aFw!{HIJIuOn)Tkhac_fAAKEH7R&@Fx^_}C5T~H>%P^A z#4rpu5(X+b>Dx>SVus?s2wXbaawl4?dY}+2I1zL|qzF-Sf`AuE1Tq|Ga}*4`LD#?O zi|AKSGxF2O9e&b?zU+2}QGpUY+#Lh|XpG!-s5s@kG*u+@_*QQH_?jA9XA;nBP<`e4 zGN%Plx+wUT3wC#U?MaRMUJfZg&+T!$`X4?q3h>aA(M?LtHH<}RI6ttW5nF9YO(ph; zP90$QG>%2B~hMVvy=kp)f)P7r`)dH>=0qJ^w3^A z%EDX!ZcNDJtHu`oD>_!71))WqowKi-=<>iajM^wQcvpX@5Kk{`YG%X(YBp#7S=WpdOX*Z4hJMyQ=?dEL?_dbcVkx~ZO$w5kS+=@? z?krg+R>eg@SkPa-w$PkS)^}*X)2@`bjE1a#Q$gv-}HUXfrp2qh4_eL_?*pd zDipfY5Ing|NmpOPyxzKbet@=5BHR?R#7iW9aS~dj`xAGcVjvZV`4n1?fDI-YNfB+^ zOw7(9Ip)n)yHO#e0wo;!>STo6r=3QmGP8u0WOm-2e&CT4-4f*CakdI&Neer@vOHOIv6r~fT>#pUs^?`D5o_5$n@M91o6`SYOw%E zDO#hVpTR5kR`aRK34 zKr0FjJ8Mm#vGL^5l*703)99kwn$j~!A(=Iyz)T_=K>Ij5IZ-8vkk~?gn^1+p10%Hwl>tLDw8oceZ%*| z7;V#y{k2S(V%ixs_!eJFZD#f|XR;L_l!op*jHVmztv9(>@BUbtB(#m>uHDbJK0T>tfZ*SQR`!an+PM>#KI|mbnxF+=}^fV<1ilRY&(`s4)Hpce*8f zoowTA_I$BnBt4U+D7lxx?I3f%8y|D^>5)W$Ro}JIB?v1O*kTKq&?G!qpJAt_?U=?e zX}I-r_k~SrfSjfr9EgZIs+JXUZ_^{Pu~E{L<*V1#c+VEJJv^;XZDOl6h!z5jSqY}> zxHKl8rN0=O`&>K~jf}R>@apuo)GkYAQXk%XG5vgEx^%PGq`+4pS`et~^{q-#*=dhD z9miyaT#_bH9GxC7^}U^Kh-dm<|E(N%bK$ak0%D}3a|Waa_t4m4r?WAw=G9hUYFXun z^DX`2DRv9^qwT$q2+t?Bo=dCC!`2rTqXqmYQ-VJ$sCtM|awfapC2Q8ZIHOi{>&{mNV|xmRo$ zQCQ?t`u6Gwb4SKwVm|v7)ol-)#sHHo3Y<;dp(JHsgEZvacyYHH4?K*Ymq;iz#VznP>*xGj1q-5>XKFfM zhHUpu`uTQ^$r2L2)`e?+zF@6$yfoX{_I}VM^Q+GG@CuNz`DbGuj+&9Zq?vtNr&J7Y z`=OqAmVhFAgVfuF+7syPb@#q$U50+m0cmPq(UzPVRE$0<#dNKuF)aK%f+Vw}>GwOf z`mcCMkqLozS_VG5tel9e(&gn?P@A;>5REYWdmfWQ5qkVr4@H^+Sqe9^!LMFgmaD0QuhKREXVXTMU&RXAK zYGB&_>CsqR6?~=}+f(_4FZYe->=_}LIg7)$AOCHmP?cc#LAf;Z?N4)zC6px`l<2So z&=fGvTDT#;u@GW&BqMD{XQ%bmRirUS8&S)^?pVOsU2KkX z>{`p0{S0Gs-fwyeFn)SBT_j&8povK`7s8G-0M}!=KZ{A&QY15CfpZyO* zCK17N{b~90i|L-*E38O)c;$du+Mq$pOPUrXo{4;1AF2GbEa}nvW6?N%2)1aBhECP5 zeGd#AT)ZevD%%2qn!5RQX?o$LLd-@}U4B6guYCg^yt$ur=`vCh{+RHVJPbK^LG+)5~J_piii5NPSs=m;KY*Ox!eFNb<;J- z+qmcb=C8=B2Lc)TrUgBaAcKMYFe#9Z!R8lgb+e0&kMqKFxu8U_qAgu3`m^+f3+3g} zuPro@lI+C{2-Kn5=64Vjc1&&xzV&~K8VNc?*1$9%v<>&nnao+A$w@QA7iYQmT zDv(2JqhGkOEf!`oo#p(sbMcImr|Nj{$_85N@kHM>X(Eg__bg_p1ZWud^ii^#jWNws zfP2;2c#F!wV+YKp0tL@w4SZIf`)?y^WMs&Q*P{CN=Vkn!)!?%{EQr;kJ)+PGqhl`U8Brrn_Ks<9T%!iEd z@0wkgD$(HDLmbXC?bQweMfU;bthdv{nN0%AV|Zz}o6t;4H#+L^&oEGQ*WX6dgYx#- z*8T3S*OO7?Yzw*MZBrE@C}KJwH53nN4Q!~BG>md@yF-hvm-gS@I{IB)@VjV@Z!%Z6 z5ZOkE#6{nv=e2k@pBi3w?V5f4J=a!1=s-D{dh>QoprulG{>a45ugPc&5dDDgd-;Qp zPv`p9FWh9taSeMRAPp;mE=$;2+LAtIZ7&LM`10(X`EeM@ku}L;uL`L_j0gd(qk2V~ zziy8q3tNd zO@{hCaey?D$E<^stcQY_VfJV2xnWXBs5yi*HVrUxm4!CXW^-P4XAtesPj9>JsT`y* zC$#rT({lVE2tNdE5f7aeJP4XaK|L$k6nl+W9Mpiq@$7>Xra_G=;ei-?}^DLwX0)J#0r7i^ECG%u2N4NvIRJfYN$VRdE_H zh}c>On@O8?B1^BJHscZo_cp4D!%j#=BM%F85uNdPV&Z3NVF^#L3-lBV>2JKDq&XQL p9$w8-KGpMo>`MMu4EGViM4vZe=h~m1R|!CerRhnNG9%Zh{{hM6@(}<4 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 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K54sfsm$=7f9rU5C=0*}aI1_o|n5N2eUHAey{ z$XFcY?!>U}oXkrghb7(7*O7r?V?XzwL{=c5uf#Q?Bsf2|M*{7L9o@Md&-y7ymO>g%r~@XEI8EqdWNveoQAcJnJyi1 zb$P&6z;d5q`oyLwx-klhzf-SmTI8zKYb$2`uh#BTRa(^P-(P2M|9vk6>~5gl$ROTH z+wJRvi|?*wu1`Pw@{QZ-x%PGU?R9Phn0E*A=Kl*noLE=)FU{hPE>NUvTbm}U7PW^4)UHdnRLGO4`0)~4Y?c=JsTH|wciUcD;x36L4`2P3a$FTQb*Yxg= z{!~;UTX)&c&NAvwteCVn!=33+YRi*J6%`@Ge0t~M*E`|x0 zF7~;`JGtv<$qrWH0@dy6@8#WHXiyBV zhaVQKpTVLGj~dH{PRskp|5rxG@(AZW|9+=Hk;%X``}dFSiY$j8wl{GXaT^%czU8z# z{4?yJDhIKVdb&~l-R}AQ)x8N@C#L7uKL7jZ_I6huWrlNWrb%wUH%~l}C86rxwW}Lj z%Ox0R%$WUl_1tTI^{X#_eOkc%VM#|4UqaHqsG3?68M*oU9|ZLfPFmNP3^eumzeO8L zA$*3M8h`qfrhJ!w*SE>vK0f00>3jJ$e?LFC?i!oy#_+*%@7buY@!Ph`pMNDK%j)s-NPI#bw01zq+0Jy&Z;231#RskSD8UWVq z06;kd0C>DJ8%$Ke55W(t^qdR~05On;12705@OKY?kD(6X|36v>av7lehYtmSSXTh{ zZy7_d{QI&4pTFDuZ%LO6`L{I4%Z2_&8Up0f{f7oxKxyEm&rugxFnQmw_5%Rcj=vvB zsyHh@0G#=yr=@W_7_ymV;K$W{>PHRm2l&}nT5=nLxL#I}kMBbQ&y54-Rt;iokg^Rw(>G@-R-3z=wC?kHxA2mv(kG(;lTa%4P9H zJ(AP^AiF<&air4pz0~6$;#X-Qmpe>;{?5u{HfPfg&;p=yaGJzKS*d4fwPDkt>C<@& zyC3-C*umLoS=#&Oc@|G!9Pk8K=+}>OFEA*@tg?tcV`$$ue1)yE?uiOt*8|(ze`JVh zrY*#94Sb>0<6^Y5!f}b?=-QhlvA@QdbMsc$svHUJ?y$a&>eYh zIY2+I`1;$AyAgBas`96TR@v>nP8@sa&C@Z@6C#!9v zU+vfwLC%A{Eb@BS_N*YWGU7p%ctM>;D{I%D5$nk4ap&^Ue6J->=%-KPySq(Rqd5Sq zCHmUKD`<4SLneXiaoK`tTo&W6pg^Gw=LFTp$gsq~>99ZT1GU3UA<{JUIzUS-x;PY;n>K3wq=XzN1(5bxm`)fI=sgokOl z=-Zn&L-?8vGf9ipqBCO~!FpK!hb5%}U5Sj#*Z$ft0+*zS;M)J$v!_9>KRf$qr)fKp z#7cK;h%5O$K3?-pd5QMEmM$z;f~6R$Bjqj2DwMWdgg&%p3;!~lw}E=RWVck2ZR zJm<}>hdVB#QjfnS2z@^V;v#A4rS6_dnt;$0Ir`*o5FuE!b^dPo!H*!w&%_=J{omak zZ2w5%aXE?i-)c#5NQr)%no<#D9l?J65###?9aVpJo{s|uq$yDx+axClTl*PDN_ z*A+=xDz-ei(b&;lY{nT>6^^PZsdDpiqTXlW9<41;j&#s1y6#`s0V;dO~C0X>pY{a$M!@?P6= zlVejA9t@yfYnvt68P+wETTP$XN*b4pX}uEsZF?erYeZO+o5S`=6d){W?fl#;?M3q> zyNklSwKAXf3B3J<0iedd;?=i@MHvIS?)mKtVtQs@j}VjRyAy#=XSL%83Bx8~ha%q;W$0AVUd5#$r0Wcnp4D!W^++P!-3OBNg+MEbvGWS>g1t_{i z%}S)x#Ckn#PV%%~aYcg|*nHuo{>udCwE5-85Q9B>JJ4!f z+%Q{5(Yc=rF^L6@1M9rsDC4ilMbVbT5(i4@F>Baw>| z5|RSS>UVAyJd61gvSRB0pvqCG-7@SJzU|`IrC+Z8L(tKeGS)C<$eIInxr=1d9Csl_ z1at>ELo2P^k#Anp=*=Q}1wXZ@FgL7u66f+X+c<|_6f$z&uX7!*g=J=?THU#iM8ez9o=*~T4YaR%yhpW1FJzwUst6kVNjdAAQO32wc zI>9A%MaAwUz4m#v@zc3s@;ue&(17EHY(znaxoB+H^_nK4O7xlV`R0;hOY89^O;l1#~_EJI1_2|TFzvT6p_cBVF;dAV{NkkcDH8%W3 zvKLdxJW@Ww@L=HJA^Jjl9R_Q(R<_<({w$FWfm7IwHzqLMl8d+&{BgbUp|nT&x8tUI zf;O({+#|2DVqadU@zSW~gfr^-$YH=68msUj$}tX5ydoFxKu`Ctx*ia6s1e!)kg!gFSd9;ejWdvZHi5fAYN;7cZLs{fCHUmv``#xgpo( zw*ERHn4OOCcqtz&D&224sb6fft+Sj&LSeEhQCQYPL!=j)Re1oFj=-VTP@ASqT%NQB zSqW%+t?j)EZCnkbjOK=H z3u?`z3deX^UI&raaH+C{3*t$mZTE=BK66$#TtX}+3I0zy#ncFu)C zU9camS8VJ#Ot9qFgL2K`Qv;poMut}W@qcGSmq5XXJk2OOZob9!ZVWNIR?hC-tZ~QIl^}{@Ep{x zmU+Pzk>NlKTJxJKcvr8q5@$EDQE__gLyiU$#syt%lUsVedbx~ zK;WoG*O`98B)_E*he}(&(@C5ny8gzm7M^oJ08!nYM$M9Y(!U~hMH+?;lE>!LgA~mq3D|;i0O=IjYEc8jas|YfYhgT2 zSu>I43)B7Y-&`PYLZpftO(<&E=$AXQrzT7`+l1+(JDJT&yxw7JwAwkMYRkv|3RM3| z*K%MJr%UgJX{JRHDB%p?N%P;HmcH=Xex2Aig@g*HD7CSZYoEnZ?_%06#{=~O%`WkP zr!bG>Q5;5y-tH-t(<}7AjfR9WBq&9hC16_*iFBu@pYe!637j}Ba{Bt&)5C|=mp&`4 z6HmT+@IArlIqpjvjJB!-ISN*CsX2s?{PmMoMS41G77z5PQMxCE(pB&}OfB+cqG+Df zHil3Uzx-K?{qT@a?dg2F4)Xq|GtM-LYPS8KKMD)*n?lg}?{pg-z5@>LE8y}vz_*0i zOxaKg=%~U3#c-~mZUQRk5xd`mkg}<|G?aN|M1^n`O6Bn zdcE55Qfm+tJ=9?C9`07X4@K~;2g!&ml3FPp^@`-N;oTM9r@C6m%-F4udjtb(t)CKE zkt1o+zkTmWv<@H#)LOD0%bvJh0VWSMqp!BAE)}cr*VW5PW}=`a4UheY62#*NHaG}5 zTkktCW#k(4?C4Dnd`bPVDd4ityFSCON{p<1LUqs3Lt&(#{WWRn%xiDn?v%CpzM}33 zjkdDYhME`Yj`U<8p)cQ=s6gz<#I8QlZlcahgIj0Sc2Z{xGbEu$d;RZs_LZ`oXe7bP zM`f1gJvBw}VSS*QTAPNYm>)HcJn`diHq?y=_PE3v;|u#qE0+>x6GvsVaE5&z&69AP zEA5f{Lwl?7!vJvNANUUqA00LPMg27*i-(^YwzR%!&H~{jJToO8^cz)4ISna<6~XR1={+nM7n<{AuT;c)Le6P_@DQWa zyl;I^R)cNHeMYIH6Y>JMEAi4ea4eOBQ)UFGLrk(-LCtwu@Zop#pH!Tkp zkrpf9M3VL&yd|Iqq{N$sOTR1?P($gcXfFmyWw+BzJ10zl^ep_JUzJ= zXK4r&@}UW7%5{-M%GB@tWd-%FjE}35uZri#gZ*BQ%{Qvt4!JiG!(CMHE)HIR8(+3 z842~jJ?AkTme@Xd7{6|u8pZbd=0#&B$Y8|cR4D|jts;E>_?$LLm#XO691#N_WMtY| zsqL~S9X`Mdyt{>he-xVg1h|mP--xeaU^<_wP^h`$E_>c)6;1!tsxc@+UCJ_)gJ~=4 zv#oa#J`XkbvTL4lqVM6Qzhx$~L23R1cXqF~rurwrDf~yOYOL*t@iMvZi0ik2!n5d)scuo!jbxE~Fzy}2Q{=Jy z%4#M>cAPxf>&;!!D^S`|pU1@}POJOshilPA9c78+%U_R$Y$WjC=#l=CGHb@ZJ*wfK zQ52B?Zamoah7XsI&}H4|qKDW6;{N407H9!8ZeN0Prv@F6tByWSxUR8A^}A-SCW-CrUM?vUg7JyBr&o8?vM9g}`QDo`u4B%7u zwBVRufGtuhc%z@>Cqxoype|1!-0h6 zihaFXiU_=3VtXrPjq%q6G0yNSoZrpB&K{ECL@QffD-MW1n_8vHIyM>V1lD@)gGb)$(qE96`z#tD+kVHx^Pa;3urKv155 zbqv-o@eZ3d1W(U&pBunamsn6;^T;_i{12#Iia>bZXp(*BUcY34BR}2&%nTd9bkdKK@MI7BX{8s*VdV#++dhY4>LiyaDZp@k-y15yPA*ty#5R)wPTqtL}t~XYoY9g`~`ZxnV$0+fb|Qx?R-&0O@0vwg3PC From 2254a87591aad99ce91a11954510b6c59a198bf6 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:30:27 +0200 Subject: [PATCH 35/67] 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 7acd3c846bd9be35efa627120b450e9201e29254 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:33:12 +0200 Subject: [PATCH 36/67] 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 d61cdfe2b87155f266bb8c347fa077a818f8940e Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:33:45 +0200 Subject: [PATCH 37/67] 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 6250f4d64877c838c437bc6db2089ef7fa8595a9 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:34:13 +0200 Subject: [PATCH 38/67] 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 f9e137c38ec5f39f099e01d691d5f38f9d8ce009 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 15 Jun 2026 22:34:21 +0200 Subject: [PATCH 39/67] 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 83dd6d1aa841a798dfaff9e2c698b3051d569579 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 10:24:03 +0200 Subject: [PATCH 40/67] 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 1996b086e22994160c52c2725f42e99fb02e2472 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 10:24:16 +0200 Subject: [PATCH 41/67] 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 d2f9e47a0e027565e51f40a360a7fb2002ba8899 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 18 Jun 2026 11:27:25 +0200 Subject: [PATCH 42/67] 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 60d4fc2a453b50d109a1453018bfcdb0fd25f4ea Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 19 Jun 2026 09:47:44 +0200 Subject: [PATCH 43/67] 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 44/67] 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 From bdc7ed7d4208d9e47bbd60c32cd7a0230e15a473 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 24 Jun 2026 17:15:47 +0200 Subject: [PATCH 45/67] fix(image-cropper-web): swap placeholder to SVG and fix editor preview captions --- .../src/ImageCropper.editorPreview.tsx | 14 +++++++++----- .../src/__tests__/ImageCropper.editor.spec.tsx | 7 ++++--- .../src/assets/cropper-placeholder.png | Bin 5459 -> 0 bytes .../src/assets/cropper-placeholder.svg | 5 +++++ .../src/components/CropArea.tsx | 1 - 5 files changed, 18 insertions(+), 9 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index a7eaca0725..3741fe40c2 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -1,12 +1,12 @@ -import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import classNames from "classnames"; import { ReactElement, createRef, useState } from "react"; import { type Crop } from "react-image-crop"; +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; +import CropperPlaceholderIcon from "./assets/cropper-placeholder.svg"; 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; @@ -59,7 +59,13 @@ function StaticCropPreview(props: { imageUrl: string; values: ImageCropperPrevie } export function preview(props: ImageCropperPreviewProps): ReactElement { + // Narrow on the object (not a derived boolean) so TS keeps .imageUrl / .entity typed. const staticImage = props.image?.type === "static" ? props.image : undefined; + const dynamicEntity = props.image?.type === "dynamic" ? props.image.entity : undefined; + + // Dynamic bindings carry only the entity name — no design-time pixels — so they still show the + // placeholder, but the caption must reflect that an attribute IS bound (not "nothing selected"). + const caption = staticImage ? describeConfig(props) : dynamicEntity || "[No image selected yet]"; return (
)}
-

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

+

{caption}

); } diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index b220d1828a..78cc9f802e 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -81,11 +81,12 @@ describe("ImageCropper design mode (preview)", () => { expect(getByText("[No image selected yet]")).toBeInTheDocument(); }); - test("treats a dynamic image as not previewable (glyph + empty caption)", () => { + test("shows the bound entity for a dynamic image (placeholder glyph, not previewable)", () => { const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" } }); - const { container, getByText } = render(preview(props)); + const { container, getByText, queryByText } = render(preview(props)); expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); - expect(getByText("[No image selected yet]")).toBeInTheDocument(); + expect(getByText("MyModule.Photo")).toBeInTheDocument(); + expect(queryByText("[No image selected yet]")).toBeNull(); }); test("renders the real image and config caption for a static image", () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png deleted file mode 100644 index 0afd4095b5e85963c572a5e2de7b048f797217b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5459 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 akU)-JnrRiF1QGJg6VTH(*1~AOqW%Looanp& diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg new file mode 100644 index 0000000000..51a1a51c51 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index f9008bd9e7..fc769e76c7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -113,7 +113,6 @@ export function CropArea(props: CropAreaProps): ReactElement { ref={props.imageRef} src={safeSrc} alt="" - crossOrigin="anonymous" style={{ width: displaySize?.width, height: displaySize?.height, From bf88967b7525e0156b815b58c5ff41e7ec1ef0ea Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 25 Jun 2026 10:10:30 +0200 Subject: [PATCH 46/67] feat(image-cropper-web): flip/grayscale toolbar polish and crop-commit fixes Terminology: - rename user-facing "Rotate" to "Flip" (toolbar, tooltips, enableFlip property) - rename "Black and white"/"B&W" toggle to "Grayscale" Toolbar: - single-row layout: flip-left, flip-right, grayscale, zoom slider, reset - flip buttons use SVG icons; square, icon-only styling - always-on native tooltips on every toolbar button Layout: - size widget to its image and render block-level so sibling widgets stack below - editor design-mode preview fills available width - remove redundant "Image cropper" label above the design-mode preview Behavior: - keep a color working image on flip so grayscale stays reversible; bake grayscale only into the committed file when the toggle is on - gate crop auto-commit on a genuine user drag so editing one cropper does not commit sibling instances in a list Tests: rename specs for flip/grayscale; add grayscale-reversibility and multi-instance commit regression specs. --- .../image-cropper-web/jest.config.js | 16 +- .../src/ImageCropper.editorPreview.tsx | 1 - .../image-cropper-web/src/ImageCropper.xml | 12 +- .../__tests__/ImageCropper.editor.spec.tsx | 2 +- .../src/__tests__/ImageCropper.spec.tsx | 8 +- .../__tests__/ImageCropperGrayscale.spec.tsx | 193 +++++++++++++++++ .../ImageCropperMultiInstance.spec.tsx | 197 ++++++++++++++++++ .../__tests__/ImageCropperRotation.spec.tsx | 32 ++- .../src/assets/flip-left.svg | 3 + .../src/assets/flip-right.svg | 3 + .../src/components/CropArea.tsx | 2 + .../src/components/CropToolbar.tsx | 50 +++-- .../src/components/ImageCropperContainer.tsx | 57 +++-- .../components/__tests__/CropToolbar.spec.tsx | 42 ++-- .../src/ui/ImageCropper.scss | 47 ++++- .../src/utils/rotateImage.ts | 5 +- .../typings/ImageCropperProps.d.ts | 4 +- 17 files changed, 593 insertions(+), 81 deletions(-) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg create mode 100644 packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg diff --git a/packages/pluggableWidgets/image-cropper-web/jest.config.js b/packages/pluggableWidgets/image-cropper-web/jest.config.js index 8ee98da701..1340950f89 100644 --- a/packages/pluggableWidgets/image-cropper-web/jest.config.js +++ b/packages/pluggableWidgets/image-cropper-web/jest.config.js @@ -1,6 +1,20 @@ const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); +const { join } = require("path"); + +// Override the SVG transform: the base config returns a React component for *.svg imports, +// but we import SVGs as URL strings (declare module "*.svg" { const content: string }). +// Using assetsTransformer returns the filename as a plain string, matching the runtime behaviour +// and avoiding the React "Invalid value for prop `src`" warning in tests. +const assetsTransformer = join( + require.resolve("@mendix/pluggable-widgets-tools/test-config/jest.config.js"), + "../assetsTransformer.js" +); module.exports = { ...base, - setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")] + transform: { + ...base.transform, + "^.+\\.svg$": assetsTransformer + }, + setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), join(__dirname, "jest.setup.ts")] }; diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index 3741fe40c2..5f3075bfd3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -72,7 +72,6 @@ export function preview(props: ImageCropperPreviewProps): ReactElement { className={classNames(props.class, "widget-image-cropper", "widget-image-cropper--preview")} style={parseStyle(props.style)} > -

Image cropper

{staticImage ? ( diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index eda2c231e7..ae02851601 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -84,17 +84,17 @@ - - Enable rotation - Show rotate-left / rotate-right buttons. Rotation is baked into the saved image. + + Enable flip + Show flip-left / flip-right buttons. The flip is baked into the saved image. - Enable black and white - Show a grayscale toggle. When on, the saved image is converted to black and white. + Enable grayscale + Show a grayscale toggle. When on, the saved image is converted to grayscale (black and white). Show reset button - Show a Reset button that restores the original image and clears zoom, rotation, and crop. + Show a Reset button that restores the original image and clears zoom, flip, and crop. diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 78cc9f802e..e5b903f174 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -24,7 +24,7 @@ function makePreviewProps(overrides: Partial = {}): Im previewWidth: null, previewHeight: null, resizableEnabled: true, - enableRotation: true, + enableFlip: true, enableGrayscale: true, showResetButton: true, zoomEnabled: true, 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 32f6d8c313..80eeb9585a 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -11,6 +11,7 @@ import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps interface CapturedCropArea { onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; setZoom: (next: number) => void; wheelZoomMode: string; } @@ -21,12 +22,14 @@ jest.mock("../components/CropArea", () => ({ imageRef: Ref; onImageLoad: CapturedCropArea["onImageLoad"]; onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; setZoom: CapturedCropArea["setZoom"]; wheelZoomMode: string; }) => { captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart, setZoom: props.setZoom, wheelZoomMode: props.wheelZoomMode }; @@ -97,7 +100,7 @@ function makeProps(overrides: Partial = {}): ImageCr outputFormat: "png", outputQuality: new Big(0.92), outputSize: "original", - enableRotation: true, + enableFlip: true, enableGrayscale: false, showResetButton: true, onCropAction: actionValue(), @@ -152,6 +155,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -164,6 +168,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -189,6 +194,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx new file mode 100644 index 0000000000..5b889f151d --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -0,0 +1,193 @@ +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"; + +// Integration test: proves grayscale reversibility after flip. + +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; + } + }} + /> + ); + } +})); + +interface CapturedRotateOptions { + rotation: number; + outputFormat: string; + grayscale: boolean; +} +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 {}, + 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, + enableFlip: 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 + }; +} + +describe(" grayscale reversibility after flip", () => { + 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(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("flip with grayscale ON produces a COLOR working image (grayscale:false) so toggling off is reversible", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); // ON + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Flip right")); + await Promise.resolve(); + await Promise.resolve(); + }); + // The WORKING image (the one shown + reloaded into imageRef) must be COLOR. + // With approach B, handleFlip calls rotateImage twice: once color (working), + // once baked (commit). The color call is the one whose result feeds the preview. + const colorWorkingCall = rotateImageOptions.some(o => o.grayscale === false); + expect(colorWorkingCall).toBe(true); + }); + + test("flip with grayscale ON still bakes a B&W file for the committed setValue", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); // ON + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Flip right")); + await Promise.resolve(); + await Promise.resolve(); + }); + // committed file must be the baked one + expect(rotateImageOptions.some(o => o.grayscale === true)).toBe(true); + expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); + }); + + test("flip with grayscale OFF produces only a color file (no baked B&W)", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Flip right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions.every(o => o.grayscale === false)).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx new file mode 100644 index 0000000000..1ea48bef65 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -0,0 +1,197 @@ +import { act, render } 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"; + +// Multi-instance integration test: verifies that auto-commit only fires for the +// cropper instance where the user actually dragged, not for layout-driven completes +// or sibling instances. + +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; +} +const captures: CapturedCropArea[] = []; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; + }) => { + captures.push({ + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart + }); + 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; + } + }} + /> + ); + } +})); + +jest.mock("../utils/cropImage", () => ({ + CropError: class CropError extends Error {}, + cropImage: jest.fn(() => Promise.resolve(new File(["x"], "crop.png", { type: "image/png" }))) +})); + +jest.mock("../utils/rotateImage", () => ({ + rotateImage: jest.fn(() => Promise.resolve(new File(["x"], "rotate.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, + enableFlip: 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(" multi-instance crop gating", () => { + beforeEach(() => { + jest.useFakeTimers(); + captures.length = 0; + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test"; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("layout-driven onCropComplete without a user drag does not commit", async () => { + const image = makeImageProp(); + render(); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // layout-driven complete, NO preceding onUserInteractStart + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).not.toHaveBeenCalled(); + }); + + test("a genuine user drag then onCropComplete DOES commit", async () => { + const image = makeImageProp(); + render(); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + captures[0].onUserInteractStart?.(); + }); // user grabs the crop + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).toHaveBeenCalled(); + }); + + test("editing one cropper does not commit its sibling", async () => { + const imageA = makeImageProp(); + const imageB = makeImageProp(); + render( + <> + + + + ); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + captures[1].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // only A receives a genuine user drag + act(() => { + captures[0].onUserInteractStart?.(); + }); + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + // B receives a layout-driven complete with NO drag signal — must NOT commit + act(() => { + captures[1].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(imageA.setValue).toHaveBeenCalled(); + expect(imageB.setValue).not.toHaveBeenCalled(); + }); +}); 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 818b85cf61..b2033acce7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -11,6 +11,7 @@ import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps interface CapturedCropArea { onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; } let captured: CapturedCropArea; @@ -19,8 +20,13 @@ jest.mock("../components/CropArea", () => ({ imageRef: Ref; onImageLoad: CapturedCropArea["onImageLoad"]; onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; }) => { - captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete }; + captured = { + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart + }; return ( = {}): ImageCr boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, - enableRotation: true, + enableFlip: true, enableGrayscale: true, showResetButton: true, zoomEnabled: true, @@ -151,7 +157,7 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByLabelText("Flip right")); await Promise.resolve(); await Promise.resolve(); }); @@ -160,16 +166,18 @@ describe(" rotation/grayscale integration", () => { expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); }); - test("black & white on then rotate bakes grayscale into the rotated file", async () => { + test("flip with grayscale on bakes grayscale into the COMMITTED file", async () => { + // handleFlip also produces a color working image (first call, grayscale:false); + // the last call is the baked commit (grayscale:true) used for setValue. render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); act(() => { - fireEvent.click(screen.getByLabelText("Black and white")); + fireEvent.click(screen.getByLabelText("Grayscale")); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByLabelText("Flip right")); await Promise.resolve(); await Promise.resolve(); }); @@ -182,7 +190,7 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate left")); + fireEvent.click(screen.getByLabelText("Flip left")); await Promise.resolve(); await Promise.resolve(); }); @@ -195,11 +203,14 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); await act(async () => { - fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByLabelText("Flip right")); await Promise.resolve(); await Promise.resolve(); }); cropImageOptions.length = 0; + act(() => { + captured.onUserInteractStart?.(); + }); act(() => { captured.onCropComplete(PIXEL_CROP); }); @@ -214,7 +225,10 @@ describe(" rotation/grayscale integration", () => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); act(() => { - fireEvent.click(screen.getByLabelText("Black and white")); + fireEvent.click(screen.getByLabelText("Grayscale")); + }); + act(() => { + captured.onUserInteractStart?.(); }); act(() => { captured.onCropComplete(PIXEL_CROP); diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg new file mode 100644 index 0000000000..6e056262b3 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg new file mode 100644 index 0000000000..17063fa892 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/flip-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index fc769e76c7..8f2080dfbf 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -16,6 +16,7 @@ export interface CropAreaProps { crop: Crop | undefined; onCropChange: (crop: Crop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; aspect: number | undefined; circular: boolean; resizable: boolean; @@ -104,6 +105,7 @@ export function CropArea(props: CropAreaProps): ReactElement { crop={props.crop} onChange={(_pixel, percent) => props.onCropChange(percent)} onComplete={pixel => props.onCropComplete(pixel)} + onDragStart={() => props.onUserInteractStart?.()} aspect={props.aspect} circularCrop={props.circular} disabled={!props.resizable} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx index d46ec3cae0..c53d49e3c2 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -1,41 +1,51 @@ import classNames from "classnames"; import { ReactElement } from "react"; +import { ZoomSlider } from "./ZoomSlider"; +import FlipLeftIcon from "../assets/flip-left.svg"; +import FlipRightIcon from "../assets/flip-right.svg"; interface CropToolbarProps { - showRotation: boolean; + showFlip: boolean; showGrayscale: boolean; + showZoom: boolean; showReset: boolean; grayscale: boolean; canReset: boolean; - onRotateLeft: () => void; - onRotateRight: () => void; + zoom: number; + minZoom: number; + maxZoom: number; + onFlipLeft: () => void; + onFlipRight: () => void; onToggleGrayscale: () => void; + onZoomChange: (zoom: number) => void; onReset: () => void; } export function CropToolbar(props: CropToolbarProps): ReactElement | null { - if (!props.showRotation && !props.showGrayscale && !props.showReset) { + if (!props.showFlip && !props.showGrayscale && !props.showZoom && !props.showReset) { return null; } return (
- {props.showRotation && ( + {props.showFlip && ( <> )} @@ -45,17 +55,27 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { className={classNames("btn btn-default widget-image-cropper__tool", { active: props.grayscale })} - aria-label="Black and white" + aria-label="Grayscale" + title="Grayscale" aria-pressed={props.grayscale} onClick={props.onToggleGrayscale} > - B&W + Grayscale )} + {props.showZoom && ( + + )} {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 c4f37371bc..774b8f02a5 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -155,7 +155,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE [setZoom, auto] ); - const handleFlip = useCallback( + const handleRotate = useCallback( async (deltaDeg: number) => { const img = state.imageRef.current; if (!img || props.image.readOnly || props.image.status !== ValueStatus.Available || !props.image.value) { @@ -171,7 +171,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE grayscale: false, originalName: props.image.value.name }); - // Commit a baked B&W file only while the toggle is ON, so a flip-then-Save + // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save // with no further crop still persists grayscale. const committed = grayscaleRef.current ? await rotateImage({ @@ -216,8 +216,8 @@ 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) { - // Mirror handleFlip: setValue defers the commit, so drive the live preview with the - // original bytes too — otherwise a stale rotated/flipped blob keeps rendering after Reset. + // Mirror handleRotate: setValue defers the commit, so drive the live preview with the + // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. showPreviewRef.current(file); markInternalRef.current(); props.image.setValue(file); @@ -301,7 +301,7 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE imageRef={state.imageRef} /> handleFlip(-90)} - onFlipRight={() => handleFlip(90)} + onRotateLeft={() => handleRotate(-90)} + onRotateRight={() => handleRotate(90)} onToggleGrayscale={handleToggleGrayscale} onReset={handleReset} /> 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 index 8f491064a7..8843334f8c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -4,7 +4,7 @@ import { CropToolbar } from "../CropToolbar"; function props(overrides = {}): ComponentProps { return { - showFlip: true, + showRotation: true, showGrayscale: true, showZoom: true, showReset: true, @@ -13,8 +13,8 @@ function props(overrides = {}): ComponentProps { zoom: 1, minZoom: 1, maxZoom: 4, - onFlipLeft: jest.fn(), - onFlipRight: jest.fn(), + onRotateLeft: jest.fn(), + onRotateRight: jest.fn(), onToggleGrayscale: jest.fn(), onZoomChange: jest.fn(), onReset: jest.fn(), @@ -23,14 +23,14 @@ function props(overrides = {}): ComponentProps { } describe("", () => { - test("fires flip and reset callbacks", () => { + test("fires rotate and reset callbacks", () => { const p = props(); render(); - fireEvent.click(screen.getByLabelText("Flip left")); - fireEvent.click(screen.getByLabelText("Flip right")); + fireEvent.click(screen.getByLabelText("Rotate left")); + fireEvent.click(screen.getByLabelText("Rotate right")); fireEvent.click(screen.getByRole("button", { name: "Reset" })); - expect(p.onFlipLeft).toHaveBeenCalledTimes(1); - expect(p.onFlipRight).toHaveBeenCalledTimes(1); + expect(p.onRotateLeft).toHaveBeenCalledTimes(1); + expect(p.onRotateRight).toHaveBeenCalledTimes(1); expect(p.onReset).toHaveBeenCalledTimes(1); }); @@ -40,8 +40,8 @@ describe("", () => { }); test("hides controls when their flags are false", () => { - render(); - expect(screen.queryByLabelText("Flip left")).toBeNull(); + render(); + expect(screen.queryByLabelText("Rotate left")).toBeNull(); expect(screen.queryByLabelText("Grayscale")).toBeNull(); expect(screen.queryByRole("button", { name: "Reset" })).toBeNull(); }); @@ -58,8 +58,8 @@ describe("", () => { test("every toolbar button exposes a native title tooltip", () => { render(); - expect(screen.getByRole("button", { name: "Flip left" })).toHaveAttribute("title", "Flip left"); - expect(screen.getByRole("button", { name: "Flip right" })).toHaveAttribute("title", "Flip right"); + expect(screen.getByRole("button", { name: "Rotate left" })).toHaveAttribute("title", "Rotate left"); + expect(screen.getByRole("button", { name: "Rotate right" })).toHaveAttribute("title", "Rotate right"); expect(screen.getByRole("button", { name: "Grayscale" })).toHaveAttribute("title", "Grayscale"); expect(screen.getByRole("button", { name: "Reset" })).toHaveAttribute("title", "Reset"); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 990ab66612..96665cc8c1 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -44,6 +44,12 @@ $image-cropper-gray-light: #6c757d; } } + // The "Zoom" text sits in a ; Atlas styles labels bold, so set the weight on the + // span itself (class specificity) to render it regular like the other toolbar labels. + &__zoom-label { + font-weight: normal; + } + &__preview { border: 1px solid #ddd; background: #fff; @@ -68,7 +74,7 @@ $image-cropper-gray-light: #6c757d; } } - // Icon-only buttons (flip left/right). Atlas .btn owns the vertical box + // Icon-only buttons (rotate left/right). Atlas .btn owns the vertical box // (line-height + padding-y + border) → height auto-matches Grayscale/Reset; // we do NOT touch it (Atlas sizes win — frontend-guidelines). We only neutralise // the text-button min-width and equalise horizontal padding to the .btn vertical @@ -83,7 +89,7 @@ $image-cropper-gray-light: #6c757d; // Atlas's `.btn img` rule styles this icon for the "icon + text" case: // height: calc(var(--font-size-default) + 4px); margin-right: 4px; // vertical-align: text-top; - // Our flip buttons are icon-ONLY, so that right margin pushed the icon + // Our rotate buttons are icon-ONLY, so that right margin pushed the icon // off-centre (widening the box) and text-top shoved it up. We KEEP Atlas's // icon height (convention: Atlas sizes win) and only neutralise the two // text-affordance properties — which centres the icon and tightens the box. diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts index b9f0cb2bcc..3b9de97f74 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts @@ -33,7 +33,7 @@ export async function rotateImage(options: RotateImageOptions): Promise { ctx.fillRect(0, 0, canvas.width, canvas.height); } if (grayscale) { - // Bake B&W for the COMMITTED file only. handleFlip keeps a separate color + // Bake B&W for the COMMITTED file only. handleRotate keeps a separate color // working image so toggling grayscale off stays reversible; this baked file // is what gets persisted via setValue while the toggle is on. ctx.filter = "grayscale(1)"; diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 3b16b48341..9ca354fff7 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -34,7 +34,7 @@ export interface ImageCropperContainerProps { previewWidth: number; previewHeight: number; resizableEnabled: boolean; - enableFlip: boolean; + enableRotation: boolean; enableGrayscale: boolean; showResetButton: boolean; zoomEnabled: boolean; @@ -70,7 +70,7 @@ export interface ImageCropperPreviewProps { previewWidth: number | null; previewHeight: number | null; resizableEnabled: boolean; - enableFlip: boolean; + enableRotation: boolean; enableGrayscale: boolean; showResetButton: boolean; zoomEnabled: boolean; From 732d1b761f4217e70f55c56a73e76d241c54a321 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 3 Jul 2026 15:35:15 +0200 Subject: [PATCH 53/67] docs(image-cropper-web): add openspec --- .../image-cropper-web/openspec/config.yaml | 20 ++ .../openspec/specs/image-cropper/spec.md | 249 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/pluggableWidgets/image-cropper-web/openspec/config.yaml create mode 100644 packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml b/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml new file mode 100644 index 0000000000..392946c67c --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md new file mode 100644 index 0000000000..3cdcbb96f9 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -0,0 +1,249 @@ +# image-cropper Specification + +## Purpose + +The Image Cropper widget lets a Mendix end user crop, rotate, and recolor an image bound to +an image attribute, writing the edited result back to that same attribute. It is a client-side +widget: all image transforms happen on an HTML canvas in the browser, and the edited bytes are +pushed to the Mendix runtime via `EditableValue.setValue`. This spec captures the behavior of +the shipped widget (v1.0.0) as a baseline. + +## Requirements + +### Requirement: Image source binding and lifecycle states + +The widget SHALL bind to a single required image attribute and SHALL render distinct states for +the Mendix value lifecycle. All edits (crop, rotate, grayscale, reset) SHALL write back to that +same attribute via `setValue`. + +#### Scenario: Loading state + +- **WHEN** the bound image value status is `Loading` +- **THEN** the widget SHALL render a placeholder container marked `aria-busy="true"` +- **AND** SHALL NOT render the crop area or toolbar + +#### Scenario: No image available + +- **WHEN** the bound image status is not `Available` or has no value +- **THEN** the widget SHALL render an empty state reading "No image" + +#### Scenario: Read-only attribute + +- **WHEN** the bound image is `readOnly` +- **THEN** editing operations SHALL NOT call `setValue` and the attribute SHALL be left unchanged + +### Requirement: Crop shape + +The widget SHALL support a rectangular and a circular crop shape, selected by the `cropShape` +property. + +#### Scenario: Circle shape masks corners + +- **WHEN** `cropShape` is `circle` +- **THEN** the on-screen selection SHALL be rendered as a circle +- **AND** the exported image SHALL be clipped to an ellipse inscribed in the crop rectangle so the corners are transparent (PNG) or filled (JPEG) + +#### Scenario: Rectangle shape + +- **WHEN** `cropShape` is `rect` +- **THEN** the full crop rectangle SHALL be exported with no corner masking + +### Requirement: Aspect ratio + +The widget SHALL constrain the crop selection to the ratio chosen in `aspectRatio`, supporting +free-form, preset ratios, and a custom ratio built from `customAspectWidth` / `customAspectHeight`. + +#### Scenario: Preset ratio locks proportions + +- **WHEN** `aspectRatio` is a preset (`square` = 1:1, `landscape16x9` = 16:9, `landscape4x3` = 4:3, `portrait3x4` = 3:4) +- **THEN** the crop selection SHALL keep that width-to-height proportion while being resized + +#### Scenario: Free ratio + +- **WHEN** `aspectRatio` is `free` +- **THEN** the crop selection SHALL be resizable to any proportion + +#### Scenario: Custom ratio + +- **WHEN** `aspectRatio` is `custom` and both `customAspectWidth` and `customAspectHeight` are greater than 0 +- **THEN** the crop selection SHALL be locked to `customAspectWidth / customAspectHeight` +- **AND** if either value is not greater than 0, the crop SHALL fall back to free-form + +### Requirement: Default crop selection + +On image load, the widget SHALL seed a default crop box centered on the image at the resolved +aspect ratio. + +#### Scenario: Initial box covers 80% centered + +- **WHEN** an image finishes loading +- **THEN** the default selection SHALL cover 80% of the image, centered, at the resolved aspect ratio (falling back to the image's own ratio when free) + +### Requirement: Resizable handles + +The widget SHALL show or hide the selection's resize handles based on `resizableEnabled`. + +#### Scenario: Handles disabled + +- **WHEN** `resizableEnabled` is `false` +- **THEN** the user SHALL NOT be able to resize the selection by dragging its corners + +### Requirement: Crop persistence (auto-apply) + +The widget SHALL persist the edited image to the bound attribute automatically, with no manual +"apply" button. Direct crop-box edits SHALL be written back immediately on pointer release, while +zoom and grayscale changes SHALL be written back on a 400 ms debounce so rapid changes collapse +into a single write. Programmatic changes to the crop box (image load, source change, reset) SHALL +NOT trigger an auto-apply. + +#### Scenario: Crop box edit applies immediately + +- **WHEN** the user moves or resizes the crop box and releases the pointer +- **THEN** the cropped result SHALL be written back to the bound attribute immediately, cancelling any pending debounced write + +#### Scenario: Zoom and grayscale apply on a debounce + +- **WHEN** the user changes the zoom level or toggles grayscale +- **THEN** the edited result SHALL be written back after a 400 ms debounce +- **AND** further zoom or grayscale changes within that window SHALL reset the timer so only one write occurs + +#### Scenario: Programmatic re-seed does not auto-save + +- **WHEN** the crop box is re-seeded programmatically (image load, bound source change, or reset) +- **THEN** the widget SHALL NOT auto-apply a crop, because auto-apply only fires after a real user interaction + +### Requirement: Zoom + +The widget SHALL let the user zoom the image between `minZoom` and `maxZoom` via a slider and/or +the mouse wheel, gated by `zoomEnabled`, `showZoomSlider`, and `wheelZoomMode`. + +#### Scenario: Zoom master switch off + +- **WHEN** `zoomEnabled` is `false` +- **THEN** the slider and mouse-wheel zoom SHALL be disabled and the image SHALL stay at 1× + +#### Scenario: Slider hidden but wheel active + +- **WHEN** `zoomEnabled` is `true` and `showZoomSlider` is `false` +- **THEN** the zoom slider SHALL be hidden while mouse-wheel zoom remains available per `wheelZoomMode` + +#### Scenario: Wheel zoom modes + +- **WHEN** `wheelZoomMode` is `onWithCtrl` +- **THEN** the wheel SHALL zoom only while Ctrl is held, leaving normal page scroll otherwise +- **AND** when `wheelZoomMode` is `on` the wheel SHALL always zoom, and when `off` the wheel SHALL never zoom + +#### Scenario: Zoom anchored on the crop-box center + +- **WHEN** the zoom level changes +- **THEN** the zoom SHALL be anchored at the crop box's current center so the framed region stays put on screen +- **AND** the exported pixels SHALL use the same anchor so the saved image matches the on-screen framing + +### Requirement: Rotation + +When `enableRotation` is true, the widget SHALL show rotate-left and rotate-right buttons that +rotate the image in 90° steps and bake the rotation into the saved image. + +#### Scenario: Rotate buttons hidden + +- **WHEN** `enableRotation` is `false` +- **THEN** the rotate-left / rotate-right buttons SHALL NOT be shown + +#### Scenario: Rotate bakes into the image + +- **WHEN** the user clicks rotate-left (−90°) or rotate-right (+90°) +- **THEN** the image SHALL be redrawn on a canvas sized to the rotated dimensions and written back to the attribute +- **AND** a live preview of the rotated (color) pixels SHALL be shown immediately, before the Mendix commit lands + +#### Scenario: Rotation preserves grayscale reversibility + +- **WHEN** the grayscale toggle is on and the user rotates +- **THEN** the committed file SHALL be baked black-and-white, while the working image kept in memory SHALL remain color so toggling grayscale off stays reversible + +### Requirement: Grayscale + +When `enableGrayscale` is true, the widget SHALL show a grayscale toggle; enabling it SHALL render +the image gray on screen and convert the saved image to black-and-white. + +#### Scenario: Grayscale toggle hidden + +- **WHEN** `enableGrayscale` is `false` +- **THEN** the grayscale toggle SHALL NOT be shown + +#### Scenario: Grayscale on + +- **WHEN** the grayscale toggle is on +- **THEN** the crop area SHALL render the image with a grayscale CSS filter +- **AND** the exported image SHALL be drawn with `grayscale(1)` so the saved bytes are black-and-white + +### Requirement: Reset + +When `showResetButton` is true, the widget SHALL show a Reset button that restores the original +image and clears zoom, rotation, and grayscale, re-seeding the default crop box. + +#### Scenario: Reset button availability + +- **WHEN** `showResetButton` is `true` and the original image bytes were captured +- **THEN** the Reset button SHALL be enabled; if the original could not be captured it SHALL be disabled + +#### Scenario: Reset restores original state + +- **WHEN** the user clicks Reset +- **THEN** zoom SHALL return to `minZoom`, grayscale SHALL turn off, the original image bytes SHALL be written back, and the default 80%-centered crop box SHALL be re-seeded +- **AND** the reset itself SHALL NOT trigger an auto-apply write of a crop + +### Requirement: Live preview pane + +When `showPreview` is true, the widget SHALL render a live thumbnail of the current crop next to +the crop area at the configured `previewWidth` × `previewHeight`. + +#### Scenario: Preview reflects current crop + +- **WHEN** `showPreview` is `true` +- **THEN** a thumbnail SHALL be shown reflecting the current crop, zoom, grayscale, and crop shape (circular mask when `cropShape` is `circle`) + +#### Scenario: Preview hidden + +- **WHEN** `showPreview` is `false` +- **THEN** no preview thumbnail SHALL be rendered + +### Requirement: Output encoding + +The widget SHALL encode the saved image according to `outputFormat`, `outputQuality`, and +`outputSize`. + +#### Scenario: PNG output + +- **WHEN** `outputFormat` is `png` +- **THEN** the file SHALL be encoded as `image/png` with a `.png` extension and transparency preserved, ignoring `outputQuality` + +#### Scenario: JPEG output + +- **WHEN** `outputFormat` is `jpeg` +- **THEN** the file SHALL be encoded as `image/jpeg` with a `.jpg` extension, a white background filled behind the image, and `outputQuality` clamped to 0.0–1.0 + +#### Scenario: Output resolution + +- **WHEN** `outputSize` is `original` +- **THEN** the crop SHALL be exported at the source (natural) resolution of the cropped region +- **AND** when `outputSize` is `viewport` the crop SHALL be exported at the canvas dimensions (`boundaryWidth` × `boundaryHeight`) and the attribute thumbnail size SHALL be set to match + +### Requirement: On crop event + +When configured, the widget SHALL run the `onCropAction` each time a crop is auto-applied. + +#### Scenario: Action runs on apply + +- **WHEN** a crop is written back to the attribute and `onCropAction.canExecute` is true +- **THEN** the widget SHALL call `onCropAction.execute()` + +### Requirement: Canvas sizing + +The widget SHALL scale the on-screen crop area to fit within `boundaryWidth` × `boundaryHeight` +without changing the saved image resolution. + +#### Scenario: Image scales to fit the canvas + +- **WHEN** the source image is larger than `boundaryWidth` × `boundaryHeight` +- **THEN** the rendered image SHALL scale down to fit, and the canvas SHALL wrap the rendered image so smaller crops produce a smaller canvas with no blank gaps +- **AND** the on-screen scaling SHALL NOT change the exported resolution From c236eb276e2725c00e406e7cfbe633683663620d Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 6 Jul 2026 12:49:19 +0200 Subject: [PATCH 54/67] test(image-cropper-web): align editor spec with structure preview refactor --- .../image-cropper-web/CHANGELOG.md | 4 +++- .../src/__tests__/ImageCropper.editor.spec.tsx | 15 +++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index c34e6e44bd..bc5fa0bfdd 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -6,6 +6,8 @@ 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. +- 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. diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 78cc9f802e..2ad1a9a6f9 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -50,17 +50,12 @@ function collectText(node: any): string[] { } describe("ImageCropper structure mode (getPreview)", () => { - test("shows the widget title", () => { + test("shows the configure placeholder when nothing is bound", () => { const texts = collectText(getPreview(makePreviewProps(), false)); - expect(texts).toContain("Image cropper"); + expect(texts).toContain("[Configure 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", () => { + test("shows config summary caption when an image is bound", () => { const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" }, cropShape: "circle", @@ -69,8 +64,8 @@ describe("ImageCropper structure mode (getPreview)", () => { outputSize: "viewport" }); const texts = collectText(getPreview(props, false)); - expect(texts).toContain("Circle · 1:1 · JPEG · Viewport"); - expect(texts).not.toContain("[No attribute selected]"); + expect(texts).toContain("[Circle · 1:1 · JPEG · Viewport] Image Cropper"); + expect(texts).not.toContain("[Configure Image Cropper]"); }); }); From 41e22c35cada735ff355242a6de7f6cf6ebd3629 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 8 Jul 2026 17:23:12 +0200 Subject: [PATCH 55/67] feat(image-cropper-web): remove live preview pane Remove the showPreview / previewWidth / previewHeight properties and the PreviewPane component that rendered a live crop thumbnail next to the canvas. Drops the XML property group, editor hide-logic, SCSS rule, spec requirement, and the related test mock props. The usePreviewSrc hook (rotate/reset live feedback) and the Studio Pro editor preview machinery are unrelated and left intact. --- .../openspec/specs/image-cropper/spec.md | 15 ----- .../src/ImageCropper.editorConfig.ts | 4 -- .../image-cropper-web/src/ImageCropper.xml | 14 ---- .../__tests__/ImageCropper.editor.spec.tsx | 3 - .../src/__tests__/ImageCropper.spec.tsx | 3 - .../__tests__/ImageCropperGrayscale.spec.tsx | 3 - .../ImageCropperMultiInstance.spec.tsx | 3 - .../__tests__/ImageCropperRotation.spec.tsx | 3 - .../src/components/ImageCropperContainer.tsx | 13 ---- .../src/components/PreviewPane.tsx | 62 ------------------ .../components/__tests__/PreviewPane.spec.tsx | 65 ------------------- .../src/hooks/useImageCropperState.ts | 2 +- .../src/ui/ImageCropper.scss | 5 -- .../image-cropper-web/src/utils/cropImage.ts | 2 +- .../typings/ImageCropperProps.d.ts | 6 -- 15 files changed, 2 insertions(+), 201 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md index 3cdcbb96f9..236052138e 100644 --- a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -192,21 +192,6 @@ image and clears zoom, rotation, and grayscale, re-seeding the default crop box. - **THEN** zoom SHALL return to `minZoom`, grayscale SHALL turn off, the original image bytes SHALL be written back, and the default 80%-centered crop box SHALL be re-seeded - **AND** the reset itself SHALL NOT trigger an auto-apply write of a crop -### Requirement: Live preview pane - -When `showPreview` is true, the widget SHALL render a live thumbnail of the current crop next to -the crop area at the configured `previewWidth` × `previewHeight`. - -#### Scenario: Preview reflects current crop - -- **WHEN** `showPreview` is `true` -- **THEN** a thumbnail SHALL be shown reflecting the current crop, zoom, grayscale, and crop shape (circular mask when `cropShape` is `circle`) - -#### Scenario: Preview hidden - -- **WHEN** `showPreview` is `false` -- **THEN** no preview thumbnail SHALL be rendered - ### Requirement: Output encoding The widget SHALL encode the saved image according to `outputFormat`, `outputQuality`, and diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts index 57eb5e3565..726400ea8d 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -20,10 +20,6 @@ export function getProperties(values: ImageCropperPreviewProps, defaultPropertie propsToHide.push("showZoomSlider", "wheelZoomMode", "minZoom", "maxZoom"); } - if (!values.showPreview) { - propsToHide.push("previewWidth", "previewHeight"); - } - if (values.outputFormat !== "jpeg") { propsToHide.push("outputQuality"); } diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index a5163f5cd8..ea35211211 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -61,20 +61,6 @@ Maximum on-screen height of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. - - - Show preview - Show a live thumbnail of the current crop next to the canvas. - - - Preview width (px) - Width of the preview thumbnail. - - - Preview height (px) - Height of the preview thumbnail. - - diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 2ad1a9a6f9..40352bc211 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -20,9 +20,6 @@ function makePreviewProps(overrides: Partial = {}): Im onCropAction: null, boundaryWidth: null, boundaryHeight: null, - showPreview: false, - previewWidth: null, - previewHeight: null, resizableEnabled: true, enableRotation: true, enableGrayscale: true, 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 75e58a8433..7fd8872a79 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -97,9 +97,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx index ca2b6f3259..e43fe42904 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -109,9 +109,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx index 0dd34e3fce..49af30088f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -101,9 +101,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", 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 4e76caad7d..ca2bd62a74 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -118,9 +118,6 @@ function makeProps(overrides: Partial = {}): ImageCr 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", diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 774b8f02a5..ba8092e70f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -4,7 +4,6 @@ import { ReactElement, SetStateAction, useCallback, useEffect, useRef, useState import { type Crop, type PixelCrop } from "react-image-crop"; import { CropArea } from "./CropArea"; import { CropToolbar } from "./CropToolbar"; -import { PreviewPane } from "./PreviewPane"; import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop"; import { useImageCropperState } from "../hooks/useImageCropperState"; @@ -316,18 +315,6 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE 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 deleted file mode 100644 index 2c5383967c..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ReactElement, useEffect, useRef } from "react"; -import type { PixelCrop } from "react-image-crop"; -import { computeSourceRect, type ZoomAnchor } from "../utils/cropImage"; - -interface PreviewPaneProps { - image: HTMLImageElement | null; - pixelCrop: PixelCrop | undefined; - zoom: number; - zoomAnchor?: ZoomAnchor; - width: number; - height: number; - circle: boolean; - grayscale: boolean; -} - -export function PreviewPane({ - image, - pixelCrop, - zoom, - zoomAnchor, - width, - height, - circle, - grayscale -}: PreviewPaneProps): ReactElement { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !image || !pixelCrop || !image.naturalWidth) { - return; - } - - const dpr = window.devicePixelRatio || 1; - canvas.width = width * dpr; - canvas.height = height * dpr; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - ctx.scale(dpr, dpr); - ctx.clearRect(0, 0, width, height); - if (pixelCrop.width === 0 || pixelCrop.height === 0) { - // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. - return; - } - if (grayscale) { - ctx.filter = "grayscale(1)"; - } - if (circle) { - ctx.beginPath(); - ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); - ctx.clip(); - } - const { sx, sy, sw, sh } = computeSourceRect(pixelCrop, image, zoom, zoomAnchor); - ctx.drawImage(image, sx, sy, sw, sh, 0, 0, width, height); - }, [image, pixelCrop, zoom, zoomAnchor, width, height, circle, grayscale]); - - return ; -} 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 deleted file mode 100644 index b7ba8ead09..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 grayscale (canvas mock)", () => { - const { container } = render( - - ); - expect(container.querySelector("canvas")).not.toBeNull(); - }); - - test("draws with the box-center source rect (matches cropImage) at zoom > 1", () => { - const proto = CanvasRenderingContext2D.prototype as unknown as { - drawImage: (...args: unknown[]) => void; - }; - const original = proto.drawImage; - const calls: unknown[][] = []; - proto.drawImage = function (...args: unknown[]) { - calls.push(args); - }; - try { - // natural 1000x800, rendered 400x320 → scaleX=2.5, scaleY=2.5. - // anchor == box center: (100+100/2)/400=0.375, (100+100/2)/320=0.46875 → natural (375,375). - // z=2 → sw=sh=(100/2)*2.5=125; sx=sy=375-125/2=312.5. - render( - - ); - const [, sx, sy, sw, sh] = calls[0] as number[]; - expect(sw).toBeCloseTo(125, 5); - expect(sh).toBeCloseTo(125, 5); - expect(sx).toBeCloseTo(312.5, 5); - expect(sy).toBeCloseTo(312.5, 5); - } finally { - proto.drawImage = original; - } - }); -}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts index f899e713ec..b000864e64 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts @@ -3,7 +3,7 @@ import type { Crop, PixelCrop } from "react-image-crop"; interface ImageCropperState { // liveCrop: % units, updated per pointer move (onChange) — survives container resize. - // committedCrop: px units, set on release (onComplete) — consumed by cropImage/PreviewPane. + // committedCrop: px units, set on release (onComplete) — consumed by cropImage. liveCrop: Crop | undefined; setLiveCrop: Dispatch>; committedCrop: PixelCrop | undefined; diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 96665cc8c1..6b2af98af0 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -50,11 +50,6 @@ $image-cropper-gray-light: #6c757d; font-weight: normal; } - &__preview { - border: 1px solid #ddd; - background: #fff; - } - &__toolbar { display: flex; flex-wrap: nowrap; diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts index ede30f0ea7..462f11a593 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -29,7 +29,7 @@ export const CENTER_ANCHOR: ZoomAnchor = { x: 0.5, y: 0.5 }; * Maps the crop box (in the displayed image's unscaled layout px) to a source rectangle on the * natural image. Zoom is a CSS `transform: scale(z)` anchored on `anchor` (a fixed point that * stays put on screen while scaling), so the visible region shrinks by 1/z about that point. - * Shared by cropImage (export) and PreviewPane so exported pixels match the on-screen framing. + * Used by cropImage (export) so exported pixels match the on-screen framing. * Result is clamped to the image. When anchor == the crop-box center this reduces to the plain * "zoom into the box" mapping. */ diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 9ca354fff7..e208662799 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -30,9 +30,6 @@ export interface ImageCropperContainerProps { onCropAction?: ActionValue; boundaryWidth: number; boundaryHeight: number; - showPreview: boolean; - previewWidth: number; - previewHeight: number; resizableEnabled: boolean; enableRotation: boolean; enableGrayscale: boolean; @@ -66,9 +63,6 @@ export interface ImageCropperPreviewProps { onCropAction: {} | null; boundaryWidth: number | null; boundaryHeight: number | null; - showPreview: boolean; - previewWidth: number | null; - previewHeight: number | null; resizableEnabled: boolean; enableRotation: boolean; enableGrayscale: boolean; From 9b625cea7fa895c75b2f56f901af2bd0497b313d Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 9 Jul 2026 14:36:09 +0200 Subject: [PATCH 56/67] fix(image-cropper-web): add aria-label to reset button --- .../image-cropper-web/src/__tests__/ImageCropper.spec.tsx | 6 +++--- .../src/__tests__/ImageCropperRotation.spec.tsx | 2 +- .../image-cropper-web/src/components/CropToolbar.tsx | 1 + .../src/components/__tests__/CropToolbar.spec.tsx | 8 ++++---- 4 files changed, 9 insertions(+), 8 deletions(-) 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 7fd8872a79..1a01746126 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -247,7 +247,7 @@ describe("", () => { await Promise.resolve(); }); (image.setValue as jest.Mock).mockClear(); - fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await flushApply(); expect((image.setValue as jest.Mock).mock.calls[0]?.[0]).toBeInstanceOf(File); }); @@ -265,7 +265,7 @@ describe("", () => { act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); }); - fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await flushApply(); // Box is re-seeded (not undefined) to the default 80%-centered percent crop. expect(captured.crop).toBeDefined(); @@ -282,6 +282,6 @@ describe("", () => { await Promise.resolve(); await Promise.resolve(); }); - expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Reset crop" })).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 index ca2bd62a74..b3806b507e 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -245,7 +245,7 @@ describe(" rotation/grayscale integration", () => { // Reset should stop showing the rotated preview (revert to original bytes / bound uri). await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); await Promise.resolve(); await Promise.resolve(); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx index 1f66f2a40c..3cc9a727cd 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -75,6 +75,7 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { )} {props.showZoom && ( @@ -68,6 +76,8 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { zoom={props.zoom} minZoom={props.minZoom} maxZoom={props.maxZoom} + label={props.zoomCaption} + ariaLabel={props.zoomAriaLabel} onChange={props.onZoomChange} /> )} @@ -75,12 +85,12 @@ export function CropToolbar(props: CropToolbarProps): ReactElement | null { )}
diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index ba8092e70f..486a3289ae 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -309,6 +309,14 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE zoom={state.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} + rotateLeftLabel={props.rotateLeftLabel?.value ?? "Rotate left"} + rotateRightLabel={props.rotateRightLabel?.value ?? "Rotate right"} + grayscaleCaption={props.grayscaleCaption?.value ?? "Grayscale"} + grayscaleAriaLabel={props.grayscaleAriaLabel?.value ?? "Grayscale"} + resetCaption={props.resetCaption?.value ?? "Reset"} + resetAriaLabel={props.resetAriaLabel?.value ?? "Reset crop"} + zoomCaption={props.zoomCaption?.value ?? "Zoom"} + zoomAriaLabel={props.zoomAriaLabel?.value ?? "Zoom"} onZoomChange={handleZoomChange} onRotateLeft={() => handleRotate(-90)} onRotateRight={() => handleRotate(90)} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx index 2edc53abe7..d0ab6d0f26 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx @@ -4,20 +4,22 @@ interface ZoomSliderProps { zoom: number; minZoom: number; maxZoom: number; + label: string; + ariaLabel: string; onChange: (zoom: number) => void; } -export function ZoomSlider({ zoom, minZoom, maxZoom, onChange }: ZoomSliderProps): ReactElement { +export function ZoomSlider({ zoom, minZoom, maxZoom, label, ariaLabel, onChange }: ZoomSliderProps): ReactElement { return ( 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 index 3032fc7e06..49c9e2b4f7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -13,6 +13,14 @@ function props(overrides = {}): ComponentProps { zoom: 1, minZoom: 1, maxZoom: 4, + rotateLeftLabel: "Rotate left", + rotateRightLabel: "Rotate right", + grayscaleCaption: "Grayscale", + grayscaleAriaLabel: "Grayscale", + resetCaption: "Reset", + resetAriaLabel: "Reset crop", + zoomCaption: "Zoom", + zoomAriaLabel: "Zoom", onRotateLeft: jest.fn(), onRotateRight: jest.fn(), onToggleGrayscale: jest.fn(), @@ -56,6 +64,25 @@ describe("", () => { expect(screen.queryByLabelText("Zoom")).toBeNull(); }); + test("renders custom labels and keeps caption independent from aria-label", () => { + render( + + ); + // aria-label drives the accessible name; caption is the visible text/title. + const resetBtn = screen.getByRole("button", { name: "Undo all crop changes" }); + expect(resetBtn).toHaveTextContent("Undo"); + expect(resetBtn).toHaveAttribute("title", "Undo"); + const grayscaleBtn = screen.getByRole("button", { name: "Black and white toggle" }); + expect(grayscaleBtn).toHaveTextContent("B&W"); + }); + test("every toolbar button exposes a native title tooltip", () => { render(); expect(screen.getByRole("button", { name: "Rotate left" })).toHaveAttribute("title", "Rotate left"); diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index e208662799..4d99c310d0 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -3,7 +3,7 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ActionValue, EditableImageValue, WebImage } from "mendix"; +import { ActionValue, DynamicValue, EditableImageValue, WebImage } from "mendix"; import { Big } from "big.js"; import { CSSProperties } from "react"; @@ -39,6 +39,14 @@ export interface ImageCropperContainerProps { wheelZoomMode: WheelZoomModeEnum; minZoom: Big; maxZoom: Big; + grayscaleCaption?: DynamicValue; + resetCaption?: DynamicValue; + zoomCaption?: DynamicValue; + rotateLeftLabel?: DynamicValue; + rotateRightLabel?: DynamicValue; + grayscaleAriaLabel?: DynamicValue; + resetAriaLabel?: DynamicValue; + zoomAriaLabel?: DynamicValue; outputFormat: OutputFormatEnum; outputQuality: Big; outputSize: OutputSizeEnum; @@ -72,6 +80,14 @@ export interface ImageCropperPreviewProps { wheelZoomMode: WheelZoomModeEnum; minZoom: number | null; maxZoom: number | null; + grayscaleCaption: string; + resetCaption: string; + zoomCaption: string; + rotateLeftLabel: string; + rotateRightLabel: string; + grayscaleAriaLabel: string; + resetAriaLabel: string; + zoomAriaLabel: string; outputFormat: OutputFormatEnum; outputQuality: number | null; outputSize: OutputSizeEnum; From c12814f6fcfbf0154eff7c7fc0751ff41756a869 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 9 Jul 2026 17:23:02 +0200 Subject: [PATCH 58/67] feat(image-cropper-web): style empty state with info color and icon Replace the plain red 'No image' text with an informational placeholder: an inline info-circle icon plus 'No uploaded image to crop', laid out on a single row and colored with Atlas's semantic --brand-info token instead of the error red. The icon inherits the text color via currentColor. --- .../src/__tests__/ImageCropper.spec.tsx | 2 +- .../src/components/ImageCropperContainer.tsx | 21 +++++++++++++++- .../src/ui/ImageCropper.scss | 24 +++++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) 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 eb5f8356c4..df3db0adc4 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -140,7 +140,7 @@ describe("", () => { test("renders empty state when image has no value", () => { const props = makeProps({ image: makeImageProp({ value: undefined }) }); render(); - expect(screen.getByText("No image")).toBeInTheDocument(); + expect(screen.getByText("No uploaded image to crop")).toBeInTheDocument(); }); test("does NOT auto-apply on initial image load (no data mutation without user intent)", async () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 486a3289ae..648a26e3a9 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -267,7 +267,26 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE style={props.style} tabIndex={props.tabIndex} > - No image + + No uploaded image 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 6b2af98af0..3cc522d5f7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -94,12 +94,32 @@ $image-cropper-gray-light: #6c757d; vertical-align: middle; } - &__error, - &--empty { + &__error { padding: 8px; color: #b00; } + // Informational empty state — no image bound yet. Uses Atlas's semantic + // "info" color (not the error red) so it reads as guidance, not a failure. + &--empty { + display: flex; + flex-direction: row; // override the base column so icon + text share one line + align-items: center; + gap: 8px; + padding: 12px; + color: var(--brand-info, #0086d9); + } + + &__empty-icon { + flex: 0 0 auto; + width: 1.5em; // scales with the text via font-size / em + height: 1.5em; + } + + &__empty-text { + line-height: 1.2; + } + &--loading { min-height: 200px; } From fabb009c259bcb82f1b8954a7bcd95d896724659 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 12:36:56 +0200 Subject: [PATCH 59/67] feat(image-cropper-web): make no-image message editable, tidy aria captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a noImageCaption textTemplate (default "No uploaded image to crop") so the empty-state message shown when no image is bound can be edited and translated. Drop the redundant "aria label" wording from the grayscale/reset/zoom caption titles — the Aria labels group already makes their purpose clear. --- .../image-cropper-web/src/ImageCropper.xml | 14 +++++++++++--- .../src/__tests__/ImageCropper.editor.spec.tsx | 1 + .../src/components/ImageCropperContainer.tsx | 4 +++- .../typings/ImageCropperProps.d.ts | 2 ++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index ed39b0543f..c7268821d7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -137,6 +137,14 @@ Zoomen + + No image message + Shown when no image is bound to the image attribute. + + No uploaded image to crop + Geen geüploade afbeelding om bij te snijden + + @@ -156,7 +164,7 @@ - Grayscale aria label + Grayscale Accessible name for the grayscale toggle (announced by screen readers). Grayscale @@ -164,7 +172,7 @@ - Reset aria label + Reset Accessible name for the reset button (announced by screen readers). Reset crop @@ -172,7 +180,7 @@ - Zoom aria label + Zoom Accessible name for the zoom slider (announced by screen readers). Zoom diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index b9f8e05f27..8121eaca81 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -32,6 +32,7 @@ function makePreviewProps(overrides: Partial = {}): Im grayscaleCaption: "Grayscale", resetCaption: "Reset", zoomCaption: "Zoom", + noImageCaption: "No uploaded image to crop", rotateLeftLabel: "Rotate left", rotateRightLabel: "Rotate right", grayscaleAriaLabel: "Grayscale", diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 648a26e3a9..20b9c721af 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -286,7 +286,9 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE /> - No uploaded image to crop + + {props.noImageCaption?.value ?? "No uploaded image to crop"} +
); } diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 4d99c310d0..515e799308 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -42,6 +42,7 @@ export interface ImageCropperContainerProps { grayscaleCaption?: DynamicValue; resetCaption?: DynamicValue; zoomCaption?: DynamicValue; + noImageCaption?: DynamicValue; rotateLeftLabel?: DynamicValue; rotateRightLabel?: DynamicValue; grayscaleAriaLabel?: DynamicValue; @@ -83,6 +84,7 @@ export interface ImageCropperPreviewProps { grayscaleCaption: string; resetCaption: string; zoomCaption: string; + noImageCaption: string; rotateLeftLabel: string; rotateRightLabel: string; grayscaleAriaLabel: string; From 69a287859808f1ead15a2a14a98cc143010100f7 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 12:37:03 +0200 Subject: [PATCH 60/67] docs(image-cropper-web): point README to Mendix documentation --- packages/pluggableWidgets/image-cropper-web/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/README.md b/packages/pluggableWidgets/image-cropper-web/README.md index 0eeb43c514..318c1dcb35 100644 --- a/packages/pluggableWidgets/image-cropper-web/README.md +++ b/packages/pluggableWidgets/image-cropper-web/README.md @@ -1,5 +1 @@ -# Image Cropper - -Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute. - -See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs. +Please see [Image Cropper](https://docs.mendix.com/appstore/widgets/image-cropper) in the Mendix documentation for details. From 25c64fe88fd9983af3df977f23192a9bf11119df Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:06:42 +0200 Subject: [PATCH 61/67] build(image-cropper-web): add mobx, mobx-react-lite, widget-plugin-mobx-kit Dependencies for the MobX crop store: mobx 6.12.3 and mobx-react-lite 4.0.7 (pinned to match the rest of the monorepo) plus the shared widget-plugin-mobx-kit gate/setup utilities. --- packages/pluggableWidgets/image-cropper-web/package.json | 3 +++ pnpm-lock.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/pluggableWidgets/image-cropper-web/package.json b/packages/pluggableWidgets/image-cropper-web/package.json index 097d544871..6be4bfb431 100644 --- a/packages/pluggableWidgets/image-cropper-web/package.json +++ b/packages/pluggableWidgets/image-cropper-web/package.json @@ -41,7 +41,10 @@ "verify": "rui-verify-package-format" }, "dependencies": { + "@mendix/widget-plugin-mobx-kit": "workspace:*", "classnames": "^2.5.1", + "mobx": "6.12.3", + "mobx-react-lite": "4.0.7", "react-image-crop": "^11.0.10" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c87e25d1e..52d7918eda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1682,9 +1682,18 @@ importers: packages/pluggableWidgets/image-cropper-web: dependencies: + '@mendix/widget-plugin-mobx-kit': + specifier: workspace:* + version: link:../../shared/widget-plugin-mobx-kit classnames: specifier: ^2.5.1 version: 2.5.1 + mobx: + specifier: 6.12.3 + version: 6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9) + mobx-react-lite: + specifier: 4.0.7 + version: 4.0.7(patch_hash=47fd2d1b5c35554ddd4fa32fcaa928a16fda9f82dca0ff68bcdc1f7c3e5f9d1a)(mobx@6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9))(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@18.3.1) From 13e78bcb5b94ddb10e892461d5388de02e384eec Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:07:11 +0200 Subject: [PATCH 62/67] feat(image-cropper-web): add ImageCropperStore for crop state Centralize crop-domain working state (live/committed crop, zoom, grayscale, zoom anchor) and its operations in a MobX store. Store methods read observable state directly, so the debounced auto-apply no longer needs a stable-identity callback reading state-mirroring refs. The store owns the debounce + armed/user-interacted apply gate (previously useAutoApplyCrop) and exposes crop operations as actions: setZoom, commitCrop, toggleGrayscale, rotate, reset, initFromImageLoad, onImageChanged. aspect is a computed derived from gated props. Imperative React-owned dependencies (live img element, blob-URL preview, original-image capture, internal-change flag) are injected via setDeps and not owned by the store. Preview generation stays an async action, not a computed, because cropImage/rotateImage are async canvas operations with side effects. Add headless unit tests covering the commit gate, debounce coalescing, no-stale-reads, rotate/reset/grayscale flows, inbound image sync, and read-only/no-crop guards. --- .../src/stores/ImageCropperStore.ts | 306 +++++++++++++++++ .../__tests__/ImageCropperStore.spec.ts | 312 ++++++++++++++++++ 2 files changed, 618 insertions(+) create mode 100644 packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts create mode 100644 packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts new file mode 100644 index 0000000000..b235b78b2b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -0,0 +1,306 @@ +import { ValueStatus } from "mendix"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { type Crop, type PixelCrop } from "react-image-crop"; +import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; +import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; +import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { CENTER_ANCHOR, CropError, cropImage, type ZoomAnchor } from "../utils/cropImage"; +import { buildInitialCrop } from "../utils/initialCrop"; +import { rotateImage } from "../utils/rotateImage"; + +const DEBOUNCE_MS = 400; + +/** + * Imperative, React-owned dependencies the store needs but must not own itself: + * a live DOM , the blob-URL preview lifecycle, the original-image capture, and + * the internal-change flag. All are stable except getImage/getOriginal, which read + * refs — so they are re-injected each render via {@link ImageCropperStore.setDeps}. + */ +export interface CropperDeps { + getImage: () => HTMLImageElement | null; + showPreview: (file: File) => void; + markInternalChange: () => void; + getOriginal: () => File | undefined; +} + +const NOOP_DEPS: CropperDeps = { + getImage: () => null, + showPreview: () => undefined, + markInternalChange: () => undefined, + getOriginal: () => undefined +}; + +/** + * Owns the crop-domain working state (crop box, zoom, grayscale, zoom anchor) and the + * operations over it. Replaces the state-mirroring refs the React container needed: a + * store method reads `this.zoom` directly, so it never goes stale inside the debounced + * apply the way a stable-identity useCallback did. + * + * Controlled/uncontrolled: the ONLY externally controlled value is `props.image` + * (an EditableValue). It is read live through the gate and written back via setValue. + * Zoom/grayscale/crop are uncontrolled internal state. Config props (minZoom, cropShape, + * outputFormat, …) are read live through the gate and never copied into observables. + */ +export class ImageCropperStore implements SetupComponent { + // liveCrop: % units, updated per pointer move — survives container resize. + liveCrop: Crop | undefined = undefined; + // committedCrop: px units, set on release — consumed by cropImage on export. + committedCrop: PixelCrop | undefined = undefined; + zoom: number; + grayscale = false; + // Fixed point the CSS zoom pivots on; frozen at the box center when zoom changes so + // the image stays put while the box moves. Drives CropArea transformOrigin AND export. + zoomAnchor: ZoomAnchor = CENTER_ANCHOR; + + private gate: DerivedPropsGate; + private deps: CropperDeps = NOOP_DEPS; + + // Imperative interaction state — never drives render, so deliberately non-observable. + private userDragged = false; + private userInteracted = false; + private applyDebouncedFn: (() => void) | undefined = undefined; + private abort: (() => void) | undefined = undefined; + + constructor(gate: DerivedPropsGate) { + this.gate = gate; + this.zoom = Number(gate.props.minZoom); + // Explicit annotations (not makeAutoObservable): only the crop-domain working state is + // observable; gate/deps and the imperative apply gate stay plain. Reference-typed crop + // values use observable.ref (we always replace, never mutate in place). + makeObservable(this, { + liveCrop: observable.ref, + committedCrop: observable.ref, + zoom: observable, + grayscale: observable, + zoomAnchor: observable.ref, + props: computed, + aspect: computed, + setLiveCrop: action, + markUserDragged: action, + commitCrop: action, + setZoom: action, + toggleGrayscale: action, + rotate: action, + reset: action, + initFromImageLoad: action, + onImageChanged: action, + // applyCrop/applyNow/applyDebounced/armed read or write only the non-observable + // apply gate (userInteracted); they are intentionally NOT actions. + applyCrop: false, + applyNow: false, + applyDebounced: false, + armed: false + }); + } + + private get props(): ImageCropperContainerProps { + return this.gate.props; + } + + get aspect(): number | undefined { + return resolveAspectRatio(this.props.aspectRatio, this.props.customAspectWidth, this.props.customAspectHeight); + } + + setup(): () => void { + const [debounced, abort] = debounce(() => { + if (this.userInteracted) { + // Fire-and-forget: applyCrop swallows CropError itself; an unexpected throw + // surfaces as an unhandled rejection (logged), same as the old hook. + this.applyCrop(); + } + }, DEBOUNCE_MS); + this.applyDebouncedFn = debounced; + this.abort = abort; + return abort; + } + + // Re-injected every render (getImage/getOriginal close over React refs). + setDeps(deps: CropperDeps): void { + this.deps = deps; + } + + setLiveCrop(crop: Crop | undefined): void { + this.liveCrop = crop; + } + + markUserDragged(): void { + this.userDragged = true; + } + + commitCrop(pixelCrop: PixelCrop): void { + this.committedCrop = pixelCrop; + // A genuine user drag commits immediately and disarms; a programmatic onComplete + // (e.g. the box the library seeds right after image load) only updates committedCrop. + if (this.userDragged) { + this.userDragged = false; + this.applyNow(); + } + } + + setZoom(next: number): void { + // Freeze the anchor at the current box center. Recomputing it only here (not while the + // box moves) keeps the image stable during drags but still zooms into the box. + const live = this.liveCrop; + if (live && live.unit === "%") { + this.zoomAnchor = { x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }; + } + this.zoom = next; + this.applyDebounced(); + } + + toggleGrayscale(): void { + this.grayscale = !this.grayscale; + this.applyDebounced(); + } + + async rotate(deltaDeg: number): Promise { + const img = this.deps.getImage(); + const image = this.props.image; + if (!img || image.readOnly || image.status !== ValueStatus.Available || !image.value) { + return; + } + try { + // Working image is ALWAYS color so toggling grayscale OFF stays reversible. + const working = await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + grayscale: false, + originalName: image.value.name + }); + // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save + // with no further crop still persists grayscale. + const committed = this.grayscale + ? await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + grayscale: true, + originalName: image.value.name + }) + : working; + runInAction(() => { + this.liveCrop = undefined; + this.committedCrop = undefined; + this.armed(); + }); + // Show COLOR working pixels; CropArea reloads from this blob and rebuilds a fresh + // crop against the swapped dimensions on its onLoad. The CSS grayscale filter from + // this.grayscale still renders gray on screen. + this.deps.showPreview(working); + this.deps.markInternalChange(); + image.setValue(committed); + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + throw err; + } + } + } + + reset(): void { + this.zoom = Number(this.props.minZoom); + this.zoomAnchor = CENTER_ANCHOR; + this.grayscale = false; + this.armed(); // do not auto-apply the reset itself + const image = this.props.image; + const file = this.deps.getOriginal(); + if (file && !image.readOnly && image.status === ValueStatus.Available) { + // Mirror rotate: setValue defers the commit, so drive the live preview with the + // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. + this.deps.showPreview(file); + this.deps.markInternalChange(); + image.setValue(file); + } + // Re-seed the default cropbox. If restoring original bytes changed the uri (e.g. after a + // rotation), CropArea's onLoad re-seeds again against the correct dimensions; when no + // edit occurred the uri is unchanged and onLoad won't refire, so this direct re-seed is + // what puts the box back. + const img = this.deps.getImage(); + if (img && img.naturalWidth) { + const { percentCrop, pixelCrop } = buildInitialCrop(img, this.aspect); + this.liveCrop = percentCrop; + this.committedCrop = pixelCrop; + } else { + this.liveCrop = undefined; + this.committedCrop = undefined; + } + } + + initFromImageLoad(percentCrop: Crop, pixelCrop: PixelCrop): void { + this.zoom = Number(this.props.minZoom); + this.zoomAnchor = CENTER_ANCHOR; + this.liveCrop = percentCrop; + this.committedCrop = pixelCrop; + this.armed(); // programmatic load must not auto-commit + } + + // Inbound sync: called from a React effect when the bound image uri changes. + onImageChanged(): void { + this.liveCrop = undefined; + this.committedCrop = undefined; + this.armed(); + } + + // --- apply gate (was useAutoApplyCrop) --------------------------------------------------- + + private armed(): void { + this.userInteracted = false; + } + + private applyNow(): void { + this.userInteracted = true; + this.abort?.(); + // Fire-and-forget (see setup): applyCrop handles its own errors. + this.applyCrop(); + } + + private applyDebounced(): void { + this.userInteracted = true; + this.applyDebouncedFn?.(); + } + + private async applyCrop(): Promise { + const img = this.deps.getImage(); + const committedCrop = this.committedCrop; + const image = this.props.image; + if (!img || !committedCrop || image.readOnly || image.status !== ValueStatus.Available || !image.value) { + return; + } + try { + const file = await cropImage({ + image: img, + pixelCrop: committedCrop, + zoom: this.zoom, + zoomAnchor: this.zoomAnchor, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + outputSize: this.props.outputSize, + cropShape: this.props.cropShape, + viewportWidth: this.props.boundaryWidth, + viewportHeight: this.props.boundaryHeight, + grayscale: this.grayscale, + originalName: image.value.name + }); + if (this.props.outputSize === "viewport") { + image.setThumbnailSize(this.props.boundaryWidth, this.props.boundaryHeight); + } + this.deps.markInternalChange(); + image.setValue(file); + if (this.props.onCropAction?.canExecute) { + this.props.onCropAction.execute(); + } + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + console.error("[image-cropper-web] unexpected error:", err); + throw err; + } + } + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts new file mode 100644 index 0000000000..03eb965b25 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -0,0 +1,312 @@ +import { Big } from "big.js"; +import { ValueStatus } from "mendix"; +import type { Crop, PixelCrop } from "react-image-crop"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; +import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; + +// The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts +// orchestration (when/what is committed) without a real canvas. +jest.mock("../../utils/cropImage", () => { + const actual = jest.requireActual("../../utils/cropImage"); + return { + ...actual, + cropImage: jest.fn(async () => new File(["cropped"], "cropped.png", { type: "image/png" })) + }; +}); +jest.mock("../../utils/rotateImage", () => ({ + rotateImage: jest.fn( + async (opts: { grayscale: boolean }) => + new File([opts.grayscale ? "bw" : "color"], "rotated.png", { type: "image/png" }) + ) +})); + +import { cropImage } from "../../utils/cropImage"; +import { rotateImage } from "../../utils/rotateImage"; + +const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; +const PERCENT_CROP: Crop = { unit: "%", x: 20, y: 20, width: 40, height: 40 }; + +type ImageProp = ImageCropperContainerProps["image"]; +type WebImage = NonNullable; + +function makeImageProp(overrides: Partial = {}): 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(), + ...overrides + } as unknown as ImageProp; +} + +function makeProps(overrides: Partial = {}): ImageCropperContainerProps { + return { + name: "imageCrop", + class: "", + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "square", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + ...overrides + } as ImageCropperContainerProps; +} + +// A mutable gate whose props can be swapped, mimicking GateProvider.setProps. +class FakeGate implements DerivedPropsGate { + props: ImageCropperContainerProps; + constructor(props: ImageCropperContainerProps) { + this.props = props; + } +} + +// Minimal stand-in for the on-screen the store reads for export/rotation math. +function fakeImage(): HTMLImageElement { + return { naturalWidth: 400, naturalHeight: 300, width: 400, height: 300 } as HTMLImageElement; +} + +function makeDeps(overrides: Partial = {}): jest.Mocked { + return { + getImage: jest.fn(() => fakeImage()), + showPreview: jest.fn(), + markInternalChange: jest.fn(), + getOriginal: jest.fn(() => undefined), + ...overrides + } as jest.Mocked; +} + +// Build a store already wired up: gate + setup() (debounce armed) + deps injected. +function makeStore(propsOverride: Partial = {}): { + store: ImageCropperStore; + gate: FakeGate; + deps: jest.Mocked; + dispose: () => void; +} { + const gate = new FakeGate(makeProps(propsOverride)); + const store = new ImageCropperStore(gate); + const dispose = store.setup(); + const deps = makeDeps(); + store.setDeps(deps); + return { store, gate, deps, dispose }; +} + +async function flush(): Promise { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("ImageCropperStore", () => { + beforeEach(() => { + jest.useFakeTimers(); + (cropImage as jest.Mock).mockClear(); + (rotateImage as jest.Mock).mockClear(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + describe("initial state", () => { + it("seeds zoom from minZoom prop and defaults", () => { + const { store, dispose } = makeStore({ minZoom: new Big(2) }); + expect(store.zoom).toBe(2); + expect(store.grayscale).toBe(false); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + }); + + describe("aspect computed", () => { + it("derives from gate props and recomputes when props change", () => { + const { store, gate, dispose } = makeStore({ aspectRatio: "square" }); + expect(store.aspect).toBe(1); + gate.props = makeProps({ aspectRatio: "landscape16x9" }); + expect(store.aspect).toBeCloseTo(16 / 9); + dispose(); + }); + }); + + describe("commitCrop gate (user-drag vs programmatic)", () => { + it("does NOT auto-commit a programmatic complete (no preceding drag)", async () => { + const { store, deps, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(store.committedCrop).toEqual(PIXEL_CROP); + expect(deps.markInternalChange).not.toHaveBeenCalled(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + + it("auto-commits immediately after a user drag, then disarms", async () => { + const { store, gate, deps, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + + // A subsequent programmatic complete must NOT commit (flag was reset). + (cropImage as jest.Mock).mockClear(); + store.commitCrop({ ...PIXEL_CROP, x: 50 }); + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + }); + + describe("setZoom", () => { + it("freezes the anchor at the live-crop center and debounces the apply", async () => { + const { store, deps, dispose } = makeStore(); + store.setLiveCrop(PERCENT_CROP); + store.commitCrop(PIXEL_CROP); // programmatic — no commit yet + store.setZoom(2); + // anchor = box center: (x + w/2)/100, (y + h/2)/100 = (40/100, 40/100) + expect(store.zoomAnchor).toEqual({ x: 0.4, y: 0.4 }); + expect(store.zoom).toBe(2); + expect(cropImage).not.toHaveBeenCalled(); // still within debounce window + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + dispose(); + }); + + it("coalesces rapid zoom changes into a single apply", async () => { + const { store, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + store.setZoom(2); + store.setZoom(3); + store.setZoom(4); + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + describe("no stale reads", () => { + it("applies with the LATEST zoom/grayscale, not the value at trigger time", async () => { + const { store, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + store.setZoom(2); + store.toggleGrayscale(); // grayscale -> true, also schedules apply + store.setZoom(3.5); // change again before the debounce fires + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + const opts = (cropImage as jest.Mock).mock.calls[0][0]; + expect(opts.zoom).toBe(3.5); + expect(opts.grayscale).toBe(true); + dispose(); + }); + }); + + describe("rotate", () => { + it("commits a color file when grayscale is off and shows the working preview", async () => { + const { store, gate, deps, dispose } = makeStore(); + await store.rotate(90); + expect(rotateImage).toHaveBeenCalledTimes(1); + expect((rotateImage as jest.Mock).mock.calls[0][0].grayscale).toBe(false); + expect(deps.showPreview).toHaveBeenCalledTimes(1); + expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + + it("bakes a separate B&W committed file when grayscale is on", async () => { + const { store, dispose } = makeStore(); + store.toggleGrayscale(); + await store.rotate(-90); + // Two rotateImage calls: color working + B&W committed. + expect(rotateImage).toHaveBeenCalledTimes(2); + const grayscales = (rotateImage as jest.Mock).mock.calls.map(c => c[0].grayscale); + expect(grayscales).toEqual([false, true]); + dispose(); + }); + }); + + describe("reset", () => { + it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", () => { + const original = new File(["orig"], "img.png", { type: "image/png" }); + const gate = new FakeGate(makeProps({ minZoom: new Big(1) })); + const store = new ImageCropperStore(gate); + const dispose = store.setup(); + const deps = makeDeps({ getOriginal: jest.fn(() => original) }); + store.setDeps(deps); + + store.toggleGrayscale(); + store.setZoom(3); + store.reset(); + + expect(store.zoom).toBe(1); + expect(store.grayscale).toBe(false); + expect(store.zoomAnchor).toEqual({ x: 0.5, y: 0.5 }); + expect(deps.showPreview).toHaveBeenCalledWith(original); + expect(deps.markInternalChange).toHaveBeenCalled(); + expect(gate.props.image.setValue).toHaveBeenCalledWith(original); + expect(store.liveCrop).toBeDefined(); // re-seeded from the fake image + dispose(); + }); + }); + + describe("onImageChanged (inbound sync)", () => { + it("clears the crop and disarms so a queued apply cannot fire", async () => { + const { store, deps, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + (cropImage as jest.Mock).mockClear(); + (deps.markInternalChange as jest.Mock).mockClear(); + + store.setZoom(2); // arms + schedules a debounced apply + store.onImageChanged(); // new image arrived: disarm + clear + await flush(); + + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + expect(cropImage).not.toHaveBeenCalled(); // disarmed before the debounce fired + dispose(); + }); + }); + + describe("guards / no feedback loop", () => { + it("does not write back when the image is read-only", async () => { + const { store, gate, dispose } = makeStore(); + gate.props = makeProps({ image: makeImageProp({ readOnly: true } as Partial) }); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + expect(gate.props.image.setValue).not.toHaveBeenCalled(); + dispose(); + }); + + it("does nothing when no committed crop exists", async () => { + const { store, dispose } = makeStore(); + store.setZoom(2); // arms + debounces, but committedCrop is undefined + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + }); +}); From eb8fe0d58790bb096ff2a743a9abff3e72233469 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:07:32 +0200 Subject: [PATCH 63/67] refactor(image-cropper-web): drive container from ImageCropperStore Wire ImageCropperContainer to the MobX store via the mobx-kit gate pattern (GateProvider + useConst + useSetup) and wrap it in observer. Crop operations become plain store method calls. Remove the five state-mirroring refs (committedCropRef, zoomRef, grayscaleRef, liveCropRef, zoomAnchorRef) and the two callback mirrors (markInternalRef, showPreviewRef); the store reads its own observable state, so stable-identity closures no longer need them. Config and the controlled image value are read live through the gate; the bound uri is synced inbound via a single effect calling store.onImageChanged. usePreviewSrc, useOriginalImage and the DOM image ref stay in React and are injected into the store through setDeps. Delete useAutoApplyCrop and useImageCropperState, now subsumed by the store. --- .../src/components/CropArea.tsx | 2 +- .../src/components/ImageCropperContainer.tsx | 304 +++--------------- .../__tests__/useImageCropperState.spec.ts | 18 -- .../src/hooks/useAutoApplyCrop.ts | 47 --- .../src/hooks/useImageCropperState.ts | 35 -- 5 files changed, 51 insertions(+), 355 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 2fad22010e..4c5a9e27f3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -2,8 +2,8 @@ import { Dispatch, ReactElement, Ref, SetStateAction, SyntheticEvent, useCallbac import { default as ReactCrop, type Crop, type PixelCrop } from "react-image-crop"; import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; -import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; import { isStrayCrop, MIN_CROP_PX } from "../utils/cropGuard"; +import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; import { buildInitialCrop } from "../utils/initialCrop"; import { safeImageUri } from "../utils/safeImageUri"; diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 20b9c721af..9e39418e67 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -1,254 +1,54 @@ import classNames from "classnames"; import { ValueStatus } from "mendix"; -import { ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; -import { type Crop, type PixelCrop } from "react-image-crop"; +import { observer } from "mobx-react-lite"; +import { ReactElement, useEffect, useRef } from "react"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CropArea } from "./CropArea"; import { CropToolbar } from "./CropToolbar"; 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, CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; -import { buildInitialCrop } from "../utils/initialCrop"; -import { rotateImage } from "../utils/rotateImage"; +import { ImageCropperStore } from "../stores/ImageCropperStore"; -export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { - const state = useImageCropperState(Number(props.minZoom)); +export const ImageCropperContainer = observer(function ImageCropperContainer( + props: ImageCropperContainerProps +): ReactElement | null { + const gateProvider = useConst(() => new GateProvider(props)); + const store = useSetup(() => new ImageCropperStore(gateProvider.gate)); - const { setZoom, setLiveCrop, setCommittedCrop, setGrayscale } = state; - - const committedCropRef = useRef(undefined); - committedCropRef.current = state.committedCrop; - const zoomRef = useRef(state.zoom); - zoomRef.current = state.zoom; - const grayscaleRef = useRef(state.grayscale); - grayscaleRef.current = state.grayscale; - const userDraggedRef = useRef(false); - // Live crop mirror so handleZoomChange (stable identity) can read the box's current center. - const liveCropRef = useRef(undefined); - liveCropRef.current = state.liveCrop; - - // Fixed point of the zoom, captured from the box center when zoom changes and frozen while the - // box moves/draws (so the image stays stable). State drives CropArea's transformOrigin; the ref - // mirror lets applyCrop's stable closure read it for the matching export math. - const [zoomAnchor, setZoomAnchor] = useState(CENTER_ANCHOR); - const zoomAnchorRef = useRef(zoomAnchor); - zoomAnchorRef.current = zoomAnchor; - - const applyCrop = useCallback(async () => { - const img = state.imageRef.current; - const committedCrop = committedCropRef.current; - if ( - !img || - !committedCrop || - props.image.readOnly || - props.image.status !== ValueStatus.Available || - !props.image.value - ) { - return; - } - try { - const file = await cropImage({ - image: img, - pixelCrop: committedCrop, - zoom: zoomRef.current, - zoomAnchor: zoomAnchorRef.current, - outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality), - outputSize: props.outputSize, - cropShape: props.cropShape, - viewportWidth: props.boundaryWidth, - viewportHeight: props.boundaryHeight, - 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(); - } - } catch (err) { - if (err instanceof CropError) { - console.error("[image-cropper-web] CropError:", err.message); - } else { - console.error("[image-cropper-web] unexpected error:", err); - throw err; - } - } - }, [ - state.imageRef, - props.image, - props.outputFormat, - props.outputQuality, - props.outputSize, - props.cropShape, - props.boundaryWidth, - props.boundaryHeight, - props.onCropAction - ]); + // Push fresh Mendix props into the gate every render (config + image read live off it). + useEffect(() => { + gateProvider.setProps(props); + }); - const auto = useAutoApplyCrop(applyCrop); - const { armed } = auto; - - const handleImageLoad = useCallback( - (percentCrop: Crop, pixelCrop: PixelCrop) => { - setZoom(Number(props.minZoom)); - setZoomAnchor(CENTER_ANCHOR); - setLiveCrop(percentCrop); - setCommittedCrop(pixelCrop); - armed(); - }, - [setZoom, setLiveCrop, setCommittedCrop, props.minZoom, armed] - ); + const imageRef = useRef(null); 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 ); - - // 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 + // Live preview for baked rotations/reset: 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; + // Feed the store its imperative React-owned deps. getImage/getOriginal close over refs, so + // re-inject every render; assigning plain (non-observable) fields triggers no reactions. useEffect(() => { - setLiveCrop(undefined); - setCommittedCrop(undefined); - armed(); - }, [uri, setLiveCrop, setCommittedCrop, armed]); - - const handleCropComplete = useCallback( - (pixelCrop: PixelCrop) => { - committedCropRef.current = pixelCrop; - setCommittedCrop(pixelCrop); - if (userDraggedRef.current) { - userDraggedRef.current = false; - auto.applyNow(); - } - }, - [setCommittedCrop, auto] - ); - - const handleZoomChange = useCallback( - (next: SetStateAction) => { - // Freeze the zoom anchor at the current box center. Recomputing it only here (not while - // the box moves) keeps the image stable during drags/draws but still zooms into the box. - const live = liveCropRef.current; - if (live && live.unit === "%") { - setZoomAnchor({ x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }); - } - setZoom(next); - auto.applyDebounced(); - }, - [setZoom, auto] - ); - - const handleRotate = useCallback( - async (deltaDeg: number) => { - const img = state.imageRef.current; - if (!img || props.image.readOnly || props.image.status !== ValueStatus.Available || !props.image.value) { - return; - } - try { - // Working image is ALWAYS color so toggling grayscale OFF stays reversible. - const working = await rotateImage({ - image: img, - rotation: deltaDeg, - outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality), - grayscale: false, - originalName: props.image.value.name - }); - // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save - // with no further crop still persists grayscale. - const committed = grayscaleRef.current - ? await rotateImage({ - image: img, - rotation: deltaDeg, - outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality), - grayscale: true, - originalName: props.image.value.name - }) - : working; - setLiveCrop(undefined); - setCommittedCrop(undefined); - committedCropRef.current = undefined; - armed(); - // Show COLOR working pixels; CropArea reloads from this blob and rebuilds - // a fresh crop against the swapped dimensions on its onLoad. - // The CSS grayscale filter from state.grayscale still renders gray on screen. - showPreviewRef.current(working); - markInternalRef.current(); - props.image.setValue(committed); - } catch (err) { - if (err instanceof CropError) { - console.error("[image-cropper-web] CropError:", err.message); - } else { - throw err; - } - } - }, - [state.imageRef, props.image, props.outputFormat, props.outputQuality, setLiveCrop, setCommittedCrop, armed] - ); - - const handleToggleGrayscale = useCallback(() => { - setGrayscale(prev => !prev); - auto.applyDebounced(); - }, [setGrayscale, auto]); - - const handleReset = useCallback(() => { - setZoom(Number(props.minZoom)); - setZoomAnchor(CENTER_ANCHOR); - setGrayscale(false); - armed(); // do not auto-apply the reset itself - const file = original.getOriginal(); - if (file && !props.image.readOnly && props.image.status === ValueStatus.Available) { - // Mirror handleRotate: setValue defers the commit, so drive the live preview with the - // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. - showPreviewRef.current(file); - markInternalRef.current(); - props.image.setValue(file); - } - // Re-seed the default cropbox to its initial state. If restoring original bytes changed - // the uri (e.g. after a rotation), CropArea's onLoad re-seeds again against the correct - // dimensions; when no edit occurred the uri is unchanged and onLoad won't refire, so this - // direct re-seed is what puts the box back. - const img = state.imageRef.current; - if (img && img.naturalWidth) { - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); - const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect); - setLiveCrop(percentCrop); - setCommittedCrop(pixelCrop); - } else { - setLiveCrop(undefined); - setCommittedCrop(undefined); - } - }, [ - setZoom, - props.minZoom, - props.image, - props.aspectRatio, - props.customAspectWidth, - props.customAspectHeight, - setGrayscale, - setLiveCrop, - setCommittedCrop, - armed, - original, - state.imageRef - ]); + store.setDeps({ + getImage: () => imageRef.current, + showPreview, + markInternalChange: original.markInternalChange, + getOriginal: original.getOriginal + }); + }); + + // Inbound image sync: a new bound uri means the source changed — clear the crop and disarm. + useEffect(() => { + store.onImageChanged(); + }, [uri, store]); if (props.image.status === ValueStatus.Loading) { return ( @@ -293,41 +93,37 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE ); } - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); - return (
{ - userDraggedRef.current = true; - }} - aspect={aspect} + crop={store.liveCrop} + onCropChange={crop => store.setLiveCrop(crop)} + onCropComplete={pixelCrop => store.commitCrop(pixelCrop)} + onUserInteractStart={() => store.markUserDragged()} + aspect={store.aspect} circular={props.cropShape === "circle"} resizable={props.resizableEnabled} boundaryWidth={props.boundaryWidth} boundaryHeight={props.boundaryHeight} - onImageLoad={handleImageLoad} - zoom={state.zoom} + onImageLoad={(percentCrop, pixelCrop) => store.initFromImageLoad(percentCrop, pixelCrop)} + zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} - setZoom={handleZoomChange} - zoomAnchor={zoomAnchor} + setZoom={next => store.setZoom(typeof next === "function" ? next(store.zoom) : next)} + zoomAnchor={store.zoomAnchor} wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"} - grayscale={state.grayscale} - imageRef={state.imageRef} + grayscale={store.grayscale} + imageRef={imageRef} /> handleRotate(-90)} - onRotateRight={() => handleRotate(90)} - onToggleGrayscale={handleToggleGrayscale} - onReset={handleReset} + onZoomChange={zoom => store.setZoom(zoom)} + onRotateLeft={() => store.rotate(-90)} + onRotateRight={() => store.rotate(90)} + onToggleGrayscale={() => store.toggleGrayscale()} + onReset={() => store.reset()} />
); -} +}); 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 deleted file mode 100644 index fd34122a17..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { useImageCropperState } from "../useImageCropperState"; - -describe("useImageCropperState", () => { - test("initializes zoom from arg, grayscale false", () => { - const { result } = renderHook(() => useImageCropperState(1)); - expect(result.current.zoom).toBe(1); - expect(result.current.grayscale).toBe(false); - }); - - test("setGrayscale updates state", () => { - const { result } = renderHook(() => useImageCropperState(1)); - act(() => { - result.current.setGrayscale(true); - }); - expect(result.current.grayscale).toBe(true); - }); -}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts deleted file mode 100644 index 6b217f04ae..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; - -const DEBOUNCE_MS = 400; - -export interface AutoApplyCrop { - armed: () => void; - applyNow: () => void; - applyDebounced: () => void; -} - -export function useAutoApplyCrop(applyCrop: () => void | Promise): AutoApplyCrop { - const latest = useRef(applyCrop); - latest.current = applyCrop; - - const userInteracted = useRef(false); - - const [applyDebounced, abort] = useMemo( - () => - debounce(() => { - if (userInteracted.current) { - latest.current(); - } - }, DEBOUNCE_MS), - [] - ); - - useEffect(() => abort, [abort]); - - return useMemo( - () => ({ - armed: () => { - userInteracted.current = false; - }, - applyNow: () => { - userInteracted.current = true; - abort(); - latest.current(); - }, - applyDebounced: () => { - userInteracted.current = true; - applyDebounced(); - } - }), - [abort, applyDebounced] - ); -} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts deleted file mode 100644 index b000864e64..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react"; -import type { Crop, PixelCrop } from "react-image-crop"; - -interface ImageCropperState { - // liveCrop: % units, updated per pointer move (onChange) — survives container resize. - // committedCrop: px units, set on release (onComplete) — consumed by cropImage. - liveCrop: Crop | undefined; - setLiveCrop: Dispatch>; - committedCrop: PixelCrop | undefined; - setCommittedCrop: Dispatch>; - zoom: number; - setZoom: Dispatch>; - grayscale: boolean; - setGrayscale: Dispatch>; - imageRef: RefObject; -} - -export function useImageCropperState(initialZoom: number): ImageCropperState { - const [liveCrop, setLiveCrop] = useState(undefined); - const [committedCrop, setCommittedCrop] = useState(undefined); - const [zoom, setZoom] = useState(initialZoom); - const [grayscale, setGrayscale] = useState(false); - const imageRef = useRef(null); - return { - liveCrop, - setLiveCrop, - committedCrop, - setCommittedCrop, - zoom, - setZoom, - grayscale, - setGrayscale, - imageRef - }; -} From cac0dc11a4e4f859497c259ecf1739fc63eb5f32 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 17:01:14 +0200 Subject: [PATCH 64/67] refactor(image-cropper-web): own image fetch and preview in ImageCropperStore --- .../src/components/ImageCropperContainer.tsx | 31 +-- .../hooks/__tests__/useOriginalImage.spec.ts | 59 ----- .../src/hooks/__tests__/usePreviewSrc.spec.ts | 63 ------ .../src/hooks/useOriginalImage.ts | 67 ------ .../src/hooks/usePreviewSrc.ts | 49 ----- .../src/stores/ImageCropperStore.ts | 172 +++++++++++++-- .../__tests__/ImageCropperStore.spec.ts | 208 +++++++++++++++--- 7 files changed, 334 insertions(+), 315 deletions(-) delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts delete mode 100644 packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 9e39418e67..ceebebf28a 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -8,8 +8,6 @@ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CropArea } from "./CropArea"; import { CropToolbar } from "./CropToolbar"; import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; -import { useOriginalImage } from "../hooks/useOriginalImage"; -import { usePreviewSrc } from "../hooks/usePreviewSrc"; import { ImageCropperStore } from "../stores/ImageCropperStore"; export const ImageCropperContainer = observer(function ImageCropperContainer( @@ -25,31 +23,16 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( const imageRef = useRef(null); - 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 - ); - // Live preview for baked rotations/reset: setValue defers the commit, so show a local - // blob URL until the bound uri catches up on Save. - const { previewSrc, showPreview } = usePreviewSrc(uri); - - // Feed the store its imperative React-owned deps. getImage/getOriginal close over refs, so - // re-inject every render; assigning plain (non-observable) fields triggers no reactions. + // Feed the store the one React-owned dep it still needs: the live on-screen used as + // the canvas draw source. It closes over a ref, so re-inject every render; assigning a + // plain (non-observable) field triggers no reactions. Fetch/preview/capture now live in the + // store, driven by its own reaction on the bound uri. useEffect(() => { store.setDeps({ - getImage: () => imageRef.current, - showPreview, - markInternalChange: original.markInternalChange, - getOriginal: original.getOriginal + getImage: () => imageRef.current }); }); - // Inbound image sync: a new bound uri means the source changed — clear the crop and disarm. - useEffect(() => { - store.onImageChanged(); - }, [uri, store]); - if (props.image.status === ValueStatus.Loading) { return (
store.setLiveCrop(crop)} onCropComplete={pixelCrop => store.commitCrop(pixelCrop)} @@ -122,7 +105,7 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( showZoom={props.zoomEnabled && props.showZoomSlider} showReset={props.showResetButton} grayscale={store.grayscale} - canReset={original.canRestore} + canReset={store.canRestore} zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} 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 deleted file mode 100644 index ddfe47475f..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { renderHook, act, 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(); - }); - - 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 deleted file mode 100644 index 3d735d8495..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 7d99926908..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 -// 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); - 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); - 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, - markInternalChange - }; -} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts deleted file mode 100644 index b0a41fc07b..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts +++ /dev/null @@ -1,49 +0,0 @@ -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/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index b235b78b2b..b97019646c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -1,5 +1,5 @@ import { ValueStatus } from "mendix"; -import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; import { type Crop, type PixelCrop } from "react-image-crop"; import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; @@ -12,23 +12,18 @@ import { rotateImage } from "../utils/rotateImage"; const DEBOUNCE_MS = 400; /** - * Imperative, React-owned dependencies the store needs but must not own itself: - * a live DOM , the blob-URL preview lifecycle, the original-image capture, and - * the internal-change flag. All are stable except getImage/getOriginal, which read - * refs — so they are re-injected each render via {@link ImageCropperStore.setDeps}. + * The one imperative, React-owned dependency the store still needs but must not own itself: + * the live on-screen DOM , used as the canvas draw source for crop/rotate export. It + * reads a ref, so it is re-injected each render via {@link ImageCropperStore.setDeps}. The + * blob-URL preview, the original-image capture, and the internal-change flag were absorbed + * into the store (they are pure browser resources, not React-lifecycle-bound in practice). */ export interface CropperDeps { getImage: () => HTMLImageElement | null; - showPreview: (file: File) => void; - markInternalChange: () => void; - getOriginal: () => File | undefined; } const NOOP_DEPS: CropperDeps = { - getImage: () => null, - showPreview: () => undefined, - markInternalChange: () => undefined, - getOriginal: () => undefined + getImage: () => null }; /** @@ -53,6 +48,13 @@ export class ImageCropperStore implements SetupComponent { // the image stays put while the box moves. Drives CropArea transformOrigin AND export. zoomAnchor: ZoomAnchor = CENTER_ANCHOR; + // Local blob: URL shown instead of the bound uri until the deferred setValue commits on + // Save, or undefined to use the bound uri. Read by the container JSX. (was usePreviewSrc) + previewSrc: string | undefined = undefined; + // Whether the original bytes were captured, so Reset can restore them. Drives the Reset + // button's enabled state. (was useOriginalImage's canRestore) + canRestore = false; + private gate: DerivedPropsGate; private deps: CropperDeps = NOOP_DEPS; @@ -62,18 +64,35 @@ export class ImageCropperStore implements SetupComponent { private applyDebouncedFn: (() => void) | undefined = undefined; private abort: (() => void) | undefined = undefined; + // Image-capture / preview plumbing (was useOriginalImage + usePreviewSrc). Plain, not + // observable: blobUrl/originalFile are browser resources; internalChange/capturedUri are + // the fetch-dedupe gate that must not drive render. + private originalFile: File | undefined = undefined; + private blobUrl: string | undefined = undefined; + private internalChange = false; + // Bumped on every uri change so an in-flight fetch superseded by a newer uri is dropped + // when it resolves (the reactive analog of useOriginalImage's `cancelled` guard). + private fetchGeneration = 0; + // Disposer for the uri reaction, torn down alongside the debounce in setup(). + private disposeUriEffect: (() => void) | undefined = undefined; + constructor(gate: DerivedPropsGate) { this.gate = gate; this.zoom = Number(gate.props.minZoom); // Explicit annotations (not makeAutoObservable): only the crop-domain working state is // observable; gate/deps and the imperative apply gate stay plain. Reference-typed crop // values use observable.ref (we always replace, never mutate in place). - makeObservable(this, { + makeObservable< + this, + "props" | "applyCrop" | "applyNow" | "applyDebounced" | "armed" | "onUriChanged" | "revokePreview" + >(this, { liveCrop: observable.ref, committedCrop: observable.ref, zoom: observable, grayscale: observable, zoomAnchor: observable.ref, + previewSrc: observable, + canRestore: observable, props: computed, aspect: computed, setLiveCrop: action, @@ -85,12 +104,17 @@ export class ImageCropperStore implements SetupComponent { reset: action, initFromImageLoad: action, onImageChanged: action, + showPreview: action, + // onUriChanged writes crop/preview/canRestore observables (fired by the reaction). + onUriChanged: action, // applyCrop/applyNow/applyDebounced/armed read or write only the non-observable - // apply gate (userInteracted); they are intentionally NOT actions. + // apply gate (userInteracted); revokePreview touches only the plain blobUrl. + // captureOriginal/markInternalChange wrap their own runInAction / touch plain state. applyCrop: false, applyNow: false, applyDebounced: false, - armed: false + armed: false, + revokePreview: false }); } @@ -112,14 +136,113 @@ export class ImageCropperStore implements SetupComponent { }, DEBOUNCE_MS); this.applyDebouncedFn = debounced; this.abort = abort; - return abort; + + // React to the bound image's uri changing (was useOriginalImage's [uri,name] effect + + // the container's onImageChanged effect). The data fn reads the observable gate props, + // so it re-tracks each render; the effect only fires when the derived uri value changes. + this.disposeUriEffect = reaction( + () => { + const image = this.props.image; + const value = image.status === ValueStatus.Available ? image.value : undefined; + return { uri: value?.uri, name: value?.name }; + }, + ({ uri, name }) => this.onUriChanged(uri, name), + { fireImmediately: true } + ); + + // Combined teardown: stop the debounce, dispose the uri reaction, revoke any live blob. + return () => { + abort(); + this.disposeUriEffect?.(); + this.revokePreview(); + }; } - // Re-injected every render (getImage/getOriginal close over React refs). + // Handles an image-uri change. Two branches: + // - internal bake (our own setValue): adopt the uri, don't refetch, keep the crop box; + // - external swap (a genuinely new image): capture original bytes for Reset, clear the + // crop + disarm, and drop any stale preview blob so the bound uri shows through. + // Called by the setup() reaction (also fires immediately for the initial image). + private onUriChanged(uri: string | undefined, name: string | undefined): void { + // A fresh committed uri means the previous local preview is superseded — drop it + // unconditionally (usePreviewSrc did this on ANY committed-uri change). + this.revokePreview(); + this.previewSrc = undefined; + + if (!uri) { + return; + } + + if (this.internalChange) { + // Our own bake produced this uri — adopt it, keep the original + crop, skip fetch. + this.internalChange = false; + return; + } + + // A genuinely new external image arrived. Invalidate any in-flight fetch, reset the + // capture state, clear the crop + disarm, then recapture the original bytes for Reset. + const generation = ++this.fetchGeneration; + this.originalFile = undefined; + this.canRestore = false; + this.onImageChanged(); + void this.captureOriginal(uri, name, generation); + } + + // Async original-bytes capture for Reset. Guarded by the generation token so a fetch + // superseded by a newer uri is discarded when it resolves. (was useOriginalImage's fetch) + private async captureOriginal(uri: string, name: string | undefined, generation: number): Promise { + try { + const res = await fetch(uri); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + const blob = await res.blob(); + if (generation !== this.fetchGeneration) { + return; // superseded by a newer uri — drop this result + } + runInAction(() => { + this.originalFile = new File([blob], name ?? "original", { type: blob.type || "image/png" }); + this.canRestore = true; + }); + } catch { + if (generation === this.fetchGeneration) { + runInAction(() => { + this.canRestore = false; + }); + } + } + } + + // Re-injected every render (getImage closes over a React ref). setDeps(deps: CropperDeps): void { this.deps = deps; } + // Show a baked File immediately via a local blob: URL, before the deferred setValue commit + // changes the bound uri. Revokes any prior URL first. (was usePreviewSrc.showPreview) + showPreview(file: File): void { + this.revokePreview(); + const url = URL.createObjectURL(file); + this.blobUrl = url; + this.previewSrc = url; + } + + // Revoke the live blob URL (browser resource cleanup). Called before the next showPreview, + // when the bound uri catches up, and on teardown. Does not touch previewSrc — callers that + // need the fallback-to-bound-uri behavior clear previewSrc themselves. + private revokePreview(): void { + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = undefined; + } + } + + // Flag the next uri change as our own bake so the uri effect adopts it without refetching. + // Called synchronously before every internal setValue. (was useOriginalImage.markInternalChange) + private markInternalChange(): void { + this.internalChange = true; + } + setLiveCrop(crop: Crop | undefined): void { this.liveCrop = crop; } @@ -190,8 +313,8 @@ export class ImageCropperStore implements SetupComponent { // Show COLOR working pixels; CropArea reloads from this blob and rebuilds a fresh // crop against the swapped dimensions on its onLoad. The CSS grayscale filter from // this.grayscale still renders gray on screen. - this.deps.showPreview(working); - this.deps.markInternalChange(); + this.showPreview(working); + this.markInternalChange(); image.setValue(committed); } catch (err) { if (err instanceof CropError) { @@ -208,12 +331,12 @@ export class ImageCropperStore implements SetupComponent { this.grayscale = false; this.armed(); // do not auto-apply the reset itself const image = this.props.image; - const file = this.deps.getOriginal(); + const file = this.originalFile; if (file && !image.readOnly && image.status === ValueStatus.Available) { // Mirror rotate: setValue defers the commit, so drive the live preview with the // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. - this.deps.showPreview(file); - this.deps.markInternalChange(); + this.showPreview(file); + this.markInternalChange(); image.setValue(file); } // Re-seed the default cropbox. If restoring original bytes changed the uri (e.g. after a @@ -239,7 +362,8 @@ export class ImageCropperStore implements SetupComponent { this.armed(); // programmatic load must not auto-commit } - // Inbound sync: called from a React effect when the bound image uri changes. + // Inbound sync: clear the crop box and disarm the apply gate when the bound image changes. + // Called by onUriChanged's external-swap branch (was a React effect on the uri). onImageChanged(): void { this.liveCrop = undefined; this.committedCrop = undefined; @@ -289,7 +413,7 @@ export class ImageCropperStore implements SetupComponent { if (this.props.outputSize === "viewport") { image.setThumbnailSize(this.props.boundaryWidth, this.props.boundaryHeight); } - this.deps.markInternalChange(); + this.markInternalChange(); image.setValue(file); if (this.props.onCropAction?.canExecute) { this.props.onCropAction.execute(); diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts index 03eb965b25..7a93bab0e6 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -1,5 +1,6 @@ import { Big } from "big.js"; import { ValueStatus } from "mendix"; +import { action, makeObservable, observable } from "mobx"; import type { Crop, PixelCrop } from "react-image-crop"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; @@ -71,11 +72,17 @@ function makeProps(overrides: Partial = {}): ImageCr } as ImageCropperContainerProps; } -// A mutable gate whose props can be swapped, mimicking GateProvider.setProps. +// A mutable gate whose props can be swapped, mimicking GateProvider. props is observable.ref + +// setProps is an action, matching the real gate — the store's uri reaction and `aspect`/`props` +// computeds only re-evaluate when props is swapped through the observable, not a plain write. class FakeGate implements DerivedPropsGate { props: ImageCropperContainerProps; constructor(props: ImageCropperContainerProps) { this.props = props; + makeObservable(this, { props: observable.ref, setProps: action }); + } + setProps(props: ImageCropperContainerProps): void { + this.props = props; } } @@ -87,14 +94,13 @@ function fakeImage(): HTMLImageElement { function makeDeps(overrides: Partial = {}): jest.Mocked { return { getImage: jest.fn(() => fakeImage()), - showPreview: jest.fn(), - markInternalChange: jest.fn(), - getOriginal: jest.fn(() => undefined), ...overrides } as jest.Mocked; } -// Build a store already wired up: gate + setup() (debounce armed) + deps injected. +// Build a store already wired up: gate + setup() (debounce armed + uri reaction) + deps injected. +// setup() runs the reaction with fireImmediately, so the initial image is captured right away; +// callers that assert on the fetch should `await flush()` first. function makeStore(propsOverride: Partial = {}): { store: ImageCropperStore; gate: FakeGate; @@ -103,9 +109,9 @@ function makeStore(propsOverride: Partial = {}): { } { const gate = new FakeGate(makeProps(propsOverride)); const store = new ImageCropperStore(gate); - const dispose = store.setup(); const deps = makeDeps(); store.setDeps(deps); + const dispose = store.setup(); return { store, gate, deps, dispose }; } @@ -113,16 +119,41 @@ async function flush(): Promise { jest.runOnlyPendingTimers(); await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); +} + +// jsdom implements neither blob URLs nor fetch; stub both so the store's preview + capture +// paths can run and be spied on. +if (!URL.createObjectURL) { + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => ""; +} +if (!URL.revokeObjectURL) { + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; } describe("ImageCropperStore", () => { + let createSpy: jest.SpyInstance; + let revokeSpy: jest.SpyInstance; + beforeEach(() => { jest.useFakeTimers(); (cropImage as jest.Mock).mockClear(); (rotateImage as jest.Mock).mockClear(); + + let n = 0; + createSpy = jest.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:mock-${++n}`); + revokeSpy = jest.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + + // Default: fetch succeeds with a tiny png blob (the initial-image capture the reaction + // fires on setup). Individual tests override global.fetch as needed. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["orig"], { type: "image/png" })) + }) as jest.Mock; }); afterEach(() => { jest.useRealTimers(); + jest.restoreAllMocks(); }); describe("initial state", () => { @@ -140,7 +171,7 @@ describe("ImageCropperStore", () => { it("derives from gate props and recomputes when props change", () => { const { store, gate, dispose } = makeStore({ aspectRatio: "square" }); expect(store.aspect).toBe(1); - gate.props = makeProps({ aspectRatio: "landscape16x9" }); + gate.setProps(makeProps({ aspectRatio: "landscape16x9" })); expect(store.aspect).toBeCloseTo(16 / 9); dispose(); }); @@ -148,22 +179,21 @@ describe("ImageCropperStore", () => { describe("commitCrop gate (user-drag vs programmatic)", () => { it("does NOT auto-commit a programmatic complete (no preceding drag)", async () => { - const { store, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); store.commitCrop(PIXEL_CROP); await flush(); expect(store.committedCrop).toEqual(PIXEL_CROP); - expect(deps.markInternalChange).not.toHaveBeenCalled(); + expect(gate.props.image.setValue).not.toHaveBeenCalled(); expect(cropImage).not.toHaveBeenCalled(); dispose(); }); it("auto-commits immediately after a user drag, then disarms", async () => { - const { store, gate, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); store.markUserDragged(); store.commitCrop(PIXEL_CROP); await flush(); expect(cropImage).toHaveBeenCalledTimes(1); - expect(deps.markInternalChange).toHaveBeenCalledTimes(1); expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); // A subsequent programmatic complete must NOT commit (flag was reset). @@ -177,7 +207,7 @@ describe("ImageCropperStore", () => { describe("setZoom", () => { it("freezes the anchor at the live-crop center and debounces the apply", async () => { - const { store, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); store.setLiveCrop(PERCENT_CROP); store.commitCrop(PIXEL_CROP); // programmatic — no commit yet store.setZoom(2); @@ -187,7 +217,7 @@ describe("ImageCropperStore", () => { expect(cropImage).not.toHaveBeenCalled(); // still within debounce window await flush(); expect(cropImage).toHaveBeenCalledTimes(1); - expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); dispose(); }); @@ -221,12 +251,15 @@ describe("ImageCropperStore", () => { describe("rotate", () => { it("commits a color file when grayscale is off and shows the working preview", async () => { - const { store, gate, deps, dispose } = makeStore(); + const { store, gate, dispose } = makeStore(); + await flush(); // let the initial-image capture settle before asserting on preview + createSpy.mockClear(); await store.rotate(90); expect(rotateImage).toHaveBeenCalledTimes(1); expect((rotateImage as jest.Mock).mock.calls[0][0].grayscale).toBe(false); - expect(deps.showPreview).toHaveBeenCalledTimes(1); - expect(deps.markInternalChange).toHaveBeenCalledTimes(1); + // showPreview minted a blob URL for the working file and exposed it. + expect(createSpy).toHaveBeenCalledTimes(1); + expect(store.previewSrc).toBe("blob:mock-1"); expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); expect(store.liveCrop).toBeUndefined(); expect(store.committedCrop).toBeUndefined(); @@ -246,13 +279,11 @@ describe("ImageCropperStore", () => { }); describe("reset", () => { - it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", () => { - const original = new File(["orig"], "img.png", { type: "image/png" }); - const gate = new FakeGate(makeProps({ minZoom: new Big(1) })); - const store = new ImageCropperStore(gate); - const dispose = store.setup(); - const deps = makeDeps({ getOriginal: jest.fn(() => original) }); - store.setDeps(deps); + it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", async () => { + const { store, gate, dispose } = makeStore({ minZoom: new Big(1) }); + // The initial-image reaction fetches + captures the original bytes for Reset. + await flush(); + expect(store.canRestore).toBe(true); store.toggleGrayscale(); store.setZoom(3); @@ -261,9 +292,11 @@ describe("ImageCropperStore", () => { expect(store.zoom).toBe(1); expect(store.grayscale).toBe(false); expect(store.zoomAnchor).toEqual({ x: 0.5, y: 0.5 }); - expect(deps.showPreview).toHaveBeenCalledWith(original); - expect(deps.markInternalChange).toHaveBeenCalled(); - expect(gate.props.image.setValue).toHaveBeenCalledWith(original); + // Reset drives the live preview with the captured original and writes it back. + expect(store.previewSrc).toBeDefined(); + const restored = (gate.props.image.setValue as jest.Mock).mock.calls.at(-1)?.[0]; + expect(restored).toBeInstanceOf(File); + expect(restored.name).toBe("img.png"); // the captured original's name expect(store.liveCrop).toBeDefined(); // re-seeded from the fake image dispose(); }); @@ -271,12 +304,11 @@ describe("ImageCropperStore", () => { describe("onImageChanged (inbound sync)", () => { it("clears the crop and disarms so a queued apply cannot fire", async () => { - const { store, deps, dispose } = makeStore(); + const { store, dispose } = makeStore(); store.markUserDragged(); store.commitCrop(PIXEL_CROP); await flush(); (cropImage as jest.Mock).mockClear(); - (deps.markInternalChange as jest.Mock).mockClear(); store.setZoom(2); // arms + schedules a debounced apply store.onImageChanged(); // new image arrived: disarm + clear @@ -292,7 +324,7 @@ describe("ImageCropperStore", () => { describe("guards / no feedback loop", () => { it("does not write back when the image is read-only", async () => { const { store, gate, dispose } = makeStore(); - gate.props = makeProps({ image: makeImageProp({ readOnly: true } as Partial) }); + gate.setProps(makeProps({ image: makeImageProp({ readOnly: true } as Partial) })); store.markUserDragged(); store.commitCrop(PIXEL_CROP); await flush(); @@ -309,4 +341,122 @@ describe("ImageCropperStore", () => { dispose(); }); }); + + describe("original-image capture (was useOriginalImage)", () => { + it("fetches the bound image and reports canRestore", async () => { + const { store, dispose } = makeStore(); + await flush(); + expect(global.fetch).toHaveBeenCalledWith("http://localhost/img.png"); + expect(store.canRestore).toBe(true); + dispose(); + }); + + it("reports canRestore=false when the fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + const { store, dispose } = makeStore(); + await flush(); + expect(store.canRestore).toBe(false); + dispose(); + }); + + it("re-captures and clears the crop when a new external uri arrives", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); + (global.fetch as jest.Mock).mockClear(); + store.setLiveCrop(PERCENT_CROP); + store.commitCrop(PIXEL_CROP); + + // A genuinely new external image (no preceding internal bake). + gate.setProps( + makeProps({ image: makeImageProp({ value: { uri: "http://x/new.png", name: "new.png" } as WebImage }) }) + ); + await flush(); + + expect(global.fetch).toHaveBeenCalledWith("http://x/new.png"); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + + it("adopts our own bake's uri without refetching or clearing the crop", async () => { + const { store, gate, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); // applyCrop -> markInternalChange -> setValue + expect(store.committedCrop).toEqual(PIXEL_CROP); + (global.fetch as jest.Mock).mockClear(); + + // Simulate the deferred commit landing: the bound uri changes to our baked output. + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/baked.png", name: "baked.png" } as WebImage }) + }) + ); + await flush(); + + expect(global.fetch).not.toHaveBeenCalled(); // adopted, not recaptured + expect(store.committedCrop).toEqual(PIXEL_CROP); // crop preserved + dispose(); + }); + + it("drops a superseded in-flight fetch (stale-fetch cancellation)", async () => { + // First fetch never resolves within the window; a second uri supersedes it. + let resolveFirst: (r: unknown) => void = () => undefined; + const slowBlob = new Blob(["slow"], { type: "image/png" }); + global.fetch = jest + .fn() + .mockImplementationOnce(() => new Promise(res => (resolveFirst = res))) + .mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(slowBlob) }) as jest.Mock; + + const { store, gate, dispose } = makeStore(); + // Supersede before the first fetch resolves. + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/second.png", name: "second.png" } as WebImage }) + }) + ); + await flush(); + // Now resolve the STALE first fetch — it must be discarded (generation mismatch). + resolveFirst({ ok: true, blob: () => Promise.resolve(new Blob(["stale"], { type: "image/png" })) }); + await flush(); + + // canRestore reflects the SECOND (winning) fetch, not the stale first. + expect(store.canRestore).toBe(true); + dispose(); + }); + }); + + describe("preview blob lifecycle (was usePreviewSrc)", () => { + it("drops and revokes the preview when the committed uri catches up", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); + await store.rotate(90); // mints a preview blob (internal bake -> markInternalChange) + expect(store.previewSrc).toBeDefined(); + const minted = store.previewSrc; + revokeSpy.mockClear(); + + // The deferred commit lands: bound uri becomes our baked output. onUriChanged drops + // the local preview and revokes the blob (adopt branch, previewSrc cleared first). + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/baked.png", name: "baked.png" } as WebImage }) + }) + ); + await flush(); + + expect(store.previewSrc).toBeUndefined(); + expect(revokeSpy).toHaveBeenCalledWith(minted); + dispose(); + }); + + it("revokes the live blob URL on teardown", async () => { + const { store, dispose } = makeStore(); + await flush(); + await store.rotate(90); + const minted = store.previewSrc; + revokeSpy.mockClear(); + dispose(); + expect(revokeSpy).toHaveBeenCalledWith(minted); + }); + }); }); From ffcbacec20821198bd1a1ff1af1a80d7644e2cc1 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 14 Jul 2026 15:03:40 +0200 Subject: [PATCH 65/67] refactor(image-cropper-web): resolve zoom updater in store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the SetStateAction resolution out of the container and into ImageCropperStore.setZoom, which now accepts a value or updater and resolves the functional form against its own zoom. The container drops to setZoom={next => store.setZoom(next)}, making the store the single owner of current zoom. The functional-updater contract stays on the CropArea/ZoomContainer props (the wheel-zoom hook drives setZoom(prev => …) and the editor preview passes a no-op). Behavior unchanged. Also sync the image-cropper openspec: the "No image available" scenario now reflects the editable noImageCaption empty-state text. --- .../openspec/specs/image-cropper/spec.md | 2 +- .../src/components/ImageCropperContainer.tsx | 2 +- .../image-cropper-web/src/stores/ImageCropperStore.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md index 236052138e..52d188cfcb 100644 --- a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -25,7 +25,7 @@ same attribute via `setValue`. #### Scenario: No image available - **WHEN** the bound image status is not `Available` or has no value -- **THEN** the widget SHALL render an empty state reading "No image" +- **THEN** the widget SHALL render an empty state showing the configurable `noImageCaption` (default "No uploaded image to crop") #### Scenario: Read-only attribute diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index ceebebf28a..f6a80ffe8f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -93,7 +93,7 @@ export const ImageCropperContainer = observer(function ImageCropperContainer( zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} - setZoom={next => store.setZoom(typeof next === "function" ? next(store.zoom) : next)} + setZoom={next => store.setZoom(next)} zoomAnchor={store.zoomAnchor} wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"} grayscale={store.grayscale} diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index b97019646c..b59d52eb2d 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -1,5 +1,6 @@ import { ValueStatus } from "mendix"; import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; +import { type SetStateAction } from "react"; import { type Crop, type PixelCrop } from "react-image-crop"; import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; @@ -261,14 +262,18 @@ export class ImageCropperStore implements SetupComponent { } } - setZoom(next: number): void { + setZoom(next: SetStateAction): void { + // Accept the React setState contract (value or updater). The wheel-zoom hook drives + // zoom with setZoom(prev => …), so the store — the owner of `zoom` — resolves the + // updater against its own value instead of leaking that into the container. + const value = typeof next === "function" ? next(this.zoom) : next; // Freeze the anchor at the current box center. Recomputing it only here (not while the // box moves) keeps the image stable during drags but still zooms into the box. const live = this.liveCrop; if (live && live.unit === "%") { this.zoomAnchor = { x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }; } - this.zoom = next; + this.zoom = value; this.applyDebounced(); } From 0416702d648b18719a7a36ebef4fa9c196141481 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 14 Jul 2026 16:05:12 +0200 Subject: [PATCH 66/67] style(image-cropper-web): lint --- .../image-cropper-web/src/stores/ImageCropperStore.ts | 4 +++- .../src/stores/__tests__/ImageCropperStore.spec.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index b59d52eb2d..9b2e986c17 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -186,7 +186,9 @@ export class ImageCropperStore implements SetupComponent { this.originalFile = undefined; this.canRestore = false; this.onImageChanged(); - void this.captureOriginal(uri, name, generation); + // Fire-and-forget: captureOriginal never rejects (it handles its own errors below), so + // there's no floating-promise to await. Bare call keeps eslint's no-void rule happy. + this.captureOriginal(uri, name, generation); } // Async original-bytes capture for Reset. Guarded by the generation token so a fetch diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts index 7a93bab0e6..f8df72000c 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -4,7 +4,6 @@ import { action, makeObservable, observable } from "mobx"; import type { Crop, PixelCrop } from "react-image-crop"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; -import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; // The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts // orchestration (when/what is committed) without a real canvas. @@ -24,6 +23,7 @@ jest.mock("../../utils/rotateImage", () => ({ import { cropImage } from "../../utils/cropImage"; import { rotateImage } from "../../utils/rotateImage"; +import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; const PERCENT_CROP: Crop = { unit: "%", x: 20, y: 20, width: 40, height: 40 }; From 8d13d6b42799cea78224399c921a978c54c3fbfd Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 14 Jul 2026 16:15:44 +0200 Subject: [PATCH 67/67] fix(image-cropper-web): migrate dynamic() calls to dynamic.available() --- .../image-cropper-web/src/__tests__/ImageCropper.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 df3db0adc4..6bf764e3a8 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -291,9 +291,9 @@ describe("", () => { {...makeProps({ enableRotation: true, showResetButton: true, - resetCaption: dynamic("Herstellen"), - resetAriaLabel: dynamic("Uitsnede herstellen"), - rotateLeftLabel: dynamic("Naar links draaien") + resetCaption: dynamic.available("Herstellen"), + resetAriaLabel: dynamic.available("Uitsnede herstellen"), + rotateLeftLabel: dynamic.available("Naar links draaien") })} /> );