diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index 21a86e5502..280f3a23e1 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -9,3 +9,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Initial release of Image cropper widget. +- Custom aspect ratio width and height can be set with an expression or bound to an attribute. diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx index 5f3075bfd3..9f69b24eff 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -21,10 +21,16 @@ function StaticCropPreview(props: { imageUrl: string; values: ImageCropperPrevie const [crop, setCrop] = useState(undefined); const imageRef = createRef(); + // Preview only has the expression *text* (no runtime data). Numeric literals render a real + // ratio; an attribute/expression path can't be evaluated here, so it falls back to free aspect. + const toNumber = (v: string | null): number | undefined => { + const n = Number(v); + return v != null && v !== "" && Number.isFinite(n) ? n : undefined; + }; const aspect = resolveAspectRatio( values.aspectRatio, - values.customAspectWidth ?? 0, - values.customAspectHeight ?? 0 + toNumber(values.customAspectWidth), + toNumber(values.customAspectHeight) ); const handleImageLoad = (percentCrop: Crop): void => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index c7268821d7..96491e96d0 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -34,13 +34,15 @@ Custom - + Custom aspect width - Width side of the ratio (e.g. 3 in 3:2). Used when Aspect ratio is Custom. + Width side of the ratio (e.g. 3 in 3:2). Used when Aspect ratio is Custom. Can be bound to an attribute or expression. + - + Custom aspect height - Height side of the ratio (e.g. 2 in 3:2). Used when Aspect ratio is Custom. + Height side of the ratio (e.g. 2 in 3:2). Used when Aspect ratio is Custom. Can be bound to an attribute or expression. + diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx index 8121eaca81..bad50c9b84 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -15,8 +15,8 @@ function makePreviewProps(overrides: Partial = {}): Im image: null, cropShape: "rect", aspectRatio: "free", - customAspectWidth: null, - customAspectHeight: null, + customAspectWidth: "1", + customAspectHeight: "1", onCropAction: null, boundaryWidth: null, boundaryHeight: null, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx index 6bf764e3a8..381897fed0 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -87,8 +87,8 @@ function makeProps(overrides: Partial = {}): ImageCr image: makeImageProp(), cropShape: "rect", aspectRatio: "free", - customAspectWidth: 1, - customAspectHeight: 1, + customAspectWidth: dynamic.available(new Big(1)), + customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx index e43fe42904..a80237e6be 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -3,7 +3,7 @@ import { Big } from "big.js"; import { ValueStatus } from "mendix"; import { Ref } from "react"; import type { Crop, PixelCrop } from "react-image-crop"; -import { actionValue } from "@mendix/widget-plugin-test-utils"; +import { actionValue, dynamic } from "@mendix/widget-plugin-test-utils"; import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; // Integration test: proves grayscale reversibility after rotate. @@ -96,8 +96,8 @@ function makeProps(overrides: Partial = {}): ImageCr image: makeImageProp(), cropShape: "rect", aspectRatio: "free", - customAspectWidth: 1, - customAspectHeight: 1, + customAspectWidth: dynamic.available(new Big(1)), + customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx index 49af30088f..f42d4b068a 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -3,7 +3,7 @@ import { Big } from "big.js"; import { ValueStatus } from "mendix"; import { Ref } from "react"; import type { Crop, PixelCrop } from "react-image-crop"; -import { actionValue } from "@mendix/widget-plugin-test-utils"; +import { actionValue, dynamic } from "@mendix/widget-plugin-test-utils"; import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; // Multi-instance integration test: verifies that auto-commit only fires for the @@ -88,8 +88,8 @@ function makeProps(overrides: Partial = {}): ImageCr image: makeImageProp(), cropShape: "rect", aspectRatio: "free", - customAspectWidth: 1, - customAspectHeight: 1, + customAspectWidth: dynamic.available(new Big(1)), + customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx index b3806b507e..9094eeb222 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -3,7 +3,7 @@ import { Big } from "big.js"; import { ValueStatus } from "mendix"; import { Ref } from "react"; import type { Crop, PixelCrop } from "react-image-crop"; -import { actionValue } from "@mendix/widget-plugin-test-utils"; +import { actionValue, dynamic } from "@mendix/widget-plugin-test-utils"; import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; // Integration test: proves the rotate/grayscale actions reach the right util with the right args. @@ -105,8 +105,8 @@ function makeProps(overrides: Partial = {}): ImageCr image: makeImageProp(), cropShape: "rect", aspectRatio: "free", - customAspectWidth: 1, - customAspectHeight: 1, + customAspectWidth: dynamic.available(new Big(1)), + customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts index 9b2e986c17..6f899875ab 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -1,4 +1,5 @@ -import { ValueStatus } from "mendix"; +import { Big } from "big.js"; +import { DynamicValue, 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"; @@ -124,7 +125,13 @@ export class ImageCropperStore implements SetupComponent { } get aspect(): number | undefined { - return resolveAspectRatio(this.props.aspectRatio, this.props.customAspectWidth, this.props.customAspectHeight); + const toNumber = (p: DynamicValue): number | undefined => + p.status === ValueStatus.Available && p.value ? p.value.toNumber() : undefined; + return resolveAspectRatio( + this.props.aspectRatio, + toNumber(this.props.customAspectWidth), + toNumber(this.props.customAspectHeight) + ); } setup(): () => void { 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 f8df72000c..1c3829a65d 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 @@ -3,6 +3,7 @@ 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 { dynamic } from "@mendix/widget-plugin-test-utils"; import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; // The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts @@ -52,8 +53,8 @@ function makeProps(overrides: Partial = {}): ImageCr image: makeImageProp(), cropShape: "rect", aspectRatio: "square", - customAspectWidth: 1, - customAspectHeight: 1, + customAspectWidth: dynamic.available(new Big(1)), + customAspectHeight: dynamic.available(new Big(1)), boundaryWidth: 300, boundaryHeight: 300, resizableEnabled: true, @@ -175,6 +176,34 @@ describe("ImageCropperStore", () => { expect(store.aspect).toBeCloseTo(16 / 9); dispose(); }); + + it("derives a custom ratio from the width/height expression values", () => { + const { store, gate, dispose } = makeStore({ + aspectRatio: "custom", + customAspectWidth: dynamic.available(new Big(16)), + customAspectHeight: dynamic.available(new Big(9)) + }); + expect(store.aspect).toBeCloseTo(16 / 9); + gate.setProps( + makeProps({ + aspectRatio: "custom", + customAspectWidth: dynamic.available(new Big(3)), + customAspectHeight: dynamic.available(new Big(2)) + }) + ); + expect(store.aspect).toBeCloseTo(3 / 2); + dispose(); + }); + + it("falls back to free aspect when a custom expression value is unavailable", () => { + const { store, dispose } = makeStore({ + aspectRatio: "custom", + customAspectWidth: dynamic.unavailable(), + customAspectHeight: dynamic.available(new Big(9)) + }); + expect(store.aspect).toBeUndefined(); + dispose(); + }); }); describe("commitCrop gate (user-drag vs programmatic)", () => { diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts index e5d305dce4..905a330352 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts @@ -2,8 +2,8 @@ import { AspectRatioEnum } from "../../typings/ImageCropperProps"; export function resolveAspectRatio( aspect: AspectRatioEnum, - customWidth: number, - customHeight: number + customWidth: number | undefined, + customHeight: number | undefined ): number | undefined { switch (aspect) { case "free": @@ -17,7 +17,7 @@ export function resolveAspectRatio( case "portrait3x4": return 3 / 4; case "custom": - if (customWidth > 0 && customHeight > 0) { + if (customWidth != null && customHeight != null && customWidth > 0 && customHeight > 0) { return customWidth / customHeight; } return undefined; diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index 515e799308..c05ef67416 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -25,8 +25,8 @@ export interface ImageCropperContainerProps { image: EditableImageValue; cropShape: CropShapeEnum; aspectRatio: AspectRatioEnum; - customAspectWidth: number; - customAspectHeight: number; + customAspectWidth: DynamicValue; + customAspectHeight: DynamicValue; onCropAction?: ActionValue; boundaryWidth: number; boundaryHeight: number; @@ -67,8 +67,8 @@ export interface ImageCropperPreviewProps { image: { type: "static"; imageUrl: string; } | { type: "dynamic"; entity: string; } | null; cropShape: CropShapeEnum; aspectRatio: AspectRatioEnum; - customAspectWidth: number | null; - customAspectHeight: number | null; + customAspectWidth: string; + customAspectHeight: string; onCropAction: {} | null; boundaryWidth: number | null; boundaryHeight: number | null;