Skip to content

Commit 2741159

Browse files
committed
feat(image-cropper-web): flip/grayscale toolbar polish and crop-commit fixes
Terminology: - rename user-facing "Rotate" to "Flip" (toolbar, tooltips, enableFlip property) - rename "Black and white"/"B&W" toggle to "Grayscale" Toolbar: - single-row layout: flip-left, flip-right, grayscale, zoom slider, reset - flip buttons use SVG icons; square, icon-only styling - always-on native tooltips on every toolbar button Layout: - size widget to its image and render block-level so sibling widgets stack below - editor design-mode preview fills available width - remove redundant "Image cropper" label above the design-mode preview Behavior: - keep a color working image on flip so grayscale stays reversible; bake grayscale only into the committed file when the toggle is on - gate crop auto-commit on a genuine user drag so editing one cropper does not commit sibling instances in a list Tests: rename specs for flip/grayscale; add grayscale-reversibility and multi-instance commit regression specs.
1 parent 1717e04 commit 2741159

17 files changed

Lines changed: 593 additions & 81 deletions
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js");
2+
const { join } = require("path");
3+
4+
// Override the SVG transform: the base config returns a React component for *.svg imports,
5+
// but we import SVGs as URL strings (declare module "*.svg" { const content: string }).
6+
// Using assetsTransformer returns the filename as a plain string, matching the runtime behaviour
7+
// and avoiding the React "Invalid value for prop `src`" warning in tests.
8+
const assetsTransformer = join(
9+
require.resolve("@mendix/pluggable-widgets-tools/test-config/jest.config.js"),
10+
"../assetsTransformer.js"
11+
);
212

313
module.exports = {
414
...base,
5-
setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")]
15+
transform: {
16+
...base.transform,
17+
"^.+\\.svg$": assetsTransformer
18+
},
19+
setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), join(__dirname, "jest.setup.ts")]
620
};

