diff --git a/packages/pluggableWidgets/image-cropper-web/README.md b/packages/pluggableWidgets/image-cropper-web/README.md index 63b3ae1a70..318c1dcb35 100644 --- a/packages/pluggableWidgets/image-cropper-web/README.md +++ b/packages/pluggableWidgets/image-cropper-web/README.md @@ -1,5 +1 @@ -# 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. +Please see [Image Cropper](https://docs.mendix.com/appstore/widgets/image-cropper) in the Mendix documentation for details. diff --git a/packages/pluggableWidgets/image-cropper-web/jest.config.js b/packages/pluggableWidgets/image-cropper-web/jest.config.js index 8ee98da701..1340950f89 100644 --- a/packages/pluggableWidgets/image-cropper-web/jest.config.js +++ b/packages/pluggableWidgets/image-cropper-web/jest.config.js @@ -1,6 +1,20 @@ const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); +const { join } = require("path"); + +// Override the SVG transform: the base config returns a React component for *.svg imports, +// but we import SVGs as URL strings (declare module "*.svg" { const content: string }). +// Using assetsTransformer returns the filename as a plain string, matching the runtime behaviour +// and avoiding the React "Invalid value for prop `src`" warning in tests. +const assetsTransformer = join( + require.resolve("@mendix/pluggable-widgets-tools/test-config/jest.config.js"), + "../assetsTransformer.js" +); module.exports = { ...base, - setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")] + transform: { + ...base.transform, + "^.+\\.svg$": assetsTransformer + }, + setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), join(__dirname, "jest.setup.ts")] }; diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml b/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml new file mode 100644 index 0000000000..392946c67c --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md new file mode 100644 index 0000000000..52d188cfcb --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/openspec/specs/image-cropper/spec.md @@ -0,0 +1,234 @@ +# image-cropper Specification + +## Purpose + +The Image Cropper widget lets a Mendix end user crop, rotate, and recolor an image bound to +an image attribute, writing the edited result back to that same attribute. It is a client-side +widget: all image transforms happen on an HTML canvas in the browser, and the edited bytes are +pushed to the Mendix runtime via `EditableValue.setValue`. This spec captures the behavior of +the shipped widget (v1.0.0) as a baseline. + +## Requirements + +### Requirement: Image source binding and lifecycle states + +The widget SHALL bind to a single required image attribute and SHALL render distinct states for +the Mendix value lifecycle. All edits (crop, rotate, grayscale, reset) SHALL write back to that +same attribute via `setValue`. + +#### Scenario: Loading state + +- **WHEN** the bound image value status is `Loading` +- **THEN** the widget SHALL render a placeholder container marked `aria-busy="true"` +- **AND** SHALL NOT render the crop area or toolbar + +#### Scenario: No image available + +- **WHEN** the bound image status is not `Available` or has no value +- **THEN** the widget SHALL render an empty state showing the configurable `noImageCaption` (default "No uploaded image to crop") + +#### Scenario: Read-only attribute + +- **WHEN** the bound image is `readOnly` +- **THEN** editing operations SHALL NOT call `setValue` and the attribute SHALL be left unchanged + +### Requirement: Crop shape + +The widget SHALL support a rectangular and a circular crop shape, selected by the `cropShape` +property. + +#### Scenario: Circle shape masks corners + +- **WHEN** `cropShape` is `circle` +- **THEN** the on-screen selection SHALL be rendered as a circle +- **AND** the exported image SHALL be clipped to an ellipse inscribed in the crop rectangle so the corners are transparent (PNG) or filled (JPEG) + +#### Scenario: Rectangle shape + +- **WHEN** `cropShape` is `rect` +- **THEN** the full crop rectangle SHALL be exported with no corner masking + +### Requirement: Aspect ratio + +The widget SHALL constrain the crop selection to the ratio chosen in `aspectRatio`, supporting +free-form, preset ratios, and a custom ratio built from `customAspectWidth` / `customAspectHeight`. + +#### Scenario: Preset ratio locks proportions + +- **WHEN** `aspectRatio` is a preset (`square` = 1:1, `landscape16x9` = 16:9, `landscape4x3` = 4:3, `portrait3x4` = 3:4) +- **THEN** the crop selection SHALL keep that width-to-height proportion while being resized + +#### Scenario: Free ratio + +- **WHEN** `aspectRatio` is `free` +- **THEN** the crop selection SHALL be resizable to any proportion + +#### Scenario: Custom ratio + +- **WHEN** `aspectRatio` is `custom` and both `customAspectWidth` and `customAspectHeight` are greater than 0 +- **THEN** the crop selection SHALL be locked to `customAspectWidth / customAspectHeight` +- **AND** if either value is not greater than 0, the crop SHALL fall back to free-form + +### Requirement: Default crop selection + +On image load, the widget SHALL seed a default crop box centered on the image at the resolved +aspect ratio. + +#### Scenario: Initial box covers 80% centered + +- **WHEN** an image finishes loading +- **THEN** the default selection SHALL cover 80% of the image, centered, at the resolved aspect ratio (falling back to the image's own ratio when free) + +### Requirement: Resizable handles + +The widget SHALL show or hide the selection's resize handles based on `resizableEnabled`. + +#### Scenario: Handles disabled + +- **WHEN** `resizableEnabled` is `false` +- **THEN** the user SHALL NOT be able to resize the selection by dragging its corners + +### Requirement: Crop persistence (auto-apply) + +The widget SHALL persist the edited image to the bound attribute automatically, with no manual +"apply" button. Direct crop-box edits SHALL be written back immediately on pointer release, while +zoom and grayscale changes SHALL be written back on a 400 ms debounce so rapid changes collapse +into a single write. Programmatic changes to the crop box (image load, source change, reset) SHALL +NOT trigger an auto-apply. + +#### Scenario: Crop box edit applies immediately + +- **WHEN** the user moves or resizes the crop box and releases the pointer +- **THEN** the cropped result SHALL be written back to the bound attribute immediately, cancelling any pending debounced write + +#### Scenario: Zoom and grayscale apply on a debounce + +- **WHEN** the user changes the zoom level or toggles grayscale +- **THEN** the edited result SHALL be written back after a 400 ms debounce +- **AND** further zoom or grayscale changes within that window SHALL reset the timer so only one write occurs + +#### Scenario: Programmatic re-seed does not auto-save + +- **WHEN** the crop box is re-seeded programmatically (image load, bound source change, or reset) +- **THEN** the widget SHALL NOT auto-apply a crop, because auto-apply only fires after a real user interaction + +### Requirement: Zoom + +The widget SHALL let the user zoom the image between `minZoom` and `maxZoom` via a slider and/or +the mouse wheel, gated by `zoomEnabled`, `showZoomSlider`, and `wheelZoomMode`. + +#### Scenario: Zoom master switch off + +- **WHEN** `zoomEnabled` is `false` +- **THEN** the slider and mouse-wheel zoom SHALL be disabled and the image SHALL stay at 1× + +#### Scenario: Slider hidden but wheel active + +- **WHEN** `zoomEnabled` is `true` and `showZoomSlider` is `false` +- **THEN** the zoom slider SHALL be hidden while mouse-wheel zoom remains available per `wheelZoomMode` + +#### Scenario: Wheel zoom modes + +- **WHEN** `wheelZoomMode` is `onWithCtrl` +- **THEN** the wheel SHALL zoom only while Ctrl is held, leaving normal page scroll otherwise +- **AND** when `wheelZoomMode` is `on` the wheel SHALL always zoom, and when `off` the wheel SHALL never zoom + +#### Scenario: Zoom anchored on the crop-box center + +- **WHEN** the zoom level changes +- **THEN** the zoom SHALL be anchored at the crop box's current center so the framed region stays put on screen +- **AND** the exported pixels SHALL use the same anchor so the saved image matches the on-screen framing + +### Requirement: Rotation + +When `enableRotation` is true, the widget SHALL show rotate-left and rotate-right buttons that +rotate the image in 90° steps and bake the rotation into the saved image. + +#### Scenario: Rotate buttons hidden + +- **WHEN** `enableRotation` is `false` +- **THEN** the rotate-left / rotate-right buttons SHALL NOT be shown + +#### Scenario: Rotate bakes into the image + +- **WHEN** the user clicks rotate-left (−90°) or rotate-right (+90°) +- **THEN** the image SHALL be redrawn on a canvas sized to the rotated dimensions and written back to the attribute +- **AND** a live preview of the rotated (color) pixels SHALL be shown immediately, before the Mendix commit lands + +#### Scenario: Rotation preserves grayscale reversibility + +- **WHEN** the grayscale toggle is on and the user rotates +- **THEN** the committed file SHALL be baked black-and-white, while the working image kept in memory SHALL remain color so toggling grayscale off stays reversible + +### Requirement: Grayscale + +When `enableGrayscale` is true, the widget SHALL show a grayscale toggle; enabling it SHALL render +the image gray on screen and convert the saved image to black-and-white. + +#### Scenario: Grayscale toggle hidden + +- **WHEN** `enableGrayscale` is `false` +- **THEN** the grayscale toggle SHALL NOT be shown + +#### Scenario: Grayscale on + +- **WHEN** the grayscale toggle is on +- **THEN** the crop area SHALL render the image with a grayscale CSS filter +- **AND** the exported image SHALL be drawn with `grayscale(1)` so the saved bytes are black-and-white + +### Requirement: Reset + +When `showResetButton` is true, the widget SHALL show a Reset button that restores the original +image and clears zoom, rotation, and grayscale, re-seeding the default crop box. + +#### Scenario: Reset button availability + +- **WHEN** `showResetButton` is `true` and the original image bytes were captured +- **THEN** the Reset button SHALL be enabled; if the original could not be captured it SHALL be disabled + +#### Scenario: Reset restores original state + +- **WHEN** the user clicks Reset +- **THEN** zoom SHALL return to `minZoom`, grayscale SHALL turn off, the original image bytes SHALL be written back, and the default 80%-centered crop box SHALL be re-seeded +- **AND** the reset itself SHALL NOT trigger an auto-apply write of a crop + +### Requirement: Output encoding + +The widget SHALL encode the saved image according to `outputFormat`, `outputQuality`, and +`outputSize`. + +#### Scenario: PNG output + +- **WHEN** `outputFormat` is `png` +- **THEN** the file SHALL be encoded as `image/png` with a `.png` extension and transparency preserved, ignoring `outputQuality` + +#### Scenario: JPEG output + +- **WHEN** `outputFormat` is `jpeg` +- **THEN** the file SHALL be encoded as `image/jpeg` with a `.jpg` extension, a white background filled behind the image, and `outputQuality` clamped to 0.0–1.0 + +#### Scenario: Output resolution + +- **WHEN** `outputSize` is `original` +- **THEN** the crop SHALL be exported at the source (natural) resolution of the cropped region +- **AND** when `outputSize` is `viewport` the crop SHALL be exported at the canvas dimensions (`boundaryWidth` × `boundaryHeight`) and the attribute thumbnail size SHALL be set to match + +### Requirement: On crop event + +When configured, the widget SHALL run the `onCropAction` each time a crop is auto-applied. + +#### Scenario: Action runs on apply + +- **WHEN** a crop is written back to the attribute and `onCropAction.canExecute` is true +- **THEN** the widget SHALL call `onCropAction.execute()` + +### Requirement: Canvas sizing + +The widget SHALL scale the on-screen crop area to fit within `boundaryWidth` × `boundaryHeight` +without changing the saved image resolution. + +#### Scenario: Image scales to fit the canvas + +- **WHEN** the source image is larger than `boundaryWidth` × `boundaryHeight` +- **THEN** the rendered image SHALL scale down to fit, and the canvas SHALL wrap the rendered image so smaller crops produce a smaller canvas with no blank gaps +- **AND** the on-screen scaling SHALL NOT change the exported resolution diff --git a/packages/pluggableWidgets/image-cropper-web/package.json b/packages/pluggableWidgets/image-cropper-web/package.json index 097d544871..6be4bfb431 100644 --- a/packages/pluggableWidgets/image-cropper-web/package.json +++ b/packages/pluggableWidgets/image-cropper-web/package.json @@ -41,7 +41,10 @@ "verify": "rui-verify-package-format" }, "dependencies": { + "@mendix/widget-plugin-mobx-kit": "workspace:*", "classnames": "^2.5.1", + "mobx": "6.12.3", + "mobx-react-lite": "4.0.7", "react-image-crop": "^11.0.10" }, "devDependencies": { diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts index fa0455b645..952cbe1749 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -1,10 +1,13 @@ import { hidePropertiesIn, Properties } from "@mendix/pluggable-widgets-tools"; import { StructurePreviewProps, - structurePreviewPalette + structurePreviewPalette, + rowLayout, + container, + text } from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; -import CropIconSvg from "./assets/crop-icon.svg"; +import { describeConfig } from "./utils/describeConfig"; export function getProperties(values: ImageCropperPreviewProps, defaultProperties: Properties): Properties { const propsToHide: Array = []; @@ -17,8 +20,20 @@ export function getProperties(values: ImageCropperPreviewProps, defaultPropertie propsToHide.push("showZoomSlider", "wheelZoomMode", "minZoom", "maxZoom"); } - if (!values.showPreview) { - propsToHide.push("previewWidth", "previewHeight"); + if (!values.enableRotation) { + propsToHide.push("rotateLeftLabel", "rotateRightLabel"); + } + + if (!values.enableGrayscale) { + propsToHide.push("grayscaleCaption", "grayscaleAriaLabel"); + } + + if (!values.showResetButton) { + propsToHide.push("resetCaption", "resetAriaLabel"); + } + + if (!values.zoomEnabled || !values.showZoomSlider) { + propsToHide.push("zoomCaption", "zoomAriaLabel"); } if (values.outputFormat !== "jpeg") { @@ -31,87 +46,17 @@ export function getProperties(values: ImageCropperPreviewProps, defaultPropertie 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 - } - ] - } - ] - } - ] - }; + const previewCaption = values.image ? `[${describeConfig(values)}] Image Cropper` : "[Configure Image Cropper]"; + + return rowLayout({ columnSize: "grow", borders: true, backgroundColor: palette.background.containerFill })( + container()(), + rowLayout({ grow: 2, padding: 8 })(text({ fontColor: palette.text.primary, grow: 10 })(previewCaption)), + container()() + ); } 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 index 1732eadd89..5f3075bfd3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -1,14 +1,85 @@ import classNames from "classnames"; -import { ReactElement } from "react"; +import { ReactElement, createRef, useState } from "react"; +import { type Crop } from "react-image-crop"; +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; +import CropperPlaceholderIcon from "./assets/cropper-placeholder.svg"; +import { CropArea } from "./components/CropArea"; +import { resolveAspectRatio } from "./utils/aspectRatio"; +import { describeConfig } from "./utils/describeConfig"; + +declare function require(name: string): string; + +// Defaults used when boundary props are blank in the editor — keep the preview box compact. +const PREVIEW_BOUNDARY_WIDTH = 260; +const PREVIEW_BOUNDARY_HEIGHT = 170; + +// Renders the real CropArea against a static (design-time) image URL with all interaction +// disabled, so design mode shows a faithful, non-clickable crop preview. +function StaticCropPreview(props: { imageUrl: string; values: ImageCropperPreviewProps }): ReactElement { + const { imageUrl, values } = props; + const [crop, setCrop] = useState(undefined); + const imageRef = createRef(); + + const aspect = resolveAspectRatio( + values.aspectRatio, + values.customAspectWidth ?? 0, + values.customAspectHeight ?? 0 + ); + + const handleImageLoad = (percentCrop: Crop): void => { + // Display-only preview: just draw the centered selection CropArea computed for us. + // No zoom/commit/auto-apply machinery — that's runtime-only. + setCrop(percentCrop); + }; + + return ( +
+ undefined} + aspect={aspect} + circular={values.cropShape === "circle"} + resizable={false} + boundaryWidth={values.boundaryWidth ?? PREVIEW_BOUNDARY_WIDTH} + boundaryHeight={values.boundaryHeight ?? PREVIEW_BOUNDARY_HEIGHT} + onImageLoad={handleImageLoad} + zoom={values.minZoom ?? 1} + minZoom={values.minZoom ?? 1} + maxZoom={values.maxZoom ?? 1} + setZoom={() => undefined} + wheelZoomMode="off" + grayscale={false} + imageRef={imageRef} + /> +
+ ); +} export function preview(props: ImageCropperPreviewProps): ReactElement { + // Narrow on the object (not a derived boolean) so TS keeps .imageUrl / .entity typed. + const staticImage = props.image?.type === "static" ? props.image : undefined; + const dynamicEntity = props.image?.type === "dynamic" ? props.image.entity : undefined; + + // Dynamic bindings carry only the entity name — no design-time pixels — so they still show the + // placeholder, but the caption must reflect that an attribute IS bound (not "nothing selected"). + const caption = staticImage ? describeConfig(props) : dynamicEntity || "[No image selected yet]"; + return ( -
-
-
-

Image Cropper

+
+
+ {staticImage ? ( + + ) : ( + + )}
+

{caption}

); } diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png index 1cae9739f5..3e915a5ef1 100755 Binary files a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.dark.png 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 index 8c7b266490..5f280f2179 100755 Binary files a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.icon.png 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 index 66e7bf88a7..31e1e0008a 100755 Binary files a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.dark.png 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 index f7f7732cc7..250f934c64 100755 Binary files a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png and b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.tile.png differ diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml index 50e45d7495..c7268821d7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml @@ -2,6 +2,8 @@ Image Cropper Crop an image attribute + Images, videos & files + Images, Videos & Files https://docs.mendix.com/appstore/widgets/image-cropper @@ -59,20 +61,6 @@ 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. - - @@ -81,6 +69,20 @@ Let the user resize the selection by dragging its corners. + + + Enable rotation + Show rotate-left / rotate-right buttons. The rotation is baked into the saved image. + + + Enable grayscale + Show a grayscale toggle. When on, the saved image is converted to grayscale (black and white). + + + Enable reset + Show a Reset button that restores the original image and clears zoom, rotation, and crop. + + Enable zoom @@ -109,6 +111,84 @@ + + + + Grayscale caption + Visible text and tooltip for the grayscale toggle. + + Grayscale + Grijstinten + + + + Reset caption + Visible text and tooltip for the reset button. + + Reset + Herstellen + + + + Zoom caption + Visible label for the zoom slider. + + Zoom + Zoomen + + + + No image message + Shown when no image is bound to the image attribute. + + No uploaded image to crop + Geen geüploade afbeelding om bij te snijden + + + + + + Rotate left + Accessible name and tooltip for the rotate-left button. + + Rotate left + Naar links draaien + + + + Rotate right + Accessible name and tooltip for the rotate-right button. + + Rotate right + Naar rechts draaien + + + + Grayscale + Accessible name for the grayscale toggle (announced by screen readers). + + Grayscale + Grijstinten + + + + Reset + Accessible name for the reset button (announced by screen readers). + + Reset crop + Uitsnede herstellen + + + + Zoom + Accessible name for the zoom slider (announced by screen readers). + + Zoom + Zoomen + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx new file mode 100644 index 0000000000..8121eaca81 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -0,0 +1,108 @@ +import { render } from "@testing-library/react"; +import { ImageCropperPreviewProps } from "../../typings/ImageCropperProps"; +import { getPreview } from "../ImageCropper.editorConfig"; +import { preview } from "../ImageCropper.editorPreview"; + +function makePreviewProps(overrides: Partial = {}): ImageCropperPreviewProps { + return { + className: "", + class: "", + style: "", + styleObject: undefined, + readOnly: false, + renderMode: "design", + translate: (t: string) => t, + image: null, + cropShape: "rect", + aspectRatio: "free", + customAspectWidth: null, + customAspectHeight: null, + onCropAction: null, + boundaryWidth: null, + boundaryHeight: null, + resizableEnabled: true, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: null, + maxZoom: null, + grayscaleCaption: "Grayscale", + resetCaption: "Reset", + zoomCaption: "Zoom", + noImageCaption: "No uploaded image to crop", + rotateLeftLabel: "Rotate left", + rotateRightLabel: "Rotate right", + grayscaleAriaLabel: "Grayscale", + resetAriaLabel: "Reset crop", + zoomAriaLabel: "Zoom", + outputFormat: "png", + outputSize: "original", + outputQuality: null, + ...overrides + }; +} + +// Walk the StructurePreviewProps tree and collect every Text node's content. +function collectText(node: any): string[] { + if (!node || typeof node !== "object") { + return []; + } + const here = node.type === "Text" && typeof node.content === "string" ? [node.content] : []; + const kids = Array.isArray(node.children) ? node.children.flatMap(collectText) : []; + return [...here, ...kids]; +} + +describe("ImageCropper structure mode (getPreview)", () => { + test("shows the configure placeholder when nothing is bound", () => { + const texts = collectText(getPreview(makePreviewProps(), false)); + expect(texts).toContain("[Configure Image Cropper]"); + }); + + test("shows config summary caption when an image is bound", () => { + const props = makePreviewProps({ + image: { type: "dynamic", entity: "MyModule.Photo" }, + cropShape: "circle", + aspectRatio: "square", + outputFormat: "jpeg", + outputSize: "viewport" + }); + const texts = collectText(getPreview(props, false)); + expect(texts).toContain("[Circle · 1:1 · JPEG · Viewport] Image Cropper"); + expect(texts).not.toContain("[Configure Image Cropper]"); + }); +}); + +describe("ImageCropper design mode (preview)", () => { + test("renders the placeholder glyph and empty caption when nothing is bound", () => { + const { container, getByText } = render(preview(makePreviewProps({ image: null }))); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); + expect(getByText("[No image selected yet]")).toBeInTheDocument(); + }); + + test("shows the bound entity for a dynamic image (placeholder glyph, not previewable)", () => { + const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" } }); + const { container, getByText, queryByText } = render(preview(props)); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); + expect(getByText("MyModule.Photo")).toBeInTheDocument(); + expect(queryByText("[No image selected yet]")).toBeNull(); + }); + + test("renders the real image and config caption for a static image", () => { + const props = makePreviewProps({ + image: { type: "static", imageUrl: "http://localhost/photo.png" }, + cropShape: "rect", + aspectRatio: "free", + outputFormat: "png", + outputSize: "original" + }); + const { container, getByText } = render(preview(props)); + const img = container.querySelector("img") as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.getAttribute("src")).toBe("http://localhost/photo.png"); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).toBeNull(); + expect(getByText("Rectangle · Free aspect · PNG · Original")).toBeInTheDocument(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx index 758321d875..6bf764e3a8 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -1,9 +1,9 @@ -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, 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 { actionValue, dynamic } 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 @@ -11,8 +11,10 @@ import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps interface CapturedCropArea { onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; setZoom: (next: number) => void; wheelZoomMode: string; + crop: Crop | undefined; } let captured: CapturedCropArea; @@ -21,14 +23,18 @@ jest.mock("../components/CropArea", () => ({ imageRef: Ref; onImageLoad: CapturedCropArea["onImageLoad"]; onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; setZoom: CapturedCropArea["setZoom"]; wheelZoomMode: string; + crop: Crop | undefined; }) => { captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart, setZoom: props.setZoom, - wheelZoomMode: props.wheelZoomMode + wheelZoomMode: props.wheelZoomMode, + crop: props.crop }; return ( = {}): ImageCr 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", + enableRotation: true, + enableGrayscale: false, + showResetButton: true, onCropAction: actionValue(), ...overrides }; @@ -114,6 +120,10 @@ async function flushApply(): Promise { describe("", () => { beforeEach(() => { jest.useFakeTimers(); + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + // jsdom lacks blob URL APIs used by the live-preview hook (Reset drives it too). + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test"; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; }); afterEach(() => { jest.runOnlyPendingTimers(); @@ -130,7 +140,7 @@ describe("", () => { test("renders empty state when image has no value", () => { const props = makeProps({ image: makeImageProp({ value: undefined }) }); render(); - expect(screen.getByText("No image")).toBeInTheDocument(); + expect(screen.getByText("No uploaded image to crop")).toBeInTheDocument(); }); test("does NOT auto-apply on initial image load (no data mutation without user intent)", async () => { @@ -148,6 +158,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -160,6 +171,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -185,6 +197,7 @@ describe("", () => { render(); act(() => { captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + captured.onUserInteractStart?.(); captured.onCropComplete(PIXEL_CROP); }); await flushApply(); @@ -223,4 +236,69 @@ describe("", () => { render(); expect(captured.wheelZoomMode).toBe("off"); }); + + test("reset restores the captured original via setValue", async () => { + const blob = new Blob(["x"], { type: "image/png" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) as jest.Mock; + const image = makeImageProp(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + (image.setValue as jest.Mock).mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); + await flushApply(); + expect((image.setValue as jest.Mock).mock.calls[0]?.[0]).toBeInstanceOf(File); + }); + + test("reset re-seeds the default cropbox instead of clearing it", async () => { + const blob = new Blob(["x"], { type: "image/png" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) as jest.Mock; + const image = makeImageProp(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // Seed then move the box off its default so we can prove reset restores the default. + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); + await flushApply(); + // Box is re-seeded (not undefined) to the default 80%-centered percent crop. + expect(captured.crop).toBeDefined(); + expect(captured.crop!.unit).toBe("%"); + expect(captured.crop!.width).toBeCloseTo(80, 5); + // centered horizontally: x = (100 - 80) / 2 = 10 + expect(captured.crop!.x).toBeCloseTo(10, 5); + }); + + test("reset button disabled when original capture failed", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByRole("button", { name: "Reset crop" })).toBeDisabled(); + }); + + test("configured accessibility labels flow through to the toolbar", () => { + render( + + ); + expect(screen.getByLabelText("Naar links draaien")).toBeInTheDocument(); + const resetBtn = screen.getByRole("button", { name: "Uitsnede herstellen" }); + expect(resetBtn).toHaveTextContent("Herstellen"); + }); }); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx new file mode 100644 index 0000000000..e43fe42904 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperGrayscale.spec.tsx @@ -0,0 +1,190 @@ +import { act, fireEvent, 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"; + +// Integration test: proves grayscale reversibility after rotate. + +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; +} +let captured: CapturedCropArea; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + }) => { + captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete }; + 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; + } + }} + /> + ); + } +})); + +interface CapturedRotateOptions { + rotation: number; + outputFormat: string; + grayscale: boolean; +} +const rotateImageOptions: CapturedRotateOptions[] = []; +jest.mock("../utils/rotateImage", () => ({ + rotateImage: jest.fn((options: CapturedRotateOptions) => { + rotateImageOptions.push(options); + return Promise.resolve(new File(["x"], "rotate.png", { type: "image/png" })); + }) +})); + +interface CapturedCropOptions { + grayscale: boolean; +} +const cropImageOptions: CapturedCropOptions[] = []; +jest.mock("../utils/cropImage", () => ({ + CropError: class CropError extends Error {}, + cropImage: jest.fn((options: CapturedCropOptions) => { + cropImageOptions.push(options); + return Promise.resolve(new File(["x"], "crop.png", { type: "image/png" })); + }) +})); + +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(): 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() + } 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, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + onCropAction: actionValue(), + ...overrides + }; +} + +describe(" grayscale reversibility after rotate", () => { + beforeEach(() => { + jest.useFakeTimers(); + rotateImageOptions.length = 0; + cropImageOptions.length = 0; + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + // jsdom lacks blob URL APIs used by the live-preview hook. + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test"; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("rotate with grayscale ON produces a COLOR working image (grayscale:false) so toggling off is reversible", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); // ON + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + // The WORKING image (the one shown + reloaded into imageRef) must be COLOR. + // With approach B, handleRotate calls rotateImage twice: once color (working), + // once baked (commit). The color call is the one whose result feeds the preview. + const colorWorkingCall = rotateImageOptions.some(o => o.grayscale === false); + expect(colorWorkingCall).toBe(true); + }); + + test("rotate with grayscale ON still bakes a B&W file for the committed setValue", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); // ON + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + // committed file must be the baked one + expect(rotateImageOptions.some(o => o.grayscale === true)).toBe(true); + expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); + }); + + test("rotate with grayscale OFF produces only a color file (no baked B&W)", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + rotateImageOptions.length = 0; + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions.every(o => o.grayscale === false)).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx new file mode 100644 index 0000000000..49af30088f --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperMultiInstance.spec.tsx @@ -0,0 +1,194 @@ +import { act, render } 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"; + +// Multi-instance integration test: verifies that auto-commit only fires for the +// cropper instance where the user actually dragged, not for layout-driven completes +// or sibling instances. + +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; +} +const captures: CapturedCropArea[] = []; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; + }) => { + captures.push({ + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart + }); + 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; + } + }} + /> + ); + } +})); + +jest.mock("../utils/cropImage", () => ({ + CropError: class CropError extends Error {}, + cropImage: jest.fn(() => Promise.resolve(new File(["x"], "crop.png", { type: "image/png" }))) +})); + +jest.mock("../utils/rotateImage", () => ({ + rotateImage: jest.fn(() => Promise.resolve(new File(["x"], "rotate.png", { type: "image/png" }))) +})); + +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(): 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() + } 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, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + onCropAction: actionValue(), + ...overrides + }; +} + +async function flushApply(): Promise { + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe(" multi-instance crop gating", () => { + beforeEach(() => { + jest.useFakeTimers(); + captures.length = 0; + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test"; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("layout-driven onCropComplete without a user drag does not commit", async () => { + const image = makeImageProp(); + render(); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // layout-driven complete, NO preceding onUserInteractStart + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).not.toHaveBeenCalled(); + }); + + test("a genuine user drag then onCropComplete DOES commit", async () => { + const image = makeImageProp(); + render(); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + captures[0].onUserInteractStart?.(); + }); // user grabs the crop + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(image.setValue).toHaveBeenCalled(); + }); + + test("editing one cropper does not commit its sibling", async () => { + const imageA = makeImageProp(); + const imageB = makeImageProp(); + render( + <> + + + + ); + act(() => { + captures[0].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + captures[1].onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // only A receives a genuine user drag + act(() => { + captures[0].onUserInteractStart?.(); + }); + act(() => { + captures[0].onCropComplete(PIXEL_CROP); + }); + // B receives a layout-driven complete with NO drag signal — must NOT commit + act(() => { + captures[1].onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(imageA.setValue).toHaveBeenCalled(); + expect(imageB.setValue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx new file mode 100644 index 0000000000..b3806b507e --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -0,0 +1,272 @@ +import { act, fireEvent, 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"; + +// Integration test: proves the rotate/grayscale actions reach the right util with the right args. + +interface CapturedCropArea { + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; + src: string; +} +let captured: CapturedCropArea; + +jest.mock("../components/CropArea", () => ({ + CropArea: (props: { + imageRef: Ref; + onImageLoad: CapturedCropArea["onImageLoad"]; + onCropComplete: CapturedCropArea["onCropComplete"]; + onUserInteractStart?: CapturedCropArea["onUserInteractStart"]; + src: string; + }) => { + captured = { + onImageLoad: props.onImageLoad, + onCropComplete: props.onCropComplete, + onUserInteractStart: props.onUserInteractStart, + src: props.src + }; + 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; + } + }} + /> + ); + } +})); + +interface CapturedRotateOptions { + rotation: number; + outputFormat: string; + grayscale: boolean; +} +const rotateImageOptions: CapturedRotateOptions[] = []; +jest.mock("../utils/rotateImage", () => ({ + rotateImage: jest.fn((options: CapturedRotateOptions) => { + rotateImageOptions.push(options); + return Promise.resolve(new File(["x"], "rotate.png", { type: "image/png" })); + }) +})); + +interface CapturedCropOptions { + grayscale: boolean; +} +const cropImageOptions: CapturedCropOptions[] = []; +jest.mock("../utils/cropImage", () => ({ + CropError: class CropError extends Error {}, + cropImage: jest.fn((options: CapturedCropOptions) => { + cropImageOptions.push(options); + return Promise.resolve(new File(["x"], "crop.png", { type: "image/png" })); + }) +})); + +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(): 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() + } 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, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + onCropAction: actionValue(), + ...overrides + }; +} + +async function flushApply(): Promise { + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe(" rotation/grayscale integration", () => { + beforeEach(() => { + jest.useFakeTimers(); + rotateImageOptions.length = 0; + cropImageOptions.length = 0; + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; + // jsdom lacks blob URL APIs used by the live-preview hook. Return a distinct URL per + // File so a rotated-blob preview is distinguishable from an original-blob preview. + let blobSeq = 0; + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => `blob:test-${blobSeq++}`; + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test("rotate-right calls rotateImage with rotation=90 and writes the result via setValue", async () => { + const image = makeImageProp(); + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions.length).toBeGreaterThan(0); + expect(rotateImageOptions[rotateImageOptions.length - 1].rotation).toBe(90); + expect(image.setValue).toHaveBeenCalledWith(expect.any(File)); + }); + + test("rotate with grayscale on bakes grayscale into the COMMITTED file", async () => { + // handleRotate also produces a color working image (first call, grayscale:false); + // the last call is the baked commit (grayscale:true) used for setValue. + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions[rotateImageOptions.length - 1].grayscale).toBe(true); + }); + + test("rotate-left calls rotateImage with rotation=-90", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate left")); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rotateImageOptions[rotateImageOptions.length - 1].rotation).toBe(-90); + }); + + test("subsequent crop-complete after rotate calls cropImage without a rotation field", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + cropImageOptions.length = 0; + act(() => { + captured.onUserInteractStart?.(); + }); + act(() => { + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(cropImageOptions.length).toBeGreaterThan(0); + expect(cropImageOptions[cropImageOptions.length - 1]).not.toHaveProperty("rotation"); + }); + + test("reset after a rotate reverts the displayed image away from the rotated preview", async () => { + // Original must be capturable for Reset to restore it. + const blob = new Blob(["orig"], { type: "image/png" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) as jest.Mock; + const image = makeImageProp(); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + // Rotate → live preview switches to the rotated blob. + await act(async () => { + fireEvent.click(screen.getByLabelText("Rotate right")); + await Promise.resolve(); + await Promise.resolve(); + }); + const rotatedSrc = captured.src; + expect(rotatedSrc).toMatch(/^blob:/); // sanity: we are showing the rotated blob + + // Reset should stop showing the rotated preview (revert to original bytes / bound uri). + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(captured.src).not.toBe(rotatedSrc); + }); + + test("black & white toggle then crop-complete passes grayscale=true to cropImage", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Grayscale")); + }); + act(() => { + captured.onUserInteractStart?.(); + }); + act(() => { + captured.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(cropImageOptions[cropImageOptions.length - 1].grayscale).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg deleted file mode 100644 index 534cf020b2..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/assets/crop-icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg new file mode 100644 index 0000000000..51a1a51c51 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/rotate-left.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/rotate-left.svg new file mode 100644 index 0000000000..6e056262b3 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/rotate-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/assets/rotate-right.svg b/packages/pluggableWidgets/image-cropper-web/src/assets/rotate-right.svg new file mode 100644 index 0000000000..17063fa892 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/assets/rotate-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 68b5d24a6a..4c5a9e27f3 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -1,20 +1,18 @@ 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 { default as ReactCrop, type Crop, type PixelCrop } from "react-image-crop"; import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; +import { isStrayCrop, MIN_CROP_PX } from "../utils/cropGuard"; +import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage"; +import { buildInitialCrop } from "../utils/initialCrop"; +import { safeImageUri } from "../utils/safeImageUri"; -interface CropAreaProps { +export interface CropAreaProps { src: string; crop: Crop | undefined; onCropChange: (crop: Crop) => void; onCropComplete: (pixelCrop: PixelCrop) => void; + onUserInteractStart?: () => void; aspect: number | undefined; circular: boolean; resizable: boolean; @@ -22,27 +20,18 @@ interface CropAreaProps { boundaryHeight: number; onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; zoom: number; + // Fixed point of the zoom, as fractions (0..1) of the displayed image. Owned by the container + // and only re-derived when zoom changes, so moving/drawing the box never pans the image. + // Optional: static callers (editor preview) omit it and default to the image center. + zoomAnchor?: ZoomAnchor; minZoom: number; maxZoom: number; setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; + grayscale: boolean; 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, @@ -78,7 +67,40 @@ export function CropArea(props: CropAreaProps): ReactElement { [aspect, onImageLoad, boundaryWidth, boundaryHeight] ); - if (loadError) { + const { onCropChange, onCropComplete } = props; + + // Ignore a stray click (a ~0-size crop from mousedown+mouseup with no drag) so the existing + // box survives — see isStrayCrop. Real drags clear the floor (also enforced by minWidth/minHeight). + const handleChange = useCallback( + (pixel: PixelCrop, percent: Crop) => { + if (isStrayCrop(pixel, displaySize)) { + return; + } + onCropChange(percent); + }, + [displaySize, onCropChange] + ); + + const handleComplete = useCallback( + (pixel: PixelCrop) => { + if (isStrayCrop(pixel, displaySize)) { + return; + } + onCropComplete(pixel); + }, + [displaySize, onCropComplete] + ); + + // Zoom is anchored on props.zoomAnchor (fractions of the displayed image), owned by the + // container and updated ONLY when the zoom value changes. transformOrigin is the one point + // scale() keeps fixed on screen, so a frozen anchor keeps the image stable while the box + // moves/draws; the same anchor drives the export math so saved pixels match the screen. + const anchor = props.zoomAnchor ?? CENTER_ANCHOR; + const transformOrigin = `${anchor.x * 100}% ${anchor.y * 100}%`; + + const safeSrc = safeImageUri(props.src); + + if (loadError || !safeSrc) { return (
Could not load this image. If it is a remote image, the server must allow cross-origin access. @@ -98,25 +120,27 @@ export function CropArea(props: CropAreaProps): ReactElement { > props.onCropChange(percent)} - onComplete={pixel => props.onCropComplete(pixel)} + onChange={(pixel, percent) => handleChange(pixel, percent)} + onComplete={pixel => handleComplete(pixel)} + onDragStart={() => props.onUserInteractStart?.()} aspect={props.aspect} circularCrop={props.circular} disabled={!props.resizable} - keepSelection + minWidth={MIN_CROP_PX} + minHeight={MIN_CROP_PX} > setLoadError(true)} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx new file mode 100644 index 0000000000..3836cc0452 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -0,0 +1,98 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +import { ZoomSlider } from "./ZoomSlider"; +import RotateLeftIcon from "../assets/rotate-left.svg"; +import RotateRightIcon from "../assets/rotate-right.svg"; + +interface CropToolbarProps { + showRotation: boolean; + showGrayscale: boolean; + showZoom: boolean; + showReset: boolean; + grayscale: boolean; + canReset: boolean; + zoom: number; + minZoom: number; + maxZoom: number; + rotateLeftLabel: string; + rotateRightLabel: string; + grayscaleCaption: string; + grayscaleAriaLabel: string; + resetCaption: string; + resetAriaLabel: string; + zoomCaption: string; + zoomAriaLabel: string; + onRotateLeft: () => void; + onRotateRight: () => void; + onToggleGrayscale: () => void; + onZoomChange: (zoom: number) => void; + onReset: () => void; +} + +export function CropToolbar(props: CropToolbarProps): ReactElement | null { + if (!props.showRotation && !props.showGrayscale && !props.showZoom && !props.showReset) { + return null; + } + return ( +
+ {props.showRotation && ( + <> + + + + )} + {props.showGrayscale && ( + + )} + {props.showZoom && ( + + )} + {props.showReset && ( + + )} +
+ ); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx index 462b418129..f6a80ffe8f 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -1,114 +1,37 @@ 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 { observer } from "mobx-react-lite"; +import { ReactElement, useEffect, useRef } from "react"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CropArea } from "./CropArea"; -import { PreviewPane } from "./PreviewPane"; -import { ZoomSlider } from "./ZoomSlider"; +import { CropToolbar } from "./CropToolbar"; 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"; +import { ImageCropperStore } from "../stores/ImageCropperStore"; -export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { - const state = useImageCropperState(Number(props.minZoom)); +export const ImageCropperContainer = observer(function ImageCropperContainer( + props: ImageCropperContainerProps +): ReactElement | null { + const gateProvider = useConst(() => new GateProvider(props)); + const store = useSetup(() => new ImageCropperStore(gateProvider.gate)); - const { setZoom, setLiveCrop, setCommittedCrop } = 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; + // Push fresh Mendix props into the gate every render (config + image read live off it). useEffect(() => { - setLiveCrop(undefined); - setCommittedCrop(undefined); - armed(); - }, [uri, setLiveCrop, setCommittedCrop, armed]); + gateProvider.setProps(props); + }); - const handleCropComplete = useCallback( - (pixelCrop: PixelCrop) => { - committedCropRef.current = pixelCrop; - setCommittedCrop(pixelCrop); - auto.applyNow(); - }, - [setCommittedCrop, auto] - ); + const imageRef = useRef(null); - const handleZoomChange = useCallback( - (next: SetStateAction) => { - setZoom(next); - auto.applyDebounced(); - }, - [setZoom, auto] - ); + // Feed the store the one React-owned dep it still needs: the live on-screen used as + // the canvas draw source. It closes over a ref, so re-inject every render; assigning a + // plain (non-observable) field triggers no reactions. Fetch/preview/capture now live in the + // store, driven by its own reaction on the bound uri. + useEffect(() => { + store.setDeps({ + getImage: () => imageRef.current + }); + }); if (props.image.status === ValueStatus.Loading) { return ( @@ -127,51 +50,79 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE style={props.style} tabIndex={props.tabIndex} > - No image + + + {props.noImageCaption?.value ?? "No uploaded image to crop"} +
); } - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); - return (
store.setLiveCrop(crop)} + onCropComplete={pixelCrop => store.commitCrop(pixelCrop)} + onUserInteractStart={() => store.markUserDragged()} + aspect={store.aspect} circular={props.cropShape === "circle"} resizable={props.resizableEnabled} boundaryWidth={props.boundaryWidth} boundaryHeight={props.boundaryHeight} - onImageLoad={handleImageLoad} - zoom={state.zoom} + onImageLoad={(percentCrop, pixelCrop) => store.initFromImageLoad(percentCrop, pixelCrop)} + zoom={store.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} - setZoom={handleZoomChange} + setZoom={next => store.setZoom(next)} + zoomAnchor={store.zoomAnchor} wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"} - imageRef={state.imageRef} + grayscale={store.grayscale} + imageRef={imageRef} + /> + store.setZoom(zoom)} + onRotateLeft={() => store.rotate(-90)} + onRotateRight={() => store.rotate(90)} + onToggleGrayscale={() => store.toggleGrayscale()} + onReset={() => store.reset()} /> - {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 deleted file mode 100644 index c58ed85094..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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/ZoomSlider.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx index 2edc53abe7..d0ab6d0f26 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ZoomSlider.tsx @@ -4,20 +4,22 @@ interface ZoomSliderProps { zoom: number; minZoom: number; maxZoom: number; + label: string; + ariaLabel: string; onChange: (zoom: number) => void; } -export function ZoomSlider({ zoom, minZoom, maxZoom, onChange }: ZoomSliderProps): ReactElement { +export function ZoomSlider({ zoom, minZoom, maxZoom, label, ariaLabel, onChange }: ZoomSliderProps): ReactElement { return ( diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx new file mode 100644 index 0000000000..b13d84f744 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx @@ -0,0 +1,66 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { CropArea, type CropAreaProps } from "../CropArea"; + +function baseProps(overrides: Partial = {}): CropAreaProps { + return { + src: "http://localhost/img.png", + crop: undefined, + onCropChange: jest.fn(), + onCropComplete: jest.fn(), + aspect: undefined, + circular: false, + resizable: true, + boundaryWidth: 300, + boundaryHeight: 300, + onImageLoad: jest.fn(), + zoom: 1, + zoomAnchor: { x: 0.5, y: 0.5 }, + minZoom: 1, + maxZoom: 4, + setZoom: jest.fn(), + wheelZoomMode: "off" as const, + grayscale: true, + imageRef: createRef(), + ...overrides + }; +} + +describe("", () => { + test("applies zoom scale and grayscale filter to the image (no CSS rotation)", () => { + const { container } = render(); + const img = container.querySelector("img")!; + expect(img.style.transform).toContain("scale(1)"); + expect(img.style.transform).not.toContain("rotate("); + expect(img.style.filter).toContain("grayscale(1)"); + }); + + test("no grayscale filter when grayscale is false", () => { + const { container } = render(); + const img = container.querySelector("img")!; + expect(img.style.filter === "" || img.style.filter === "none").toBe(true); + }); + + test("transformOrigin follows the zoomAnchor prop (fractions → percent)", () => { + const { container } = render(); + const img = container.querySelector("img")!; + expect(img.style.transformOrigin).toBe("30% 35%"); + }); + + test("transformOrigin ignores box movement (stays on the frozen anchor)", () => { + // The image must stay stable while the box moves: origin depends ONLY on zoomAnchor, + // NOT on the live crop. Moving the crop with the same anchor keeps the origin fixed. + const anchor = { x: 0.5, y: 0.5 }; + const { container, rerender } = render( + + ); + const img = container.querySelector("img")!; + expect(img.style.transformOrigin).toBe("50% 50%"); + rerender( + + ); + expect(img.style.transformOrigin).toBe("50% 50%"); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx new file mode 100644 index 0000000000..49c9e2b4f7 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -0,0 +1,93 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { type ComponentProps } from "react"; +import { CropToolbar } from "../CropToolbar"; + +function props(overrides = {}): ComponentProps { + return { + showRotation: true, + showGrayscale: true, + showZoom: true, + showReset: true, + grayscale: false, + canReset: true, + zoom: 1, + minZoom: 1, + maxZoom: 4, + rotateLeftLabel: "Rotate left", + rotateRightLabel: "Rotate right", + grayscaleCaption: "Grayscale", + grayscaleAriaLabel: "Grayscale", + resetCaption: "Reset", + resetAriaLabel: "Reset crop", + zoomCaption: "Zoom", + zoomAriaLabel: "Zoom", + onRotateLeft: jest.fn(), + onRotateRight: jest.fn(), + onToggleGrayscale: jest.fn(), + onZoomChange: jest.fn(), + onReset: jest.fn(), + ...overrides + }; +} + +describe("", () => { + test("fires rotate and reset callbacks", () => { + const p = props(); + render(); + fireEvent.click(screen.getByLabelText("Rotate left")); + fireEvent.click(screen.getByLabelText("Rotate right")); + fireEvent.click(screen.getByRole("button", { name: "Reset crop" })); + expect(p.onRotateLeft).toHaveBeenCalledTimes(1); + expect(p.onRotateRight).toHaveBeenCalledTimes(1); + expect(p.onReset).toHaveBeenCalledTimes(1); + }); + + test("grayscale toggle reflects aria-pressed", () => { + render(); + expect(screen.getByLabelText("Grayscale")).toHaveAttribute("aria-pressed", "true"); + }); + + test("hides controls when their flags are false", () => { + render(); + expect(screen.queryByLabelText("Rotate left")).toBeNull(); + expect(screen.queryByLabelText("Grayscale")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reset crop" })).toBeNull(); + }); + + test("reset button disabled when canReset is false", () => { + render(); + expect(screen.getByRole("button", { name: "Reset crop" })).toBeDisabled(); + }); + + test("hides zoom slider when showZoom is false", () => { + render(); + expect(screen.queryByLabelText("Zoom")).toBeNull(); + }); + + test("renders custom labels and keeps caption independent from aria-label", () => { + render( + + ); + // aria-label drives the accessible name; caption is the visible text/title. + const resetBtn = screen.getByRole("button", { name: "Undo all crop changes" }); + expect(resetBtn).toHaveTextContent("Undo"); + expect(resetBtn).toHaveAttribute("title", "Undo"); + const grayscaleBtn = screen.getByRole("button", { name: "Black and white toggle" }); + expect(grayscaleBtn).toHaveTextContent("B&W"); + }); + + test("every toolbar button exposes a native title tooltip", () => { + render(); + expect(screen.getByRole("button", { name: "Rotate left" })).toHaveAttribute("title", "Rotate left"); + expect(screen.getByRole("button", { name: "Rotate right" })).toHaveAttribute("title", "Rotate right"); + expect(screen.getByRole("button", { name: "Grayscale" })).toHaveAttribute("title", "Grayscale"); + expect(screen.getByRole("button", { name: "Reset crop" })).toHaveAttribute("title", "Reset"); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts deleted file mode 100644 index 6b217f04ae..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useAutoApplyCrop.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; - -const DEBOUNCE_MS = 400; - -export interface AutoApplyCrop { - armed: () => void; - applyNow: () => void; - applyDebounced: () => void; -} - -export function useAutoApplyCrop(applyCrop: () => void | Promise): AutoApplyCrop { - const latest = useRef(applyCrop); - latest.current = applyCrop; - - const userInteracted = useRef(false); - - const [applyDebounced, abort] = useMemo( - () => - debounce(() => { - if (userInteracted.current) { - latest.current(); - } - }, DEBOUNCE_MS), - [] - ); - - useEffect(() => abort, [abort]); - - return useMemo( - () => ({ - armed: () => { - userInteracted.current = false; - }, - applyNow: () => { - userInteracted.current = true; - abort(); - latest.current(); - }, - applyDebounced: () => { - userInteracted.current = true; - applyDebounced(); - } - }), - [abort, applyDebounced] - ); -} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts deleted file mode 100644 index 0dff232ae2..0000000000 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react"; -import type { Crop, PixelCrop } from "react-image-crop"; - -interface ImageCropperState { - // liveCrop: % units, updated per pointer move (onChange) — survives container resize. - // committedCrop: px units, set on release (onComplete) — consumed by cropImage/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/stores/ImageCropperStore.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts new file mode 100644 index 0000000000..9b2e986c17 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/ImageCropperStore.ts @@ -0,0 +1,437 @@ +import { ValueStatus } from "mendix"; +import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; +import { type SetStateAction } from "react"; +import { type Crop, type PixelCrop } from "react-image-crop"; +import { DerivedPropsGate, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; +import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; +import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { CENTER_ANCHOR, CropError, cropImage, type ZoomAnchor } from "../utils/cropImage"; +import { buildInitialCrop } from "../utils/initialCrop"; +import { rotateImage } from "../utils/rotateImage"; + +const DEBOUNCE_MS = 400; + +/** + * The one imperative, React-owned dependency the store still needs but must not own itself: + * the live on-screen DOM , used as the canvas draw source for crop/rotate export. It + * reads a ref, so it is re-injected each render via {@link ImageCropperStore.setDeps}. The + * blob-URL preview, the original-image capture, and the internal-change flag were absorbed + * into the store (they are pure browser resources, not React-lifecycle-bound in practice). + */ +export interface CropperDeps { + getImage: () => HTMLImageElement | null; +} + +const NOOP_DEPS: CropperDeps = { + getImage: () => null +}; + +/** + * Owns the crop-domain working state (crop box, zoom, grayscale, zoom anchor) and the + * operations over it. Replaces the state-mirroring refs the React container needed: a + * store method reads `this.zoom` directly, so it never goes stale inside the debounced + * apply the way a stable-identity useCallback did. + * + * Controlled/uncontrolled: the ONLY externally controlled value is `props.image` + * (an EditableValue). It is read live through the gate and written back via setValue. + * Zoom/grayscale/crop are uncontrolled internal state. Config props (minZoom, cropShape, + * outputFormat, …) are read live through the gate and never copied into observables. + */ +export class ImageCropperStore implements SetupComponent { + // liveCrop: % units, updated per pointer move — survives container resize. + liveCrop: Crop | undefined = undefined; + // committedCrop: px units, set on release — consumed by cropImage on export. + committedCrop: PixelCrop | undefined = undefined; + zoom: number; + grayscale = false; + // Fixed point the CSS zoom pivots on; frozen at the box center when zoom changes so + // the image stays put while the box moves. Drives CropArea transformOrigin AND export. + zoomAnchor: ZoomAnchor = CENTER_ANCHOR; + + // Local blob: URL shown instead of the bound uri until the deferred setValue commits on + // Save, or undefined to use the bound uri. Read by the container JSX. (was usePreviewSrc) + previewSrc: string | undefined = undefined; + // Whether the original bytes were captured, so Reset can restore them. Drives the Reset + // button's enabled state. (was useOriginalImage's canRestore) + canRestore = false; + + private gate: DerivedPropsGate; + private deps: CropperDeps = NOOP_DEPS; + + // Imperative interaction state — never drives render, so deliberately non-observable. + private userDragged = false; + private userInteracted = false; + private applyDebouncedFn: (() => void) | undefined = undefined; + private abort: (() => void) | undefined = undefined; + + // Image-capture / preview plumbing (was useOriginalImage + usePreviewSrc). Plain, not + // observable: blobUrl/originalFile are browser resources; internalChange/capturedUri are + // the fetch-dedupe gate that must not drive render. + private originalFile: File | undefined = undefined; + private blobUrl: string | undefined = undefined; + private internalChange = false; + // Bumped on every uri change so an in-flight fetch superseded by a newer uri is dropped + // when it resolves (the reactive analog of useOriginalImage's `cancelled` guard). + private fetchGeneration = 0; + // Disposer for the uri reaction, torn down alongside the debounce in setup(). + private disposeUriEffect: (() => void) | undefined = undefined; + + constructor(gate: DerivedPropsGate) { + this.gate = gate; + this.zoom = Number(gate.props.minZoom); + // Explicit annotations (not makeAutoObservable): only the crop-domain working state is + // observable; gate/deps and the imperative apply gate stay plain. Reference-typed crop + // values use observable.ref (we always replace, never mutate in place). + makeObservable< + this, + "props" | "applyCrop" | "applyNow" | "applyDebounced" | "armed" | "onUriChanged" | "revokePreview" + >(this, { + liveCrop: observable.ref, + committedCrop: observable.ref, + zoom: observable, + grayscale: observable, + zoomAnchor: observable.ref, + previewSrc: observable, + canRestore: observable, + props: computed, + aspect: computed, + setLiveCrop: action, + markUserDragged: action, + commitCrop: action, + setZoom: action, + toggleGrayscale: action, + rotate: action, + reset: action, + initFromImageLoad: action, + onImageChanged: action, + showPreview: action, + // onUriChanged writes crop/preview/canRestore observables (fired by the reaction). + onUriChanged: action, + // applyCrop/applyNow/applyDebounced/armed read or write only the non-observable + // apply gate (userInteracted); revokePreview touches only the plain blobUrl. + // captureOriginal/markInternalChange wrap their own runInAction / touch plain state. + applyCrop: false, + applyNow: false, + applyDebounced: false, + armed: false, + revokePreview: false + }); + } + + private get props(): ImageCropperContainerProps { + return this.gate.props; + } + + get aspect(): number | undefined { + return resolveAspectRatio(this.props.aspectRatio, this.props.customAspectWidth, this.props.customAspectHeight); + } + + setup(): () => void { + const [debounced, abort] = debounce(() => { + if (this.userInteracted) { + // Fire-and-forget: applyCrop swallows CropError itself; an unexpected throw + // surfaces as an unhandled rejection (logged), same as the old hook. + this.applyCrop(); + } + }, DEBOUNCE_MS); + this.applyDebouncedFn = debounced; + this.abort = abort; + + // React to the bound image's uri changing (was useOriginalImage's [uri,name] effect + + // the container's onImageChanged effect). The data fn reads the observable gate props, + // so it re-tracks each render; the effect only fires when the derived uri value changes. + this.disposeUriEffect = reaction( + () => { + const image = this.props.image; + const value = image.status === ValueStatus.Available ? image.value : undefined; + return { uri: value?.uri, name: value?.name }; + }, + ({ uri, name }) => this.onUriChanged(uri, name), + { fireImmediately: true } + ); + + // Combined teardown: stop the debounce, dispose the uri reaction, revoke any live blob. + return () => { + abort(); + this.disposeUriEffect?.(); + this.revokePreview(); + }; + } + + // Handles an image-uri change. Two branches: + // - internal bake (our own setValue): adopt the uri, don't refetch, keep the crop box; + // - external swap (a genuinely new image): capture original bytes for Reset, clear the + // crop + disarm, and drop any stale preview blob so the bound uri shows through. + // Called by the setup() reaction (also fires immediately for the initial image). + private onUriChanged(uri: string | undefined, name: string | undefined): void { + // A fresh committed uri means the previous local preview is superseded — drop it + // unconditionally (usePreviewSrc did this on ANY committed-uri change). + this.revokePreview(); + this.previewSrc = undefined; + + if (!uri) { + return; + } + + if (this.internalChange) { + // Our own bake produced this uri — adopt it, keep the original + crop, skip fetch. + this.internalChange = false; + return; + } + + // A genuinely new external image arrived. Invalidate any in-flight fetch, reset the + // capture state, clear the crop + disarm, then recapture the original bytes for Reset. + const generation = ++this.fetchGeneration; + this.originalFile = undefined; + this.canRestore = false; + this.onImageChanged(); + // Fire-and-forget: captureOriginal never rejects (it handles its own errors below), so + // there's no floating-promise to await. Bare call keeps eslint's no-void rule happy. + this.captureOriginal(uri, name, generation); + } + + // Async original-bytes capture for Reset. Guarded by the generation token so a fetch + // superseded by a newer uri is discarded when it resolves. (was useOriginalImage's fetch) + private async captureOriginal(uri: string, name: string | undefined, generation: number): Promise { + try { + const res = await fetch(uri); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + const blob = await res.blob(); + if (generation !== this.fetchGeneration) { + return; // superseded by a newer uri — drop this result + } + runInAction(() => { + this.originalFile = new File([blob], name ?? "original", { type: blob.type || "image/png" }); + this.canRestore = true; + }); + } catch { + if (generation === this.fetchGeneration) { + runInAction(() => { + this.canRestore = false; + }); + } + } + } + + // Re-injected every render (getImage closes over a React ref). + setDeps(deps: CropperDeps): void { + this.deps = deps; + } + + // Show a baked File immediately via a local blob: URL, before the deferred setValue commit + // changes the bound uri. Revokes any prior URL first. (was usePreviewSrc.showPreview) + showPreview(file: File): void { + this.revokePreview(); + const url = URL.createObjectURL(file); + this.blobUrl = url; + this.previewSrc = url; + } + + // Revoke the live blob URL (browser resource cleanup). Called before the next showPreview, + // when the bound uri catches up, and on teardown. Does not touch previewSrc — callers that + // need the fallback-to-bound-uri behavior clear previewSrc themselves. + private revokePreview(): void { + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = undefined; + } + } + + // Flag the next uri change as our own bake so the uri effect adopts it without refetching. + // Called synchronously before every internal setValue. (was useOriginalImage.markInternalChange) + private markInternalChange(): void { + this.internalChange = true; + } + + setLiveCrop(crop: Crop | undefined): void { + this.liveCrop = crop; + } + + markUserDragged(): void { + this.userDragged = true; + } + + commitCrop(pixelCrop: PixelCrop): void { + this.committedCrop = pixelCrop; + // A genuine user drag commits immediately and disarms; a programmatic onComplete + // (e.g. the box the library seeds right after image load) only updates committedCrop. + if (this.userDragged) { + this.userDragged = false; + this.applyNow(); + } + } + + setZoom(next: SetStateAction): void { + // Accept the React setState contract (value or updater). The wheel-zoom hook drives + // zoom with setZoom(prev => …), so the store — the owner of `zoom` — resolves the + // updater against its own value instead of leaking that into the container. + const value = typeof next === "function" ? next(this.zoom) : next; + // Freeze the anchor at the current box center. Recomputing it only here (not while the + // box moves) keeps the image stable during drags but still zooms into the box. + const live = this.liveCrop; + if (live && live.unit === "%") { + this.zoomAnchor = { x: (live.x + live.width / 2) / 100, y: (live.y + live.height / 2) / 100 }; + } + this.zoom = value; + this.applyDebounced(); + } + + toggleGrayscale(): void { + this.grayscale = !this.grayscale; + this.applyDebounced(); + } + + async rotate(deltaDeg: number): Promise { + const img = this.deps.getImage(); + const image = this.props.image; + if (!img || image.readOnly || image.status !== ValueStatus.Available || !image.value) { + return; + } + try { + // Working image is ALWAYS color so toggling grayscale OFF stays reversible. + const working = await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + grayscale: false, + originalName: image.value.name + }); + // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save + // with no further crop still persists grayscale. + const committed = this.grayscale + ? await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + grayscale: true, + originalName: image.value.name + }) + : working; + runInAction(() => { + this.liveCrop = undefined; + this.committedCrop = undefined; + this.armed(); + }); + // Show COLOR working pixels; CropArea reloads from this blob and rebuilds a fresh + // crop against the swapped dimensions on its onLoad. The CSS grayscale filter from + // this.grayscale still renders gray on screen. + this.showPreview(working); + this.markInternalChange(); + image.setValue(committed); + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + throw err; + } + } + } + + reset(): void { + this.zoom = Number(this.props.minZoom); + this.zoomAnchor = CENTER_ANCHOR; + this.grayscale = false; + this.armed(); // do not auto-apply the reset itself + const image = this.props.image; + const file = this.originalFile; + if (file && !image.readOnly && image.status === ValueStatus.Available) { + // Mirror rotate: setValue defers the commit, so drive the live preview with the + // original bytes too — otherwise a stale rotated blob keeps rendering after Reset. + this.showPreview(file); + this.markInternalChange(); + image.setValue(file); + } + // Re-seed the default cropbox. If restoring original bytes changed the uri (e.g. after a + // rotation), CropArea's onLoad re-seeds again against the correct dimensions; when no + // edit occurred the uri is unchanged and onLoad won't refire, so this direct re-seed is + // what puts the box back. + const img = this.deps.getImage(); + if (img && img.naturalWidth) { + const { percentCrop, pixelCrop } = buildInitialCrop(img, this.aspect); + this.liveCrop = percentCrop; + this.committedCrop = pixelCrop; + } else { + this.liveCrop = undefined; + this.committedCrop = undefined; + } + } + + initFromImageLoad(percentCrop: Crop, pixelCrop: PixelCrop): void { + this.zoom = Number(this.props.minZoom); + this.zoomAnchor = CENTER_ANCHOR; + this.liveCrop = percentCrop; + this.committedCrop = pixelCrop; + this.armed(); // programmatic load must not auto-commit + } + + // Inbound sync: clear the crop box and disarm the apply gate when the bound image changes. + // Called by onUriChanged's external-swap branch (was a React effect on the uri). + onImageChanged(): void { + this.liveCrop = undefined; + this.committedCrop = undefined; + this.armed(); + } + + // --- apply gate (was useAutoApplyCrop) --------------------------------------------------- + + private armed(): void { + this.userInteracted = false; + } + + private applyNow(): void { + this.userInteracted = true; + this.abort?.(); + // Fire-and-forget (see setup): applyCrop handles its own errors. + this.applyCrop(); + } + + private applyDebounced(): void { + this.userInteracted = true; + this.applyDebouncedFn?.(); + } + + private async applyCrop(): Promise { + const img = this.deps.getImage(); + const committedCrop = this.committedCrop; + const image = this.props.image; + if (!img || !committedCrop || image.readOnly || image.status !== ValueStatus.Available || !image.value) { + return; + } + try { + const file = await cropImage({ + image: img, + pixelCrop: committedCrop, + zoom: this.zoom, + zoomAnchor: this.zoomAnchor, + outputFormat: this.props.outputFormat, + outputQuality: Number(this.props.outputQuality), + outputSize: this.props.outputSize, + cropShape: this.props.cropShape, + viewportWidth: this.props.boundaryWidth, + viewportHeight: this.props.boundaryHeight, + grayscale: this.grayscale, + originalName: image.value.name + }); + if (this.props.outputSize === "viewport") { + image.setThumbnailSize(this.props.boundaryWidth, this.props.boundaryHeight); + } + this.markInternalChange(); + image.setValue(file); + if (this.props.onCropAction?.canExecute) { + this.props.onCropAction.execute(); + } + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + console.error("[image-cropper-web] unexpected error:", err); + throw err; + } + } + } +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts new file mode 100644 index 0000000000..f8df72000c --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/stores/__tests__/ImageCropperStore.spec.ts @@ -0,0 +1,462 @@ +import { Big } from "big.js"; +import { ValueStatus } from "mendix"; +import { action, makeObservable, observable } from "mobx"; +import type { Crop, PixelCrop } from "react-image-crop"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import type { ImageCropperContainerProps } from "../../../typings/ImageCropperProps"; + +// The store calls cropImage/rotateImage (async canvas work). Mock them so the spec asserts +// orchestration (when/what is committed) without a real canvas. +jest.mock("../../utils/cropImage", () => { + const actual = jest.requireActual("../../utils/cropImage"); + return { + ...actual, + cropImage: jest.fn(async () => new File(["cropped"], "cropped.png", { type: "image/png" })) + }; +}); +jest.mock("../../utils/rotateImage", () => ({ + rotateImage: jest.fn( + async (opts: { grayscale: boolean }) => + new File([opts.grayscale ? "bw" : "color"], "rotated.png", { type: "image/png" }) + ) +})); + +import { cropImage } from "../../utils/cropImage"; +import { rotateImage } from "../../utils/rotateImage"; +import { CropperDeps, ImageCropperStore } from "../ImageCropperStore"; + +const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 }; +const PERCENT_CROP: Crop = { unit: "%", x: 20, y: 20, width: 40, height: 40 }; + +type ImageProp = ImageCropperContainerProps["image"]; +type WebImage = NonNullable; + +function makeImageProp(overrides: Partial = {}): ImageProp { + return { + status: ValueStatus.Available, + value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage, + readOnly: false, + validation: undefined, + setValidator: jest.fn(), + setValue: jest.fn(), + setThumbnailSize: jest.fn(), + ...overrides + } as unknown as ImageProp; +} + +function makeProps(overrides: Partial = {}): ImageCropperContainerProps { + return { + name: "imageCrop", + class: "", + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "square", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: new Big(1), + maxZoom: new Big(4), + outputFormat: "png", + outputQuality: new Big(0.92), + outputSize: "original", + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + ...overrides + } as ImageCropperContainerProps; +} + +// A mutable gate whose props can be swapped, mimicking GateProvider. props is observable.ref + +// setProps is an action, matching the real gate — the store's uri reaction and `aspect`/`props` +// computeds only re-evaluate when props is swapped through the observable, not a plain write. +class FakeGate implements DerivedPropsGate { + props: ImageCropperContainerProps; + constructor(props: ImageCropperContainerProps) { + this.props = props; + makeObservable(this, { props: observable.ref, setProps: action }); + } + setProps(props: ImageCropperContainerProps): void { + this.props = props; + } +} + +// Minimal stand-in for the on-screen the store reads for export/rotation math. +function fakeImage(): HTMLImageElement { + return { naturalWidth: 400, naturalHeight: 300, width: 400, height: 300 } as HTMLImageElement; +} + +function makeDeps(overrides: Partial = {}): jest.Mocked { + return { + getImage: jest.fn(() => fakeImage()), + ...overrides + } as jest.Mocked; +} + +// Build a store already wired up: gate + setup() (debounce armed + uri reaction) + deps injected. +// setup() runs the reaction with fireImmediately, so the initial image is captured right away; +// callers that assert on the fetch should `await flush()` first. +function makeStore(propsOverride: Partial = {}): { + store: ImageCropperStore; + gate: FakeGate; + deps: jest.Mocked; + dispose: () => void; +} { + const gate = new FakeGate(makeProps(propsOverride)); + const store = new ImageCropperStore(gate); + const deps = makeDeps(); + store.setDeps(deps); + const dispose = store.setup(); + return { store, gate, deps, dispose }; +} + +async function flush(): Promise { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +// jsdom implements neither blob URLs nor fetch; stub both so the store's preview + capture +// paths can run and be spied on. +if (!URL.createObjectURL) { + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => ""; +} +if (!URL.revokeObjectURL) { + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; +} + +describe("ImageCropperStore", () => { + let createSpy: jest.SpyInstance; + let revokeSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + (cropImage as jest.Mock).mockClear(); + (rotateImage as jest.Mock).mockClear(); + + let n = 0; + createSpy = jest.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:mock-${++n}`); + revokeSpy = jest.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + + // Default: fetch succeeds with a tiny png blob (the initial-image capture the reaction + // fires on setup). Individual tests override global.fetch as needed. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["orig"], { type: "image/png" })) + }) as jest.Mock; + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe("initial state", () => { + it("seeds zoom from minZoom prop and defaults", () => { + const { store, dispose } = makeStore({ minZoom: new Big(2) }); + expect(store.zoom).toBe(2); + expect(store.grayscale).toBe(false); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + }); + + describe("aspect computed", () => { + it("derives from gate props and recomputes when props change", () => { + const { store, gate, dispose } = makeStore({ aspectRatio: "square" }); + expect(store.aspect).toBe(1); + gate.setProps(makeProps({ aspectRatio: "landscape16x9" })); + expect(store.aspect).toBeCloseTo(16 / 9); + dispose(); + }); + }); + + describe("commitCrop gate (user-drag vs programmatic)", () => { + it("does NOT auto-commit a programmatic complete (no preceding drag)", async () => { + const { store, gate, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(store.committedCrop).toEqual(PIXEL_CROP); + expect(gate.props.image.setValue).not.toHaveBeenCalled(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + + it("auto-commits immediately after a user drag, then disarms", async () => { + const { store, gate, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + + // A subsequent programmatic complete must NOT commit (flag was reset). + (cropImage as jest.Mock).mockClear(); + store.commitCrop({ ...PIXEL_CROP, x: 50 }); + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + }); + + describe("setZoom", () => { + it("freezes the anchor at the live-crop center and debounces the apply", async () => { + const { store, gate, dispose } = makeStore(); + store.setLiveCrop(PERCENT_CROP); + store.commitCrop(PIXEL_CROP); // programmatic — no commit yet + store.setZoom(2); + // anchor = box center: (x + w/2)/100, (y + h/2)/100 = (40/100, 40/100) + expect(store.zoomAnchor).toEqual({ x: 0.4, y: 0.4 }); + expect(store.zoom).toBe(2); + expect(cropImage).not.toHaveBeenCalled(); // still within debounce window + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + dispose(); + }); + + it("coalesces rapid zoom changes into a single apply", async () => { + const { store, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + store.setZoom(2); + store.setZoom(3); + store.setZoom(4); + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + describe("no stale reads", () => { + it("applies with the LATEST zoom/grayscale, not the value at trigger time", async () => { + const { store, dispose } = makeStore(); + store.commitCrop(PIXEL_CROP); + store.setZoom(2); + store.toggleGrayscale(); // grayscale -> true, also schedules apply + store.setZoom(3.5); // change again before the debounce fires + await flush(); + expect(cropImage).toHaveBeenCalledTimes(1); + const opts = (cropImage as jest.Mock).mock.calls[0][0]; + expect(opts.zoom).toBe(3.5); + expect(opts.grayscale).toBe(true); + dispose(); + }); + }); + + describe("rotate", () => { + it("commits a color file when grayscale is off and shows the working preview", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); // let the initial-image capture settle before asserting on preview + createSpy.mockClear(); + await store.rotate(90); + expect(rotateImage).toHaveBeenCalledTimes(1); + expect((rotateImage as jest.Mock).mock.calls[0][0].grayscale).toBe(false); + // showPreview minted a blob URL for the working file and exposed it. + expect(createSpy).toHaveBeenCalledTimes(1); + expect(store.previewSrc).toBe("blob:mock-1"); + expect(gate.props.image.setValue).toHaveBeenCalledTimes(1); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + + it("bakes a separate B&W committed file when grayscale is on", async () => { + const { store, dispose } = makeStore(); + store.toggleGrayscale(); + await store.rotate(-90); + // Two rotateImage calls: color working + B&W committed. + expect(rotateImage).toHaveBeenCalledTimes(2); + const grayscales = (rotateImage as jest.Mock).mock.calls.map(c => c[0].grayscale); + expect(grayscales).toEqual([false, true]); + dispose(); + }); + }); + + describe("reset", () => { + it("restores zoom/grayscale, re-seeds the crop, and restores original bytes", async () => { + const { store, gate, dispose } = makeStore({ minZoom: new Big(1) }); + // The initial-image reaction fetches + captures the original bytes for Reset. + await flush(); + expect(store.canRestore).toBe(true); + + store.toggleGrayscale(); + store.setZoom(3); + store.reset(); + + expect(store.zoom).toBe(1); + expect(store.grayscale).toBe(false); + expect(store.zoomAnchor).toEqual({ x: 0.5, y: 0.5 }); + // Reset drives the live preview with the captured original and writes it back. + expect(store.previewSrc).toBeDefined(); + const restored = (gate.props.image.setValue as jest.Mock).mock.calls.at(-1)?.[0]; + expect(restored).toBeInstanceOf(File); + expect(restored.name).toBe("img.png"); // the captured original's name + expect(store.liveCrop).toBeDefined(); // re-seeded from the fake image + dispose(); + }); + }); + + describe("onImageChanged (inbound sync)", () => { + it("clears the crop and disarms so a queued apply cannot fire", async () => { + const { store, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + (cropImage as jest.Mock).mockClear(); + + store.setZoom(2); // arms + schedules a debounced apply + store.onImageChanged(); // new image arrived: disarm + clear + await flush(); + + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + expect(cropImage).not.toHaveBeenCalled(); // disarmed before the debounce fired + dispose(); + }); + }); + + describe("guards / no feedback loop", () => { + it("does not write back when the image is read-only", async () => { + const { store, gate, dispose } = makeStore(); + gate.setProps(makeProps({ image: makeImageProp({ readOnly: true } as Partial) })); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + expect(gate.props.image.setValue).not.toHaveBeenCalled(); + dispose(); + }); + + it("does nothing when no committed crop exists", async () => { + const { store, dispose } = makeStore(); + store.setZoom(2); // arms + debounces, but committedCrop is undefined + await flush(); + expect(cropImage).not.toHaveBeenCalled(); + dispose(); + }); + }); + + describe("original-image capture (was useOriginalImage)", () => { + it("fetches the bound image and reports canRestore", async () => { + const { store, dispose } = makeStore(); + await flush(); + expect(global.fetch).toHaveBeenCalledWith("http://localhost/img.png"); + expect(store.canRestore).toBe(true); + dispose(); + }); + + it("reports canRestore=false when the fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + const { store, dispose } = makeStore(); + await flush(); + expect(store.canRestore).toBe(false); + dispose(); + }); + + it("re-captures and clears the crop when a new external uri arrives", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); + (global.fetch as jest.Mock).mockClear(); + store.setLiveCrop(PERCENT_CROP); + store.commitCrop(PIXEL_CROP); + + // A genuinely new external image (no preceding internal bake). + gate.setProps( + makeProps({ image: makeImageProp({ value: { uri: "http://x/new.png", name: "new.png" } as WebImage }) }) + ); + await flush(); + + expect(global.fetch).toHaveBeenCalledWith("http://x/new.png"); + expect(store.liveCrop).toBeUndefined(); + expect(store.committedCrop).toBeUndefined(); + dispose(); + }); + + it("adopts our own bake's uri without refetching or clearing the crop", async () => { + const { store, gate, dispose } = makeStore(); + store.markUserDragged(); + store.commitCrop(PIXEL_CROP); + await flush(); // applyCrop -> markInternalChange -> setValue + expect(store.committedCrop).toEqual(PIXEL_CROP); + (global.fetch as jest.Mock).mockClear(); + + // Simulate the deferred commit landing: the bound uri changes to our baked output. + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/baked.png", name: "baked.png" } as WebImage }) + }) + ); + await flush(); + + expect(global.fetch).not.toHaveBeenCalled(); // adopted, not recaptured + expect(store.committedCrop).toEqual(PIXEL_CROP); // crop preserved + dispose(); + }); + + it("drops a superseded in-flight fetch (stale-fetch cancellation)", async () => { + // First fetch never resolves within the window; a second uri supersedes it. + let resolveFirst: (r: unknown) => void = () => undefined; + const slowBlob = new Blob(["slow"], { type: "image/png" }); + global.fetch = jest + .fn() + .mockImplementationOnce(() => new Promise(res => (resolveFirst = res))) + .mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(slowBlob) }) as jest.Mock; + + const { store, gate, dispose } = makeStore(); + // Supersede before the first fetch resolves. + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/second.png", name: "second.png" } as WebImage }) + }) + ); + await flush(); + // Now resolve the STALE first fetch — it must be discarded (generation mismatch). + resolveFirst({ ok: true, blob: () => Promise.resolve(new Blob(["stale"], { type: "image/png" })) }); + await flush(); + + // canRestore reflects the SECOND (winning) fetch, not the stale first. + expect(store.canRestore).toBe(true); + dispose(); + }); + }); + + describe("preview blob lifecycle (was usePreviewSrc)", () => { + it("drops and revokes the preview when the committed uri catches up", async () => { + const { store, gate, dispose } = makeStore(); + await flush(); + await store.rotate(90); // mints a preview blob (internal bake -> markInternalChange) + expect(store.previewSrc).toBeDefined(); + const minted = store.previewSrc; + revokeSpy.mockClear(); + + // The deferred commit lands: bound uri becomes our baked output. onUriChanged drops + // the local preview and revokes the blob (adopt branch, previewSrc cleared first). + gate.setProps( + makeProps({ + image: makeImageProp({ value: { uri: "http://x/baked.png", name: "baked.png" } as WebImage }) + }) + ); + await flush(); + + expect(store.previewSrc).toBeUndefined(); + expect(revokeSpy).toHaveBeenCalledWith(minted); + dispose(); + }); + + it("revokes the live blob URL on teardown", async () => { + const { store, dispose } = makeStore(); + await flush(); + await store.rotate(90); + const minted = store.previewSrc; + revokeSpy.mockClear(); + dispose(); + expect(revokeSpy).toHaveBeenCalledWith(minted); + }); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 27d52b9c36..3cc522d5f7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -3,10 +3,15 @@ $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; + // Block-level (not inline-flex) so sibling widgets — e.g. a Save button in the + // same List View row — stack BELOW the cropper instead of flowing to its right. + // fit-content keeps the box sized to the image so the toolbar still matches the + // image width rather than stretching across the whole container. + display: flex; + width: fit-content; + max-width: 100%; flex-direction: column; gap: 8px; @@ -30,6 +35,8 @@ $image-cropper-icon: url(../assets/crop-icon.svg); display: flex; align-items: center; gap: 8px; + flex: 1; // absorb the row's free space so the toolbar spans the preview width + font-weight: normal; input[type="range"] { flex: 1; @@ -37,21 +44,82 @@ $image-cropper-icon: url(../assets/crop-icon.svg); } } - &__preview { - border: 1px solid #ddd; - background: #fff; + // The "Zoom" text sits in a ; Atlas styles labels bold, so set the weight on the + // span itself (class specificity) to render it regular like the other toolbar labels. + &__zoom-label { + font-weight: normal; } - &__button { - align-self: flex-start; + &__toolbar { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 8px; + width: 100%; } - &__error, - &--empty { + &__tool { + min-width: 36px; + padding: 4px 8px; + line-height: 1; + + &.active { + background-color: var(--brand-primary, #264ae5); + color: #fff; + } + } + + // Icon-only buttons (rotate left/right). Atlas .btn owns the vertical box + // (line-height + padding-y + border) → height auto-matches Grayscale/Reset; + // we do NOT touch it (Atlas sizes win — frontend-guidelines). We only neutralise + // the text-button min-width and equalise horizontal padding to the .btn vertical + // padding so the box is square WITHOUT an aspect-ratio (which copied the fat + // width axis and inflated the height to 48px). + &__tool--icon { + min-width: 0; + padding-left: 6px; + padding-right: 6px; + } + + // Atlas's `.btn img` rule styles this icon for the "icon + text" case: + // height: calc(var(--font-size-default) + 4px); margin-right: 4px; + // vertical-align: text-top; + // Our rotate buttons are icon-ONLY, so that right margin pushed the icon + // off-centre (widening the box) and text-top shoved it up. We KEEP Atlas's + // icon height (convention: Atlas sizes win) and only neutralise the two + // text-affordance properties — which centres the icon and tightens the box. + // `.btn &` raises specificity above Atlas's `.btn img` so these win. + .btn &__tool-icon { + margin-right: 0; + vertical-align: middle; + } + + &__error { padding: 8px; color: #b00; } + // Informational empty state — no image bound yet. Uses Atlas's semantic + // "info" color (not the error red) so it reads as guidance, not a failure. + &--empty { + display: flex; + flex-direction: row; // override the base column so icon + text share one line + align-items: center; + gap: 8px; + padding: 12px; + color: var(--brand-info, #0086d9); + } + + &__empty-icon { + flex: 0 0 auto; + width: 1.5em; // scales with the text via font-size / em + height: 1.5em; + } + + &__empty-text { + line-height: 1.2; + } + &--loading { min-height: 200px; } @@ -59,32 +127,37 @@ $image-cropper-icon: url(../assets/crop-icon.svg); &--preview { display: flex; flex-direction: column; + gap: 4px; + align-items: stretch; + // Editor preview fills the available width; overrides the base fit-content + // (which keeps the RUNTIME widget sized to the image so siblings stack below). + width: 100%; + } - .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); - } + &__preview-box { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 12px; + border: 1px solid var(--border-color-default, $image-cropper-border-color-default); + border-radius: 4px; + 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; - } + // Real CropArea rendered for the static-image preview — display only, no interaction. + &__preview-canvas { + pointer-events: none; + } - .widget-image-cropper__label { - margin: 0; - font-size: 11px; - color: var(--gray-light, $image-cropper-gray-light); - } + &__preview-glyph { + display: block; + width: 56px; + height: auto; + } + + &__preview-caption { + margin: 0; + font-size: 11px; + color: var(--gray-light, $image-cropper-gray-light); } } diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropGuard.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropGuard.spec.ts new file mode 100644 index 0000000000..509a0a4ab5 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropGuard.spec.ts @@ -0,0 +1,32 @@ +import type { PixelCrop } from "react-image-crop"; +import { isStrayCrop, MIN_DRAW_FRACTION } from "../cropGuard"; + +const px = (width: number, height: number): PixelCrop => ({ unit: "px", x: 0, y: 0, width, height }); +const display = { width: 300, height: 200 }; + +describe("isStrayCrop", () => { + test("treats a zero-size crop (bare click) as stray", () => { + expect(isStrayCrop(px(0, 0), display)).toBe(true); + }); + + test("treats a sub-threshold crop as stray", () => { + // threshold = 300 * 0.02 = 6 px wide, 200 * 0.02 = 4 px tall + expect(isStrayCrop(px(5, 3), display)).toBe(true); + }); + + test("keeps a crop at or above the threshold on both axes", () => { + const minW = display.width * MIN_DRAW_FRACTION; + const minH = display.height * MIN_DRAW_FRACTION; + expect(isStrayCrop(px(minW, minH), display)).toBe(false); + expect(isStrayCrop(px(240, 160), display)).toBe(false); + }); + + test("stray when only one axis is below threshold", () => { + expect(isStrayCrop(px(240, 2), display)).toBe(true); + expect(isStrayCrop(px(2, 160), display)).toBe(true); + }); + + test("never filters before the image has loaded (unknown display size)", () => { + expect(isStrayCrop(px(0, 0), null)).toBe(false); + }); +}); 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 index 2d5a2fb578..32629a7268 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts @@ -25,7 +25,8 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }) ).rejects.toBeInstanceOf(CropError); }); @@ -41,7 +42,8 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }); expect(file.name.endsWith(".png")).toBe(true); expect(file.type).toBe("image/png"); @@ -58,7 +60,8 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }); expect(file.name.endsWith(".jpg")).toBe(true); expect(file.type).toBe("image/jpeg"); @@ -76,7 +79,8 @@ describe("cropImage", () => { outputSize: "viewport", cropShape: "rect", viewportWidth: 50, - viewportHeight: 40 + viewportHeight: 40, + grayscale: false }) ); const ctx = calls[0].ctx as CanvasRenderingContext2D; @@ -84,26 +88,107 @@ describe("cropImage", () => { expect(ctx.canvas.height).toBe(40); }); - test("divides source rect by zoom factor when zoom > 1", async () => { + test("drawImage dest starts at top-left (no center-translate)", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300, + grayscale: false + }) + ); + // dest top-left must be (0, 0) — no rotation translate + const [, , , , , dx, dy] = calls[0]; + expect(dx).toBe(0); + expect(dy).toBe(0); + }); + + test("zoom shrinks source rect by 1/z around the anchor (anchor == box center)", 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, + // box center (200,200) as fractions of the displayed image + zoomAnchor: { x: 0.2, y: 0.25 }, outputFormat: "png", outputQuality: 1, outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }) ); const [, sx, sy, sw, sh] = calls[0]; - expect(sx).toBe(50); - expect(sy).toBe(50); + // anchor == box center (200,200); at z=2 the window is 100x100, centered on (200,200): + // sx = 200 - 100/2 = 150. expect(sw).toBe(100); expect(sh).toBe(100); + expect(sx).toBe(150); + expect(sy).toBe(150); + expect(sx + sw / 2).toBe(200); + expect(sy + sh / 2).toBe(200); + }); + + test("keeps a frozen off-center anchor fixed while the box sits elsewhere", async () => { + const img = makeImg(1000, 800, 1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + // box is NOT centered on the anchor — simulates moving the box after zooming + pixelCrop: { unit: "px", x: 100, y: 100, width: 200, height: 200 }, + zoom: 2, + // anchor frozen at natural point ox=0.5*1000=500, oy=0.4*800=320 + zoomAnchor: { x: 0.5, y: 0.4 }, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300, + grayscale: false + }) + ); + const [, sx, sy, sw, sh] = calls[0]; + // sx = ox + (cropX - ox)/z = 500 + (100 - 500)/2 = 300; sy = 320 + (100 - 320)/2 = 210. + expect(sw).toBe(100); + expect(sh).toBe(100); + expect(sx).toBe(300); + expect(sy).toBe(210); + }); + + test("clamps the source rect into the image when zoomed out (z < 1)", async () => { + const img = makeImg(1000, 800, 1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + // small box near the top-left corner, zoomed out so the read window exceeds it + pixelCrop: { unit: "px", x: 20, y: 20, width: 100, height: 100 }, + zoom: 0.5, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300, + grayscale: false + }) + ); + const [, sx, sy, sw, sh] = calls[0]; + // z=0.5 → window 200x200; centered on (70,70) would give sx=-30, clamped to 0. + expect(sw).toBe(200); + expect(sh).toBe(200); + expect(sx).toBe(0); + expect(sy).toBe(0); }); test("returns a valid File when cropShape is circle", async () => { @@ -117,7 +202,8 @@ describe("cropImage", () => { outputSize: "original", cropShape: "circle", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }); expect(file).toBeInstanceOf(File); expect(file.name.endsWith(".png")).toBe(true); @@ -140,13 +226,31 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }) ).rejects.toBeInstanceOf(CropError); } finally { HTMLCanvasElement.prototype.toBlob = originalToBlob; } }); + + test("grayscale option produces a File without throwing under canvas mock", 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, + grayscale: true + }); + expect(file).toBeInstanceOf(File); + }); }); async function captureDrawImageCalls( diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts new file mode 100644 index 0000000000..d5cf3509ee --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropMapping.spec.ts @@ -0,0 +1,28 @@ +import { normalizeRotation, rotatedCanvasSize } from "../cropMapping"; + +describe("normalizeRotation", () => { + test.each([ + [0, 0], + [90, 90], + [180, 180], + [270, 270], + [360, 0], + [-90, 270], + [450, 90], + [44, 0], + [46, 90] + ])("snaps %i° to %i°", (input, expected) => { + expect(normalizeRotation(input)).toBe(expected); + }); +}); + +describe("rotatedCanvasSize", () => { + test("keeps dimensions for 0/180", () => { + expect(rotatedCanvasSize(100, 60, 0)).toEqual({ width: 100, height: 60 }); + expect(rotatedCanvasSize(100, 60, 180)).toEqual({ width: 100, height: 60 }); + }); + test("swaps dimensions for 90/270", () => { + expect(rotatedCanvasSize(100, 60, 90)).toEqual({ width: 60, height: 100 }); + expect(rotatedCanvasSize(100, 60, 270)).toEqual({ width: 60, height: 100 }); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts new file mode 100644 index 0000000000..29ef30e455 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/rotateImage.spec.ts @@ -0,0 +1,133 @@ +import { CropError } from "../cropImage"; +import { rotateImage, RotateImageOptions } from "../rotateImage"; + +function makeImg(naturalW: number, naturalH: number): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: naturalW }); + Object.defineProperty(img, "naturalHeight", { value: naturalH }); + Object.defineProperty(img, "width", { value: naturalW }); + Object.defineProperty(img, "height", { value: naturalH }); + return img; +} + +const baseOpts: Omit = { + outputFormat: "png", + outputQuality: 1, + grayscale: false, + originalName: "photo.png" +}; + +describe("rotateImage", () => { + test("rejects with CropError when image has zero natural width", async () => { + const img = makeImg(0, 0); + await expect(rotateImage({ ...baseOpts, image: img, rotation: 90 })).rejects.toBeInstanceOf(CropError); + }); + + test("swaps canvas dimensions for 90° rotation (1000x800 → 800x1000)", async () => { + const img = makeImg(1000, 800); + const spy = jest.spyOn(document, "createElement"); + const file = await rotateImage({ ...baseOpts, image: img, rotation: 90 }); + const canvas = spy.mock.results.map(r => r.value).find(el => el?.tagName === "CANVAS") as HTMLCanvasElement; + expect(canvas.width).toBe(800); + expect(canvas.height).toBe(1000); + expect(file).toBeInstanceOf(File); + spy.mockRestore(); + }); + + test("keeps canvas dimensions for 180° rotation (1000x800 → 1000x800)", async () => { + const img = makeImg(1000, 800); + const spy = jest.spyOn(document, "createElement"); + await rotateImage({ ...baseOpts, image: img, rotation: 180 }); + const canvas = spy.mock.results.map(r => r.value).find(el => el?.tagName === "CANVAS") as HTMLCanvasElement; + expect(canvas.width).toBe(1000); + expect(canvas.height).toBe(800); + spy.mockRestore(); + }); + + test("drawImage receives full source rect centered (1000x800, rotation 90)", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => rotateImage({ ...baseOpts, image: img, rotation: 90 })); + // centered: dest top-left at (-nw/2, -nh/2) = (-500, -400); size = natural (1000, 800) + const [drawImg, sx, sy, sw, sh, dx, dy, dw, dh] = calls[0]; + expect(drawImg).toBe(img); + expect(sx).toBe(0); + expect(sy).toBe(0); + expect(sw).toBe(1000); + expect(sh).toBe(800); + expect(dx).toBe(-500); + expect(dy).toBe(-400); + expect(dw).toBe(1000); + expect(dh).toBe(800); + }); + + test("returns a .png File for png outputFormat", async () => { + const img = makeImg(1000, 800); + const file = await rotateImage({ ...baseOpts, image: img, rotation: 90, outputFormat: "png" }); + expect(file.name.endsWith(".png")).toBe(true); + expect(file.type).toBe("image/png"); + }); + + test("returns a .jpg File for jpeg outputFormat", async () => { + const img = makeImg(1000, 800); + const file = await rotateImage({ + ...baseOpts, + image: img, + rotation: 90, + outputFormat: "jpeg", + outputQuality: 0.8, + originalName: "photo.jpg" + }); + expect(file.name.endsWith(".jpg")).toBe(true); + expect(file.type).toBe("image/jpeg"); + }); + + test("applies grayscale filter to the canvas when grayscale is true", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + rotateImage({ ...baseOpts, image: img, rotation: 90, grayscale: true }) + ); + expect(calls[0].ctx.filter).toBe("grayscale(1)"); + }); + + test("leaves the canvas filter unset when grayscale is false", async () => { + const img = makeImg(1000, 800); + const calls = await captureDrawImageCalls(() => + rotateImage({ ...baseOpts, image: img, rotation: 90, grayscale: false }) + ); + expect(calls[0].ctx.filter === "none" || calls[0].ctx.filter === "").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(rotateImage({ ...baseOpts, image: img, rotation: 90 })).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/__tests__/safeImageUri.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/safeImageUri.spec.ts new file mode 100644 index 0000000000..4164827c9f --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/safeImageUri.spec.ts @@ -0,0 +1,24 @@ +import { safeImageUri } from "../safeImageUri"; + +describe("safeImageUri", () => { + test.each([ + "http://localhost/img.png", + "https://cdn.example.com/a.jpg?x=1", + "blob:http://localhost/abc-123", + "data:image/png;base64,iVBORw0KGgo=" + ])("passes through allowed scheme: %s", uri => { + expect(safeImageUri(uri)).toBe(uri); + }); + + test.each(["javascript:alert(1)", "data:text/html,", "vbscript:msgbox(1)"])( + "returns undefined for disallowed scheme: %s", + uri => { + expect(safeImageUri(uri)).toBeUndefined(); + } + ); + + test("returns undefined for nullish input", () => { + expect(safeImageUri(undefined)).toBeUndefined(); + expect(safeImageUri("")).toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropGuard.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropGuard.ts new file mode 100644 index 0000000000..be06bee128 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropGuard.ts @@ -0,0 +1,24 @@ +import type { PixelCrop } from "react-image-crop"; + +// Below this fraction of the displayed image an incoming crop is treated as a stray click +// (react-image-crop emits a ~0-size crop on mousedown+mouseup with no drag) rather than a real +// draw. We dropped keepSelection to allow drawing a fresh box, so without this guard a stray +// click would replace the existing box with a 0-size selection. +export const MIN_DRAW_FRACTION = 0.02; + +// Floor size (px, in displayed-image space) for a box the user actively drags — passed to +// , which only clamps during an actual drag-move. +export const MIN_CROP_PX = 16; + +/** + * True when the crop is too small to be a deliberate draw, given the displayed image size. + * When displaySize is unknown (before the image loads) nothing is filtered. + */ +export function isStrayCrop(pixel: PixelCrop, displaySize: { width: number; height: number } | null): boolean { + if (!displaySize) { + return false; + } + const minW = displaySize.width * MIN_DRAW_FRACTION; + const minH = displaySize.height * MIN_DRAW_FRACTION; + return pixel.width < minW || pixel.height < minH; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts index e5aa835cde..462f11a593 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -8,16 +8,71 @@ export class CropError extends Error { } } +export interface SourceRect { + sx: number; + sy: number; + sw: number; + sh: number; +} + +// Fixed point of the CSS zoom, as fractions (0..1) of the displayed image. The center of the +// initial cropbox is {x: 0.5, y: 0.5}. Owned by the container so display and export share it. +export interface ZoomAnchor { + x: number; + y: number; +} + +// Default anchor (image center) for callers that predate frozen anchors / have none yet. +export const CENTER_ANCHOR: ZoomAnchor = { x: 0.5, y: 0.5 }; + +/** + * Maps the crop box (in the displayed image's unscaled layout px) to a source rectangle on the + * natural image. Zoom is a CSS `transform: scale(z)` anchored on `anchor` (a fixed point that + * stays put on screen while scaling), so the visible region shrinks by 1/z about that point. + * Used by cropImage (export) so exported pixels match the on-screen framing. + * Result is clamped to the image. When anchor == the crop-box center this reduces to the plain + * "zoom into the box" mapping. + */ +export function computeSourceRect( + pixelCrop: PixelCrop, + image: HTMLImageElement, + zoom: number, + anchor: ZoomAnchor = CENTER_ANCHOR +): SourceRect { + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + + const sw = (pixelCrop.width / z) * scaleX; + const sh = (pixelCrop.height / z) * scaleY; + + // Anchor in natural px (anchor is a fraction of the displayed image; * width * scaleX == * natural). + const ox = anchor.x * image.naturalWidth; + const oy = anchor.y * image.naturalHeight; + // Invert screen = O + z·(layout − O): the layout point under the crop box top-left is + // O + (cropTopLeft − O)/z. Collapses to (center − sw/2) when the anchor is the box center. + let sx = ox + (pixelCrop.x * scaleX - ox) / z; + let sy = oy + (pixelCrop.y * scaleY - oy) / z; + + // Keep the read window inside the image (guards zoom-out, z<1, from reading off-canvas). + sx = Math.max(0, Math.min(sx, image.naturalWidth - sw)); + sy = Math.max(0, Math.min(sy, image.naturalHeight - sh)); + + return { sx, sy, sw, sh }; +} + export interface CropImageOptions { image: HTMLImageElement; pixelCrop: PixelCrop; zoom: number; + zoomAnchor?: ZoomAnchor; outputFormat: OutputFormatEnum; outputQuality: number; outputSize: OutputSizeEnum; cropShape: CropShapeEnum; viewportWidth: number; viewportHeight: number; + grayscale: boolean; originalName?: string; } @@ -26,12 +81,14 @@ export async function cropImage(options: CropImageOptions): Promise { image, pixelCrop, zoom, + zoomAnchor, outputFormat, outputQuality, outputSize, cropShape, viewportWidth, viewportHeight, + grayscale, originalName } = options; @@ -39,21 +96,14 @@ export async function cropImage(options: CropImageOptions): Promise { 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, sy, sw, sh } = computeSourceRect(pixelCrop, image, zoom, zoomAnchor); - 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 destW = Math.max(1, Math.round(outputSize === "viewport" ? viewportWidth : sw)); + const destH = Math.max(1, Math.round(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)); + canvas.width = destW; + canvas.height = destH; const ctx = canvas.getContext("2d"); if (!ctx) { @@ -62,22 +112,20 @@ export async function cropImage(options: CropImageOptions): Promise { if (outputFormat === "jpeg") { ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillRect(0, 0, destW, destH); + } + + if (grayscale) { + ctx.filter = "grayscale(1)"; } 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.ellipse(destW / 2, destH / 2, destW / 2, destH / 2, 0, 0, Math.PI * 2); ctx.clip(); } - ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); - - if (cropShape === "circle") { - ctx.restore(); - } + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, destW, destH); const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; const ext = outputFormat === "jpeg" ? "jpg" : "png"; diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts new file mode 100644 index 0000000000..08185b4c26 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropMapping.ts @@ -0,0 +1,12 @@ +// Rotation is supported only at 90° multiples; arbitrary angles are snapped. +export function normalizeRotation(deg: number): 0 | 90 | 180 | 270 { + const snapped = Math.round(deg / 90) * 90; + const mod = ((snapped % 360) + 360) % 360; + return mod as 0 | 90 | 180 | 270; +} + +// Destination canvas size after a quarter-turn rotation: 90/270 swap w/h. +export function rotatedCanvasSize(width: number, height: number, rotation: number): { width: number; height: number } { + const r = normalizeRotation(rotation); + return r === 90 || r === 270 ? { width: height, height: width } : { width, height }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/describeConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/describeConfig.ts new file mode 100644 index 0000000000..774bce5808 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/describeConfig.ts @@ -0,0 +1,32 @@ +import { ImageCropperPreviewProps } from "../../typings/ImageCropperProps"; + +// Shared by both editor surfaces: structure mode (editorConfig getPreview) renders this as the +// body row, design mode (editorPreview) renders it as the caption under the static image. +export 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(" · "); +} + +export 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/utils/initialCrop.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/initialCrop.ts new file mode 100644 index 0000000000..1843efb77f --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/initialCrop.ts @@ -0,0 +1,17 @@ +import { centerCrop, convertToPixelCrop, makeAspectCrop, type Crop, type PixelCrop } from "react-image-crop"; + +// Default selection covers 80% of the image, centered, at the resolved aspect ratio. +// Shared by CropArea's onLoad (initial box) and Reset (re-seed the box to its initial state). +export 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) }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts new file mode 100644 index 0000000000..3b9de97f74 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts @@ -0,0 +1,62 @@ +import { CropError } from "./cropImage"; +import { normalizeRotation, rotatedCanvasSize } from "./cropMapping"; +import type { OutputFormatEnum } from "../../typings/ImageCropperProps"; + +export interface RotateImageOptions { + image: HTMLImageElement; + rotation: number; // delta degrees; snapped to 90° multiples + outputFormat: OutputFormatEnum; + outputQuality: number; + grayscale: boolean; + originalName?: string; +} + +export async function rotateImage(options: RotateImageOptions): Promise { + const { image, rotation, outputFormat, outputQuality, grayscale, originalName } = options; + const nw = image.naturalWidth; + const nh = image.naturalHeight; + if (!nw || !nh) { + throw new CropError("Image not loaded."); + } + const rot = normalizeRotation(rotation); + const dest = rotatedCanvasSize(nw, nh, rot); + + const canvas = document.createElement("canvas"); + canvas.width = dest.width; + canvas.height = dest.height; + 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 (grayscale) { + // Bake B&W for the COMMITTED file only. handleRotate keeps a separate color + // working image so toggling grayscale off stays reversible; this baked file + // is what gets persisted via setValue while the toggle is on. + ctx.filter = "grayscale(1)"; + } + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate((rot * Math.PI) / 180); + ctx.drawImage(image, 0, 0, nw, nh, -nw / 2, -nh / 2, nw, nh); + 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 { + resolve(null); + } + }); + if (!blob) { + throw new CropError("Could not export the rotated image (canvas may be tainted)."); + } + const baseName = originalName ? originalName.replace(/\.[^.]+$/, "") : `rotate-${Date.now()}`; + return new File([blob], `${baseName}.${ext}`, { type: mime }); +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts new file mode 100644 index 0000000000..5456aaa0c5 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/safeImageUri.ts @@ -0,0 +1,10 @@ +// Allow only the URI schemes the Mendix platform legitimately produces for images. +// Blocks javascript:/vbscript:/data:text style payloads as defense-in-depth. +const ALLOWED = /^(https?:|blob:|data:image\/)/i; + +export function safeImageUri(uri: string | undefined): string | undefined { + if (!uri) { + return undefined; + } + return ALLOWED.test(uri.trim()) ? uri : undefined; +} diff --git a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts index e03d303554..515e799308 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/ImageCropperProps.d.ts @@ -3,9 +3,9 @@ * 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 { ActionValue, DynamicValue, EditableImageValue, WebImage } from "mendix"; import { Big } from "big.js"; +import { CSSProperties } from "react"; export type CropShapeEnum = "rect" | "circle"; @@ -30,15 +30,24 @@ export interface ImageCropperContainerProps { onCropAction?: ActionValue; boundaryWidth: number; boundaryHeight: number; - showPreview: boolean; - previewWidth: number; - previewHeight: number; resizableEnabled: boolean; + enableRotation: boolean; + enableGrayscale: boolean; + showResetButton: boolean; zoomEnabled: boolean; showZoomSlider: boolean; wheelZoomMode: WheelZoomModeEnum; minZoom: Big; maxZoom: Big; + grayscaleCaption?: DynamicValue; + resetCaption?: DynamicValue; + zoomCaption?: DynamicValue; + noImageCaption?: DynamicValue; + rotateLeftLabel?: DynamicValue; + rotateRightLabel?: DynamicValue; + grayscaleAriaLabel?: DynamicValue; + resetAriaLabel?: DynamicValue; + zoomAriaLabel?: DynamicValue; outputFormat: OutputFormatEnum; outputQuality: Big; outputSize: OutputSizeEnum; @@ -63,15 +72,24 @@ export interface ImageCropperPreviewProps { onCropAction: {} | null; boundaryWidth: number | null; boundaryHeight: number | null; - showPreview: boolean; - previewWidth: number | null; - previewHeight: number | null; resizableEnabled: boolean; + enableRotation: boolean; + enableGrayscale: boolean; + showResetButton: boolean; zoomEnabled: boolean; showZoomSlider: boolean; wheelZoomMode: WheelZoomModeEnum; minZoom: number | null; maxZoom: number | null; + grayscaleCaption: string; + resetCaption: string; + zoomCaption: string; + noImageCaption: string; + rotateLeftLabel: string; + rotateRightLabel: string; + grayscaleAriaLabel: string; + resetAriaLabel: string; + zoomAriaLabel: string; outputFormat: OutputFormatEnum; outputQuality: number | null; outputSize: OutputSizeEnum; diff --git a/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts b/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts index 54427b8d64..b9cc0118ac 100644 --- a/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts +++ b/packages/pluggableWidgets/image-cropper-web/typings/modules.d.ts @@ -1,2 +1,6 @@ declare module "*.css"; declare module "*.scss"; +declare module "*.png" { + const content: string; + export = content; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c87e25d1e..52d7918eda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1682,9 +1682,18 @@ importers: packages/pluggableWidgets/image-cropper-web: dependencies: + '@mendix/widget-plugin-mobx-kit': + specifier: workspace:* + version: link:../../shared/widget-plugin-mobx-kit classnames: specifier: ^2.5.1 version: 2.5.1 + mobx: + specifier: 6.12.3 + version: 6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9) + mobx-react-lite: + specifier: 4.0.7 + version: 4.0.7(patch_hash=47fd2d1b5c35554ddd4fa32fcaa928a16fda9f82dca0ff68bcdc1f7c3e5f9d1a)(mobx@6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9))(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1) react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@18.3.1)