diff --git a/.github/configs/labeler.yml b/.github/configs/labeler.yml index bbd16badda..2727e9efb7 100644 --- a/.github/configs/labeler.yml +++ b/.github/configs/labeler.yml @@ -1,92 +1,94 @@ # Common documentation: - - all: ["*.md"] + - all: ["*.md"] test: - - all: ["*.spec.*"] + - all: ["*.spec.*"] # Modules data-widgets: - - packages/*/data-widgets/**/* - - packages/*/datagrid-*/**/* - - packages/*/dropdown-sort-web/**/* - - packages/*/gallery-web/**/* - - packages/*/tree-node-web/**/* + - packages/*/data-widgets/**/* + - packages/*/datagrid-*/**/* + - packages/*/dropdown-sort-web/**/* + - packages/*/gallery-web/**/* + - packages/*/tree-node-web/**/* web-actions: - - packages/*/web-actions/**/* + - packages/*/web-actions/**/* google-tag: - - packages/*/google-tag/**/* + - packages/*/google-tag/**/* anychart-buildingblocks: - - packages/*/anychart-buildingblocks/**/* + - packages/*/anychart-buildingblocks/**/* # Widgets accessibility-helper-web: - - packages/*/accessibility-helper-web/**/* + - packages/*/accessibility-helper-web/**/* accordion-web: - - packages/*/accordion-web/**/* + - packages/*/accordion-web/**/* badge-button-web: - - packages/*/badge-button-web/**/* + - packages/*/badge-button-web/**/* badge-web: - - packages/*/badge-web/**/* + - packages/*/badge-web/**/* barcode-scanner-web: - - packages/*/barcode-scanner-web/**/* + - packages/*/barcode-scanner-web/**/* carousel-web: - - packages/*/carousel-web/**/* + - packages/*/carousel-web/**/* charts-web: - - packages/shared/charts/**/* - - packages/*/*-chart-web/**/* - - packages/*/charts-web/**/* + - packages/shared/charts/**/* + - packages/*/*-chart-web/**/* + - packages/*/charts-web/**/* color-picker-web: - - packages/*/color-picker-web/**/* + - packages/*/color-picker-web/**/* markdown-web: - - packages/*/markdown-web/**/* + - packages/*/markdown-web/**/* fieldset-web: - - packages/*/fieldset-web/**/* + - packages/*/fieldset-web/**/* html-element-web: - - packages/*/html-element-web/**/* + - packages/*/html-element-web/**/* image-web: - - packages/*/image-web/**/* + - packages/*/image-web/**/* language-selector-web: - - packages/*/language-selector-web/**/* + - packages/*/language-selector-web/**/* maps-web: - - packages/*/maps-web/**/* + - packages/*/maps-web/**/* popup-menu-web: - - packages/*/popup-menu-web/**/* + - packages/*/popup-menu-web/**/* progress-bar-web: - - packages/*/progress-bar-web/**/* + - packages/*/progress-bar-web/**/* progress-circle-web: - - packages/*/progress-circle-web/**/* + - packages/*/progress-circle-web/**/* range-slider-web: - - packages/*/range-slider-web/**/* + - packages/*/range-slider-web/**/* rating-web: - - packages/*/rating-web/**/* + - packages/*/rating-web/**/* rich-text-web: - - packages/*/rich-text-web/**/* + - packages/*/rich-text-web/**/* slider-web: - - packages/*/slider-web/**/* + - packages/*/slider-web/**/* switch-web: - - packages/*/switch-web/**/* + - packages/*/switch-web/**/* timeline-web: - - packages/*/timeline-web/**/* + - packages/*/timeline-web/**/* tooltip-web: - - packages/*/tooltip-web/**/* + - packages/*/tooltip-web/**/* video-player-web: - - packages/*/video-player-web/**/* + - packages/*/video-player-web/**/* any-chart-web: - - packages/*/any-chart-web/**/* + - packages/*/any-chart-web/**/* calendar-web: - - packages/*/calendar-web/**/* + - packages/*/calendar-web/**/* signature-web: - - packages/*/signature-web/**/* + - packages/*/signature-web/**/* combobox-web: - - packages/*/combobox-web/**/* + - packages/*/combobox-web/**/* google-tag-web: - - packages/*/google-tag-web/**/* + - packages/*/google-tag-web/**/* +image-cropper: + - packages/*/image-cropper/**/* # Internals shared: - - packages/shared/**/* + - packages/shared/**/* automation: - - automation/**/* + - automation/**/* workflows: - - .github/workflows/* \ No newline at end of file + - .github/workflows/* diff --git a/packages/pluggableWidgets/image-cropper-web/.gitignore b/packages/pluggableWidgets/image-cropper-web/.gitignore new file mode 100644 index 0000000000..8cbb8444c7 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.mpk diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md new file mode 100644 index 0000000000..bc5fa0bfdd --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this widget will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2026-05-21 + +### Added + +- Initial release. Crops a bound `EditableImageValue` attribute with rectangular or circular viewport, optional zoom (slider + wheel), live preview pane, and PNG/JPEG output. Replaces the legacy ImageCrop widget. diff --git a/packages/pluggableWidgets/image-cropper-web/LICENSE b/packages/pluggableWidgets/image-cropper-web/LICENSE new file mode 100644 index 0000000000..e5576bd26b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/LICENSE @@ -0,0 +1,15 @@ +The Apache License v2.0 + +Copyright © Mendix Technology BV 2026. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/pluggableWidgets/image-cropper-web/README.md b/packages/pluggableWidgets/image-cropper-web/README.md new file mode 100644 index 0000000000..63b3ae1a70 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/README.md @@ -0,0 +1,5 @@ +# Image Crop + +Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute. + +See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs. diff --git a/packages/pluggableWidgets/image-cropper-web/eslint.config.mjs b/packages/pluggableWidgets/image-cropper-web/eslint.config.mjs new file mode 100644 index 0000000000..ed68ae9e78 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs"; + +export default config; diff --git a/packages/pluggableWidgets/image-cropper-web/jest.config.js b/packages/pluggableWidgets/image-cropper-web/jest.config.js new file mode 100644 index 0000000000..8ee98da701 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/jest.config.js @@ -0,0 +1,6 @@ +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); + +module.exports = { + ...base, + setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")] +}; diff --git a/packages/pluggableWidgets/image-cropper-web/jest.setup.ts b/packages/pluggableWidgets/image-cropper-web/jest.setup.ts new file mode 100644 index 0000000000..f76dbf0343 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/jest.setup.ts @@ -0,0 +1,61 @@ +/** + * Jest setup for image-cropper tests. + * + * Problem: when `canvas` npm package is installed, jsdom uses node-canvas. Its `drawImage` + * rejects jsdom HTMLImageElement objects. Also, the test's `captureDrawImageCalls` helper spies on + * `CanvasRenderingContext2D.prototype.drawImage` — which must be the mock class prototype for the + * spy to fire. + * + * Fix: + * 1. Replace `global.CanvasRenderingContext2D` with the jest-canvas-mock class. + * 2. Override `HTMLCanvasElement.prototype.getContext` to return a MockCRC2D instance. + * This makes the context returned by our code an instance of MockCRC2D, so the spec's spy + * on `CanvasRenderingContext2D.prototype.drawImage` (which equals MockCRC2D.prototype.drawImage) + * fires correctly. + * 3. Override `HTMLCanvasElement.prototype.toBlob` to return a valid Blob synchronously + * (avoiding node-canvas toBuffer issues in tests). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MockCRC2D = require("jest-canvas-mock/lib/classes/CanvasRenderingContext2D").default; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MockImageBitmap = require("jest-canvas-mock/lib/classes/ImageBitmap").default; + +// Make global.CanvasRenderingContext2D the mock class so spec spies on the right prototype +(global as any).CanvasRenderingContext2D = MockCRC2D; +// MockCRC2D's drawImage references ImageBitmap globally — provide a stub if jsdom doesn't have it +if (!(global as any).ImageBitmap) { + (global as any).ImageBitmap = MockImageBitmap; +} + +// Per-canvas context map for idempotency +const contextMap = new WeakMap>(); + +// Patch HTMLCanvasElement.prototype.getContext — jsdom exposes this as a regular JS method +const origGetContext = HTMLCanvasElement.prototype.getContext; +(HTMLCanvasElement.prototype as any).getContext = function ( + this: HTMLCanvasElement, + type: string, + ...rest: unknown[] +): unknown { + if (type === "2d") { + if (!contextMap.has(this)) { + contextMap.set(this, new MockCRC2D(this)); + } + return contextMap.get(this); + } + return (origGetContext as Function).apply(this, [type, ...rest]); +}; + +// Patch HTMLCanvasElement.prototype.toBlob to avoid node-canvas's toBuffer path +(HTMLCanvasElement.prototype as any).toBlob = function ( + this: HTMLCanvasElement, + callback: (blob: Blob | null) => void, + type?: string +): void { + const mime = type === "image/jpeg" || type === "image/webp" ? type : "image/png"; + const length = this.width * this.height * 4; + const data = new Uint8Array(length); + const blob = new Blob([data], { type: mime }); + setTimeout(() => callback(blob), 0); +}; diff --git a/packages/pluggableWidgets/image-cropper-web/package.json b/packages/pluggableWidgets/image-cropper-web/package.json new file mode 100644 index 0000000000..097d544871 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/package.json @@ -0,0 +1,57 @@ +{ + "name": "@mendix/image-cropper-web", + "widgetName": "ImageCropper", + "version": "1.0.0", + "description": "Crop images bound to a Mendix image attribute", + "copyright": "© Mendix Technology BV 2026. All rights reserved.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/mendix/web-widgets.git" + }, + "config": {}, + "mxpackage": { + "name": "ImageCropper", + "type": "widget", + "mpkName": "com.mendix.widget.web.ImageCropper.mpk" + }, + "packagePath": "com.mendix.widget.web", + "marketplace": { + "minimumMXVersion": "10.21.0", + "appName": "Image Cropper", + "appNumber": 1, + "reactReady": true + }, + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "image-cropper" + }, + "scripts": { + "build": "pluggable-widgets-tools build:web", + "create-gh-release": "rui-create-gh-release", + "create-translation": "rui-create-translation", + "dev": "pluggable-widgets-tools start:web", + "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", + "lint": "eslint src/ package.json", + "publish-marketplace": "rui-publish-marketplace", + "release": "pluggable-widgets-tools release:web", + "start": "pluggable-widgets-tools start:server", + "test": "jest --projects jest.config.js", + "update-changelog": "rui-update-changelog-widget", + "verify": "rui-verify-package-format" + }, + "dependencies": { + "classnames": "^2.5.1", + "react-image-crop": "^11.0.10" + }, + "devDependencies": { + "@mendix/automation-utils": "workspace:*", + "@mendix/eslint-config-web-widgets": "workspace:*", + "@mendix/pluggable-widgets-tools": "*", + "@mendix/prettier-config-web-widgets": "workspace:*", + "@mendix/rollup-web-widgets": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*", + "@mendix/widget-plugin-test-utils": "workspace:*", + "jest-canvas-mock": "^2.5.2" + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/rollup.config.mjs b/packages/pluggableWidgets/image-cropper-web/rollup.config.mjs new file mode 100644 index 0000000000..688a1a7197 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/rollup.config.mjs @@ -0,0 +1,5 @@ +import copyFiles from "@mendix/rollup-web-widgets/copyFiles.mjs"; + +export default args => { + return copyFiles(args); +}; diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts new file mode 100644 index 0000000000..fa0455b645 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -0,0 +1,117 @@ +import { hidePropertiesIn, Properties } from "@mendix/pluggable-widgets-tools"; +import { + StructurePreviewProps, + structurePreviewPalette +} from "@mendix/widget-plugin-platform/preview/structure-preview-api"; +import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; +import CropIconSvg from "./assets/crop-icon.svg"; + +export function getProperties(values: ImageCropperPreviewProps, defaultProperties: Properties): Properties { + const propsToHide: Array = []; + + if (values.aspectRatio !== "custom") { + propsToHide.push("customAspectWidth", "customAspectHeight"); + } + + if (!values.zoomEnabled) { + propsToHide.push("showZoomSlider", "wheelZoomMode", "minZoom", "maxZoom"); + } + + if (!values.showPreview) { + propsToHide.push("previewWidth", "previewHeight"); + } + + if (values.outputFormat !== "jpeg") { + propsToHide.push("outputQuality"); + } + + hidePropertiesIn(defaultProperties, values, propsToHide); + return defaultProperties; +} + +export function getPreview(values: ImageCropperPreviewProps, isDarkMode: boolean): StructurePreviewProps { + const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"]; + const iconDocument = decodeURIComponent(CropIconSvg.replace("data:image/svg+xml,", "")); + + return { + type: "Container", + borders: true, + borderRadius: 4, + backgroundColor: palette.background.containerFill, + children: [ + { + type: "RowLayout", + columnSize: "grow", + padding: 12, + children: [ + { + type: "Container", + grow: 0, + padding: 4, + children: [ + { + type: "Image", + document: iconDocument, + width: 28, + height: 22 + } + ] + }, + { + type: "Container", + grow: 1, + children: [ + { + type: "Text", + content: "Image Cropper", + bold: true, + fontColor: palette.text.primary, + fontSize: 10 + }, + { + type: "Text", + content: describeConfig(values), + fontColor: palette.text.secondary, + fontSize: 8 + } + ] + } + ] + } + ] + }; +} + +export function getCustomCaption(values: ImageCropperPreviewProps): string { + const shape = values.cropShape === "circle" ? "Circle" : "Rectangle"; + return `Image Cropper (${shape})`; +} + +function describeConfig(values: ImageCropperPreviewProps): string { + const parts: string[] = []; + parts.push(values.cropShape === "circle" ? "Circle" : "Rectangle"); + parts.push(aspectLabel(values)); + parts.push(`${values.outputFormat.toUpperCase()} · ${values.outputSize === "viewport" ? "Viewport" : "Original"}`); + return parts.join(" · "); +} + +function aspectLabel(values: ImageCropperPreviewProps): string { + switch (values.aspectRatio) { + case "free": + return "Free aspect"; + case "square": + return "1:1"; + case "landscape16x9": + return "16:9"; + case "landscape4x3": + return "4:3"; + case "portrait3x4": + return "3:4"; + case "custom": + return `${values.customAspectWidth}:${values.customAspectHeight}`; + default: { + const _exhaustive: never = values.aspectRatio; + return _exhaustive; + } + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx new file mode 100644 index 0000000000..1732eadd89 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -0,0 +1,18 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; + +export function preview(props: ImageCropperPreviewProps): ReactElement { + return ( +
+
+
+

