From 1491c3d695b1a50a854ca5972d1704b3964c09c8 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:06:42 +0200 Subject: [PATCH 1/5] 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 92e10c0d9b7378f004b820369207eadaa444a4e6 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:07:11 +0200 Subject: [PATCH 2/5] 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 edfa936d57634c728870966df9973646f2947d33 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 10:07:32 +0200 Subject: [PATCH 3/5] 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 d7be27b88e105ea4dfc97fbfa173fe2dae9719d5 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 13 Jul 2026 17:01:14 +0200 Subject: [PATCH 4/5] 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 b3141974172e793a4397e5b35e8bc5e912d9dcd8 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 14 Jul 2026 15:03:40 +0200 Subject: [PATCH 5/5] 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(); }