packages/pluggableWidgets/image-cropper-web/src/ImageCropper.editorPreview.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export function preview(props: ImageCropperPreviewProps): ReactElement {
7272
className={classNames(props.class, "widget-image-cropper", "widget-image-cropper--preview")}
7373
style={parseStyle(props.style)}
7474
>
75-
<p className="widget-image-cropper__preview-label">Image cropper</p>
7675
<div className="widget-image-cropper__preview-box">
7776
{staticImage ? (
7877
<StaticCropPreview imageUrl={staticImage.imageUrl} values={props} />

packages/pluggableWidgets/image-cropper-web/src/ImageCropper.xml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,17 @@
8484
</property>
8585
</propertyGroup>
8686
<propertyGroup caption="Editing">
87-
<property key="enableRotation" type="boolean" defaultValue="true" required="true">
88-
<caption>Enable rotation</caption>
89-
<description>Show rotate-left / rotate-right buttons. Rotation is baked into the saved image.</description>
87+
<property key="enableFlip" type="boolean" defaultValue="true" required="true">
88+
<caption>Enable flip</caption>
89+
<description>Show flip-left / flip-right buttons. The flip is baked into the saved image.</description>
9090
</property>
9191
<property key="enableGrayscale" type="boolean" defaultValue="false" required="true">
92-
<caption>Enable black and white</caption>
93-
<description>Show a grayscale toggle. When on, the saved image is converted to black and white.</description>
92+
<caption>Enable grayscale</caption>
93+
<description>Show a grayscale toggle. When on, the saved image is converted to grayscale (black and white).</description>
9494
</property>
9595
<property key="showResetButton" type="boolean" defaultValue="true" required="true">
9696
<caption>Show reset button</caption>
97-
<description>Show a Reset button that restores the original image and clears zoom, rotation, and crop.</description>
97+
<description>Show a Reset button that restores the original image and clears zoom, flip, and crop.</description>
9898
</property>
9999
</propertyGroup>
100100
<propertyGroup caption="Zoom">

packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.editor.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function makePreviewProps(overrides: Partial<ImageCropperPreviewProps> = {}): Im
2424
previewWidth: null,
2525
previewHeight: null,
2626
resizableEnabled: true,
27-
enableRotation: true,
27+
enableFlip: true,
2828
enableGrayscale: true,
2929
showResetButton: true,
3030
zoomEnabled: true,

packages/pluggableWidgets/image-cropper-web/src/__tests__/ImageCropper.spec.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps
1111
interface CapturedCropArea {
1212
onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void;
1313
onCropComplete: (pixelCrop: PixelCrop) => void;
14+
onUserInteractStart?: () => void;
1415
setZoom: (next: number) => void;
1516
wheelZoomMode: string;
1617
}
@@ -21,12 +22,14 @@ jest.mock("../components/CropArea", () => ({
2122
imageRef: Ref<HTMLImageElement>;
2223
onImageLoad: CapturedCropArea["onImageLoad"];
2324
onCropComplete: CapturedCropArea["onCropComplete"];
25+
onUserInteractStart?: CapturedCropArea["onUserInteractStart"];
2426
setZoom: CapturedCropArea["setZoom"];
2527
wheelZoomMode: string;
2628
}) => {
2729
captured = {
2830
onImageLoad: props.onImageLoad,
2931
onCropComplete: props.onCropComplete,
32+
onUserInteractStart: props.onUserInteractStart,
3033
setZoom: props.setZoom,
3134
wheelZoomMode: props.wheelZoomMode
3235
};
@@ -97,7 +100,7 @@ function makeProps(overrides: Partial<ImageCropperContainerProps> = {}): ImageCr
97100
outputFormat: "png",
98101
outputQuality: new Big(0.92),
99102
outputSize: "original",
100-
enableRotation: true,
103+
enableFlip: true,
101104
enableGrayscale: false,
102105
showResetButton: true,
103106
onCropAction: actionValue(),
@@ -152,6 +155,7 @@ describe("<ImageCropper>", () => {
152155
render(<ImageCropper {...makeProps({ image })} />);
153156
act(() => {
154157
captured.onImageLoad(PERCENT_CROP, PIXEL_CROP);
158+
captured.onUserInteractStart?.();
155159
captured.onCropComplete(PIXEL_CROP);
156160
});
157161
await flushApply();
@@ -164,6 +168,7 @@ describe("<ImageCropper>", () => {
164168
render(<ImageCropper {...makeProps({ image })} />);
165169
act(() => {
166170
captured.onImageLoad(PERCENT_CROP, PIXEL_CROP);
171+
captured.onUserInteractStart?.();
167172
captured.onCropComplete(PIXEL_CROP);
168173
});
169174
await flushApply();
@@ -189,6 +194,7 @@ describe("<ImageCropper>", () => {
189194
render(<ImageCropper {...makeProps({ image, onCropAction: action })} />);
190195
act(() => {
191196
captured.onImageLoad(PERCENT_CROP, PIXEL_CROP);
197+
captured.onUserInteractStart?.();
192198
captured.onCropComplete(PIXEL_CROP);
193199
});
194200
await flushApply();
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { act, fireEvent, render, screen } from "@testing-library/react";
2+
import { Big } from "big.js";
3+
import { ValueStatus } from "mendix";
4+
import { Ref } from "react";
5+
import type { Crop, PixelCrop } from "react-image-crop";
6+
import { actionValue } from "@mendix/widget-plugin-test-utils";
7+
import type { ImageCropperContainerProps } from "../../typings/ImageCropperProps";
8+
9+
// Integration test: proves grayscale reversibility after flip.
10+
11+
interface CapturedCropArea {
12+
onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void;
13+
onCropComplete: (pixelCrop: PixelCrop) => void;
14+
}
15+
let captured: CapturedCropArea;
16+
17+
jest.mock("../components/CropArea", () => ({
18+
CropArea: (props: {
19+
imageRef: Ref<HTMLImageElement>;
20+
onImageLoad: CapturedCropArea["onImageLoad"];
21+
onCropComplete: CapturedCropArea["onCropComplete"];
22+
}) => {
23+
captured = { onImageLoad: props.onImageLoad, onCropComplete: props.onCropComplete };
24+
return (
25+
<img
26+
alt=""
27+
ref={node => {
28+
if (node) {
29+
Object.defineProperty(node, "naturalWidth", { value: 400, configurable: true });
30+
Object.defineProperty(node, "naturalHeight", { value: 300, configurable: true });
31+
Object.defineProperty(node, "width", { value: 400, configurable: true });
32+
Object.defineProperty(node, "height", { value: 300, configurable: true });
33+
}
34+
if (typeof props.imageRef === "function") {
35+
props.imageRef(node);
36+
} else if (props.imageRef) {
37+
(props.imageRef as { current: HTMLImageElement | null }).current = node;
38+
}
39+
}}
40+
/>
41+
);
42+
}
43+
}));
44+
45+
interface CapturedRotateOptions {
46+
rotation: number;
47+
outputFormat: string;
48+
grayscale: boolean;
49+
}
50+
const rotateImageOptions: CapturedRotateOptions[] = [];
51+
jest.mock("../utils/rotateImage", () => ({
52+
rotateImage: jest.fn((options: CapturedRotateOptions) => {
53+
rotateImageOptions.push(options);
54+
return Promise.resolve(new File(["x"], "rotate.png", { type: "image/png" }));
55+
})
56+
}));
57+
58+
interface CapturedCropOptions {
59+
grayscale: boolean;
60+
}
61+
const cropImageOptions: CapturedCropOptions[] = [];
62+
jest.mock("../utils/cropImage", () => ({
63+
CropError: class CropError extends Error {},
64+
cropImage: jest.fn((options: CapturedCropOptions) => {
65+
cropImageOptions.push(options);
66+
return Promise.resolve(new File(["x"], "crop.png", { type: "image/png" }));
67+
})
68+
}));
69+
70+
import { ImageCropper } from "../ImageCropper";
71+
72+
type ImageProp = ImageCropperContainerProps["image"];
73+
type WebImage = NonNullable<ImageProp["value"]>;
74+
75+
const PIXEL_CROP: PixelCrop = { unit: "px", x: 10, y: 10, width: 100, height: 100 };
76+
const PERCENT_CROP: Crop = { unit: "%", x: 5, y: 5, width: 50, height: 50 };
77+
78+
function makeImageProp(): ImageProp {
79+
return {
80+
status: ValueStatus.Available,
81+
value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage,
82+
readOnly: false,
83+
validation: undefined,
84+
setValidator: jest.fn(),
85+
setValue: jest.fn(),
86+
setThumbnailSize: jest.fn()
87+
} as ImageProp;
88+
}
89+
90+
function makeProps(overrides: Partial<ImageCropperContainerProps> = {}): ImageCropperContainerProps {
91+
return {
92+
name: "imageCrop",
93+
class: "",
94+
style: undefined,
95+
tabIndex: 0,
96+
image: makeImageProp(),
97+
cropShape: "rect",
98+
aspectRatio: "free",
99+
customAspectWidth: 1,
100+
customAspectHeight: 1,
101+
boundaryWidth: 300,
102+
boundaryHeight: 300,
103+
resizableEnabled: true,
104+
enableFlip: true,
105+
enableGrayscale: true,
106+
showResetButton: true,
107+
zoomEnabled: true,
108+
showZoomSlider: true,
109+
wheelZoomMode: "onWithCtrl",
110+
minZoom: new Big(1),
111+
maxZoom: new Big(4),
112+
showPreview: false,
113+
previewWidth: 100,
114+
previewHeight: 100,
115+
outputFormat: "png",
116+
outputQuality: new Big(0.92),
117+
outputSize: "original",
118+
onCropAction: actionValue(),
119+
...overrides
120+
};
121+
}
122+
123+
describe("<ImageCropper> grayscale reversibility after flip", () => {
124+
beforeEach(() => {
125+
jest.useFakeTimers();
126+
rotateImageOptions.length = 0;
127+
cropImageOptions.length = 0;
128+
global.fetch = jest.fn().mockRejectedValue(new Error("no-net")) as jest.Mock;
129+
// jsdom lacks blob URL APIs used by the live-preview hook.
130+
(URL as unknown as { createObjectURL: () => string }).createObjectURL = () => "blob:test";
131+
(URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = () => undefined;
132+
});
133+
afterEach(() => {
134+
jest.runOnlyPendingTimers();
135+
jest.useRealTimers();
136+
jest.clearAllMocks();
137+
});
138+
139+
test("flip with grayscale ON produces a COLOR working image (grayscale:false) so toggling off is reversible", async () => {
140+
render(<ImageCropper {...makeProps()} />);
141+
act(() => {
142+
captured.onImageLoad(PERCENT_CROP, PIXEL_CROP);
143+
});
144+
act(() => {
145+
fireEvent.click(screen.getByLabelText("Grayscale"));
146+
}); // ON
147+
rotateImageOptions.length = 0;
148+
await act(async () => {
149+
fireEvent.click(screen.getByLabelText("Flip right"));
150+
await Promise.resolve();
151+
await Promise.resolve();
152+
});
153+
// The WORKING image (the one shown + reloaded into imageRef) must be COLOR.
154+
// With approach B, handleFlip calls rotateImage twice: once color (working),
155+
// once baked (commit). The color call is the one whose result feeds the preview.
156+
const colorWorkingCall = rotateImageOptions.some(o => o.grayscale === false);
157+
expect(colorWorkingCall).toBe(true);
158+
});
159+
160+
test("flip with grayscale ON still bakes a B&W file for the committed setValue", async () => {
161+
const image = makeImageProp();
162+
render(<ImageCropper {...makeProps({ image })} />);
163+
act(() => {
164+
captured.onImageLoad(PERCENT_CROP, PIXEL_CROP);
165+
});
166+
act(() => {
167+
fireEvent.click(screen.getByLabelText("Grayscale"));
168+
}); // ON
169+
rotateImageOptions.length = 0;
170+
await act(async () => {
171+
fireEvent.click(screen.getByLabelText("Flip right"));
172+
await Promise.resolve();
173+
await Promise.resolve();
174+
});
175+
// committed file must be the baked one
176+
expect(rotateImageOptions.some(o => o.grayscale === true)).toBe(true);
177+
expect(image.setValue).toHaveBeenCalledWith(expect.any(File));
178+
});
179+
180+
test("flip with grayscale OFF produces only a color file (no baked B&W)", async () => {
181+
render(<ImageCropper {...makeProps()} />);
182+
act(() => {
183+
captured.onImageLoad(PERCENT_CROP, PIXEL_CROP);
184+
});
185+
rotateImageOptions.length = 0;
186+
await act(async () => {
187+
fireEvent.click(screen.getByLabelText("Flip right"));
188+
await Promise.resolve();
189+
await Promise.resolve();
190+
});
191+
expect(rotateImageOptions.every(o => o.grayscale === false)).toBe(true);
192+
});
193+
});

0 commit comments

Comments
 (0)