Image Cropper

+
+
+ ); +} + +export function getPreviewCss(): string { + return require("./ui/ImageCropper.scss"); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png new file mode 100755 index 0000000000..1cae9739f5 Binary files /dev/null and b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png differ diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.png new file mode 100755 index 0000000000..8c7b266490 Binary files /dev/null and b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.png differ diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.dark.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.dark.png new file mode 100755 index 0000000000..66e7bf88a7 Binary files /dev/null and b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.dark.png differ diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png new file mode 100755 index 0000000000..f7f7732cc7 Binary files /dev/null and b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png differ diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tsx b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tsx new file mode 100644 index 0000000000..22db4b8358 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tsx @@ -0,0 +1,8 @@ +import { ReactElement } from "react"; +import { ImageCropperContainerProps } from "../typings/ImageCropperProps"; +import { ImageCropperContainer } from "./components/ImageCropperContainer"; +import "./ui/ImageCropper.scss"; + +export function ImageCropper(props: ImageCropperContainerProps): ReactElement | null { + return ; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml new file mode 100644 index 0000000000..50e45d7495 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -0,0 +1,137 @@ + + + Image Cropper + Crop an image attribute + https://docs.mendix.com/appstore/widgets/image-cropper + + + + + Image attribute + The image to crop. The cropped result is saved back to it. + + + + + Crop shape + Shape of the crop. Circle masks the corners. + + Rectangle + Circle + + + + Aspect ratio + Locks the crop proportions. Free lets the user resize freely. + + Free + 1:1 + 16:9 + 4:3 + 3:4 + Custom + + + + Custom aspect width + Width side of the ratio (e.g. 3 in 3:2). Used when Aspect ratio is Custom. + + + Custom aspect height + Height side of the ratio (e.g. 2 in 3:2). Used when Aspect ratio is Custom. + + + + + On crop + Runs each time the crop is auto-applied to the image attribute. + + + + + + + Canvas max width (px) + Maximum on-screen width of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. + + + Canvas max height (px) + Maximum on-screen height of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. + + + + + Show preview + Show a live thumbnail of the current crop next to the canvas. + + + Preview width (px) + Width of the preview thumbnail. + + + Preview height (px) + Height of the preview thumbnail. + + + + + + + Resizable handles + Let the user resize the selection by dragging its corners. + + + + + Enable zoom + Master switch for zooming. When off, the slider and mouse-wheel zoom are disabled and the image stays at 1×. + + + Show zoom slider + Show the zoom slider below the crop area. Turn off to keep mouse-wheel zoom while hiding the slider. + + + Mouse wheel zoom + Whether the mouse wheel zooms the image. "On (hold Ctrl)" keeps page scroll working. + + Off + On + On (hold Ctrl) + + + + Minimum zoom + Smallest zoom level. 1 = image fits the canvas. Below 1 lets the user zoom out further. + + + Maximum zoom + Largest zoom level. 4 means up to 4× the canvas size. Must be greater than Minimum zoom. + + + + + + + Output format + File format. PNG keeps transparency; JPEG produces smaller files. + + PNG + JPEG + + + + JPEG quality (0.0 - 1.0) + JPEG compression. Higher = sharper and larger. Ignored for PNG. + + + Output size + Resolution of the saved crop. Original is sharpest; Viewport matches the on-screen canvas size. + + Viewport (canvas dimensions) + Original (source resolution) + + + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx new file mode 100644 index 0000000000..758321d875 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -0,0 +1,226 @@ +import { act, render, screen } from "@testing-library/react"; +import { Big } from "big.js"; +import { ValueStatus } from "mendix"; +import { Ref } from "react"; +import type { Crop, PixelCrop } from "react-image-crop"; +import { actionValue } from "@mendix/widget-plugin-test-utils"; +import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; + +// Capture the container's callbacks via a mocked CropArea. Real ReactCrop only fires +// onComplete on pointer drags, which jsdom cannot simulate — so we drive the wiring directly. +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + setZoom: (next: number) => void; + wheelZoomMode: string; +} +let captured: CapturedCropArea; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + setZoom: CapturedCropArea["setZoom"]; + wheelZoomMode: string; + }) => { + captured = { + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + setZoom: props.setZoom, + wheelZoomMode: props.wheelZoomMode + }; + return ( + { + if (node) { + Object.defineProperty(node, "naturalWidth", { value: 400, configurable: true }); + Object.defineProperty(node, "naturalHeight", { value: 300, configurable: true }); + Object.defineProperty(node, "width", { value: 400, configurable: true }); + Object.defineProperty(node, "height", { value: 300, configurable: true }); + } + if (typeof props.imageRef === "function") { + props.imageRef(node); + } else if (props.imageRef) { + (props.imageRef as { current: HTMLImageElement | null }).current = node; + } + }} + /> + ); + } +})); + +import { ImageCropper } from "../ImageCropper"; + +type ImageProp = ImageCropperContainerProps["image"]; +type WebImage = NonNullable; + +const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; +const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 }; + +function makeImageProp(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 ImageProp; +} + +function makeProps(overrides: Partial = {}): ImageCropperContainerProps { + return { + name: "imageCrop", + class: "", + style: undefined, + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "free", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + showPreview: false, + previewWidth: 100, + previewHeight: 100, + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + onCropAction: actionValue(), + ...overrides + }; +} + +// Flush cropImage's async chain: microtasks + toBlob's setTimeout(0). +async function flushApply(): Promise { + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("renders skeleton while image is loading", () => { + const props = makeProps({ image: makeImageProp({ status: ValueStatus.Loading, value: undefined }) }); + const { container } = render(); + expect(container.querySelector(".widget-image-cropper--loading")).not.toBeNull(); + }); + + test("renders empty state when image has no value", () => { + const props = makeProps({ image: makeImageProp({ value: undefined }) }); + render(); + expect(screen.getByText("No image")).toBeInTheDocument(); + }); + + test("does NOT auto-apply on initial image load (no data mutation without user intent)", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).not.toHaveBeenCalled(); + }); + + test("auto-applies on crop release (setValue called once with a File)", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).toHaveBeenCalledTimes(1); + expect((image.setValue as jest.Mock).mock.calls[0][0]).toBeInstanceOf(File); + }); + + test("debounces zoom changes into a single apply", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + (image.setValue as jest.Mock).mockClear(); + + act(() => { + captured.setZoom(1.5); + captured.setZoom(2); + captured.setZoom(2.5); + }); + // before the debounce window elapses, nothing applied + expect(image.setValue).not.toHaveBeenCalled(); + await act(async () => { + jest.advanceTimersByTime(400); + }); + await flushApply(); + expect(image.setValue).toHaveBeenCalledTimes(1); + }); + + test("fires onCropAction after an applied crop", async () => { + const action = actionValue(); + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(action.execute).toHaveBeenCalledTimes(1); + }); + + test("read-only image does not apply on crop release", async () => { + const image = makeImageProp({ readOnly: true }); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).not.toHaveBeenCalled(); + }); + + test("shows zoom slider when zoomEnabled && showZoomSlider", () => { + const { container } = render(); + expect(container.querySelector(".widget-image-cropper__zoom")).not.toBeNull(); + }); + + test("hides slider when showZoomSlider is false (wheel zoom still wired via CropArea)", () => { + const { container } = render(); + expect(container.querySelector(".widget-image-cropper__zoom")).toBeNull(); + // CropArea (wheel-zoom owner) still rendered + expect(container.querySelector("img")).not.toBeNull(); + }); + + test("hides slider when zoom disabled entirely", () => { + const { container } = render(); + expect(container.querySelector(".widget-image-cropper__zoom")).toBeNull(); + }); + + test("passes wheelZoomMode=off to CropArea when zoomEnabled is false", () => { + render(); + expect(captured.wheelZoomMode).toBe("off"); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg new file mode 100644 index 0000000000..534cf020b2 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx new file mode 100644 index 0000000000..68b5d24a6a --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -0,0 +1,127 @@ +import { Dispatch, ReactElement, Ref, SetStateAction, SyntheticEvent, useCallback, useState } from "react"; +import { + default as ReactCrop, + centerCrop, + convertToPixelCrop, + makeAspectCrop, + type Crop, + type PixelCrop +} from "react-image-crop"; +import { ZoomContainer } from "./ZoomContainer"; +import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; + +interface CropAreaProps { + src: string; + crop: Crop | undefined; + onCropChange: (crop: Crop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + aspect: number | undefined; + circular: boolean; + resizable: boolean; + boundaryWidth: number; + boundaryHeight: number; + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + zoom: number; + minZoom: number; + maxZoom: number; + setZoom: Dispatch>; + wheelZoomMode: WheelZoomModeEnum; + imageRef: Ref; +} + +function buildInitialCrop( + img: HTMLImageElement, + aspect: number | undefined +): { percentCrop: Crop; pixelCrop: PixelCrop } { + const { naturalWidth, naturalHeight, width, height } = img; + const safeAspect = aspect ?? naturalWidth / naturalHeight; + const percentCrop = centerCrop( + makeAspectCrop({ unit: "%", width: 80 }, safeAspect, naturalWidth, naturalHeight), + naturalWidth, + naturalHeight + ); + return { percentCrop, pixelCrop: convertToPixelCrop(percentCrop, width, height) }; +} + +function fitToBoundary( + naturalWidth: number, + naturalHeight: number, + boundaryWidth: number, + boundaryHeight: number +): { width: number; height: number } { + if (naturalWidth <= 0 || naturalHeight <= 0) { + return { width: boundaryWidth, height: boundaryHeight }; + } + const scale = Math.min(boundaryWidth / naturalWidth, boundaryHeight / naturalHeight); + return { width: Math.round(naturalWidth * scale), height: Math.round(naturalHeight * scale) }; +} + +export function CropArea(props: CropAreaProps): ReactElement { + const [loadError, setLoadError] = useState(false); + const [displaySize, setDisplaySize] = useState<{ width: number; height: number } | null>(null); + + const { aspect, onImageLoad, boundaryWidth, boundaryHeight, src } = props; + + const [prevSrc, setPrevSrc] = useState(src); + if (prevSrc !== src) { + setPrevSrc(src); + setDisplaySize(null); + } + + const handleImageLoad = useCallback( + (e: SyntheticEvent) => { + const img = e.currentTarget; + setDisplaySize(fitToBoundary(img.naturalWidth, img.naturalHeight, boundaryWidth, boundaryHeight)); + const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect); + onImageLoad(percentCrop, pixelCrop); + }, + [aspect, onImageLoad, boundaryWidth, boundaryHeight] + ); + + if (loadError) { + return ( +
+ Could not load this image. If it is a remote image, the server must allow cross-origin access. +
+ ); + } + + return ( + + props.onCropChange(percent)} + onComplete={pixel => props.onCropComplete(pixel)} + aspect={props.aspect} + circularCrop={props.circular} + disabled={!props.resizable} + keepSelection + > + setLoadError(true)} + /> + + + ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx new file mode 100644 index 0000000000..462b418129 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -0,0 +1,177 @@ +import classNames from "classnames"; +import { ValueStatus } from "mendix"; +import { ReactElement, SetStateAction, useCallback, useEffect, useRef } from "react"; +import { type Crop, type PixelCrop } from "react-image-crop"; +import { CropArea } from "./CropArea"; +import { PreviewPane } from "./PreviewPane"; +import { ZoomSlider } from "./ZoomSlider"; +import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; +import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop"; +import { useImageCropperState } from "../hooks/useImageCropperState"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { cropImage, CropError } from "../utils/cropImage"; + +export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { + const state = useImageCropperState(Number(props.minZoom)); + + const { setZoom, setLiveCrop, setCommittedCrop } = state; + + const committedCropRef = useRef(undefined); + committedCropRef.current = state.committedCrop; + const zoomRef = useRef(state.zoom); + zoomRef.current = state.zoom; + + 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, + outputFormat: props.outputFormat, + outputQuality: Number(props.outputQuality), + outputSize: props.outputSize, + cropShape: props.cropShape, + viewportWidth: props.boundaryWidth, + viewportHeight: props.boundaryHeight, + originalName: props.image.value.name + }); + if (props.outputSize === "viewport") { + props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); + } + props.image.setValue(file); + if (props.onCropAction?.canExecute) { + props.onCropAction.execute(); + } + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + console.error("[image-cropper-web] unexpected error:", err); + throw err; + } + } + }, [ + state.imageRef, + props.image, + props.outputFormat, + props.outputQuality, + props.outputSize, + props.cropShape, + props.boundaryWidth, + props.boundaryHeight, + props.onCropAction + ]); + + const auto = useAutoApplyCrop(applyCrop); + const { armed } = auto; + + const handleImageLoad = useCallback( + (percentCrop: Crop, pixelCrop: PixelCrop) => { + setZoom(Number(props.minZoom)); + setLiveCrop(percentCrop); + setCommittedCrop(pixelCrop); + armed(); + }, + [setZoom, setLiveCrop, setCommittedCrop, props.minZoom, armed] + ); + + const uri = props.image.status === ValueStatus.Available ? props.image.value?.uri : undefined; + useEffect(() => { + setLiveCrop(undefined); + setCommittedCrop(undefined); + armed(); + }, [uri, setLiveCrop, setCommittedCrop, armed]); + + const handleCropComplete = useCallback( + (pixelCrop: PixelCrop) => { + committedCropRef.current = pixelCrop; + setCommittedCrop(pixelCrop); + auto.applyNow(); + }, + [setCommittedCrop, auto] + ); + + const handleZoomChange = useCallback( + (next: SetStateAction) => { + setZoom(next); + auto.applyDebounced(); + }, + [setZoom, auto] + ); + + if (props.image.status === ValueStatus.Loading) { + return ( +
+ ); + } + if (props.image.status !== ValueStatus.Available || !props.image.value) { + return ( +
+ No image +
+ ); + } + + const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); + + return ( +
+ + {props.zoomEnabled && props.showZoomSlider ? ( + + ) : null} + {props.showPreview ? ( + + ) : null} +
+ ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx new file mode 100644 index 0000000000..c58ed85094 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx @@ -0,0 +1,63 @@ +import { ReactElement, useEffect, useRef } from "react"; +import type { PixelCrop } from "react-image-crop"; + +interface PreviewPaneProps { + image: HTMLImageElement | null; + pixelCrop: PixelCrop | undefined; + zoom: number; + width: number; + height: number; + circle: boolean; +} + +export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: PreviewPaneProps): ReactElement { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !image || !pixelCrop || !image.naturalWidth) { + return; + } + + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + if (pixelCrop.width === 0 || pixelCrop.height === 0) { + // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. + return; + } + if (circle) { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + ctx.clip(); + } + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + ctx.drawImage( + image, + (pixelCrop.x / z) * scaleX, + (pixelCrop.y / z) * scaleY, + (pixelCrop.width / z) * scaleX, + (pixelCrop.height / z) * scaleY, + 0, + 0, + width, + height + ); + if (circle) { + ctx.restore(); + } + }, [image, pixelCrop, zoom, width, height, circle]); + + return ; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomContainer.tsx new file mode 100644 index 0000000000..db3d4b452d --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomContainer.tsx @@ -0,0 +1,46 @@ +import classNames from "classnames"; +import { Dispatch, ReactElement, ReactNode, SetStateAction, useEffect, useRef } from "react"; +import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; +import { useWheelZoom } from "../hooks/useWheelZoom"; + +interface ZoomContainerProps { + mode: WheelZoomModeEnum; + minZoom: number; + maxZoom: number; + setZoom: Dispatch>; + boundaryWidth: number; + boundaryHeight: number; + circular: boolean; + children: ReactNode; +} + +export function ZoomContainer(props: ZoomContainerProps): ReactElement { + const containerRef = useRef(null); + const onWheel = useWheelZoom({ + mode: props.mode, + minZoom: props.minZoom, + maxZoom: props.maxZoom, + setZoom: props.setZoom + }); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [onWheel]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx new file mode 100644 index 0000000000..2edc53abe7 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx @@ -0,0 +1,25 @@ +import { ChangeEvent, ReactElement } from "react"; + +interface ZoomSliderProps { + zoom: number; + minZoom: number; + maxZoom: number; + onChange: (zoom: number) => void; +} + +export function ZoomSlider({ zoom, minZoom, maxZoom, onChange }: ZoomSliderProps): ReactElement { + return ( + + ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useWheelZoom.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useWheelZoom.spec.ts new file mode 100644 index 0000000000..efccf0e83b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useWheelZoom.spec.ts @@ -0,0 +1,77 @@ +import { renderHook, act } from "@testing-library/react"; +import { useWheelZoom } from "../useWheelZoom"; + +function makeWheelEvent(deltaY: number, ctrlKey = false): globalThis.WheelEvent { + return new globalThis.WheelEvent("wheel", { deltaY, ctrlKey, bubbles: true, cancelable: true }); +} + +function makeSetZoom(initial: number): { setZoom: jest.Mock; getZoom: () => number } { + let current = initial; + const setZoom = jest.fn((updater: ((prev: number) => number) | number) => { + current = typeof updater === "function" ? updater(current) : updater; + }); + return { setZoom, getZoom: () => current }; +} + +describe("useWheelZoom", () => { + test("mode 'off' does nothing", () => { + const { setZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "off", minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100); + const spy = jest.spyOn(e, "preventDefault"); + act(() => result.current(e)); + expect(setZoom).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + }); + + test("mode 'on' zooms in on negative deltaY", () => { + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100); + const spy = jest.spyOn(e, "preventDefault"); + act(() => result.current(e)); + expect(getZoom()).toBe(1.1); + expect(spy).toHaveBeenCalled(); + }); + + test("mode 'on' zooms out on positive deltaY", () => { + const { setZoom, getZoom } = makeSetZoom(2); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(100))); + expect(getZoom()).toBe(1.8); + }); + + test("mode 'onWithCtrl' ignores wheel without Ctrl/Meta", () => { + const { setZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "onWithCtrl", minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100, false); + const spy = jest.spyOn(e, "preventDefault"); + act(() => result.current(e)); + expect(setZoom).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + }); + + test("mode 'onWithCtrl' zooms when Ctrl is held", () => { + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "onWithCtrl", minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100, true); + const spy = jest.spyOn(e, "preventDefault"); + act(() => result.current(e)); + expect(getZoom()).toBe(1.1); + expect(spy).toHaveBeenCalled(); + }); + + test("clamps to maxZoom", () => { + const { setZoom, getZoom } = makeSetZoom(4); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(-100))); + expect(getZoom()).toBe(4); + }); + + test("clamps to minZoom", () => { + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(100))); + expect(getZoom()).toBe(1); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts new file mode 100644 index 0000000000..6b217f04ae --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000000..0dff232ae2 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts @@ -0,0 +1,22 @@ +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/PreviewPane. + liveCrop: Crop | undefined; + setLiveCrop: Dispatch>; + committedCrop: PixelCrop | undefined; + setCommittedCrop: Dispatch>; + zoom: number; + setZoom: 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 imageRef = useRef(null); + return { liveCrop, setLiveCrop, committedCrop, setCommittedCrop, zoom, setZoom, imageRef }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useWheelZoom.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useWheelZoom.ts new file mode 100644 index 0000000000..a88cd45b50 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useWheelZoom.ts @@ -0,0 +1,33 @@ +import { Dispatch, SetStateAction, useCallback } from "react"; +import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; + +interface UseWheelZoomArgs { + mode: WheelZoomModeEnum; + minZoom: number; + maxZoom: number; + setZoom: Dispatch>; +} + +const STEP = 0.1; + +export function useWheelZoom(args: UseWheelZoomArgs): (e: globalThis.WheelEvent) => void { + const { mode, minZoom, maxZoom, setZoom } = args; + + return useCallback( + (e: globalThis.WheelEvent) => { + if (mode === "off") { + return; + } + if (mode === "onWithCtrl" && !(e.ctrlKey || e.metaKey)) { + return; + } + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + setZoom(prev => { + const next = prev * (1 + STEP * direction); + return Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); + }); + }, + [mode, minZoom, maxZoom, setZoom] + ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/package.xml b/packages/pluggableWidgets/image-cropper-web/src/package.xml new file mode 100644 index 0000000000..28fb72790f --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/package.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss new file mode 100644 index 0000000000..27d52b9c36 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -0,0 +1,90 @@ +@import "react-image-crop/dist/ReactCrop.css"; + +$image-cropper-bg-color: #f5f7fa; +$image-cropper-border-color-default: #b0bec5; +$image-cropper-gray-light: #6c757d; +$image-cropper-icon: url(../assets/crop-icon.svg); + +.widget-image-cropper { + display: inline-flex; + flex-direction: column; + gap: 8px; + + &__canvas { + display: inline-block; + position: relative; + overflow: hidden; + background: #f5f5f5; + + img { + display: block; + transition: transform 80ms linear; + } + + &--circle .ReactCrop__crop-selection { + border-radius: 50%; + } + } + + &__zoom { + display: flex; + align-items: center; + gap: 8px; + + input[type="range"] { + flex: 1; + accent-color: var(--brand-primary, #264ae5); + } + } + + &__preview { + border: 1px solid #ddd; + background: #fff; + } + + &__button { + align-self: flex-start; + } + + &__error, + &--empty { + padding: 8px; + color: #b00; + } + + &--loading { + min-height: 200px; + } + + &--preview { + display: flex; + flex-direction: column; + + .widget-image-cropper__dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 106px; + padding: 12px 20px; + border-radius: 5px; + border: 1.5px dashed var(--border-color-default, $image-cropper-border-color-default); + background-color: var(--bg-color, $image-cropper-bg-color); + } + + .widget-image-cropper__icon { + width: 42px; + height: 33px; + background-image: var(--image-cropper-icon, $image-cropper-icon); + background-repeat: no-repeat; + background-size: contain; + } + + .widget-image-cropper__label { + margin: 0; + font-size: 11px; + color: var(--gray-light, $image-cropper-gray-light); + } + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/aspectRatio.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/aspectRatio.spec.ts new file mode 100644 index 0000000000..71f5966172 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/aspectRatio.spec.ts @@ -0,0 +1,39 @@ +import { resolveAspectRatio } from "../aspectRatio"; + +describe("resolveAspectRatio", () => { + test("returns undefined for 'free'", () => { + expect(resolveAspectRatio("free", 0, 0)).toBeUndefined(); + }); + + test("returns 1 for 'square'", () => { + expect(resolveAspectRatio("square", 0, 0)).toBe(1); + }); + + test("returns 16/9 for 'landscape16x9'", () => { + expect(resolveAspectRatio("landscape16x9", 0, 0)).toBeCloseTo(16 / 9); + }); + + test("returns 4/3 for 'landscape4x3'", () => { + expect(resolveAspectRatio("landscape4x3", 0, 0)).toBeCloseTo(4 / 3); + }); + + test("returns 3/4 for 'portrait3x4'", () => { + expect(resolveAspectRatio("portrait3x4", 0, 0)).toBeCloseTo(3 / 4); + }); + + test("returns custom width/height when both positive", () => { + expect(resolveAspectRatio("custom", 21, 9)).toBeCloseTo(21 / 9); + }); + + test("returns undefined when custom width is zero", () => { + expect(resolveAspectRatio("custom", 0, 9)).toBeUndefined(); + }); + + test("returns undefined when custom height is zero", () => { + expect(resolveAspectRatio("custom", 16, 0)).toBeUndefined(); + }); + + test("returns undefined when custom width is negative", () => { + expect(resolveAspectRatio("custom", -1, 9)).toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts new file mode 100644 index 0000000000..2d5a2fb578 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts @@ -0,0 +1,171 @@ +import type { PixelCrop } from "react-image-crop"; +import { cropImage, CropError } from "../cropImage"; + +function makeImg(naturalW: number, naturalH: number, renderedW = naturalW, renderedH = naturalH): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: naturalW }); + Object.defineProperty(img, "naturalHeight", { value: naturalH }); + Object.defineProperty(img, "width", { value: renderedW }); + Object.defineProperty(img, "height", { value: renderedH }); + return img; +} + +const baseCrop: PixelCrop = { unit: "px", x: 10, y: 20, width: 100, height: 80 }; + +describe("cropImage", () => { + test("rejects when the image element has zero natural width", async () => { + const img = makeImg(0, 0); + await expect( + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ).rejects.toBeInstanceOf(CropError); + }); + + test("returns a File whose name has a .png extension when outputFormat is png", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file.name.endsWith(".png")).toBe(true); + expect(file.type).toBe("image/png"); + }); + + test("returns a File whose name has a .jpg extension when outputFormat is jpeg", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "jpeg", + outputQuality: 0.7, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file.name.endsWith(".jpg")).toBe(true); + expect(file.type).toBe("image/jpeg"); + }); + + test("uses viewport dims as canvas size when outputSize is viewport", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "viewport", + cropShape: "rect", + viewportWidth: 50, + viewportHeight: 40 + }) + ); + const ctx = calls[0].ctx as CanvasRenderingContext2D; + expect(ctx.canvas.width).toBe(50); + expect(ctx.canvas.height).toBe(40); + }); + + test("divides source rect by zoom factor when zoom > 1", async () => { + const img = makeImg(1000, 800, 1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: { unit: "px", x: 100, y: 100, width: 200, height: 200 }, + zoom: 2, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ); + const [, sx, sy, sw, sh] = calls[0]; + expect(sx).toBe(50); + expect(sy).toBe(50); + expect(sw).toBe(100); + expect(sh).toBe(100); + }); + + test("returns a valid File when cropShape is circle", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "circle", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file).toBeInstanceOf(File); + expect(file.name.endsWith(".png")).toBe(true); + }); + + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { + const img = makeImg(1000, 800); + const originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function (cb: (b: Blob | null) => void) { + cb(null); + }; + try { + await expect( + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ).rejects.toBeInstanceOf(CropError); + } finally { + HTMLCanvasElement.prototype.toBlob = originalToBlob; + } + }); +}); + +async function captureDrawImageCalls( + fn: () => Promise +): Promise> { + const calls: any[] = []; + const proto = CanvasRenderingContext2D.prototype as any; + const original = proto.drawImage; + proto.drawImage = function (this: CanvasRenderingContext2D, ...args: any[]) { + const entry: any = [...args]; + entry.ctx = this; + entry.args = args; + calls.push(entry); + return original?.apply(this, args); + }; + try { + await fn(); + } finally { + proto.drawImage = original; + } + return calls; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts new file mode 100644 index 0000000000..e5d305dce4 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/aspectRatio.ts @@ -0,0 +1,29 @@ +import { AspectRatioEnum } from "../../typings/ImageCropperProps"; + +export function resolveAspectRatio( + aspect: AspectRatioEnum, + customWidth: number, + customHeight: number +): number | undefined { + switch (aspect) { + case "free": + return undefined; + case "square": + return 1; + case "landscape16x9": + return 16 / 9; + case "landscape4x3": + return 4 / 3; + case "portrait3x4": + return 3 / 4; + case "custom": + if (customWidth > 0 && customHeight > 0) { + return customWidth / customHeight; + } + return undefined; + default: { + const _exhaustive: never = aspect; + return _exhaustive; + } + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts new file mode 100644 index 0000000000..e5aa835cde --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -0,0 +1,102 @@ +import type { PixelCrop } from "react-image-crop"; +import type { CropShapeEnum, OutputFormatEnum, OutputSizeEnum } from "../../typings/ImageCropperProps"; + +export class CropError extends Error { + constructor(message: string) { + super(message); + this.name = "CropError"; + } +} + +export interface CropImageOptions { + image: HTMLImageElement; + pixelCrop: PixelCrop; + zoom: number; + outputFormat: OutputFormatEnum; + outputQuality: number; + outputSize: OutputSizeEnum; + cropShape: CropShapeEnum; + viewportWidth: number; + viewportHeight: number; + originalName?: string; +} + +export async function cropImage(options: CropImageOptions): Promise { + const { + image, + pixelCrop, + zoom, + outputFormat, + outputQuality, + outputSize, + cropShape, + viewportWidth, + viewportHeight, + originalName + } = options; + + if (!image.naturalWidth || !image.naturalHeight) { + throw new CropError("Image not loaded."); + } + + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + + const sx = (pixelCrop.x / z) * scaleX; + const sy = (pixelCrop.y / z) * scaleY; + const sw = (pixelCrop.width / z) * scaleX; + const sh = (pixelCrop.height / z) * scaleY; + + const destW = outputSize === "viewport" ? viewportWidth : sw; + const destH = outputSize === "viewport" ? viewportHeight : sh; + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(destW)); + canvas.height = Math.max(1, Math.round(destH)); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new CropError("Canvas 2D context unavailable."); + } + + if (outputFormat === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + if (cropShape === "circle") { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + } + + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + + if (cropShape === "circle") { + ctx.restore(); + } + + const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; + const ext = outputFormat === "jpeg" ? "jpg" : "png"; + const quality = outputFormat === "jpeg" ? Math.min(1, Math.max(0, outputQuality)) : undefined; + + const blob = await new Promise(resolve => { + try { + canvas.toBlob(resolve, mime, quality); + } catch (_e) { + resolve(null); + } + }); + + if (!blob) { + throw new CropError( + "Could not export the cropped image. The source may be tainted by cross-origin restrictions." + ); + } + + const baseName = originalName ? originalName.replace(/\.[^.]+$/, "") : `crop-${Date.now()}`; + return new File([blob], `${baseName}.${ext}`, { type: mime }); +} diff --git a/packages/pluggableWidgets/image-cropper-web/tsconfig.json b/packages/pluggableWidgets/image-cropper-web/tsconfig.json new file mode 100644 index 0000000000..3296cb98f5 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": ["./src", "./typings"], + "compilerOptions": { + "baseUrl": "./", + "noEmitOnError": true, + "sourceMap": true, + "module": "esnext", + "target": "es6", + "lib": ["esnext", "dom"], + "types": ["jest", "node", "testing-library__jest-dom"], + "moduleResolution": "node", + "declaration": false, + "noLib": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "strictFunctionTypes": false, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "useUnknownInCatchVariables": false, + "exactOptionalPropertyTypes": false + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts new file mode 100644 index 0000000000..e03d303554 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -0,0 +1,78 @@ +/** + * This file was generated from ImageCropper.xml + * WARNING: All changes made to this file will be overwritten + * @author Mendix Widgets Framework Team + */ +import { CSSProperties } from "react"; +import { ActionValue, EditableImageValue, WebImage } from "mendix"; +import { Big } from "big.js"; + +export type CropShapeEnum = "rect" | "circle"; + +export type AspectRatioEnum = "free" | "square" | "landscape16x9" | "landscape4x3" | "portrait3x4" | "custom"; + +export type WheelZoomModeEnum = "off" | "on" | "onWithCtrl"; + +export type OutputFormatEnum = "png" | "jpeg"; + +export type OutputSizeEnum = "viewport" | "original"; + +export interface ImageCropperContainerProps { + name: string; + class: string; + style?: CSSProperties; + tabIndex?: number; + image: EditableImageValue; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth: number; + customAspectHeight: number; + onCropAction?: ActionValue; + boundaryWidth: number; + boundaryHeight: number; + showPreview: boolean; + previewWidth: number; + previewHeight: number; + resizableEnabled: boolean; + zoomEnabled: boolean; + showZoomSlider: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: Big; + maxZoom: Big; + outputFormat: OutputFormatEnum; + outputQuality: Big; + outputSize: OutputSizeEnum; +} + +export interface ImageCropperPreviewProps { + /** + * @deprecated Deprecated since version 9.18.0. Please use class property instead. + */ + className: string; + class: string; + style: string; + styleObject?: CSSProperties; + readOnly: boolean; + renderMode: "design" | "xray" | "structure"; + translate: (text: string) => string; + image: { type: "static"; imageUrl: string; } | { type: "dynamic"; entity: string; } | null; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth: number | null; + customAspectHeight: number | null; + onCropAction: {} | null; + boundaryWidth: number | null; + boundaryHeight: number | null; + showPreview: boolean; + previewWidth: number | null; + previewHeight: number | null; + resizableEnabled: boolean; + zoomEnabled: boolean; + showZoomSlider: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: number | null; + maxZoom: number | null; + outputFormat: OutputFormatEnum; + outputQuality: number | null; + outputSize: OutputSizeEnum; +} diff --git a/packages/pluggableWidgets/image-cropper-web/typings/declare-svg.ts b/packages/pluggableWidgets/image-cropper-web/typings/declare-svg.ts new file mode 100644 index 0000000000..e6958d5a9f --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/typings/declare-svg.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: string; + export = content; +} diff --git a/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts new file mode 100644 index 0000000000..54427b8d64 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts @@ -0,0 +1,2 @@ +declare module "*.css"; +declare module "*.scss"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f01e339ea7..4557aa52bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1758,6 +1758,40 @@ importers: specifier: workspace:* version: link:../../shared/widget-plugin-platform + packages/pluggableWidgets/image-cropper-web: + dependencies: + classnames: + specifier: ^2.5.1 + version: 2.5.1 + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@18.3.1) + devDependencies: + '@mendix/automation-utils': + specifier: workspace:* + version: link:../../../automation/utils + '@mendix/eslint-config-web-widgets': + specifier: workspace:* + version: link:../../shared/eslint-config-web-widgets + '@mendix/pluggable-widgets-tools': + specifier: 11.11.0 + version: 11.11.0(patch_hash=4c1736da44bba5984fb39f604448845d42c0cdcb684e2211cefa84c89eadf1dd)(@jest/transform@30.3.0)(@jest/types@30.3.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.0)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.3.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(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 + '@mendix/rollup-web-widgets': + specifier: workspace:* + version: link:../../shared/rollup-web-widgets + '@mendix/widget-plugin-platform': + specifier: workspace:* + version: link:../../shared/widget-plugin-platform + '@mendix/widget-plugin-test-utils': + specifier: workspace:* + version: link:../../shared/widget-plugin-test-utils + jest-canvas-mock: + specifier: ^2.5.2 + version: 2.5.2 + packages/pluggableWidgets/image-web: dependencies: '@mendix/widget-plugin-component-kit': @@ -10265,6 +10299,11 @@ packages: peerDependencies: react: '>=18.0.0 <19.0.0' + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=18.0.0 <19.0.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -21016,6 +21055,10 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 + react-image-crop@11.0.10(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {}