diff --git a/.github/configs/labeler.yml b/.github/configs/labeler.yml index bbd16badda..44196c6f94 100644 --- a/.github/configs/labeler.yml +++ b/.github/configs/labeler.yml @@ -82,6 +82,8 @@ combobox-web: - packages/*/combobox-web/**/* google-tag-web: - packages/*/google-tag-web/**/* +image-crop-web: + - packages/*/image-crop-web/**/* # Internals shared: diff --git a/packages/pluggableWidgets/image-crop-web/.gitignore b/packages/pluggableWidgets/image-crop-web/.gitignore new file mode 100644 index 0000000000..2d55399e96 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.mpk +typings/ diff --git a/packages/pluggableWidgets/image-crop-web/CHANGELOG.md b/packages/pluggableWidgets/image-crop-web/CHANGELOG.md new file mode 100644 index 0000000000..bc5fa0bfdd --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/LICENSE b/packages/pluggableWidgets/image-crop-web/LICENSE new file mode 100644 index 0000000000..e5576bd26b --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/README.md b/packages/pluggableWidgets/image-crop-web/README.md new file mode 100644 index 0000000000..63b3ae1a70 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/eslint.config.mjs b/packages/pluggableWidgets/image-crop-web/eslint.config.mjs new file mode 100644 index 0000000000..ed68ae9e78 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/jest.config.js b/packages/pluggableWidgets/image-crop-web/jest.config.js new file mode 100644 index 0000000000..8ee98da701 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/jest.setup.ts b/packages/pluggableWidgets/image-crop-web/jest.setup.ts new file mode 100644 index 0000000000..3b61a5e739 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/jest.setup.ts @@ -0,0 +1,61 @@ +/** + * Jest setup for image-crop-web 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-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json new file mode 100644 index 0000000000..5a3dfbc90d --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -0,0 +1,57 @@ +{ + "name": "@mendix/image-crop-web", + "widgetName": "ImageCrop", + "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": "ImageCrop", + "type": "widget", + "mpkName": "com.mendix.widget.web.ImageCrop.mpk" + }, + "packagePath": "com.mendix.widget.web", + "marketplace": { + "minimumMXVersion": "10.21.0", + "appName": "Image Crop", + "appNumber": 1, + "reactReady": true + }, + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "image-crop-web" + }, + "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-crop-web/rollup.config.mjs b/packages/pluggableWidgets/image-crop-web/rollup.config.mjs new file mode 100644 index 0000000000..688a1a7197 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/src/ImageCrop.editorConfig.ts b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts new file mode 100644 index 0000000000..16c4a6ad61 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.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 { ImageCropPreviewProps } from "../typings/ImageCropProps"; +import CropIconSvg from "./assets/crop-icon.svg"; + +export function getProperties(values: ImageCropPreviewProps, defaultProperties: Properties): Properties { + const propsToHide: Array = []; + + if (values.aspectRatio !== "custom") { + propsToHide.push("customAspectWidth", "customAspectHeight"); + } + + if (!values.zoomEnabled) { + propsToHide.push("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: ImageCropPreviewProps, 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 Crop", + bold: true, + fontColor: palette.text.primary, + fontSize: 10 + }, + { + type: "Text", + content: describeConfig(values), + fontColor: palette.text.secondary, + fontSize: 8 + } + ] + } + ] + } + ] + }; +} + +export function getCustomCaption(values: ImageCropPreviewProps): string { + const shape = values.cropShape === "circle" ? "Circle" : "Rectangle"; + return `Image Crop (${shape})`; +} + +function describeConfig(values: ImageCropPreviewProps): 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: ImageCropPreviewProps): 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-crop-web/src/ImageCrop.editorPreview.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx new file mode 100644 index 0000000000..636598f663 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx @@ -0,0 +1,18 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +import { ImageCropPreviewProps } from "../typings/ImageCropProps"; + +export function preview(props: ImageCropPreviewProps): ReactElement { + return ( +
+
+
+

Image Crop

