Skip to content

[WC-3435]: Image Cropper Design and feats#2280

Open
rahmanunver wants to merge 15 commits into
mainfrom
feat/image-cropper-polish-rotate-bw
Open

[WC-3435]: Image Cropper Design and feats#2280
rahmanunver wants to merge 15 commits into
mainfrom
feat/image-cropper-polish-rotate-bw

Conversation

@rahmanunver

@rahmanunver rahmanunver commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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

  • Interactive crop with configurable shape (rectangle / circle) and aspect ratio (free or custom)
  • Zoom via inline slider and optional mouse-wheel zoom
  • Output configuration: format, quality, and size (original or viewport)

Toolbar

  • Single-row toolbar: flip-left, flip-right, grayscale, zoom slider, reset
  • Flip controls (90° rotation under the hood) rendered as SVG icon buttons
  • Grayscale toggle
  • Reset to the original image
  • Always-on native tooltips on every toolbar button

Editor experience (Studio Pro)

  • Structure-mode and design-mode previews
  • Design-mode preview renders the real crop area against the configured image
  • Studio Pro toolbox category and icons

Layout

  • Widget sizes to its image and renders block-level, so sibling widgets stack below it
  • Editor preview fills the available width

Behavior

  • Grayscale stays reversible after a flip; grayscale is baked into the saved file only when the toggle is on
  • Crop auto-commit is gated on a genuine user drag, so editing one cropper instance does not commit others in a list
  • Crop alignment preserved across flips via a live blob preview
  • Image source guarded through a safeImageUri allowlist

Testing

  • Unit/integration specs for flip, grayscale, zoom, crop commit, grayscale reversibility, multi-instance commit isolation, and editor previews (103 tests)

What should be covered while testing?

  • Crop, zoom, flip, grayscale, and reset on a bound image attribute
  • Grayscale ON -> flip -> grayscale OFF restores color; grayscale ON -> flip -> save with no further crop persists grayscale
  • Two croppers in a list: editing one and saving commits only that instance

@rahmanunver rahmanunver requested a review from a team as a code owner June 19, 2026 07:49
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@rahmanunver rahmanunver force-pushed the feat/image-cropper-polish-rotate-bw branch from 60f8a9b to 2741159 Compare June 25, 2026 08:15
rahmanunver and others added 15 commits June 29, 2026 15:29
… 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.
…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.
@r0b1n r0b1n force-pushed the feat/image-cropper-polish-rotate-bw branch from 2741159 to 1472687 Compare June 29, 2026 15:05
@github-actions

Copy link
Copy Markdown
Contributor

AI Code Review

🔶 Changes requested — one or more medium-severity items must be addressed


What was reviewed

