diff --git a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md index bc5fa0bfdd..c34e6e44bd 100644 --- a/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md +++ b/packages/pluggableWidgets/image-cropper-web/CHANGELOG.md @@ -6,8 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] -## [1.0.0] - 2026-05-21 - ### Added -- Initial release. Crops a bound `EditableImageValue` attribute with rectangular or circular viewport, optional zoom (slider + wheel), live preview pane, and PNG/JPEG output. Replaces the legacy ImageCrop widget. +- Initial release. diff --git a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts index fa0455b645..0a3d0b1cc7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorConfig.ts @@ -4,7 +4,7 @@ import { structurePreviewPalette } 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 = []; @@ -31,50 +31,38 @@ 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, + backgroundColor: palette.background.container, children: [ { type: "RowLayout", columnSize: "grow", - padding: 12, + backgroundColor: palette.background.topbarStandard, + borders: true, + borderWidth: 1, + padding: 8, children: [ { - type: "Container", - grow: 0, - padding: 4, - children: [ - { - type: "Image", - document: iconDocument, - width: 28, - height: 22 - } - ] - }, + type: "Text", + content: "Image cropper", + fontColor: palette.text.primary, + fontSize: 10 + } + ] + }, + { + type: "Container", + padding: 8, + children: [ { - 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 - } - ] + type: "Text", + content: values.image ? describeConfig(values) : "[No attribute selected]", + fontColor: palette.text.secondary, + fontSize: 9 } ] } @@ -86,32 +74,3 @@ 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..a7eaca0725 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx @@ -1,14 +1,82 @@ +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import classNames from "classnames"; -import { ReactElement } from "react"; +import { ReactElement, createRef, useState } from "react"; +import { type Crop } from "react-image-crop"; import { ImageCropperPreviewProps } from "../typings/ImageCropperProps"; +import { CropArea } from "./components/CropArea"; +import { resolveAspectRatio } from "./utils/aspectRatio"; +import { describeConfig } from "./utils/describeConfig"; +import CropperPlaceholderIcon from "./assets/cropper-placeholder.png"; + +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 { + const staticImage = props.image?.type === "static" ? props.image : undefined; + return ( -
-
-
-

Image Cropper

+
+

Image cropper

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

+ {staticImage ? describeConfig(props) : "[No image selected yet]"} +

); } 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..eda2c231e7 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 @@ -81,6 +83,20 @@ Let the user resize the selection by dragging its corners. + + + Enable rotation + Show rotate-left / rotate-right buttons. Rotation is baked into the saved image. + + + Enable black and white + Show a grayscale toggle. When on, the saved image is converted to black and white. + + + Show reset button + Show a Reset button that restores the original image and clears zoom, rotation, and crop. + + Enable zoom 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..b220d1828a --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx @@ -0,0 +1,106 @@ +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, + showPreview: false, + previewWidth: null, + previewHeight: null, + resizableEnabled: true, + enableRotation: true, + enableGrayscale: true, + showResetButton: true, + zoomEnabled: true, + showZoomSlider: true, + wheelZoomMode: "onWithCtrl", + minZoom: null, + maxZoom: null, + 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 widget title", () => { + const texts = collectText(getPreview(makePreviewProps(), false)); + expect(texts).toContain("Image cropper"); + }); + + test("shows placeholder body when no image is bound", () => { + const texts = collectText(getPreview(makePreviewProps({ image: null }), false)); + expect(texts).toContain("[No attribute selected]"); + }); + + test("shows config summary in body 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"); + expect(texts).not.toContain("[No attribute selected]"); + }); +}); + +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("treats a dynamic image as not previewable (glyph + empty caption)", () => { + const props = makePreviewProps({ image: { type: "dynamic", entity: "MyModule.Photo" } }); + const { container, getByText } = render(preview(props)); + expect(container.querySelector(".widget-image-cropper__preview-glyph")).not.toBeNull(); + expect(getByText("[No image selected yet]")).toBeInTheDocument(); + }); + + 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..32f6d8c313 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx @@ -1,4 +1,4 @@ -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"; @@ -97,6 +97,9 @@ function makeProps(overrides: Partial = {}): ImageCr outputFormat: "png", outputQuality: new Big(0.92), outputSize: "original", + enableRotation: true, + enableGrayscale: false, + showResetButton: true, onCropAction: actionValue(), ...overrides }; @@ -114,6 +117,7 @@ async function flushApply(): Promise { describe("", () => { beforeEach(() => { jest.useFakeTimers(); + global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock; }); afterEach(() => { jest.runOnlyPendingTimers(); @@ -223,4 +227,29 @@ 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" })); + await flushApply(); + expect((image.setValue as jest.Mock).mock.calls[0]?.[0]).toBeInstanceOf(File); + }); + + 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" })).toBeDisabled(); + }); }); 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..818b85cf61 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropperRotation.spec.tsx @@ -0,0 +1,225 @@ +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; +} +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), + showPreview: false, + previewWidth: 100, + previewHeight: 100, + 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. + (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-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("black & white on then rotate bakes grayscale into the rotated file", async () => { + render(); + act(() => { + captured.onImageLoad(PERCENT_CROP, PIXEL_CROP); + }); + act(() => { + fireEvent.click(screen.getByLabelText("Black and white")); + }); + 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.onCropComplete(PIXEL_CROP); + }); + await flushApply(); + expect(cropImageOptions.length).toBeGreaterThan(0); + expect(cropImageOptions[cropImageOptions.length - 1]).not.toHaveProperty("rotation"); + }); + + 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("Black and white")); + }); + 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.png b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png new file mode 100644 index 0000000000..0afd4095b5 Binary files /dev/null and b/packages/pluggableWidgets/image-cropper-web/src/assets/cropper-placeholder.png differ diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx index 68b5d24a6a..f9008bd9e7 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx @@ -9,8 +9,9 @@ import { } from "react-image-crop"; import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropperProps"; +import { safeImageUri } from "../utils/safeImageUri"; -interface CropAreaProps { +export interface CropAreaProps { src: string; crop: Crop | undefined; onCropChange: (crop: Crop) => void; @@ -26,6 +27,7 @@ interface CropAreaProps { maxZoom: number; setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; + grayscale: boolean; imageRef: Ref; } @@ -78,7 +80,9 @@ export function CropArea(props: CropAreaProps): ReactElement { [aspect, onImageLoad, boundaryWidth, boundaryHeight] ); - if (loadError) { + 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. @@ -107,7 +111,7 @@ export function CropArea(props: CropAreaProps): ReactElement { > 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..d46ec3cae0 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/CropToolbar.tsx @@ -0,0 +1,67 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; + +interface CropToolbarProps { + showRotation: boolean; + showGrayscale: boolean; + showReset: boolean; + grayscale: boolean; + canReset: boolean; + onRotateLeft: () => void; + onRotateRight: () => void; + onToggleGrayscale: () => void; + onReset: () => void; +} + +export function CropToolbar(props: CropToolbarProps): ReactElement | null { + if (!props.showRotation && !props.showGrayscale && !props.showReset) { + return null; + } + return ( +
+ {props.showRotation && ( + <> + + + + )} + {props.showGrayscale && ( + + )} + {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..9d78cf83b4 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/ImageCropperContainer.tsx @@ -3,23 +3,29 @@ import { ValueStatus } from "mendix"; import { ReactElement, SetStateAction, useCallback, useEffect, useRef } from "react"; import { type Crop, type PixelCrop } from "react-image-crop"; import { CropArea } from "./CropArea"; +import { CropToolbar } from "./CropToolbar"; import { PreviewPane } from "./PreviewPane"; import { ZoomSlider } from "./ZoomSlider"; import { ImageCropperContainerProps } from "../../typings/ImageCropperProps"; import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop"; import { useImageCropperState } from "../hooks/useImageCropperState"; +import { useOriginalImage } from "../hooks/useOriginalImage"; +import { usePreviewSrc } from "../hooks/usePreviewSrc"; import { resolveAspectRatio } from "../utils/aspectRatio"; import { cropImage, CropError } from "../utils/cropImage"; +import { rotateImage } from "../utils/rotateImage"; export function ImageCropperContainer(props: ImageCropperContainerProps): ReactElement | null { const state = useImageCropperState(Number(props.minZoom)); - const { setZoom, setLiveCrop, setCommittedCrop } = state; + const { setZoom, setLiveCrop, setCommittedCrop, setGrayscale } = state; const committedCropRef = useRef(undefined); committedCropRef.current = state.committedCrop; const zoomRef = useRef(state.zoom); zoomRef.current = state.zoom; + const grayscaleRef = useRef(state.grayscale); + grayscaleRef.current = state.grayscale; const applyCrop = useCallback(async () => { const img = state.imageRef.current; @@ -44,11 +50,13 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE cropShape: props.cropShape, viewportWidth: props.boundaryWidth, viewportHeight: props.boundaryHeight, + grayscale: grayscaleRef.current, originalName: props.image.value.name }); if (props.outputSize === "viewport") { props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); } + markInternalRef.current(); props.image.setValue(file); if (props.onCropAction?.canExecute) { props.onCropAction.execute(); @@ -87,6 +95,21 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE ); const uri = props.image.status === ValueStatus.Available ? props.image.value?.uri : undefined; + const original = useOriginalImage( + uri, + props.image.status === ValueStatus.Available ? props.image.value?.name : undefined + ); + + // Ref mirror so applyCrop's stable identity is untouched (same reason zoomRef exists). + const markInternalRef = useRef(original.markInternalChange); + markInternalRef.current = original.markInternalChange; + + // Live preview for baked rotations: setValue defers the commit, so show a local + // blob URL until the bound uri catches up on Save. + const { previewSrc, showPreview } = usePreviewSrc(uri); + const showPreviewRef = useRef(showPreview); + showPreviewRef.current = showPreview; + useEffect(() => { setLiveCrop(undefined); setCommittedCrop(undefined); @@ -110,6 +133,59 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE [setZoom, auto] ); + const handleRotate = useCallback( + async (deltaDeg: number) => { + const img = state.imageRef.current; + if (!img || props.image.readOnly || props.image.status !== ValueStatus.Available || !props.image.value) { + return; + } + try { + const file = await rotateImage({ + image: img, + rotation: deltaDeg, + outputFormat: props.outputFormat, + outputQuality: Number(props.outputQuality), + grayscale: grayscaleRef.current, + originalName: props.image.value.name + }); + setLiveCrop(undefined); + setCommittedCrop(undefined); + committedCropRef.current = undefined; + armed(); + // Show the rotated pixels immediately; CropArea reloads from this blob and + // rebuilds a fresh crop against the swapped dimensions on its onLoad. + showPreviewRef.current(file); + markInternalRef.current(); + props.image.setValue(file); + } catch (err) { + if (err instanceof CropError) { + console.error("[image-cropper-web] CropError:", err.message); + } else { + throw err; + } + } + }, + [state.imageRef, props.image, props.outputFormat, props.outputQuality, setLiveCrop, setCommittedCrop, armed] + ); + + const handleToggleGrayscale = useCallback(() => { + setGrayscale(prev => !prev); + auto.applyDebounced(); + }, [setGrayscale, auto]); + + const handleReset = useCallback(() => { + setZoom(Number(props.minZoom)); + setGrayscale(false); + setLiveCrop(undefined); + setCommittedCrop(undefined); + armed(); // do not auto-apply the reset itself + const file = original.getOriginal(); + if (file && !props.image.readOnly && props.image.status === ValueStatus.Available) { + markInternalRef.current(); + props.image.setValue(file); + } + }, [setZoom, props.minZoom, props.image, setGrayscale, setLiveCrop, setCommittedCrop, armed, original]); + if (props.image.status === ValueStatus.Loading) { return (
{props.zoomEnabled && props.showZoomSlider ? ( @@ -162,6 +239,17 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE onChange={handleZoomChange} /> ) : null} + handleRotate(-90)} + onRotateRight={() => handleRotate(90)} + onToggleGrayscale={handleToggleGrayscale} + onReset={handleReset} + /> {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 index c58ed85094..4fa65e9e36 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx +++ b/packages/pluggableWidgets/image-cropper-web/src/components/PreviewPane.tsx @@ -8,9 +8,18 @@ interface PreviewPaneProps { width: number; height: number; circle: boolean; + grayscale: boolean; } -export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: PreviewPaneProps): ReactElement { +export function PreviewPane({ + image, + pixelCrop, + zoom, + width, + height, + circle, + grayscale +}: PreviewPaneProps): ReactElement { const canvasRef = useRef(null); useEffect(() => { @@ -34,15 +43,17 @@ export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: P // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. return; } + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + if (grayscale) { + ctx.filter = "grayscale(1)"; + } 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, @@ -54,10 +65,7 @@ export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: P width, height ); - if (circle) { - ctx.restore(); - } - }, [image, pixelCrop, zoom, width, height, circle]); + }, [image, pixelCrop, zoom, width, height, circle, grayscale]); 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..289e246e8b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropArea.spec.tsx @@ -0,0 +1,42 @@ +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, + 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); + }); +}); 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..7d34226302 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/CropToolbar.spec.tsx @@ -0,0 +1,48 @@ +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, + showReset: true, + grayscale: false, + canReset: true, + onRotateLeft: jest.fn(), + onRotateRight: jest.fn(), + onToggleGrayscale: 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" })); + 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("Black and white")).toHaveAttribute("aria-pressed", "true"); + }); + + test("hides controls when their flags are false", () => { + render(); + expect(screen.queryByLabelText("Rotate left")).toBeNull(); + expect(screen.queryByLabelText("Black and white")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reset" })).toBeNull(); + }); + + test("reset button disabled when canReset is false", () => { + render(); + expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx new file mode 100644 index 0000000000..e396be9b86 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/components/__tests__/PreviewPane.spec.tsx @@ -0,0 +1,30 @@ +import { render } from "@testing-library/react"; +import type { PixelCrop } from "react-image-crop"; +import { PreviewPane } from "../PreviewPane"; + +function makeImage(): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: 1000, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 800, configurable: true }); + Object.defineProperty(img, "width", { value: 400, configurable: true }); + Object.defineProperty(img, "height", { value: 320, configurable: true }); + return img; +} +const crop: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 80 }; + +describe("", () => { + test("renders without throwing when grayscale (canvas mock)", () => { + const { container } = render( + + ); + expect(container.querySelector("canvas")).not.toBeNull(); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts new file mode 100644 index 0000000000..fd34122a17 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts @@ -0,0 +1,18 @@ +import { renderHook, act } from "@testing-library/react"; +import { useImageCropperState } from "../useImageCropperState"; + +describe("useImageCropperState", () => { + test("initializes zoom from arg, grayscale false", () => { + const { result } = renderHook(() => useImageCropperState(1)); + expect(result.current.zoom).toBe(1); + expect(result.current.grayscale).toBe(false); + }); + + test("setGrayscale updates state", () => { + const { result } = renderHook(() => useImageCropperState(1)); + act(() => { + result.current.setGrayscale(true); + }); + expect(result.current.grayscale).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts new file mode 100644 index 0000000000..ddfe47475f --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useOriginalImage.spec.ts @@ -0,0 +1,59 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useOriginalImage } from "../useOriginalImage"; + +describe("useOriginalImage", () => { + afterEach(() => jest.restoreAllMocks()); + + test("captures a File from the uri and reports canRestore", async () => { + const blob = new Blob(["x"], { type: "image/png" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) as jest.Mock; + const { result } = renderHook(() => useOriginalImage("http://localhost/img.png", "img.png")); + await waitFor(() => expect(result.current.canRestore).toBe(true)); + const file = result.current.getOriginal(); + expect(file).toBeInstanceOf(File); + expect(file!.name).toBe("img.png"); + }); + + test("canRestore false when fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("CORS")) as jest.Mock; + const { result } = renderHook(() => useOriginalImage("http://x/y.png", "y.png")); + await waitFor(() => expect(result.current.canRestore).toBe(false)); + expect(result.current.getOriginal()).toBeUndefined(); + }); + + test("no fetch when uri is undefined", () => { + global.fetch = jest.fn() as jest.Mock; + renderHook(() => useOriginalImage(undefined, undefined)); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("markInternalChange skips recapture on next uri change and preserves original File", async () => { + const blob1 = new Blob(["original"], { type: "image/png" }); + const blob2 = new Blob(["baked"], { type: "image/png" }); + const fetchMock = jest.fn().mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(blob1) }); + global.fetch = fetchMock as jest.Mock; + + const { result, rerender } = renderHook(({ uri }) => useOriginalImage(uri, "img.png"), { + initialProps: { uri: "http://localhost/original.png" } + }); + await waitFor(() => expect(result.current.canRestore).toBe(true)); + const originalFile = result.current.getOriginal(); + expect(originalFile).toBeInstanceOf(File); + + // Simulate an internal bake: mark then change uri + fetchMock.mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(blob2) }); + act(() => { + result.current.markInternalChange(); + }); + rerender({ uri: "http://localhost/baked.png" }); + + // fetch must NOT have been called a second time + await waitFor(() => { + // give the effect a tick to potentially run + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + // original File is preserved + expect(result.current.getOriginal()).toBe(originalFile); + expect(result.current.canRestore).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts new file mode 100644 index 0000000000..3d735d8495 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/usePreviewSrc.spec.ts @@ -0,0 +1,63 @@ +import { renderHook, act } from "@testing-library/react"; +import { usePreviewSrc } from "../usePreviewSrc"; + +describe("usePreviewSrc", () => { + // jsdom doesn't implement these; define no-op stubs so we can spy on them. + if (!URL.createObjectURL) { + (URL as unknown as { createObjectURL: () => string }).createObjectURL = () => ""; + } + if (!URL.revokeObjectURL) { + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined; + } + const createSpy = jest.spyOn(URL, "createObjectURL"); + const revokeSpy = jest.spyOn(URL, "revokeObjectURL"); + + beforeEach(() => { + let n = 0; + createSpy.mockImplementation(() => `blob:mock-${++n}`); + revokeSpy.mockImplementation(() => undefined); + }); + afterEach(() => jest.clearAllMocks()); + + const file = (): File => new File(["x"], "r.png", { type: "image/png" }); + + test("previewSrc is undefined initially", () => { + const { result } = renderHook(({ uri }) => usePreviewSrc(uri), { initialProps: { uri: "http://x/a.png" } }); + expect(result.current.previewSrc).toBeUndefined(); + }); + + test("showPreview creates a blob URL and exposes it", () => { + const { result } = renderHook(({ uri }) => usePreviewSrc(uri), { initialProps: { uri: "http://x/a.png" } }); + act(() => result.current.showPreview(file())); + expect(result.current.previewSrc).toBe("blob:mock-1"); + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + test("showPreview revokes the prior blob before creating a new one", () => { + const { result } = renderHook(({ uri }) => usePreviewSrc(uri), { initialProps: { uri: "http://x/a.png" } }); + act(() => result.current.showPreview(file())); + act(() => result.current.showPreview(file())); + expect(revokeSpy).toHaveBeenCalledWith("blob:mock-1"); + expect(result.current.previewSrc).toBe("blob:mock-2"); + }); + + test("changing committed uri drops the preview and revokes the blob", () => { + const { result, rerender } = renderHook(({ uri }) => usePreviewSrc(uri), { + initialProps: { uri: "http://x/a.png" } + }); + act(() => result.current.showPreview(file())); + expect(result.current.previewSrc).toBe("blob:mock-1"); + rerender({ uri: "http://x/b.png" }); + expect(revokeSpy).toHaveBeenCalledWith("blob:mock-1"); + expect(result.current.previewSrc).toBeUndefined(); + }); + + test("revokes the blob on unmount", () => { + const { result, unmount } = renderHook(({ uri }) => usePreviewSrc(uri), { + initialProps: { uri: "http://x/a.png" } + }); + act(() => result.current.showPreview(file())); + unmount(); + expect(revokeSpy).toHaveBeenCalledWith("blob:mock-1"); + }); +}); diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts index 0dff232ae2..f899e713ec 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useImageCropperState.ts @@ -10,6 +10,8 @@ interface ImageCropperState { setCommittedCrop: Dispatch>; zoom: number; setZoom: Dispatch>; + grayscale: boolean; + setGrayscale: Dispatch>; imageRef: RefObject; } @@ -17,6 +19,17 @@ export function useImageCropperState(initialZoom: number): ImageCropperState { const [liveCrop, setLiveCrop] = useState(undefined); const [committedCrop, setCommittedCrop] = useState(undefined); const [zoom, setZoom] = useState(initialZoom); + const [grayscale, setGrayscale] = useState(false); const imageRef = useRef(null); - return { liveCrop, setLiveCrop, committedCrop, setCommittedCrop, zoom, setZoom, imageRef }; + return { + liveCrop, + setLiveCrop, + committedCrop, + setCommittedCrop, + zoom, + setZoom, + grayscale, + setGrayscale, + imageRef + }; } diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts new file mode 100644 index 0000000000..7d99926908 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/useOriginalImage.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface OriginalImage { + getOriginal: () => File | undefined; + canRestore: boolean; + markInternalChange: () => void; +} + +// Capture the original image bytes on first load so Reset can restore them +// after auto-apply has overwritten the bound attribute. Eager fetch is the +// accepted cost for robustness against blob: URL revocation. +export function useOriginalImage(uri: string | undefined, name: string | undefined): OriginalImage { + const fileRef = useRef(undefined); + const [canRestore, setCanRestore] = useState(false); + const capturedUri = useRef(undefined); + const internalChange = useRef(false); + + // Stable setter: called by the container before every internal setValue so + // the next uri change is skipped without recapturing our own baked output. + const markInternalChange = useCallback(() => { + internalChange.current = true; + }, []); + + useEffect(() => { + if (!uri || capturedUri.current === uri) { + return; + } + // Our own bake produced this new uri — adopt it, keep the original, skip fetch. + if (internalChange.current) { + capturedUri.current = uri; + internalChange.current = false; + return; + } + capturedUri.current = uri; + fileRef.current = undefined; + setCanRestore(false); + let cancelled = false; + (async () => { + try { + const res = await fetch(uri); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + const blob = await res.blob(); + if (cancelled) { + return; + } + fileRef.current = new File([blob], name ?? "original", { type: blob.type || "image/png" }); + setCanRestore(true); + } catch { + // fetch failed: degrade to no-restore + if (!cancelled) { + setCanRestore(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [uri, name]); + + return { + getOriginal: () => fileRef.current, + canRestore, + markInternalChange + }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts b/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts new file mode 100644 index 0000000000..b0a41fc07b --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/hooks/usePreviewSrc.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface PreviewSrc { + // A local blob: URL to display instead of the bound uri, or undefined to use the bound uri. + previewSrc: string | undefined; + // Show a baked File immediately (before the deferred commit changes the bound uri). + showPreview: (file: File) => void; +} + +// Bridges the gap between an in-memory baked File (e.g. a rotation) and the Mendix +// deferred-commit model: setValue stages the file but the bound uri only changes on +// Save. We mint a blob: URL so the edit is visible right away, then drop it once the +// real commit produces a new uri. +export function usePreviewSrc(committedUri: string | undefined): PreviewSrc { + const [previewSrc, setPreviewSrc] = useState(undefined); + const blobRef = useRef(undefined); + const prevUri = useRef(committedUri); + + const revoke = useCallback(() => { + if (blobRef.current) { + URL.revokeObjectURL(blobRef.current); + blobRef.current = undefined; + } + }, []); + + const showPreview = useCallback( + (file: File) => { + revoke(); + const url = URL.createObjectURL(file); + blobRef.current = url; + setPreviewSrc(url); + }, + [revoke] + ); + + // A new committed uri means the bound value caught up (or was replaced externally): + // discard the local preview and fall back to the bound uri. + if (prevUri.current !== committedUri) { + prevUri.current = committedUri; + if (blobRef.current) { + revoke(); + setPreviewSrc(undefined); + } + } + + useEffect(() => revoke, [revoke]); + + return { previewSrc, showPreview }; +} diff --git a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss index 27d52b9c36..32f4919931 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss +++ b/packages/pluggableWidgets/image-cropper-web/src/ui/ImageCropper.scss @@ -3,7 +3,6 @@ $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; @@ -42,8 +41,27 @@ $image-cropper-icon: url(../assets/crop-icon.svg); background: #fff; } - &__button { - align-self: flex-start; + &__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + } + + &__tool { + min-width: 36px; + padding: 4px 8px; + line-height: 1; + + &.active { + background-color: var(--brand-primary, #264ae5); + color: #fff; + } + } + + &__reset { + margin-left: auto; } &__error, @@ -59,32 +77,40 @@ $image-cropper-icon: url(../assets/crop-icon.svg); &--preview { display: flex; flex-direction: column; + gap: 4px; + align-items: stretch; + } - .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-label { + margin: 0; + font-size: 12px; + color: var(--gray, #555); + } - .widget-image-cropper__icon { - width: 42px; - height: 33px; - background-image: var(--image-cropper-icon, $image-cropper-icon); - background-repeat: no-repeat; - background-size: contain; - } + &__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__label { - margin: 0; - font-size: 11px; - color: var(--gray-light, $image-cropper-gray-light); - } + // Real CropArea rendered for the static-image preview — display only, no interaction. + &__preview-canvas { + pointer-events: none; + } + + &__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__/cropImage.spec.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/__tests__/cropImage.spec.ts index 2d5a2fb578..e5fc2a9897 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,6 +88,28 @@ describe("cropImage", () => { expect(ctx.canvas.height).toBe(40); }); + 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("divides source rect by zoom factor when zoom > 1", async () => { const img = makeImg(1000, 800, 1000, 800); const calls = await captureDrawImageCalls(() => @@ -96,7 +122,8 @@ describe("cropImage", () => { outputSize: "original", cropShape: "rect", viewportWidth: 300, - viewportHeight: 300 + viewportHeight: 300, + grayscale: false }) ); const [, sx, sy, sw, sh] = calls[0]; @@ -117,7 +144,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 +168,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/cropImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts index e5aa835cde..c872f658ee 100644 --- a/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/cropImage.ts @@ -18,6 +18,7 @@ export interface CropImageOptions { cropShape: CropShapeEnum; viewportWidth: number; viewportHeight: number; + grayscale: boolean; originalName?: string; } @@ -32,6 +33,7 @@ export async function cropImage(options: CropImageOptions): Promise { cropShape, viewportWidth, viewportHeight, + grayscale, originalName } = options; @@ -48,12 +50,12 @@ export async function cropImage(options: CropImageOptions): Promise { 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 +64,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/rotateImage.ts b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts new file mode 100644 index 0000000000..6d12b4fcd4 --- /dev/null +++ b/packages/pluggableWidgets/image-cropper-web/src/utils/rotateImage.ts @@ -0,0 +1,61 @@ +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 here too: rotate replaces the staged file, so without this a + // grayscale-then-rotate-then-Save would persist a color image. + 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..9ca354fff7 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 { Big } from "big.js"; +import { CSSProperties } from "react"; export type CropShapeEnum = "rect" | "circle"; @@ -34,6 +34,9 @@ export interface ImageCropperContainerProps { previewWidth: number; previewHeight: number; resizableEnabled: boolean; + enableRotation: boolean; + enableGrayscale: boolean; + showResetButton: boolean; zoomEnabled: boolean; showZoomSlider: boolean; wheelZoomMode: WheelZoomModeEnum; @@ -67,6 +70,9 @@ export interface ImageCropperPreviewProps { previewWidth: number | null; previewHeight: number | null; resizableEnabled: boolean; + enableRotation: boolean; + enableGrayscale: boolean; + showResetButton: boolean; zoomEnabled: boolean; showZoomSlider: boolean; wheelZoomMode: WheelZoomModeEnum; 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; +}