[WC-3435]: Image Cropper Design and feats#2280
Merged
Merged
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
60f8a9b to
2741159
Compare
… fix crop alignment Replace CSS-transform rotation with a pixel re-bake so the crop selection maps to the visible image, and add a blob-URL live preview so the rotation is visible before the deferred Mendix commit (Save). Reset now restores the true first-load original via an internal-change gate in useOriginalImage.
…used rotation state
…esign mode Simplify structure mode to a title row plus a single status/config row ([No attribute selected] vs config summary), dropping the icon and large image render. Rewrite design mode into a three-state preview driven by the bound image: static images render the real CropArea (non-interactive) with a config caption, while dynamic/unbound images show a placeholder glyph with [No image selected yet]. Extract shared describeConfig/aspectLabel into utils, add the cropper placeholder asset, declare the png module for TypeScript, and cover both editor surfaces with new specs.
…t 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.
2741159 to
1472687
Compare
This comment has been minimized.
This comment has been minimized.
Remove keepSelection and add MIN_CROP_PX min width/height so dragging on empty canvas starts a fresh selection instead of nudging the old one. A bare click (mousedown+mouseup, no drag) yields a ~0-size crop from react-image-crop that minWidth/minHeight does not catch, which would wipe the box. Guard it with isStrayCrop: crops below MIN_DRAW_FRACTION of the displayed image are ignored so the existing selection survives.
Reset previously cleared the selection, leaving no box. Restore the original bytes and re-seed the 80%-centered default via the extracted buildInitialCrop (now in utils/initialCrop). Two paths: when restoring changed the uri, CropArea's onLoad re-seeds against the correct dimensions; when nothing was edited the uri is unchanged and onLoad won't refire, so the container re-seeds directly from imageRef. Also mirror handleFlip and drive the live preview with the original bytes, so a stale rotated/flipped blob stops rendering after Reset.
Zoom now anchors on a fixed point (the crop-box center, captured when the zoom value changes and frozen while the box moves/draws), so scaling keeps that point on screen instead of drifting. The container owns the anchor and passes it to both CropArea's transformOrigin and the export math. Centralize the source-rect mapping into computeSourceRect, shared by cropImage and PreviewPane, so exported pixels match the on-screen framing. This also fixes a pre-existing off-center bug: the old pixelCrop.x / z math assumed a top-left origin while the CSS used center, giving the wrong region for any off-center crop. The read window is clamped into the image to guard zoom-out (z < 1) from reading off-canvas.
This comment has been minimized.
This comment has been minimized.
The buttons rotate the image by ±90° (via rotateImage), so "flip" was the wrong term. Rename it everywhere: the enableFlip XML property key becomes enableRotation, the rotate-left/rotate-right icon assets, CropToolbar props and aria-labels/titles, the container's handleRotate handler, and all affected tests, comments, and generated typings. Also polish the toolbar: render the "Zoom" label at regular weight (Atlas styles labels bold), and rename the Reset button caption to "Enable reset". The showResetButton property key is unchanged to avoid breaking existing app bindings.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…t 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.
Remove keepSelection and add MIN_CROP_PX min width/height so dragging on empty canvas starts a fresh selection instead of nudging the old one. A bare click (mousedown+mouseup, no drag) yields a ~0-size crop from react-image-crop that minWidth/minHeight does not catch, which would wipe the box. Guard it with isStrayCrop: crops below MIN_DRAW_FRACTION of the displayed image are ignored so the existing selection survives.
Reset previously cleared the selection, leaving no box. Restore the original bytes and re-seed the 80%-centered default via the extracted buildInitialCrop (now in utils/initialCrop). Two paths: when restoring changed the uri, CropArea's onLoad re-seeds against the correct dimensions; when nothing was edited the uri is unchanged and onLoad won't refire, so the container re-seeds directly from imageRef. Also mirror handleFlip and drive the live preview with the original bytes, so a stale rotated/flipped blob stops rendering after Reset.
Zoom now anchors on a fixed point (the crop-box center, captured when the zoom value changes and frozen while the box moves/draws), so scaling keeps that point on screen instead of drifting. The container owns the anchor and passes it to both CropArea's transformOrigin and the export math. Centralize the source-rect mapping into computeSourceRect, shared by cropImage and PreviewPane, so exported pixels match the on-screen framing. This also fixes a pre-existing off-center bug: the old pixelCrop.x / z math assumed a top-left origin while the CSS used center, giving the wrong region for any off-center crop. The read window is clamped into the image to guard zoom-out (z < 1) from reading off-canvas.
The buttons rotate the image by ±90° (via rotateImage), so "flip" was the wrong term. Rename it everywhere: the enableFlip XML property key becomes enableRotation, the rotate-left/rotate-right icon assets, CropToolbar props and aria-labels/titles, the container's handleRotate handler, and all affected tests, comments, and generated typings. Also polish the toolbar: render the "Zoom" label at regular weight (Atlas styles labels bold), and rename the Reset button caption to "Enable reset". The showResetButton property key is unchanged to avoid breaking existing app bindings.
Remove the showPreview / previewWidth / previewHeight properties and the PreviewPane component that rendered a live crop thumbnail next to the canvas. Drops the XML property group, editor hide-logic, SCSS rule, spec requirement, and the related test mock props. The usePreviewSrc hook (rotate/reset live feedback) and the Studio Pro editor preview machinery are unrelated and left intact.
…ar labels Add an Accessibility property group with 8 translatable textTemplate props (3 captions, 5 aria labels) covering the rotate, grayscale, reset, and zoom toolbar controls. Labels ship en_US/nl_NL defaults and are resolved to plain strings with English fallbacks before reaching the presentational components.
Replace the plain red 'No image' text with an informational placeholder: an inline info-circle icon plus 'No uploaded image to crop', laid out on a single row and colored with Atlas's semantic --brand-info token instead of the error red. The icon inherits the text color via currentColor.
…ptions Add a noImageCaption textTemplate (default "No uploaded image to crop") so the empty-state message shown when no image is bound can be edited and translated. Drop the redundant "aria label" wording from the grayscale/reset/zoom caption titles — the Aria labels group already makes their purpose clear.
…bx-kit Dependencies for the MobX crop store: mobx 6.12.3 and mobx-react-lite 4.0.7 (pinned to match the rest of the monorepo) plus the shared widget-plugin-mobx-kit gate/setup utilities.
Centralize crop-domain working state (live/committed crop, zoom, grayscale, zoom anchor) and its operations in a MobX store. Store methods read observable state directly, so the debounced auto-apply no longer needs a stable-identity callback reading state-mirroring refs. The store owns the debounce + armed/user-interacted apply gate (previously useAutoApplyCrop) and exposes crop operations as actions: setZoom, commitCrop, toggleGrayscale, rotate, reset, initFromImageLoad, onImageChanged. aspect is a computed derived from gated props. Imperative React-owned dependencies (live img element, blob-URL preview, original-image capture, internal-change flag) are injected via setDeps and not owned by the store. Preview generation stays an async action, not a computed, because cropImage/rotateImage are async canvas operations with side effects. Add headless unit tests covering the commit gate, debounce coalescing, no-stale-reads, rotate/reset/grayscale flows, inbound image sync, and read-only/no-crop guards.
Wire ImageCropperContainer to the MobX store via the mobx-kit gate pattern (GateProvider + useConst + useSetup) and wrap it in observer. Crop operations become plain store method calls. Remove the five state-mirroring refs (committedCropRef, zoomRef, grayscaleRef, liveCropRef, zoomAnchorRef) and the two callback mirrors (markInternalRef, showPreviewRef); the store reads its own observable state, so stable-identity closures no longer need them. Config and the controlled image value are read live through the gate; the bound uri is synced inbound via a single effect calling store.onImageChanged. usePreviewSrc, useOriginalImage and the DOM image ref stay in React and are injected into the store through setDeps. Delete useAutoApplyCrop and useImageCropperState, now subsumed by the store.
Move the SetStateAction resolution out of the container and into
ImageCropperStore.setZoom, which now accepts a value or updater and
resolves the functional form against its own zoom. The container drops
to setZoom={next => store.setZoom(next)}, making the store the single
owner of current zoom. The functional-updater contract stays on the
CropArea/ZoomContainer props (the wheel-zoom hook drives setZoom(prev
=> …) and the editor preview passes a no-op). Behavior unchanged.
Also sync the image-cropper openspec: the "No image available"
scenario now reflects the editable noImageCaption empty-state text.
7c9e435 to
0416702
Compare
…ix/web-widgets into feat/image-cropper-polish-rotate-bw
leonardomendix
approved these changes
Jul 14, 2026
r0b1n
approved these changes
Jul 14, 2026
auto-merge was automatically disabled
July 14, 2026 14:28
Rebase failed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull request type
New feature (non-breaking change which adds functionality)
Description
Initial development of the Image Cropper widget. This is a pre-release widget; the items below describe its current capabilities, not changes to a shipped version.
Cropping
Toolbar
Editor experience (Studio Pro)
Layout
Behavior
safeImageUriallowlistTesting
What should be covered while testing?