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/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/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..f6a80ffe8f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -1,254 +1,37 @@ 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)); - - 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 - ]); - - 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 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 - // blob URL until the bound uri catches up on Save. - const { previewSrc, showPreview } = usePreviewSrc(uri); - const showPreviewRef = useRef(showPreview); - showPreviewRef.current = showPreview; +export const ImageCropperContainer = observer(function ImageCropperContainer( + props: ImageCropperContainerProps +): ReactElement | null { + const gateProvider = useConst(() => new GateProvider(props)); + const store = useSetup(() => new ImageCropperStore(gateProvider.gate)); + // Push fresh Mendix props into the gate every render (config + image read live off it). useEffect(() => { - setLiveCrop(undefined); - setCommittedCrop(undefined); - armed(); - }, [uri, setLiveCrop, setCommittedCrop, armed]); + gateProvider.setProps(props); + }); - const handleCropComplete = useCallback( - (pixelCrop: PixelCrop) => { - committedCropRef.current = pixelCrop; - setCommittedCrop(pixelCrop); - if (userDraggedRef.current) { - userDraggedRef.current = false; - auto.applyNow(); - } - }, - [setCommittedCrop, auto] - ); + const imageRef = useRef(null); - 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 - ]); + // 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 + }); + }); if (props.image.status === ValueStatus.Loading) { return ( @@ -293,41 +76,37 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE ); } - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); - return (
{ - userDraggedRef.current = true; - }} - aspect={aspect} + src={store.previewSrc ?? props.image.value.uri} + 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(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/__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/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 - }; -} 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 new file mode 100644 index 0000000000..b59d52eb2d --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -0,0 +1,435 @@ +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"; +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; + +/** + * 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; +} + +const NOOP_DEPS: CropperDeps = { + getImage: () => null +}; + +/** + * 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; + + // 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; + + // 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; + + // 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, + "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, + markUserDragged: action, + commitCrop: action, + setZoom: action, + toggleGrayscale: action, + rotate: action, + 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); revokePreview touches only the plain blobUrl. + // captureOriginal/markInternalChange wrap their own runInAction / touch plain state. + applyCrop: false, + applyNow: false, + applyDebounced: false, + armed: false, + revokePreview: 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; + + // 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(); + }; + } + + // 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; + } + + 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: 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 = value; + 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.showPreview(working); + this.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.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.showPreview(file); + this.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: 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; + 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.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..7a93bab0e6 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -0,0 +1,462 @@ +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"; +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. 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; + } +} + +// 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()), + ...overrides + } as jest.Mocked; +} + +// 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; + deps: jest.Mocked; + dispose: () => void; +} { + const gate = new FakeGate(makeProps(propsOverride)); + const store = new ImageCropperStore(gate); + const deps = makeDeps(); + store.setDeps(deps); + const dispose = store.setup(); + return { store, gate, deps, dispose }; +} + +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", () => { + 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.setProps(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, gate, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(store.committedCrop).toEqual(PIXEL_CROP); + 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, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).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, gate, 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(gate.props.image.setValue).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, 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); + // 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(); + 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", 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); + store.reset(); + + expect(store.zoom).toBe(1); + expect(store.grayscale).toBe(false); + expect(store.zoomAnchor).toEqual({ x: 0.5, y: 0.5 }); + // 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(); + }); + }); + + describe("onImageChanged (inbound sync)", () => { + it("clears the crop and disarms so a queued apply cannot fire", async () => { + const { store, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + (cropImage 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.setProps(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(); + }); + }); + + 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); + }); + }); +}); 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)