Skip to content

Commit bc3aacb

Browse files
[WC-3435]: Image Cropper Design and feats (#2280)
2 parents a45156f + 8d13d6b commit bc3aacb

48 files changed

Lines changed: 3330 additions & 491 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
1-
# Image Crop
2-
3-
Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute.
4-
5-
See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs.
1+
Please see [Image Cropper](https://docs.mendix.com/appstore/widgets/image-cropper) in the Mendix documentation for details.
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
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
schema: spec-driven
2+
3+
# Project context (optional)
4+
# This is shown to AI when creating artifacts.
5+
# Add your tech stack, conventions, style guides, domain knowledge, etc.
6+
# Example:
7+
# context: |
8+
# Tech stack: TypeScript, React, Node.js
9+
# We use conventional commits
10+
# Domain: e-commerce platform
11+
12+
# Per-artifact rules (optional)
13+
# Add custom rules for specific artifacts.
14+
# Example:
15+
# rules:
16+
# proposal:
17+
# - Keep proposals under 500 words
18+
# - Always include a "Non-goals" section
19+
# tasks:
20+
# - Break tasks into chunks of max 2 hours
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# image-cropper Specification
2+
3+
## Purpose
4+
5+
The Image Cropper widget lets a Mendix end user crop, rotate, and recolor an image bound to
6+
an image attribute, writing the edited result back to that same attribute. It is a client-side
7+
widget: all image transforms happen on an HTML canvas in the browser, and the edited bytes are
8+
pushed to the Mendix runtime via `EditableValue.setValue`. This spec captures the behavior of
9+
the shipped widget (v1.0.0) as a baseline.
10+
11+
## Requirements
12+
13+
### Requirement: Image source binding and lifecycle states
14+
15+
The widget SHALL bind to a single required image attribute and SHALL render distinct states for
16+
the Mendix value lifecycle. All edits (crop, rotate, grayscale, reset) SHALL write back to that
17+
same attribute via `setValue`.
18+
19+
#### Scenario: Loading state
20+
21+
- **WHEN** the bound image value status is `Loading`
22+
- **THEN** the widget SHALL render a placeholder container marked `aria-busy="true"`
23+
- **AND** SHALL NOT render the crop area or toolbar
24+
25+
#### Scenario: No image available
26+
27+
- **WHEN** the bound image status is not `Available` or has no value
28+
- **THEN** the widget SHALL render an empty state showing the configurable `noImageCaption` (default "No uploaded image to crop")
29+
30+
#### Scenario: Read-only attribute
31+
32+
- **WHEN** the bound image is `readOnly`
33+
- **THEN** editing operations SHALL NOT call `setValue` and the attribute SHALL be left unchanged
34+
35+
### Requirement: Crop shape
36+
37+
The widget SHALL support a rectangular and a circular crop shape, selected by the `cropShape`
38+
property.
39+
40+
#### Scenario: Circle shape masks corners
41+
42+
- **WHEN** `cropShape` is `circle`
43+
- **THEN** the on-screen selection SHALL be rendered as a circle
44+
- **AND** the exported image SHALL be clipped to an ellipse inscribed in the crop rectangle so the corners are transparent (PNG) or filled (JPEG)
45+
46+
#### Scenario: Rectangle shape
47+
48+
- **WHEN** `cropShape` is `rect`
49+
- **THEN** the full crop rectangle SHALL be exported with no corner masking
50+
51+
### Requirement: Aspect ratio
52+
53+
The widget SHALL constrain the crop selection to the ratio chosen in `aspectRatio`, supporting
54+
free-form, preset ratios, and a custom ratio built from `customAspectWidth` / `customAspectHeight`.
55+
56+
#### Scenario: Preset ratio locks proportions
57+
58+
- **WHEN** `aspectRatio` is a preset (`square` = 1:1, `landscape16x9` = 16:9, `landscape4x3` = 4:3, `portrait3x4` = 3:4)
59+
- **THEN** the crop selection SHALL keep that width-to-height proportion while being resized
60+
61+
#### Scenario: Free ratio
62+
63+
- **WHEN** `aspectRatio` is `free`
64+
- **THEN** the crop selection SHALL be resizable to any proportion
65+
66+
#### Scenario: Custom ratio
67+
68+
- **WHEN** `aspectRatio` is `custom` and both `customAspectWidth` and `customAspectHeight` are greater than 0
69+
- **THEN** the crop selection SHALL be locked to `customAspectWidth / customAspectHeight`
70+
- **AND** if either value is not greater than 0, the crop SHALL fall back to free-form
71+
72+
### Requirement: Default crop selection
73+
74+
On image load, the widget SHALL seed a default crop box centered on the image at the resolved
75+
aspect ratio.
76+
77+
#### Scenario: Initial box covers 80% centered
78+
79+
- **WHEN** an image finishes loading
80+
- **THEN** the default selection SHALL cover 80% of the image, centered, at the resolved aspect ratio (falling back to the image's own ratio when free)
81+
82+
### Requirement: Resizable handles
83+
84+
The widget SHALL show or hide the selection's resize handles based on `resizableEnabled`.
85+
86+
#### Scenario: Handles disabled
87+
88+
- **WHEN** `resizableEnabled` is `false`
89+
- **THEN** the user SHALL NOT be able to resize the selection by dragging its corners
90+
91+
### Requirement: Crop persistence (auto-apply)
92+
93+
The widget SHALL persist the edited image to the bound attribute automatically, with no manual
94+
"apply" button. Direct crop-box edits SHALL be written back immediately on pointer release, while
95+
zoom and grayscale changes SHALL be written back on a 400 ms debounce so rapid changes collapse
96+
into a single write. Programmatic changes to the crop box (image load, source change, reset) SHALL
97+
NOT trigger an auto-apply.
98+
99+
#### Scenario: Crop box edit applies immediately
100+
101+
- **WHEN** the user moves or resizes the crop box and releases the pointer
102+
- **THEN** the cropped result SHALL be written back to the bound attribute immediately, cancelling any pending debounced write
103+
104+
#### Scenario: Zoom and grayscale apply on a debounce
105+
106+
- **WHEN** the user changes the zoom level or toggles grayscale
107+
- **THEN** the edited result SHALL be written back after a 400 ms debounce
108+
- **AND** further zoom or grayscale changes within that window SHALL reset the timer so only one write occurs
109+
110+
#### Scenario: Programmatic re-seed does not auto-save
111+
112+
- **WHEN** the crop box is re-seeded programmatically (image load, bound source change, or reset)
113+
- **THEN** the widget SHALL NOT auto-apply a crop, because auto-apply only fires after a real user interaction
114+
115+
### Requirement: Zoom
116+
117+
The widget SHALL let the user zoom the image between `minZoom` and `maxZoom` via a slider and/or
118+
the mouse wheel, gated by `zoomEnabled`, `showZoomSlider`, and `wheelZoomMode`.
119+
120+
#### Scenario: Zoom master switch off
121+
122+
- **WHEN** `zoomEnabled` is `false`
123+
- **THEN** the slider and mouse-wheel zoom SHALL be disabled and the image SHALL stay at 1×
124+
125+
#### Scenario: Slider hidden but wheel active
126+
127+
- **WHEN** `zoomEnabled` is `true` and `showZoomSlider` is `false`
128+
- **THEN** the zoom slider SHALL be hidden while mouse-wheel zoom remains available per `wheelZoomMode`
129+
130+
#### Scenario: Wheel zoom modes
131+
132+
- **WHEN** `wheelZoomMode` is `onWithCtrl`
133+
- **THEN** the wheel SHALL zoom only while Ctrl is held, leaving normal page scroll otherwise
134+
- **AND** when `wheelZoomMode` is `on` the wheel SHALL always zoom, and when `off` the wheel SHALL never zoom
135+
136+
#### Scenario: Zoom anchored on the crop-box center
137+
138+
- **WHEN** the zoom level changes
139+
- **THEN** the zoom SHALL be anchored at the crop box's current center so the framed region stays put on screen
140+
- **AND** the exported pixels SHALL use the same anchor so the saved image matches the on-screen framing
141+
142+
### Requirement: Rotation
143+
144+
When `enableRotation` is true, the widget SHALL show rotate-left and rotate-right buttons that
145+
rotate the image in 90° steps and bake the rotation into the saved image.
146+
147+
#### Scenario: Rotate buttons hidden
148+
149+
- **WHEN** `enableRotation` is `false`
150+
- **THEN** the rotate-left / rotate-right buttons SHALL NOT be shown
151+
152+
#### Scenario: Rotate bakes into the image
153+
154+
- **WHEN** the user clicks rotate-left (−90°) or rotate-right (+90°)
155+
- **THEN** the image SHALL be redrawn on a canvas sized to the rotated dimensions and written back to the attribute
156+
- **AND** a live preview of the rotated (color) pixels SHALL be shown immediately, before the Mendix commit lands
157+
158+
#### Scenario: Rotation preserves grayscale reversibility
159+
160+
- **WHEN** the grayscale toggle is on and the user rotates
161+
- **THEN** the committed file SHALL be baked black-and-white, while the working image kept in memory SHALL remain color so toggling grayscale off stays reversible
162+
163+
### Requirement: Grayscale
164+
165+
When `enableGrayscale` is true, the widget SHALL show a grayscale toggle; enabling it SHALL render
166+
the image gray on screen and convert the saved image to black-and-white.
167+
168+
#### Scenario: Grayscale toggle hidden
169+
170+
- **WHEN** `enableGrayscale` is `false`
171+
- **THEN** the grayscale toggle SHALL NOT be shown
172+
173+
#### Scenario: Grayscale on
174+
175+
- **WHEN** the grayscale toggle is on
176+
- **THEN** the crop area SHALL render the image with a grayscale CSS filter
177+
- **AND** the exported image SHALL be drawn with `grayscale(1)` so the saved bytes are black-and-white
178+
179+
### Requirement: Reset
180+
181+
When `showResetButton` is true, the widget SHALL show a Reset button that restores the original
182+
image and clears zoom, rotation, and grayscale, re-seeding the default crop box.
183+
184+
#### Scenario: Reset button availability
185+
186+
- **WHEN** `showResetButton` is `true` and the original image bytes were captured
187+
- **THEN** the Reset button SHALL be enabled; if the original could not be captured it SHALL be disabled
188+
189+
#### Scenario: Reset restores original state
190+
191+
- **WHEN** the user clicks Reset
192+
- **THEN** zoom SHALL return to `minZoom`, grayscale SHALL turn off, the original image bytes SHALL be written back, and the default 80%-centered crop box SHALL be re-seeded
193+
- **AND** the reset itself SHALL NOT trigger an auto-apply write of a crop
194+
195+
### Requirement: Output encoding
196+
197+
The widget SHALL encode the saved image according to `outputFormat`, `outputQuality`, and
198+
`outputSize`.
199+
200+
#### Scenario: PNG output
201+
202+
- **WHEN** `outputFormat` is `png`
203+
- **THEN** the file SHALL be encoded as `image/png` with a `.png` extension and transparency preserved, ignoring `outputQuality`
204+
205+
#### Scenario: JPEG output
206+
207+
- **WHEN** `outputFormat` is `jpeg`
208+
- **THEN** the file SHALL be encoded as `image/jpeg` with a `.jpg` extension, a white background filled behind the image, and `outputQuality` clamped to 0.0–1.0
209+
210+
#### Scenario: Output resolution
211+
212+
- **WHEN** `outputSize` is `original`
213+
- **THEN** the crop SHALL be exported at the source (natural) resolution of the cropped region
214+
- **AND** when `outputSize` is `viewport` the crop SHALL be exported at the canvas dimensions (`boundaryWidth` × `boundaryHeight`) and the attribute thumbnail size SHALL be set to match
215+
216+
### Requirement: On crop event
217+
218+
When configured, the widget SHALL run the `onCropAction` each time a crop is auto-applied.
219+
220+
#### Scenario: Action runs on apply
221+
222+
- **WHEN** a crop is written back to the attribute and `onCropAction.canExecute` is true
223+
- **THEN** the widget SHALL call `onCropAction.execute()`
224+
225+
### Requirement: Canvas sizing
226+
227+
The widget SHALL scale the on-screen crop area to fit within `boundaryWidth` × `boundaryHeight`
228+
without changing the saved image resolution.
229+
230+
#### Scenario: Image scales to fit the canvas
231+
232+
- **WHEN** the source image is larger than `boundaryWidth` × `boundaryHeight`
233+
- **THEN** the rendered image SHALL scale down to fit, and the canvas SHALL wrap the rendered image so smaller crops produce a smaller canvas with no blank gaps
234+
- **AND** the on-screen scaling SHALL NOT change the exported resolution

packages/pluggableWidgets/image-cropper-web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141
"verify": "rui-verify-package-format"
4242
},
4343
"dependencies": {
44+
"@mendix/widget-plugin-mobx-kit": "workspace:*",
4445
"classnames": "^2.5.1",
46+
"mobx": "6.12.3",
47+
"mobx-react-lite": "4.0.7",
4548
"react-image-crop": "^11.0.10"
4649
},
4750
"devDependencies": {

0 commit comments

Comments
 (0)