|
| 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 |
0 commit comments