feat: image cropping / resizing T0
This commit is contained in:
1 parent
cb05b38f36
commit
e15f444603
18 files changed
+1869
-46
No files matched your search
@@ -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"
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user