feat: image cropping / resizing T0

This commit is contained in:
fsylvain committed 2026-09-28 07:46:12 +02:00
1 parent cb05b38f36
commit e15f444603
18 files changed
+1869 -46

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
Feature: Crop and resize step in the editor's image uploader
As a verified RenewCMS owner adding an image to an article
I want to crop and resize a JPEG, PNG or WEBP before it uploads
So that I control the framing and file size of images in my articles, without needing an
external editor
Background:
Given ImageUploader inserts a crop-and-resize step between the idle file picker and the
upload, for any JPEG, PNG or WEBP that already passed the browser validation described in
features/editor_image_upload.feature; that feature's idle, dragging-over, rejected,
uploading, inserted and failed states and its POST /v1/article/{articleId}/image contract
are unchanged and out of scope here
And an SVG file skips this step entirely and uploads immediately, as before
And opening the step retitles the modal from "Add an image" to "Crop and resize", with the
description "{filename} · {size}" (e.g. "north-wall.jpg · 3.9 MB")
And the stage shows the chosen image at its natural pixel dimensions with a crop frame that
starts as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px
corner handles, and a mono pixel-size label in a dark pill at the frame's bottom-left
corner, tracking the frame's current size
And the frame is reachable by Tab (tabindex="0"), aria-label "Crop area. Arrow keys move it,
Shift and arrow keys resize it."; dragging the frame moves it, dragging a handle resizes
it, arrow keys nudge it by 1 px, and Shift+arrow keys resize it by 1 px
And above the frame, "Aspect ratio" is a radiogroup of five pills — Original, Free, 16:9,
4:3, 1:1 — next to "Rotate" and "Reset" actions; "Width" is a second radiogroup — Original,
1600 px (default), 1200 px, 800 px
And a mono line below reads "Uploads as {width} × {height} px · about {size}" (KB under
1 MB, MB at or above it), recomputed whenever the ratio, width, frame rect or rotation
changes
And the output width is min(the chosen width preset, the crop frame's own pixel width) —
never upscaled — and the output height keeps the crop frame's own aspect ratio; when the
crop is narrower than the chosen width, an info row reads "The crop is {crop width} px
wide, so it's uploaded at that size. Enlarging it would make it blurry."
And the footer offers "Choose another file" (discards this step and returns to the idle file
picker with no adjustments carried over) and "Upload image" (uploads the cropped, resized
file through the unchanged POST /v1/article/{articleId}/image flow)
And "Rotate" turns the image 90° clockwise each activation, cycling 0°→90°→180°→270°→0°, and
width presets apply to the post-rotation image
And "Reset" returns the ratio to Original, the width to 1600 px, the crop frame to the whole
unrotated image, and the rotation to 0°, without closing the modal
Scenario: A raster file opens the crop-and-resize step instead of uploading immediately
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB,
natural dimensions 4032 × 3024 px
Then no request is sent to POST /v1/article/12/image
And the modal retitles to "Crop and resize" with the description "north-wall.jpg · 3.9 MB"
And the crop frame starts as the whole image, ratio "Original" is selected, and width
"1600 px" is selected
And the mono line reads "Uploads as 1600 × 1200 px · about 420 KB"
And the footer reads "Choose another file" and "Upload image"
Scenario: An SVG skips the crop step and uploads immediately
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "diagram.svg", content type "image/svg+xml", size 40 KB
Then POST /v1/article/12/image is sent immediately with that file
And the crop-and-resize step is never shown
Scenario Outline: Choosing an aspect ratio locks the frame to it
Given the crop-and-resize step is open for "north-wall.jpg" (4032 × 3024 px), ratio
"Original", width "1600 px"
When the user selects ratio "<ratio>"
Then the crop frame becomes <crop_width> × <crop_height> px, centered on the image
And dragging or resizing the frame afterwards keeps that aspect ratio
And the mono line reads "Uploads as <out_width> × <out_height> px · about {size}"
Examples:
| ratio | crop_width | crop_height | out_width | out_height |
| 16:9 | 4032 | 2268 | 1600 | 900 |
| 4:3 | 4032 | 3024 | 1600 | 1200 |
| 1:1 | 3024 | 3024 | 1600 | 1600 |
Scenario: Choosing a narrower width preset recomputes the output without upscaling
Given the crop-and-resize step is open for "north-wall.jpg", ratio "Original", width
"1600 px"
When the user selects width "800 px"
Then the mono line reads "Uploads as 800 × 600 px · about {size}", a smaller estimate than
before
Scenario: A crop narrower than the chosen width is uploaded at its own size, not upscaled
Given the crop-and-resize step is open for "north-wall.jpg", ratio "1:1", width "1600 px"
When the user resizes the crop frame down to 640 × 640 px
Then the mono line reads "Uploads as 640 × 640 px · about {size}"
And an info row reads "The crop is 640 px wide, so it's uploaded at that size. Enlarging it
would make it blurry."
Scenario: Free ratio removes the aspect-ratio constraint
Given the crop-and-resize step is open for "north-wall.jpg", ratio "Original"
When the user selects ratio "Free"
Then the crop frame can be dragged or resized to any width and height independently, no
longer snapping to a fixed ratio
Scenario: Arrow keys move the crop frame and Shift+arrow keys resize it
Given the crop-and-resize step is open for "north-wall.jpg" with the crop frame focused
When the user presses ArrowRight
Then the frame moves 1 px right and the mono line's size is unchanged
When the user presses Shift+ArrowRight
Then the frame's width grows by 1 px and the mono line updates to the new output size
Scenario: Rotate cycles the image in 90° steps and Reset returns to the opened defaults
Given the crop-and-resize step is open for "north-wall.jpg" at ratio "16:9", width "800 px"
When the user activates "Rotate"
Then the image and crop frame rotate 90° clockwise
When the user activates "Reset"
Then ratio returns to "Original", width returns to "1600 px", the frame returns to the
whole, unrotated image, and the mono line reads "Uploads as 1600 × 1200 px · about 420 KB"
Scenario: "Choose another file" returns to the file picker without uploading
Given the crop-and-resize step is open for "north-wall.jpg"
When the user activates "Choose another file"
Then no request is sent to POST /v1/article/12/image
And the idle state is shown again, with the file picker available
Scenario: A newly chosen file opens a fresh crop step, not the previous file's adjustments
Given the user returned to idle via "Choose another file" from a crop step that had ratio
"16:9" and width "800 px"
When the user chooses a new file named "sunset.jpg", content type "image/jpeg", size 2.1 MB,
natural dimensions 3000 × 2000 px
Then the crop-and-resize step reopens with ratio "Original", width "1600 px", and the frame
as the whole new image
Scenario: "Upload image" uploads the cropped, resized file, not the original
Given the crop-and-resize step is open for "north-wall.jpg" with ratio "Original", width
"1600 px" and the mono line "Uploads as 1600 × 1200 px · about 420 KB"
When the user activates "Upload image"
Then POST /v1/article/12/image is sent with a single file under the field name "file" whose
pixel dimensions are 1600 × 1200 and whose size is about 420 KB, not the original
4032 × 3024, 3.9 MB file
And the uploading state shows "Uploading 0.0 of 420 KB"
When the upload reports 180 KB sent of 420 KB
Then the uploading state shows "north-wall.jpg 43%" and "Uploading 180 of 420 KB"
+86
View File
@@ -0,0 +1,86 @@
# QA: Crop and resize step in the editor's image uploader
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the editor_core.qa.md fixture
article (id 12, "How we price a bathroom renovation", is_online false), GET already resolved
200. Reuse editor_image_upload.qa.md's fake-XHR setup for the upload itself. Stub the chosen
file's decoded natural size (e.g. mock `Image`/`createImageBitmap` or whatever the component
uses to read `naturalWidth`/`naturalHeight`) so tests control it deterministically, and stub the
crop/resize step's own output producer (whatever function turns the frame rect + width preset
into a Blob) as a spy, asserting it is called with the frame's rect in natural-image pixels and
the resolved output width/height, and stubbing its returned Blob's size — without asserting how
it renders internally (canvas/OffscreenCanvas is a coder implementation detail).
1. **A raster file opens "Crop and resize" instead of uploading** — choose `north-wall.jpg`
(image/jpeg, 3.9 MB, natural 4032×3024); assert no XHR is created; assert the modal title is
"Crop and resize", description "north-wall.jpg · 3.9 MB", ratio "Original" and width
"1600 px" selected, mono line "Uploads as 1600 × 1200 px · about 420 KB", footer "Choose
another file" / "Upload image".
2. **An SVG bypasses the step** — choose `diagram.svg` (image/svg+xml, 40 KB); assert an XHR to
`/v1/article/12/image` is created immediately and the crop step never renders.
3. **Ratio presets lock the frame, Examples-driven (16:9 / 4:3 / 1:1)** — from the opened step,
select each ratio pill; assert the frame's reported rect and the mono line's output size match
the feature's Examples table.
4. **Width preset recomputes the output** — select width "800 px"; assert the mono line reads
"Uploads as 800 × 600 px" (assert dimensions exactly; assert only that the size figure changed
to a smaller value, not a specific number).
5. **A crop narrower than the width shows the info row and keeps its own size** — resize the
frame (via its resize API/spy) to 640×640 with ratio "1:1", width "1600 px"; assert the mono
line reads "Uploads as 640 × 640 px · about {size}" and the info row reads exactly "The crop
is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
6. **Free ratio removes the constraint** — select ratio "Free"; assert a subsequent resize call
with independent width/height is accepted without being coerced back to a ratio.
7. **Arrow and Shift+Arrow keys move/resize** — focus the frame; fire `ArrowRight`; assert the
frame's x increases by 1 px and the mono line's size is unchanged; fire `Shift+ArrowRight`;
assert the frame's width increases by 1 px and the mono line updates.
8. **Rotate cycles 90° and Reset restores defaults** — activate "Rotate"; assert the reported
rotation is 90°; activate "Reset"; assert ratio "Original", width "1600 px", the frame back to
the whole unrotated image, and the mono line "Uploads as 1600 × 1200 px · about 420 KB".
9. **"Choose another file" discards the step** — activate it; assert no XHR was created and the
idle state renders.
10. **A newly chosen file reopens at defaults** — from idle after "Choose another file" out of a
16:9/800px step, choose `sunset.jpg` (image/jpeg, 2.1 MB, natural 3000×2000); assert the new
crop step opens with ratio "Original", width "1600 px", frame as the whole new image (not the
previous file's ratio/width).
11. **"Upload image" sends the cropped/resized file** — from the default 1600×1200/420 KB step,
activate "Upload image"; assert the XHR is created with a file whose declared size is 420 KB
(not 3.9 MB); assert the uploading state shows "Uploading 0.0 of 420 KB"; fire an
`upload.progress` with `loaded: 180 * 1024, total: 420 * 1024`; assert "north-wall.jpg 43%"
and "Uploading 180 of 420 KB".
## Visual (manual verification via `task visual:check -- editor` — desktop and mobile, light and
dark)
Reference: `docs/design/screens/editor.md` states "Upload: crop and resize", "Upload: crop to a
ratio", "Upload: crop narrower than the width", and the updated "Upload: in progress"; board
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Editor". Add each state's mock
setup to `web/visual/states.ts`, reusing editor_core.qa.md's fixture article (id 12, "How we
price a bathroom renovation", draft) and the sample file "north-wall.jpg · 3.9 MB".
- [ ] Upload: crop and resize — modal "Crop and resize" / "north-wall.jpg · 3.9 MB", ratio
"Original", width "1600 px", cancel "Choose another file", confirm "Upload image".
- [ ] Upload: crop to a ratio — same modal, ratio "16:9" selected, frame locked to that ratio.
- [ ] Upload: crop narrower than the width — ratio "1:1", width "1600 px", frame resized to
640×640, info row "The crop is 640 px wide, so it's uploaded at that size. Enlarging it
would make it blurry."
- [ ] Upload: in progress (updated) — modal "Add an image", uploading "north-wall.jpg 43%" /
"Uploading 180 of 420 KB", cancel "Cancel upload".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor modals.
- [ ] Keyboard/focus: the crop frame is reachable by Tab; its `aria-label` is "Crop area. Arrow
keys move it, Shift and arrow keys resize it."; Arrow/Shift+Arrow move and resize it without
moving focus elsewhere; the aspect-ratio and width radiogroups follow standard roving-tabindex
radiogroup keyboard behavior (arrow keys between pills, one tab stop per group); "Rotate" and
"Reset" are reachable and named by their visible text.
- [ ] Motion: no auto-close or timed transition in this step; frame drag/resize follows pointer
with no easing delay (not part of the 10 % capture budget, spot-checked only).
## Manual verification
- **Real image decoding and resizing** — Vitest stubs `naturalWidth`/`naturalHeight` and the
output producer; confirm real JPEG/PNG/WEBP files of varying orientations and EXIF rotation
crop, rotate and resize correctly against `task dev` with a live server, since jsdom has no
real canvas/image pipeline.
- **Real POST of the resized file** — confirm the actual uploaded bytes decode to the expected
pixel dimensions server-side, complementing `features/upload_article_image.feature`'s
httptest coverage of the handler in isolation.