File Change
src/ImageCropper.editorConfig.ts Simplified getPreview to a single-caption row layout; moved describeConfig to its own utility
src/ImageCropper.editorPreview.tsx New design-mode preview with static-image crop rendering
src/ImageCropper.xml Added enableFlip, enableGrayscale, showResetButton properties; studioCategory entries
typings/ImageCropperProps.d.ts Added the three new props above
src/components/ImageCropperContainer.tsx New toolbar controls wiring; reset handler; user-drag gate for commit; flip/grayscale bake logic
src/components/CropArea.tsx onUserInteractStart prop; safeImageUri guard; dynamic resize-to-boundary
src/components/CropToolbar.tsx Flip, grayscale, zoom-slider, and reset buttons with ARIA
src/components/PreviewPane.tsx Live canvas thumbnail of the current crop
src/hooks/useImageCropperState.ts Centralised cropper state
src/hooks/useOriginalImage.ts Captures original image bytes for reset; markInternalChange sentinel
src/hooks/usePreviewSrc.ts Blob-URL bridge for deferred-commit visibility
src/utils/cropImage.ts Canvas-based crop with grayscale, circle mask, and format options
src/utils/rotateImage.ts Canvas-based 90° rotation
src/utils/safeImageUri.ts URI allow-list (https, blob, data:image)
src/utils/describeConfig.ts Shared config summary string
src/__tests__/ImageCropper.editor.spec.tsx New — editor config/preview specs
src/__tests__/ImageCropper.spec.tsx Extended — reset and user-drag-gate tests
src/__tests__/ImageCropperGrayscale.spec.tsx New — grayscale reversibility after flip
src/__tests__/ImageCropperMultiInstance.spec.tsx New — multi-instance commit isolation
src/__tests__/ImageCropperRotation.spec.tsx New — rotation integration specs
src/components/__tests__/CropArea.spec.tsx New
src/components/__tests__/CropToolbar.spec.tsx New
src/components/__tests__/PreviewPane.spec.tsx New
src/hooks/__tests__/useImageCropperState.spec.ts New
src/hooks/__tests__/useOriginalImage.spec.ts New
src/hooks/__tests__/usePreviewSrc.spec.ts New
src/utils/__tests__/*.spec.ts New — cropImage, cropMapping, rotateImage, safeImageUri
jest.config.js SVG transform override to match runtime string imports
CHANGELOG.md Moved entry to [Unreleased]

Skipped (out of scope): dist/, pnpm-lock.yaml, binary assets (PNG icons/tiles)


Findings

🔶 Medium — createRef in a function component produces a stale ref after every re-render

File: src/ImageCropper.editorPreview.tsx line 22
Problem: createRef is called inside StaticCropPreview, so it creates a brand-new RefObject on every render. Because CropArea passes this ref to the underlying <img>, after the component re-renders (e.g. when setCrop fires on image load), imageRef.current is null in the new ref while the DOM element is still attached to the old, discarded one. Any code that reads imageRef.current after the first render gets null.
Fix:

// line 22 — replace createRef with useRef
const imageRef = useRef<HTMLImageElement>(null);

🔶 Medium — Structure-mode tests assert strings that don't match the current getPreview output

File: src/__tests__/ImageCropper.editor.spec.tsx lines 55, 60, 71
Problem: The three structure-mode tests were written for an implementation that emitted separate "Image Cropper" title and "[No attribute selected]" body Text nodes. The current getPreview implementation produces a single Text node whose content is either "[Configure Image Cropper]" (no image) or "[{desc}] Image Cropper" (bound image). Because collectText builds an array of exact content strings and toContain checks for strict element equality, all three assertions fail:

  • toContain("Image cropper") — array holds "[Configure Image Cropper]" (different case, extra brackets)
  • toContain("[No attribute selected]") — array holds "[Configure Image Cropper]"
  • toContain("Circle · 1:1 · JPEG · Viewport") — array holds "[Circle · 1:1 · JPEG · Viewport] Image Cropper"

Fix: Update the assertions to match what getPreview actually produces:

// "shows the widget title" (no image)
expect(texts).toContain("[Configure Image Cropper]");

// "shows placeholder body" — same node, rename or merge with the title test
expect(texts).not.toContain("[No attribute selected]"); // remove or adjust

// "shows config summary when bound"
expect(texts).toContain("[Circle · 1:1 · JPEG · Viewport] Image Cropper");

⚠️ Low — URL.revokeObjectURL called as a side effect during the render phase

File: src/hooks/usePreviewSrc.ts lines 38–44
Problem: The if (prevUri.current !== committedUri) block runs directly in the render body, and when a URI change is detected, it calls revoke() which invokes URL.revokeObjectURL — a browser-API side effect. React requires render functions to be pure; side effects during render can fire twice under Strict Mode or concurrent features, and React's rules explicitly prohibit mutating external state (including revokeable handles) outside an effect or event handler. In practice the blobRef.current guard prevents a double-revoke, but the pattern is fragile.
Note: Move the revocation into a useEffect that watches committedUri, similar to how the fetch cleanup in useOriginalImage is structured.


Positives

  • safeImageUri allowlist (/^(https?:|blob:|data:image\/)/i) correctly closes the javascript: / data:text XSS vector before the URI reaches an <img src> — the right place to guard it.
  • onUserInteractStart / userDraggedRef gate is a clean solution to the multi-instance auto-commit problem: only the cropper the user actually touched commits, and it's thoroughly exercised across three dedicated spec files.
  • The "approach B" dual-rotation (color working image + optional B&W baked commit) for grayscale reversibility is well-reasoned and the spec in ImageCropperGrayscale.spec.tsx faithfully documents both properties of the invariant.
  • useOriginalImage correctly marks internal changes before every setValue call to avoid recapturing our own baked output, and the cancellation flag in the async fetch effect prevents state updates on unmounted components.
  • CropToolbar buttons all carry both aria-label and title (native tooltip), and the grayscale toggle exposes aria-pressed — solid accessibility baseline for an icon-heavy toolbar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants