Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/pluggableWidgets/image-cropper-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebImage>` attribute with rectangular or circular viewport, optional zoom (slider + wheel), live preview pane, and PNG/JPEG output. Replaces the legacy ImageCrop widget.
- Initial release.
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof ImageCropperPreviewProps> = [];
Expand All @@ -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
}
]
}
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,82 @@
import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
import classNames from "classnames";

Check warning on line 2 in packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`classnames` import should occur before import of `@mendix/widget-plugin-platform/preview/parse-style`
import { ReactElement } from "react";
import { ReactElement, createRef, useState } from "react";

Check warning on line 3 in packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`react` import should occur before import of `@mendix/widget-plugin-platform/preview/parse-style`
import { type Crop } from "react-image-crop";

Check warning on line 4 in packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`react-image-crop` import should occur before import of `@mendix/widget-plugin-platform/preview/parse-style`
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";

Check warning on line 9 in packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./assets/cropper-placeholder.png` import should occur before import of `./components/CropArea`

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<Crop | undefined>(undefined);
const imageRef = createRef<HTMLImageElement>();

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 (
<div className="widget-image-cropper__preview-canvas">
<CropArea
src={imageUrl}
crop={crop}
onCropChange={setCrop}
onCropComplete={() => 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}
/>
</div>
);
}

export function preview(props: ImageCropperPreviewProps): ReactElement {
const staticImage = props.image?.type === "static" ? props.image : undefined;

return (
<div className={classNames(props.class, "widget-image-cropper", "widget-image-cropper--preview")}>
<div className="widget-image-cropper__dropzone">
<div className="widget-image-cropper__icon" />
<p className="widget-image-cropper__label">Image Cropper</p>
<div
className={classNames(props.class, "widget-image-cropper", "widget-image-cropper--preview")}
style={parseStyle(props.style)}
>
<p className="widget-image-cropper__preview-label">Image cropper</p>
<div className="widget-image-cropper__preview-box">
{staticImage ? (
<StaticCropPreview imageUrl={staticImage.imageUrl} values={props} />
) : (
<img className="widget-image-cropper__preview-glyph" src={CropperPlaceholderIcon} alt="" />
)}
</div>
<p className="widget-image-cropper__preview-caption">
{staticImage ? describeConfig(props) : "[No image selected yet]"}
</p>
</div>
);
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<widget id="com.mendix.widget.web.imagecropper.ImageCropper" pluginWidget="true" needsEntityContext="true" offlineCapable="false" supportedPlatform="Web" xmlns="http://www.mendix.com/widget/1.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mendix.com/widget/1.0/ ../node_modules/mendix/custom_widget.xsd">
<name>Image Cropper</name>
<description>Crop an image attribute</description>
<studioProCategory>Images, videos &amp; files</studioProCategory>
<studioCategory>Images, Videos &amp; Files</studioCategory>
<helpUrl>https://docs.mendix.com/appstore/widgets/image-cropper</helpUrl>
<properties>
<propertyGroup caption="General">
Expand Down Expand Up @@ -81,6 +83,20 @@
<description>Let the user resize the selection by dragging its corners.</description>
</property>
</propertyGroup>
<propertyGroup caption="Editing">
<property key="enableRotation" type="boolean" defaultValue="true" required="true">
<caption>Enable rotation</caption>
<description>Show rotate-left / rotate-right buttons. Rotation is baked into the saved image.</description>
</property>
<property key="enableGrayscale" type="boolean" defaultValue="false" required="true">
<caption>Enable black and white</caption>
<description>Show a grayscale toggle. When on, the saved image is converted to black and white.</description>
</property>
<property key="showResetButton" type="boolean" defaultValue="true" required="true">
<caption>Show reset button</caption>
<description>Show a Reset button that restores the original image and clears zoom, rotation, and crop.</description>
</property>
</propertyGroup>
<propertyGroup caption="Zoom">
<property key="zoomEnabled" type="boolean" defaultValue="true" required="true">
<caption>Enable zoom</caption>
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
Loading
Loading