+
+
+ ); +} + +export function getPreviewCss(): string { + return require("./ui/ImageCrop.scss"); +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png new file mode 100755 index 0000000000..1cae9739f5 Binary files /dev/null and b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png differ diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png new file mode 100755 index 0000000000..8c7b266490 Binary files /dev/null and b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png differ diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.dark.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.dark.png new file mode 100755 index 0000000000..66e7bf88a7 Binary files /dev/null and b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.dark.png differ diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png new file mode 100755 index 0000000000..f7f7732cc7 Binary files /dev/null and b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png differ diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx new file mode 100644 index 0000000000..1166d68880 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -0,0 +1,8 @@ +import { ReactElement } from "react"; +import { ImageCropContainerProps } from "../typings/ImageCropProps"; +import { ImageCropContainer } from "./components/ImageCropContainer"; +import "./ui/ImageCrop.scss"; + +export function ImageCrop(props: ImageCropContainerProps): ReactElement | null { + return ; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml new file mode 100644 index 0000000000..b656b212ae --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -0,0 +1,131 @@ + + + Image Crop + Crop an image attribute + https://docs.mendix.com/appstore/widgets/image-crop + + + + + 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. + + + 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. + + + Resizable handles + Let the user resize the selection by dragging its corners. + + + + + Enable zoom + Show a zoom slider below the crop area. + + + 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. + + + + + 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. + + + + + 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) + + + + + + Crop button caption + Label on the Crop button. + + Crop + Bijsnijden + + + + On crop + Runs after the cropped image is saved. + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx new file mode 100644 index 0000000000..e773e330c1 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { Big } from "big.js"; +import { ValueStatus } from "mendix"; +import { actionValue } from "@mendix/widget-plugin-test-utils"; +import type { ImageCropContainerProps } from "../../typings/ImageCropProps"; +import { ImageCrop } from "../ImageCrop"; + +type ImageProp = ImageCropContainerProps["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 ImageProp; +} + +function makeProps(overrides: Partial = {}): ImageCropContainerProps { + 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, + 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", + cropButtonCaption: { + value: "Crop", + status: ValueStatus.Available + } as ImageCropContainerProps["cropButtonCaption"], + onCropAction: actionValue(), + ...overrides + }; +} + +describe("", () => { + 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-crop--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("disables Crop button when image is read-only", () => { + const props = makeProps({ image: makeImageProp({ readOnly: true }) }); + render(); + const btn = screen.getByRole("button", { name: "Crop" }); + expect(btn).toBeDisabled(); + }); + + test("Crop button is enabled after image loads (initial crop auto-set)", () => { + const props = makeProps(); + const { container } = render(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + fireEvent.load(img!); + const btn = container.querySelector("button.widget-image-crop__button"); + expect(btn).not.toBeNull(); + expect(btn).not.toBeDisabled(); + }); + + test("before load, image is bounded by boundary as max-width/max-height ceiling", () => { + const props = makeProps({ boundaryWidth: 800, boundaryHeight: 600 }); + const { container } = render(); + const img = container.querySelector("img") as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.style.maxWidth).toBe("800px"); + expect(img!.style.maxHeight).toBe("600px"); + }); + + test("after load, image gets fit-and-scaled pixel dims; canvas wraps via inline-block + ceiling", () => { + const props = makeProps({ boundaryWidth: 800, boundaryHeight: 600 }); + const { container } = render(); + const img = container.querySelector("img") as HTMLImageElement; + Object.defineProperty(img, "naturalWidth", { value: 400, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 300, configurable: true }); + fireEvent.load(img); + const canvas = container.querySelector(".widget-image-crop__canvas") as HTMLDivElement; + expect(img.style.width).toBe("800px"); + expect(img.style.height).toBe("600px"); + expect(canvas.style.maxWidth).toBe("800px"); + expect(canvas.style.maxHeight).toBe("600px"); + }); + + test("crop is cleared between image src change and next load (button disabled)", () => { + const props = makeProps({ image: makeImageProp({ value: { uri: "http://localhost/img1.png" } }) }); + const { container, rerender } = render(); + const img1 = container.querySelector("img"); + fireEvent.load(img1!); + expect(container.querySelector("button.widget-image-crop__button")).not.toBeDisabled(); + + const newProps = makeProps({ image: makeImageProp({ value: { uri: "http://localhost/img2.png" } }) }); + rerender(); + expect(container.querySelector("button.widget-image-crop__button")).toBeDisabled(); + + const img2 = container.querySelector("img"); + fireEvent.load(img2!); + expect(container.querySelector("button.widget-image-crop__button")).not.toBeDisabled(); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg b/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg new file mode 100644 index 0000000000..534cf020b2 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx new file mode 100644 index 0000000000..35f0ac5ed8 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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/ImageCropProps"; + +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-crop-web/src/components/CropButton.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx new file mode 100644 index 0000000000..54b135436c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx @@ -0,0 +1,21 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; + +interface CropButtonProps { + caption: string; + disabled: boolean; + onClick: () => void; +} + +export function CropButton({ caption, disabled, onClick }: CropButtonProps): ReactElement { + return ( + + ); +} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx new file mode 100644 index 0000000000..895f026a95 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -0,0 +1,141 @@ +import classNames from "classnames"; +import { ValueStatus } from "mendix"; +import { ReactElement, useCallback, useEffect } from "react"; +import { type Crop, type PixelCrop } from "react-image-crop"; +import { CropArea } from "./CropArea"; +import { CropButton } from "./CropButton"; +import { PreviewPane } from "./PreviewPane"; +import { ZoomSlider } from "./ZoomSlider"; +import { ImageCropContainerProps } from "../../typings/ImageCropProps"; +import { useImageCropState } from "../hooks/useImageCropState"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { cropImage, CropError } from "../utils/cropImage"; + +export function ImageCropContainer(props: ImageCropContainerProps): ReactElement | null { + const state = useImageCropState(Number(props.minZoom)); + + const { setZoom, setCrop, setCompletedCrop } = state; + + const handleImageLoad = useCallback( + (percentCrop: Crop, pixelCrop: PixelCrop) => { + setZoom(Number(props.minZoom)); + setCrop(percentCrop); + setCompletedCrop(pixelCrop); + }, + [setZoom, setCrop, setCompletedCrop, props.minZoom] + ); + + const uri = props.image.status === ValueStatus.Available ? props.image.value?.uri : undefined; + useEffect(() => { + setCrop(undefined); + setCompletedCrop(undefined); + }, [uri, setCrop, setCompletedCrop]); + + const handleCrop = useCallback(async () => { + const img = state.imageRef.current; + if ( + !img || + !state.completedCrop || + props.image.readOnly || + props.image.status !== ValueStatus.Available || + !props.image.value + ) { + return; + } + try { + const file = await cropImage({ + image: img, + pixelCrop: state.completedCrop, + zoom: state.zoom, + 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-crop-web]", err.message); + } else { + throw err; + } + } + }, [ + state.completedCrop, + state.zoom, + state.imageRef, + props.image, + props.outputFormat, + props.outputQuality, + props.outputSize, + props.cropShape, + props.boundaryWidth, + props.boundaryHeight, + props.onCropAction + ]); + + 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); + const caption = props.cropButtonCaption?.value ?? "Crop"; + + return ( +
+ + {props.zoomEnabled ? ( + + ) : null} + {props.showPreview ? ( + + ) : null} + +
+ ); +} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx new file mode 100644 index 0000000000..918e799698 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/src/components/ZoomContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ZoomContainer.tsx new file mode 100644 index 0000000000..840cdb05e5 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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/ImageCropProps"; +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-crop-web/src/components/ZoomSlider.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx new file mode 100644 index 0000000000..b05196b7ef --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx @@ -0,0 +1,24 @@ +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-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts new file mode 100644 index 0000000000..efccf0e83b --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/src/hooks/useImageCropState.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts new file mode 100644 index 0000000000..8b159b4385 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts @@ -0,0 +1,20 @@ +import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react"; +import type { Crop, PixelCrop } from "react-image-crop"; + +interface ImageCropState { + crop: Crop | undefined; + setCrop: Dispatch>; + completedCrop: PixelCrop | undefined; + setCompletedCrop: Dispatch>; + zoom: number; + setZoom: Dispatch>; + imageRef: RefObject; +} + +export function useImageCropState(initialZoom: number): ImageCropState { + const [crop, setCrop] = useState(undefined); + const [completedCrop, setCompletedCrop] = useState(undefined); + const [zoom, setZoom] = useState(initialZoom); + const imageRef = useRef(null); + return { crop, setCrop, completedCrop, setCompletedCrop, zoom, setZoom, imageRef }; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts new file mode 100644 index 0000000000..7cb7d2fe8b --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts @@ -0,0 +1,33 @@ +import { Dispatch, SetStateAction, useCallback } from "react"; +import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; + +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-crop-web/src/package.xml b/packages/pluggableWidgets/image-crop-web/src/package.xml new file mode 100644 index 0000000000..6afc242dce --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/package.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss new file mode 100644 index 0000000000..bbe6f2a7c9 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss @@ -0,0 +1,90 @@ +@import "react-image-crop/dist/ReactCrop.css"; + +$image-crop-bg-color: #f5f7fa; +$image-crop-border-color-default: #b0bec5; +$image-crop-gray-light: #6c757d; +$image-crop-icon: url(../assets/crop-icon.svg); + +.widget-image-crop { + 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-crop__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-crop-border-color-default); + background-color: var(--bg-color, $image-crop-bg-color); + } + + .widget-image-crop__icon { + width: 42px; + height: 33px; + background-image: var(--image-crop-icon, $image-crop-icon); + background-repeat: no-repeat; + background-size: contain; + } + + .widget-image-crop__label { + margin: 0; + font-size: 11px; + color: var(--gray-light, $image-crop-gray-light); + } + } +} diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts new file mode 100644 index 0000000000..71f5966172 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts new file mode 100644 index 0000000000..2d5a2fb578 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts new file mode 100644 index 0000000000..74537c1fa7 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts @@ -0,0 +1,29 @@ +import { AspectRatioEnum } from "../../typings/ImageCropProps"; + +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-crop-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts new file mode 100644 index 0000000000..1968240edb --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts @@ -0,0 +1,102 @@ +import type { PixelCrop } from "react-image-crop"; +import type { CropShapeEnum, OutputFormatEnum, OutputSizeEnum } from "../../typings/ImageCropProps"; + +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-crop-web/tsconfig.json b/packages/pluggableWidgets/image-crop-web/tsconfig.json new file mode 100644 index 0000000000..3296cb98f5 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-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-crop-web/typings/ImageCropProps.d.ts b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts new file mode 100644 index 0000000000..9e946fb1fe --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts @@ -0,0 +1,78 @@ +/** + * This file was generated from ImageCrop.xml + * WARNING: All changes made to this file will be overwritten + * @author Mendix Widgets Framework Team + */ +import { CSSProperties } from "react"; +import { ActionValue, DynamicValue, 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 ImageCropContainerProps { + name: string; + class: string; + style?: CSSProperties; + tabIndex?: number; + image: EditableImageValue; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth: number; + customAspectHeight: number; + boundaryWidth: number; + boundaryHeight: number; + resizableEnabled: boolean; + zoomEnabled: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: Big; + maxZoom: Big; + showPreview: boolean; + previewWidth: number; + previewHeight: number; + outputFormat: OutputFormatEnum; + outputQuality: Big; + outputSize: OutputSizeEnum; + cropButtonCaption?: DynamicValue; + onCropAction?: ActionValue; +} + +export interface ImageCropPreviewProps { + /** + * @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; + boundaryWidth: number | null; + boundaryHeight: number | null; + resizableEnabled: boolean; + zoomEnabled: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: number | null; + maxZoom: number | null; + showPreview: boolean; + previewWidth: number | null; + previewHeight: number | null; + outputFormat: OutputFormatEnum; + outputQuality: number | null; + outputSize: OutputSizeEnum; + cropButtonCaption: string; + onCropAction: {} | null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50ed2ff076..9e2601b17d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1732,6 +1732,40 @@ importers: specifier: workspace:* version: link:../../shared/widget-plugin-platform + packages/pluggableWidgets/image-crop-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.8.0 + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(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': @@ -9448,6 +9482,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==} @@ -10635,6 +10674,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -18734,6 +18774,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: {}