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
@@ -28,7 +28,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [Home](screens/home.md) | `/home` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Editor](screens/editor.md) | `/article/:id/edit` | 19 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ Responsive Below lg , the details panel becomes a right slideover, opened with i
|
||||
|
||||
### ImageUploader
|
||||
|
||||
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes.
|
||||
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. A JPEG, PNG or WEBP then goes through a crop-and-resize step before it uploads; an SVG skips straight to uploading. After a successful upload the image goes in at the cursor and the modal closes.
|
||||
|
||||
Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB
|
||||
|
||||
@@ -262,9 +262,21 @@ Drop to upload
|
||||
|
||||
dragging over
|
||||
|
||||
north-wall.jpg 62%
|
||||
Crop and resize north-wall.jpg · 3.9 MB
|
||||
|
||||
Uploading 2.4 of 3.9 MB
|
||||
crop, default: whole image selected, width "1600 px"
|
||||
|
||||
Crop and resize north-wall.jpg · 3.9 MB
|
||||
|
||||
crop, ratio locked to 16:9
|
||||
|
||||
Crop and resize north-wall.jpg · 3.9 MB
|
||||
|
||||
crop, crop narrower than the chosen width
|
||||
|
||||
north-wall.jpg 43%
|
||||
|
||||
Uploading 180 of 420 KB
|
||||
|
||||
uploading
|
||||
|
||||
@@ -282,6 +294,10 @@ server error
|
||||
|
||||
Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') .
|
||||
|
||||
Crop and resize A JPEG, PNG or WEBP opens on a 640px-wide modal (`md.crop`) between the file picker and the upload, replacing the "Add an image" title with "Crop and resize" and the filename/size as the description. The stage shows the image with the crop frame starting as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px corner handles, and a pixel-size label (mono, bottom-left, dark pill) that tracks the frame's current size. The frame is `tabindex="0"`, `aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."`; dragging moves it, dragging a handle resizes it, arrow keys nudge it by 1 px and Shift+arrow keys resize it.
|
||||
|
||||
Aspect ratio is a `radiogroup` of five pills — Original, Free, 16:9, 4:3, 1:1 — plus Rotate and Reset actions above it. Width is a second `radiogroup` — Original, 1600 px (default), 1200 px, 800 px. A mono line below reads "Uploads as {width} × {height} px · about {size}", where the estimated size is shown in KB under 1 MB and in MB above it. When the chosen width is larger than the crop's own width, the output stays at the crop's size (never upscaled) and an info row explains why: "The crop is {crop width} px wide, so it's uploaded at that size. Enlarging it would make it blurry." The footer becomes "Choose another file" (back to the file picker, `data-act="upload-pick"` from `idle`) and "Upload image" (starts the upload with the cropped, resized file). SVGs never see this step: they upload as soon as they're chosen. The "in progress" state afterwards reports the resized file's size, e.g. "Uploading 180 of 420 KB".
|
||||
|
||||
Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection.
|
||||
|
||||
Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles.
|
||||
|
||||
@@ -83,13 +83,50 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
|
||||
- Editor: status "draft", save "dirty"
|
||||
- Mobile: leading arrow-left button, trailing ellipsis
|
||||
|
||||
### Upload: crop and resize
|
||||
|
||||
**Trigger:** A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.
|
||||
|
||||
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
|
||||
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
|
||||
- crop: ratio "Original", width "1600 px"
|
||||
- cancel "Choose another file"
|
||||
- confirm "Upload image"
|
||||
- Editor: status "draft", save "dirty"
|
||||
- Mobile: leading arrow-left button, trailing ellipsis
|
||||
|
||||
### Upload: crop to a ratio
|
||||
|
||||
**Trigger:** 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it.
|
||||
|
||||
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
|
||||
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
|
||||
- crop: ratio "16:9", width "1600 px"
|
||||
- cancel "Choose another file"
|
||||
- confirm "Upload image"
|
||||
- Editor: status "draft", save "dirty"
|
||||
- Mobile: leading arrow-left button, trailing ellipsis
|
||||
|
||||
### Upload: crop narrower than the width
|
||||
|
||||
**Trigger:** The crop is smaller than the chosen width, so it keeps its own size.
|
||||
|
||||
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
|
||||
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
|
||||
- crop: ratio "1:1", width "1600 px"
|
||||
- note: "The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry."
|
||||
- cancel "Choose another file"
|
||||
- confirm "Upload image"
|
||||
- Editor: status "draft", save "dirty"
|
||||
- Mobile: leading arrow-left button, trailing ellipsis
|
||||
|
||||
### Upload: in progress
|
||||
|
||||
**Trigger:** A valid file is uploading. Closing the dialog cancels the upload.
|
||||
**Trigger:** Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.
|
||||
|
||||
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
|
||||
- Modal: **Add an image**
|
||||
- upload state: state "uploading"
|
||||
- upload state: state "uploading" ("Uploading 180 of 420 KB")
|
||||
- cancel "Cancel upload"
|
||||
- Editor: status "draft", save "dirty"
|
||||
- Mobile: leading arrow-left button, trailing ellipsis
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<span data-act="preview" style="display:flex;align-items:center;gap:8px;white-space:nowrap;flex-shrink:0;color:var(--ui-text-toned)"><span style="width:32px;height:18px;padding:2px;display:flex;justify-content:{{ pvJust }};background:{{ pvBg }}"><span style="width:14px;height:14px;background:var(--ui-bg)"></span></span>Preview</span>
|
||||
<span data-act="save" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;opacity:{{ saveOp }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
|
||||
<span data-act="publish" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pub.icon }}.svg') center / contain no-repeat"></span>{{ pub.t }}</span>
|
||||
<span data-act="overflow" style="width:32px;height:32px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
|
||||
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete</span>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
</div>
|
||||
@@ -214,6 +214,41 @@
|
||||
<sc-if value="{{ s.modal.upload }}">
|
||||
<div data-act="upload-pick" style="height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;border:{{ up.bd }};background:{{ up.bg }};color:{{ up.fg }}"><span aria-hidden="true" style="display:inline-block;width:24px;height:24px;background-color:{{ up.ic }};-webkit-mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ up.icon }}.svg') center / contain no-repeat"></span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ up.title }}</span><sc-if value="{{ up.progress }}"><div style="width:80%;height:4px;background:var(--ui-bg-accented)"><div style="width:{{ up.progress }};height:4px;background:var(--ui-primary)"></div></div></sc-if><span style="color:var(--ui-text-toned);font-size:12px;max-width:300px">{{ up.text }}</span></div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ s.modal.crop }}">
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="height:{{ cr.stageH }};padding:16px;background:var(--ui-bg-elevated);display:flex;align-items:center;justify-content:center">
|
||||
<div role="img" aria-label="north-wall.jpg, 4032 × 3024 px" style="position:relative;height:100%;max-width:100%;aspect-ratio:4/3;overflow:hidden;background:#d8d0c3">
|
||||
<div style="position:absolute;left:0;right:0;bottom:0;height:32%;background:#8f8373"></div>
|
||||
<div style="position:absolute;left:56%;top:14%;width:22%;height:42%;background:#f1ece3;border:6px solid #c4b9a8"></div>
|
||||
<div tabindex="0" aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it." style="position:absolute;left:{{ cr.l }};top:{{ cr.t }};width:{{ cr.w }};height:{{ cr.h }};box-shadow:0 0 0 2000px rgb(10 10 10 / 0.55);outline:1px solid #ffffff;cursor:move">
|
||||
<div style="position:absolute;left:33.333%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
|
||||
<div style="position:absolute;left:66.666%;top:0;bottom:0;width:1px;background:rgb(255 255 255 / 0.45)"></div>
|
||||
<div style="position:absolute;top:33.333%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
|
||||
<div style="position:absolute;top:66.666%;left:0;right:0;height:1px;background:rgb(255 255 255 / 0.45)"></div>
|
||||
<div style="position:absolute;left:-6px;top:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
|
||||
<div style="position:absolute;right:-6px;top:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
|
||||
<div style="position:absolute;left:-6px;bottom:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
|
||||
<div style="position:absolute;right:-6px;bottom:-6px;width:12px;height:12px;background:#ffffff;border:1px solid #111111"></div>
|
||||
<span style="position:absolute;left:8px;bottom:8px;padding:1px 6px;background:rgb(17 17 17 / 0.8);color:#ffffff;font:400 11px/18px 'IBM Plex Mono',monospace;white-space:nowrap">{{ cr.dims }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">Aspect ratio</span><span data-act="crop-rotate" style="height:{{ btnH }};padding:0 8px;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-ccw-square.svg') center / contain no-repeat"></span>Rotate</span><span data-act="crop-reset" style="height:{{ btnH }};padding:0 8px;display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/undo-2.svg') center / contain no-repeat"></span>Reset</span></div>
|
||||
<div role="radiogroup" aria-label="Aspect ratio" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
|
||||
<sc-for list="{{ cr.ratios }}" as="p" hint-placeholder-count="5"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-weight:500;color:var(--ui-text-highlighted)">Width</span>
|
||||
<div role="radiogroup" aria-label="Width" style="display:flex;border:1px solid var(--ui-border-accented);width:{{ cr.groupW }}">
|
||||
<sc-for list="{{ cr.sizes }}" as="p" hint-placeholder-count="4"><span role="radio" aria-checked="{{ p.on }}" data-act="{{ p.act }}" style="flex:{{ cr.pillFlex }};height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;font-weight:500;white-space:nowrap;background:{{ p.bg }};color:{{ p.fg }}">{{ p.t }}</span></sc-for>
|
||||
</div>
|
||||
<span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ cr.out }}</span>
|
||||
</div>
|
||||
<sc-if value="{{ cr.note }}"><div role="status" style="display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft)"><span style="display:flex;padding-top:2px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-toned);text-wrap:pretty">{{ cr.note }}</span></div></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="padding:20px 24px;display:flex;flex-direction:{{ mBtnDir }};justify-content:flex-end;gap:8px">
|
||||
<sc-if value="{{ s.modal.cancel }}"><span data-act="modal-cancel" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:{{ mCancelFg }};font-weight:500">{{ s.modal.cancel }}</span></sc-if>
|
||||
@@ -264,12 +299,32 @@ class Component extends DCLogic {
|
||||
const mFields = (md.fields || []).map(f => { const has = f.value; return { ...f, shown: has || f.ph || '', fg: md.loading ? 'var(--ui-text-dimmed)' : has ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', bd: f.err ? 'var(--ui-error)' : f.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)', sh: f.err ? 'inset 0 0 0 1px var(--ui-error)' : f.focus ? 'inset 0 0 0 1px var(--ui-primary)' : 'none', bg: md.loading ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)', h: f.area ? (m ? '120px' : '96px') : (m ? '44px' : '40px'), p: f.area ? '8px 10px' : '0 10px', ai: f.area ? 'flex-start' : 'center', countFg: f.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)' } })
|
||||
const U = md.upload || {}
|
||||
const upMap = {
|
||||
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 2.4 of 3.9 MB', progress: '62%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
|
||||
uploading: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'north-wall.jpg', text: 'Uploading 180 of 420 KB', progress: '43%', bd: '1px solid var(--ui-border)', bg: 'var(--ui-bg)' },
|
||||
rejected: { icon: 'file-x', ic: 'var(--ui-error)', title: 'north-wall.tif is 14.2 MB', text: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' },
|
||||
idle: { icon: 'image-plus', ic: 'var(--ui-text-toned)', title: 'Drop an image here, or choose a file', text: 'PNG, JPEG, WEBP, SVG · max 10 MB', bd: '1px dashed var(--ui-border-accented)', bg: 'var(--ui-bg)' },
|
||||
error: { icon: 'circle-x', ic: 'var(--ui-error)', title: 'Upload failed', text: 'The server didn\'t accept the file. Your article hasn\'t changed.', bd: '1px solid var(--ui-error)', bg: 'var(--ui-bg)' }
|
||||
}
|
||||
const up = upMap[U.state || 'idle'] || upMap.idle
|
||||
let cr = {}
|
||||
if (md.crop) {
|
||||
const C = md.crop, IW = 4032, IH = 3024
|
||||
const ri = C.ratio || 0, si = C.size ?? 1
|
||||
const presets = [[0, 0, 100, 100], [6, 8, 88, 78], [0, 12.5, 100, 75], [0, 0, 100, 100], [12.5, 0, 75, 100]]
|
||||
const r = C.rect || presets[ri]
|
||||
const cw = Math.round(IW * r[2] / 100), ch = Math.round(IH * r[3] / 100)
|
||||
const target = [cw, 1600, 1200, 800][si]
|
||||
const ow = Math.min(target, cw), oh = Math.round(ch * ow / cw)
|
||||
const kb = Math.round(ow * oh * 0.22 / 1000)
|
||||
const pill = (pre, cur) => (t, i) => ({ t, on: i === cur, act: pre + i, bg: i === cur ? 'var(--ui-primary)' : 'transparent', fg: i === cur ? 'var(--ui-bg)' : 'var(--ui-text-toned)' })
|
||||
cr = {
|
||||
l: r[0] + '%', t: r[1] + '%', w: r[2] + '%', h: r[3] + '%', dims: fmt(cw) + ' × ' + fmt(ch),
|
||||
ratios: ['Original', 'Free', '16:9', '4:3', '1:1'].map(pill('crop-r-', ri)),
|
||||
sizes: ['Original', '1600 px', '1200 px', '800 px'].map(pill('crop-s-', si)),
|
||||
out: 'Uploads as ' + fmt(ow) + ' × ' + fmt(oh) + ' px · about ' + (kb >= 1000 ? (kb / 1000).toFixed(1) + ' MB' : kb + ' KB'),
|
||||
note: target > cw ? 'The crop is ' + cw + ' px wide, so it\'s uploaded at that size. Enlarging it would make it blurry.' : '',
|
||||
stageH: m ? '240px' : '340px', pillFlex: m ? '1 1 0' : '0 0 auto', groupW: m ? '100%' : 'max-content'
|
||||
}
|
||||
}
|
||||
const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
|
||||
const endpoints = [
|
||||
{ path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" },
|
||||
@@ -281,7 +336,7 @@ class Component extends DCLogic {
|
||||
const edReady = k === 'editor' && !e.loading && !e.notfound
|
||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||
return {
|
||||
s, nav, crumbs, edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||
cr, s, nav, crumbs, edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||
desk: !m, mob: m, notBoot: !s.boot,
|
||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||
mLead: s.mLead || 'menu',
|
||||
@@ -302,7 +357,7 @@ class Component extends DCLogic {
|
||||
saveOp: (dirty && !blocked) ? '1' : '0.4', imgCount: e.imgs != null ? String(e.imgs) : (e.image ? '4' : '3'), apiNote: online ? 'Live. Your website can read this article.' : 'Returns 404 until the article is online.',
|
||||
copyIcon: s.copied ? 'check' : 'copy', copyText: s.copied ? 'Copied' : 'Copy', copyFg: s.copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)',
|
||||
errPad: m ? '40px 20px' : '64px 48px',
|
||||
modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '16px' : '140px 16px 16px', modalW: m ? '100%' : (md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
|
||||
modalAlign: m ? 'flex-end' : 'flex-start', modalPad: m ? '16px' : (md.crop ? '56px 16px 16px' : '140px 16px 16px'), modalW: m ? '100%' : (md.crop ? '640px' : md.wide ? '560px' : '460px'), mBtnDir: m ? 'column-reverse' : 'row',
|
||||
mConfirmBg: md.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)', mCancelFg: md.loading ? 'var(--ui-text-dimmed)' : 'var(--ui-text-highlighted)',
|
||||
hasToasts: toasts.length > 0, tL: m ? '16px' : 'auto', tR: m ? '16px' : '24px', tB: m ? '16px' : '24px', tW: m ? 'auto' : '356px'
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
<span aria-live="polite" style="display:flex;align-items:center;gap:6px;color:var(--ui-warning)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-dot.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-dot.svg') center / contain no-repeat"></span>Unsaved changes</span>
|
||||
<span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/save.svg') center / contain no-repeat"></span>Save</span>
|
||||
<span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Save and publish</span>
|
||||
<span style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
|
||||
<span role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-error);color:var(--ui-error);font-weight:500;cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete</span>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="Formatting" style="padding:6px 16px;display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);overflow-x:auto;background:var(--ui-bg)">
|
||||
<sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="24">
|
||||
|
||||
@@ -294,7 +294,14 @@ class Component extends DCLogic {
|
||||
return
|
||||
}
|
||||
if (m.type === 'upload') {
|
||||
if (m.state === 'crop' && typeof a === 'string') {
|
||||
if (a === 'modal-cancel') return this.setState({ modal: { ...m, state: 'idle' } })
|
||||
if (a.startsWith('crop-r-')) return this.setState({ modal: { ...m, ratio: +a.slice(7) } })
|
||||
if (a.startsWith('crop-s-')) return this.setState({ modal: { ...m, size: +a.slice(7) } })
|
||||
if (a === 'crop-reset') return this.setState({ modal: { ...m, ratio: 0, size: 1 } })
|
||||
}
|
||||
if (a === 'modal-cancel' || a === 'modal-close') return close()
|
||||
if (a === 'upload-pick' && m.state === 'idle') return this.setState({ modal: { ...m, state: 'crop', ratio: 0, size: 1 } })
|
||||
if ((a === 'upload-pick' || a === 'modal-confirm') && m.state !== 'uploading') {
|
||||
this.setState({ modal: { ...m, state: 'uploading' } })
|
||||
this.later(() => {
|
||||
@@ -334,7 +341,7 @@ class Component extends DCLogic {
|
||||
if (s.route === 'integration') return { kind: 'integration', ...T('integration', ['Integration']), mTitle: 'Integration', copied: s.copied }
|
||||
if (s.route === 'editor') {
|
||||
const a = this.art(s.cur) || {}, e = s.ed || {}
|
||||
return { kind: 'editor', ...T('articles', ['Articles', a.t]), mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { ...e, title: a.t, imgs: (a.imgs || 0) + (e.image ? 1 : 0) }, modal: this.modalS() }
|
||||
return { kind: 'editor', ...T('articles', ['Articles', a.t]), mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'trash-2', ed: { ...e, title: a.t, imgs: (a.imgs || 0) + (e.image ? 1 : 0) }, modal: this.modalS() }
|
||||
}
|
||||
return { kind: 'home', home: { sub: '' } }
|
||||
}
|
||||
@@ -347,6 +354,7 @@ class Component extends DCLogic {
|
||||
const imgs = a.imgs === 0 ? '' : a.imgs === 1 ? ' and its image' : ' and its ' + a.imgs + ' images'
|
||||
return { title: 'Delete this article?', desc: '"' + a.t + '"' + imgs + ' will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: m.loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading: m.loading }
|
||||
}
|
||||
if (m.type === 'upload' && m.state === 'crop') return { title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop: { ratio: m.ratio, size: m.size }, cancel: 'Choose another file', confirm: 'Upload image' }
|
||||
if (m.type === 'upload') return { title: 'Add an image', close: true, upload: { state: m.state }, cancel: m.state === 'uploading' ? 'Cancel upload' : 'Cancel' }
|
||||
if (m.type === 'leave') { const a = this.art(this.state.cur) || {}; return { title: 'Leave without saving?', desc: 'Your changes to "' + a.t + '" haven\'t been saved.', cancel: 'Discard changes', confirm: 'Keep editing' } }
|
||||
return null
|
||||
|
||||
@@ -93,6 +93,7 @@ class Component extends DCLogic {
|
||||
const nf = (title, body, extra = {}) => [{ label: 'Title', value: title, count: [...title].length + ' / 200', ...(extra.t || {}) }, { label: 'First paragraph', value: body, area: true, ...(extra.b || {}) }]
|
||||
const ed = { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis' }
|
||||
const E = (o = {}, extra = {}) => withM({ ...ed, ...extra, ed: { status: 'draft', save: 'saved', ...o } })
|
||||
const cropM = crop => ({ title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop, cancel: 'Choose another file', confirm: 'Upload image' })
|
||||
const del = (loading, err) => ({ title: 'Delete this article?', desc: '"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading, notice: err })
|
||||
return [
|
||||
{ label: 'Boot', title: 'App boot', route: 'any route, first load', api: 'GET /v1/auth/me (pending)', note: 'While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.', states: [
|
||||
@@ -137,7 +138,10 @@ class Component extends DCLogic {
|
||||
E({ save: 'dirty', len: 10214 }, { name: 'Too long', trigger: 'Over 10 000. Save and Publish are disabled until it\'s shorter.' }),
|
||||
E({ preview: true }, { name: 'Preview', trigger: 'The Preview switch. Shows the title and body in the content scale, the way a public site might.' }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: choose', trigger: 'The image button in the toolbar, or a file dropped on the editor.', modal: { title: 'Add an image', close: true, upload: { state: 'idle' }, cancel: 'Cancel' } }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'A valid file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: crop and resize', trigger: 'A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.', modal: cropM({ ratio: 0, size: 1 }) }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: crop to a ratio', trigger: '16:9 chosen. The frame keeps the ratio while it\'s dragged or resized. Arrow keys move it; Shift and arrow keys resize it.', modal: cropM({ ratio: 2, size: 1 }) }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: crop narrower than the width', trigger: 'The crop is smaller than the chosen width, so it keeps its own size.', modal: cropM({ ratio: 4, size: 1, rect: [42, 34, 15.873, 21.164] }) }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
|
||||
E({ save: 'dirty' }, { name: 'Upload: rejected', trigger: 'Checked in the browser, so nothing is sent.', modal: { title: 'Add an image', close: true, upload: { state: 'rejected' }, cancel: 'Cancel', confirm: 'Choose another file' } }),
|
||||
E({ save: 'dirty', image: true }, { name: 'Upload: inserted', trigger: '201: the image goes in at the cursor, and the article now has unsaved changes.', toasts: [T('success', 'circle-check', 'Image added', 'Save to keep it in the article.')] }),
|
||||
E({ save: 'saving', pending: 'Saving and publishing…' }, { name: 'Publishing', trigger: '"Save and publish": the PATCH body, then the publish request.' }),
|
||||
|
||||
@@ -74,7 +74,7 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
|
||||
| Editor | `/article/:id/edit` | Near the limit | At least 9 000 HTML characters | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Too long | Over 10 000: Save and Publish disabled | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Preview | Switch on | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Choose, crop and resize, uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Publishing, unpublishing | PATCH publish or unpublish | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Leave guard | Navigating away while dirty | [editor](screens/editor.md) |
|
||||
| Editor | `/article/:id/edit` | Offline | navigator.onLine is false | [editor](screens/editor.md) |
|
||||
|
||||
@@ -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.
|
||||
@@ -1,13 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
IMAGE_TYPE_REJECTION_TEXT,
|
||||
formatMegabytes,
|
||||
formatUploadProgress,
|
||||
isSizeRejectionBody,
|
||||
rejectionTitle,
|
||||
sizeRejectionText,
|
||||
validateImageFile,
|
||||
} from '@/lib/imageUpload'
|
||||
import {
|
||||
ASPECT_RATIOS,
|
||||
WIDTH_PRESETS,
|
||||
type AspectRatioLabel,
|
||||
type CropRect,
|
||||
type WidthPresetLabel,
|
||||
clampRect,
|
||||
computeOutputSize,
|
||||
estimateOutputBytes,
|
||||
formatEstimatedSize,
|
||||
moveRect,
|
||||
nextRotation,
|
||||
ratioValueFor,
|
||||
readNaturalImageSize,
|
||||
rectForRatio,
|
||||
renderCroppedImage,
|
||||
resizeRect,
|
||||
resizeRectTo,
|
||||
rotateRectBySteps,
|
||||
rotatedDimensions,
|
||||
wholeRect,
|
||||
widthPresetPixels,
|
||||
} from '@/lib/imageCrop'
|
||||
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -20,16 +45,27 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
type Phase = 'idle' | 'dragging-over' | 'uploading' | 'rejected' | 'failed'
|
||||
type Phase = 'idle' | 'dragging-over' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const phase = ref<Phase>('idle')
|
||||
const chosenFile = ref<File | null>(null)
|
||||
const lastUploadedFile = ref<File | null>(null)
|
||||
const pickedFile = ref<File | null>(null)
|
||||
const rejectionKind = ref<'type' | 'size' | null>(null)
|
||||
const succeeded = ref(false)
|
||||
|
||||
const naturalWidth = ref(0)
|
||||
const naturalHeight = ref(0)
|
||||
const rotation = ref(0)
|
||||
const ratioLabel = ref<AspectRatioLabel>('Original')
|
||||
const widthLabel = ref<WidthPresetLabel>('1600 px')
|
||||
const rect = ref<CropRect>({ x: 0, y: 0, width: 0, height: 0 })
|
||||
const imageBoxRef = ref<HTMLElement | null>(null)
|
||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const sourceBitmap = ref<ImageBitmap | null>(null)
|
||||
|
||||
const { progress, start, abort } = useImageUpload()
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -57,9 +93,68 @@ const rejectedText = computed(() => {
|
||||
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
|
||||
|
||||
const showCancel = computed(
|
||||
() => phase.value !== 'failed' && !(phase.value === 'uploading' && succeeded.value),
|
||||
() =>
|
||||
phase.value !== 'failed' &&
|
||||
phase.value !== 'crop' &&
|
||||
!(phase.value === 'uploading' && succeeded.value),
|
||||
)
|
||||
|
||||
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
|
||||
|
||||
const modalDescription = computed(() => {
|
||||
if (phase.value !== 'crop' || !chosenFile.value) {
|
||||
return undefined
|
||||
}
|
||||
return `${chosenFile.value.name} · ${formatEstimatedSize(chosenFile.value.size)}`
|
||||
})
|
||||
|
||||
const modalContentClass = computed(
|
||||
() =>
|
||||
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 ${
|
||||
phase.value === 'crop' ? 'sm:max-w-160' : 'sm:max-w-120'
|
||||
}`,
|
||||
)
|
||||
|
||||
const boundsWidth = computed(
|
||||
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).width,
|
||||
)
|
||||
const boundsHeight = computed(
|
||||
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).height,
|
||||
)
|
||||
|
||||
const lockedRatioValue = computed(() =>
|
||||
ratioValueFor(ratioLabel.value, boundsWidth.value, boundsHeight.value),
|
||||
)
|
||||
|
||||
const cropWidth = computed(() => Math.round(rect.value.width))
|
||||
const cropHeight = computed(() => Math.round(rect.value.height))
|
||||
|
||||
const outputSize = computed(() =>
|
||||
computeOutputSize(cropWidth.value, cropHeight.value, widthPresetPixels(widthLabel.value)),
|
||||
)
|
||||
|
||||
const outputSizeText = computed(() =>
|
||||
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
|
||||
)
|
||||
|
||||
const monoLine = computed(
|
||||
() =>
|
||||
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`,
|
||||
)
|
||||
|
||||
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
|
||||
|
||||
const showNarrowNote = computed(
|
||||
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
|
||||
)
|
||||
|
||||
const narrowNoteText = computed(
|
||||
() =>
|
||||
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`,
|
||||
)
|
||||
|
||||
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
|
||||
|
||||
function clearCloseTimer() {
|
||||
if (closeTimer !== null) {
|
||||
clearTimeout(closeTimer)
|
||||
@@ -68,6 +163,7 @@ function clearCloseTimer() {
|
||||
}
|
||||
|
||||
async function runUpload(file: File) {
|
||||
lastUploadedFile.value = file
|
||||
const outcome = await start(props.articleId, file)
|
||||
if (outcome.kind === 'success') {
|
||||
succeeded.value = true
|
||||
@@ -85,6 +181,69 @@ async function runUpload(file: File) {
|
||||
phase.value = 'failed'
|
||||
}
|
||||
|
||||
function closeSourceBitmap() {
|
||||
if (sourceBitmap.value) {
|
||||
sourceBitmap.value.close()
|
||||
sourceBitmap.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function drawPreview() {
|
||||
const canvas = previewCanvasRef.value
|
||||
const bitmap = sourceBitmap.value
|
||||
if (!canvas || !bitmap) {
|
||||
return
|
||||
}
|
||||
const rotated = rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value)
|
||||
const maxDimension = 1200
|
||||
const scale = Math.min(1, maxDimension / Math.max(rotated.width, rotated.height))
|
||||
canvas.width = Math.max(1, Math.round(rotated.width * scale))
|
||||
canvas.height = Math.max(1, Math.round(rotated.height * scale))
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((rotation.value * Math.PI) / 180)
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
(-naturalWidth.value * scale) / 2,
|
||||
(-naturalHeight.value * scale) / 2,
|
||||
naturalWidth.value * scale,
|
||||
naturalHeight.value * scale,
|
||||
)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
async function loadPreviewBitmap(file: File) {
|
||||
closeSourceBitmap()
|
||||
if (typeof createImageBitmap !== 'function') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sourceBitmap.value = await createImageBitmap(file)
|
||||
} catch {
|
||||
sourceBitmap.value = null
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
drawPreview()
|
||||
}
|
||||
|
||||
async function openCropStep(file: File) {
|
||||
const size = await readNaturalImageSize(file)
|
||||
naturalWidth.value = size.width
|
||||
naturalHeight.value = size.height
|
||||
rotation.value = 0
|
||||
ratioLabel.value = 'Original'
|
||||
widthLabel.value = '1600 px'
|
||||
rect.value = wholeRect(size.width, size.height)
|
||||
phase.value = 'crop'
|
||||
await loadPreviewBitmap(file)
|
||||
}
|
||||
|
||||
function handleFile(file: File) {
|
||||
chosenFile.value = file
|
||||
const result = validateImageFile(file)
|
||||
@@ -95,8 +254,12 @@ function handleFile(file: File) {
|
||||
}
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
if (file.type === 'image/svg+xml') {
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
return
|
||||
}
|
||||
openCropStep(file)
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
@@ -128,21 +291,223 @@ function chooseAnotherFile() {
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
closeSourceBitmap()
|
||||
}
|
||||
|
||||
function tryAgain() {
|
||||
if (!chosenFile.value) {
|
||||
if (!lastUploadedFile.value) {
|
||||
return
|
||||
}
|
||||
succeeded.value = false
|
||||
phase.value = 'uploading'
|
||||
runUpload(chosenFile.value)
|
||||
runUpload(lastUploadedFile.value)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function selectRatio(label: AspectRatioLabel) {
|
||||
ratioLabel.value = label
|
||||
if (label === 'Free') {
|
||||
return
|
||||
}
|
||||
rect.value = rectForRatio(label, boundsWidth.value, boundsHeight.value)
|
||||
}
|
||||
|
||||
function selectWidth(label: WidthPresetLabel) {
|
||||
widthLabel.value = label
|
||||
}
|
||||
|
||||
function rotateImage() {
|
||||
const dims = rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value)
|
||||
rect.value = rotateRectBySteps(rect.value, dims.width, dims.height, 1)
|
||||
rotation.value = nextRotation(rotation.value)
|
||||
drawPreview()
|
||||
}
|
||||
|
||||
function resetCrop() {
|
||||
rotation.value = 0
|
||||
ratioLabel.value = 'Original'
|
||||
widthLabel.value = '1600 px'
|
||||
rect.value = wholeRect(naturalWidth.value, naturalHeight.value)
|
||||
drawPreview()
|
||||
}
|
||||
|
||||
function resizeCropTo(width: number, height: number) {
|
||||
rect.value = resizeRectTo(rect.value, width, height, boundsWidth.value, boundsHeight.value)
|
||||
}
|
||||
|
||||
function stagePixelScale(): number {
|
||||
const box = imageBoxRef.value?.getBoundingClientRect()
|
||||
if (!box || box.width === 0) {
|
||||
return 1
|
||||
}
|
||||
return boundsWidth.value / box.width
|
||||
}
|
||||
|
||||
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } {
|
||||
if ('touches' in event && event.touches.length > 0) {
|
||||
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
|
||||
}
|
||||
if ('changedTouches' in event && event.changedTouches.length > 0) {
|
||||
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY }
|
||||
}
|
||||
const mouseEvent = event as MouseEvent
|
||||
return { x: mouseEvent.clientX, y: mouseEvent.clientY }
|
||||
}
|
||||
|
||||
let moveOrigin: { x: number; y: number; rect: CropRect } | null = null
|
||||
|
||||
function onFrameDragStart(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault()
|
||||
const point = pointFromEvent(event)
|
||||
moveOrigin = { x: point.x, y: point.y, rect: { ...rect.value } }
|
||||
window.addEventListener('mousemove', onFrameDragMove)
|
||||
window.addEventListener('mouseup', onFrameDragEnd)
|
||||
window.addEventListener('touchmove', onFrameDragMove)
|
||||
window.addEventListener('touchend', onFrameDragEnd)
|
||||
}
|
||||
|
||||
function onFrameDragMove(event: MouseEvent | TouchEvent) {
|
||||
if (!moveOrigin) {
|
||||
return
|
||||
}
|
||||
const point = pointFromEvent(event)
|
||||
const scale = stagePixelScale()
|
||||
const dx = (point.x - moveOrigin.x) * scale
|
||||
const dy = (point.y - moveOrigin.y) * scale
|
||||
rect.value = clampRect(
|
||||
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy },
|
||||
boundsWidth.value,
|
||||
boundsHeight.value,
|
||||
)
|
||||
}
|
||||
|
||||
function onFrameDragEnd() {
|
||||
moveOrigin = null
|
||||
window.removeEventListener('mousemove', onFrameDragMove)
|
||||
window.removeEventListener('mouseup', onFrameDragEnd)
|
||||
window.removeEventListener('touchmove', onFrameDragMove)
|
||||
window.removeEventListener('touchend', onFrameDragEnd)
|
||||
}
|
||||
|
||||
let resizeOrigin: {
|
||||
x: number
|
||||
y: number
|
||||
rect: CropRect
|
||||
corner: Corner
|
||||
ratio: number | null
|
||||
} | null = null
|
||||
|
||||
function onHandleDragStart(event: MouseEvent | TouchEvent, corner: Corner) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const point = pointFromEvent(event)
|
||||
resizeOrigin = {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rect: { ...rect.value },
|
||||
corner,
|
||||
ratio: lockedRatioValue.value,
|
||||
}
|
||||
window.addEventListener('mousemove', onHandleDragMove)
|
||||
window.addEventListener('mouseup', onHandleDragEnd)
|
||||
window.addEventListener('touchmove', onHandleDragMove)
|
||||
window.addEventListener('touchend', onHandleDragEnd)
|
||||
}
|
||||
|
||||
function onHandleDragMove(event: MouseEvent | TouchEvent) {
|
||||
if (!resizeOrigin) {
|
||||
return
|
||||
}
|
||||
const point = pointFromEvent(event)
|
||||
const scale = stagePixelScale()
|
||||
const dx = (point.x - resizeOrigin.x) * scale
|
||||
const dy = (point.y - resizeOrigin.y) * scale
|
||||
const { corner, rect: startRect, ratio } = resizeOrigin
|
||||
let { x, y, width, height } = startRect
|
||||
if (corner.includes('r')) {
|
||||
width = startRect.width + dx
|
||||
} else {
|
||||
width = startRect.width - dx
|
||||
x = startRect.x + dx
|
||||
}
|
||||
if (corner.includes('b')) {
|
||||
height = startRect.height + dy
|
||||
} else {
|
||||
height = startRect.height - dy
|
||||
y = startRect.y + dy
|
||||
}
|
||||
if (ratio) {
|
||||
height = width / ratio
|
||||
if (corner.startsWith('t')) {
|
||||
y = startRect.y + startRect.height - height
|
||||
}
|
||||
}
|
||||
rect.value = clampRect({ x, y, width, height }, boundsWidth.value, boundsHeight.value)
|
||||
}
|
||||
|
||||
function onHandleDragEnd() {
|
||||
resizeOrigin = null
|
||||
window.removeEventListener('mousemove', onHandleDragMove)
|
||||
window.removeEventListener('mouseup', onHandleDragEnd)
|
||||
window.removeEventListener('touchmove', onHandleDragMove)
|
||||
window.removeEventListener('touchend', onHandleDragEnd)
|
||||
}
|
||||
|
||||
function onFrameKeydown(event: KeyboardEvent) {
|
||||
const step = 1
|
||||
const lockedRatio = lockedRatioValue.value
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
rect.value = event.shiftKey
|
||||
? resizeRect(rect.value, step, 0, boundsWidth.value, boundsHeight.value, lockedRatio)
|
||||
: moveRect(rect.value, step, 0, boundsWidth.value, boundsHeight.value)
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
rect.value = event.shiftKey
|
||||
? resizeRect(rect.value, -step, 0, boundsWidth.value, boundsHeight.value, lockedRatio)
|
||||
: moveRect(rect.value, -step, 0, boundsWidth.value, boundsHeight.value)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
rect.value = event.shiftKey
|
||||
? resizeRect(rect.value, 0, -step, boundsWidth.value, boundsHeight.value, lockedRatio)
|
||||
: moveRect(rect.value, 0, -step, boundsWidth.value, boundsHeight.value)
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
rect.value = event.shiftKey
|
||||
? resizeRect(rect.value, 0, step, boundsWidth.value, boundsHeight.value, lockedRatio)
|
||||
: moveRect(rect.value, 0, step, boundsWidth.value, boundsHeight.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCropUpload() {
|
||||
if (!chosenFile.value) {
|
||||
return
|
||||
}
|
||||
const file = chosenFile.value
|
||||
const output = outputSize.value
|
||||
const blob = await renderCroppedImage(file, {
|
||||
rect: rect.value,
|
||||
rotation: rotation.value,
|
||||
naturalWidth: naturalWidth.value,
|
||||
naturalHeight: naturalHeight.value,
|
||||
outputWidth: output.width,
|
||||
outputHeight: output.height,
|
||||
mimeType: file.type,
|
||||
})
|
||||
const resizedFile = new File([blob], file.name, { type: file.type })
|
||||
succeeded.value = false
|
||||
phase.value = 'uploading'
|
||||
runUpload(resizedFile)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
cropRect: rect,
|
||||
resizeCropTo,
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
phase.value = 'idle'
|
||||
@@ -159,17 +524,22 @@ watch(open, (isOpen) => {
|
||||
abort()
|
||||
}
|
||||
clearCloseTimer()
|
||||
closeSourceBitmap()
|
||||
emit('close')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
closeSourceBitmap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
title="Add an image"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
content: modalContentClass,
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
@@ -204,6 +574,171 @@ watch(open, (isOpen) => {
|
||||
</UFileUpload>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
|
||||
data-testid="crop-stage"
|
||||
>
|
||||
<div
|
||||
ref="imageBoxRef"
|
||||
class="relative h-full max-w-full"
|
||||
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
|
||||
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
|
||||
role="img"
|
||||
>
|
||||
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
|
||||
<canvas ref="previewCanvasRef" class="absolute inset-0 h-full w-full" />
|
||||
<div
|
||||
class="absolute"
|
||||
:style="{
|
||||
left: `${(rect.x / boundsWidth) * 100}%`,
|
||||
top: `${(rect.y / boundsHeight) * 100}%`,
|
||||
width: `${(rect.width / boundsWidth) * 100}%`,
|
||||
height: `${(rect.height / boundsHeight) * 100}%`,
|
||||
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-testid="crop-frame"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
|
||||
class="absolute cursor-move touch-none outline outline-white"
|
||||
:style="{
|
||||
left: `${(rect.x / boundsWidth) * 100}%`,
|
||||
top: `${(rect.y / boundsHeight) * 100}%`,
|
||||
width: `${(rect.width / boundsWidth) * 100}%`,
|
||||
height: `${(rect.height / boundsHeight) * 100}%`,
|
||||
}"
|
||||
@keydown="onFrameKeydown"
|
||||
@mousedown="onFrameDragStart"
|
||||
@touchstart="onFrameDragStart"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
|
||||
style="left: 33.333%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
|
||||
style="left: 66.666%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
|
||||
style="top: 33.333%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
|
||||
style="top: 66.666%"
|
||||
/>
|
||||
<div
|
||||
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
|
||||
:key="corner"
|
||||
data-testid="crop-handle"
|
||||
class="absolute size-3 touch-none border border-ink-900 bg-white"
|
||||
:class="{
|
||||
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
|
||||
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
|
||||
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
|
||||
'-bottom-1.5 -right-1.5 cursor-nwse-resize': corner === 'br',
|
||||
}"
|
||||
@mousedown="onHandleDragStart($event, corner)"
|
||||
@touchstart="onHandleDragStart($event, corner)"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
|
||||
>
|
||||
{{ dimsLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-rotate-ccw-square"
|
||||
@click="rotateImage"
|
||||
>
|
||||
Rotate
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-undo-2"
|
||||
@click="resetCrop"
|
||||
>
|
||||
Reset
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Aspect ratio"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
>
|
||||
<button
|
||||
v-for="ratio in ASPECT_RATIOS"
|
||||
:key="ratio"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="ratioLabel === ratio"
|
||||
:tabindex="ratioLabel === ratio ? 0 : -1"
|
||||
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
|
||||
:class="
|
||||
ratioLabel === ratio
|
||||
? 'bg-primary text-inverted'
|
||||
: 'bg-transparent text-toned'
|
||||
"
|
||||
@click="selectRatio(ratio)"
|
||||
>
|
||||
{{ ratio }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Width</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Width"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
>
|
||||
<button
|
||||
v-for="widthOption in WIDTH_PRESETS"
|
||||
:key="widthOption"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="widthLabel === widthOption"
|
||||
:tabindex="widthLabel === widthOption ? 0 : -1"
|
||||
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
|
||||
:class="
|
||||
widthLabel === widthOption
|
||||
? 'bg-primary text-inverted'
|
||||
: 'bg-transparent text-toned'
|
||||
"
|
||||
@click="selectWidth(widthOption)"
|
||||
>
|
||||
{{ widthOption }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showNarrowNote"
|
||||
role="status"
|
||||
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
|
||||
>
|
||||
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
|
||||
<span class="text-toned">{{ narrowNoteText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
|
||||
@@ -226,8 +761,7 @@ watch(open, (isOpen) => {
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!succeeded" class="text-sm text-toned">
|
||||
Uploading {{ formatMegabytes(progress.loaded) }} of
|
||||
{{ formatMegabytes(progress.total) }} MB
|
||||
{{ formatUploadProgress(progress.loaded, progress.total) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -257,6 +791,12 @@ watch(open, (isOpen) => {
|
||||
Choose another file
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
|
||||
<template v-if="phase === 'crop'">
|
||||
<UButton color="neutral" variant="outline" @click="chooseAnotherFile">
|
||||
Choose another file
|
||||
</UButton>
|
||||
<UButton @click="confirmCropUpload">Upload image</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
@@ -13,6 +13,15 @@ vi.mock('@/lib/imageUpload', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
|
||||
}
|
||||
})
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static instances: FakeXMLHttpRequest[] = []
|
||||
|
||||
@@ -141,6 +150,7 @@ async function chooseFile(file: File) {
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
@@ -153,6 +163,12 @@ async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function chooseRasterFileAndConfirmUpload(file: File) {
|
||||
await chooseFile(file)
|
||||
await clickButton('Upload image')
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
@@ -247,25 +263,25 @@ describe('"Choose another file" from the rejected state returns to idle', () =>
|
||||
})
|
||||
|
||||
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
|
||||
it('sends the file and reports live progress', async () => {
|
||||
it('sends the resized file and reports live progress', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
const xhr = latestXHR()
|
||||
expect(xhr.url).toBe('/v1/article/12/image')
|
||||
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 0%')
|
||||
expect(document.body.textContent).toContain('Uploading 0.0 of 3.9 MB')
|
||||
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
|
||||
expect(findButton('Cancel upload')).toBeTruthy()
|
||||
|
||||
xhr.fireProgress(mib(2.4), mib(3.9))
|
||||
xhr.fireProgress(180 * 1024, 420 * 1024)
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 62%')
|
||||
expect(document.body.textContent).toContain('Uploading 2.4 of 3.9 MB')
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 43%')
|
||||
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -274,7 +290,7 @@ describe('Closing the dialog during an upload cancels it', () => {
|
||||
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
await clickButton('Cancel upload')
|
||||
@@ -290,7 +306,7 @@ describe('A successful upload emits the location and auto-closes 600 ms later',
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
|
||||
@@ -319,7 +335,7 @@ describe('A server-side upload failure keeps the article unchanged and offers a
|
||||
])('shows the failed state for $name', async ({ trigger }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
trigger(xhr)
|
||||
@@ -339,7 +355,7 @@ describe('"Try again" re-attempts the upload with the same file', () => {
|
||||
it('resends the same file under field "file"', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
latestXHR().resolve(500, '')
|
||||
await flushPromises()
|
||||
|
||||
@@ -359,7 +375,7 @@ describe('A server 400 for a type the client should have caught shows the same r
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('notes.pdf', 'image/png', 1.2))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('notes.pdf', 'image/png', 1.2))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
|
||||
@@ -380,7 +396,7 @@ describe('A server 400 for a size the client should have caught shows the size-n
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.tif', 'image/png', 14.2))
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(400, 'the file exceeds the maximum upload size')
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import ImageUploader from '../ImageUploader.vue'
|
||||
import { renderCroppedImage } from '@/lib/imageCrop'
|
||||
import type { CropRect } from '@/lib/imageCrop'
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async (file: File) => imageSizeFor(file)),
|
||||
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
|
||||
}
|
||||
})
|
||||
|
||||
const imageSizes: Record<string, { width: number; height: number }> = {
|
||||
'north-wall.jpg': { width: 4032, height: 3024 },
|
||||
'sunset.jpg': { width: 3000, height: 2000 },
|
||||
}
|
||||
|
||||
function imageSizeFor(file: File) {
|
||||
return imageSizes[file.name] ?? { width: 4032, height: 3024 }
|
||||
}
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static instances: FakeXMLHttpRequest[] = []
|
||||
|
||||
method = ''
|
||||
url = ''
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
sentBody: FormData | null = null
|
||||
|
||||
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
|
||||
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
|
||||
|
||||
upload = {
|
||||
addEventListener: (
|
||||
type: string,
|
||||
cb: (event: { loaded: number; total: number }) => void,
|
||||
) => {
|
||||
this.uploadListeners[type] = cb
|
||||
},
|
||||
}
|
||||
|
||||
constructor() {
|
||||
FakeXMLHttpRequest.instances.push(this)
|
||||
}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
send(body: FormData) {
|
||||
this.sentBody = body
|
||||
}
|
||||
|
||||
abort() {}
|
||||
|
||||
addEventListener(type: string, cb: (event?: unknown) => void) {
|
||||
this.listeners[type] = this.listeners[type] || []
|
||||
this.listeners[type].push(cb)
|
||||
}
|
||||
|
||||
dispatch(type: string, event?: unknown) {
|
||||
;(this.listeners[type] || []).forEach((cb) => cb(event))
|
||||
}
|
||||
|
||||
fireProgress(loaded: number, total: number) {
|
||||
this.uploadListeners.progress?.({ loaded, total })
|
||||
}
|
||||
|
||||
resolve(status: number, body: string) {
|
||||
this.status = status
|
||||
this.responseText = body
|
||||
this.dispatch('load')
|
||||
}
|
||||
}
|
||||
|
||||
function latestXHR() {
|
||||
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
|
||||
}
|
||||
|
||||
function mib(value: number) {
|
||||
return Math.round(value * 1024 * 1024)
|
||||
}
|
||||
|
||||
function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
|
||||
return file
|
||||
}
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { ImageUploader },
|
||||
props: {
|
||||
articleId: { type: Number, required: true },
|
||||
},
|
||||
emits: ['uploaded', 'close'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(false)
|
||||
return {
|
||||
open,
|
||||
onUploaded: (location: string) => emit('uploaded', location),
|
||||
onClose: () => emit('close'),
|
||||
}
|
||||
},
|
||||
template: `<ImageUploader v-model:open="open" :article-id="articleId" @uploaded="onUploaded" @close="onClose" />`,
|
||||
})
|
||||
|
||||
function mountUploader() {
|
||||
return mount(Host, {
|
||||
props: { articleId: 12 },
|
||||
global: { plugins: [uiPlugin] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function fileInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
|
||||
}
|
||||
|
||||
async function chooseFile(file: File) {
|
||||
const input = fileInput()
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: { 0: file, length: 1 },
|
||||
configurable: true,
|
||||
})
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function radioPill(groupLabel: string, text: string) {
|
||||
const group = document.body.querySelector<HTMLElement>(
|
||||
`[role="radiogroup"][aria-label="${groupLabel}"]`,
|
||||
)!
|
||||
return Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
|
||||
(pill) => pill.textContent?.trim() === text,
|
||||
)!
|
||||
}
|
||||
|
||||
async function selectRatioPill(text: string) {
|
||||
radioPill('Aspect ratio', text).click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function selectWidthPill(text: string) {
|
||||
radioPill('Width', text).click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function cropFrame() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="crop-frame"]')!
|
||||
}
|
||||
|
||||
function north_wall() {
|
||||
return fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
global.XMLHttpRequest = RealXMLHttpRequest
|
||||
})
|
||||
|
||||
describe('A raster file opens the crop-and-resize step instead of uploading immediately', () => {
|
||||
it('opens "Crop and resize" with the opened defaults', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
await chooseFile(north_wall())
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain('Crop and resize')
|
||||
expect(document.body.textContent).toContain('north-wall.jpg · 3.9 MB')
|
||||
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
|
||||
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
|
||||
expect(document.body.textContent).toContain('Uploads as 1600 × 1200 px · about 420 KB')
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(findButton('Upload image')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('An SVG skips the crop step and uploads immediately', () => {
|
||||
it('sends the SVG right away', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(latestXHR().url).toBe('/v1/article/12/image')
|
||||
expect(document.body.textContent).not.toContain('Crop and resize')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choosing an aspect ratio locks the frame to it', () => {
|
||||
it.each([
|
||||
{ ratio: '16:9', cropWidth: 4032, cropHeight: 2268, outWidth: 1600, outHeight: 900 },
|
||||
{ ratio: '4:3', cropWidth: 4032, cropHeight: 3024, outWidth: 1600, outHeight: 1200 },
|
||||
{ ratio: '1:1', cropWidth: 3024, cropHeight: 3024, outWidth: 1600, outHeight: 1600 },
|
||||
])(
|
||||
'locks the frame for $ratio',
|
||||
async ({ ratio, cropWidth, cropHeight, outWidth, outHeight }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
|
||||
await selectRatioPill(ratio)
|
||||
|
||||
const rect = (
|
||||
wrapper.findComponent(ImageUploader).vm as unknown as { cropRect: CropRect }
|
||||
).cropRect
|
||||
expect(rect.width).toBe(cropWidth)
|
||||
expect(rect.height).toBe(cropHeight)
|
||||
expect(document.body.textContent).toContain(
|
||||
`Uploads as ${outWidth} × ${outHeight} px · about`,
|
||||
)
|
||||
wrapper.unmount()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Choosing a narrower width preset recomputes the output without upscaling', () => {
|
||||
it('shows a smaller estimate', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
|
||||
await selectWidthPill('800 px')
|
||||
|
||||
expect(document.body.textContent).toContain('Uploads as 800 × 600 px · about')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A crop narrower than the chosen width is uploaded at its own size, not upscaled', () => {
|
||||
it('shows the info row and keeps the crop size', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
await selectRatioPill('1:1')
|
||||
|
||||
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
|
||||
resizeCropTo: (w: number, h: number) => void
|
||||
}
|
||||
uploader.resizeCropTo(640, 640)
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('Uploads as 640 × 640 px · about')
|
||||
expect(document.body.textContent).toContain(
|
||||
"The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
|
||||
)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Free ratio removes the aspect-ratio constraint', () => {
|
||||
it('accepts an independent width and height', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
|
||||
await selectRatioPill('Free')
|
||||
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
|
||||
resizeCropTo: (w: number, h: number) => void
|
||||
cropRect: CropRect
|
||||
}
|
||||
uploader.resizeCropTo(1000, 400)
|
||||
await nextTick()
|
||||
|
||||
expect(uploader.cropRect.width).toBe(1000)
|
||||
expect(uploader.cropRect.height).toBe(400)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Arrow keys move the crop frame and Shift+arrow keys resize it', () => {
|
||||
it('moves 1px on ArrowRight and resizes 1px on Shift+ArrowRight', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
await selectRatioPill('1:1')
|
||||
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
|
||||
cropRect: CropRect
|
||||
}
|
||||
const before = { ...uploader.cropRect }
|
||||
|
||||
cropFrame().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(uploader.cropRect.x).toBe(before.x + 1)
|
||||
expect(uploader.cropRect.width).toBe(before.width)
|
||||
expect(document.body.textContent).toContain('Uploads as 1600 × 1600 px · about')
|
||||
|
||||
cropFrame().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }),
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(uploader.cropRect.width).toBe(before.width + 1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rotate cycles the image in 90° steps and Reset returns to the opened defaults', () => {
|
||||
it('rotates then resets', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
await selectRatioPill('16:9')
|
||||
await selectWidthPill('800 px')
|
||||
|
||||
await clickButton('Rotate')
|
||||
|
||||
expect(document.body.textContent).toContain('Uploads as')
|
||||
|
||||
await clickButton('Reset')
|
||||
|
||||
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
|
||||
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
|
||||
expect(document.body.textContent).toContain('Uploads as 1600 × 1200 px · about 420 KB')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Choose another file" returns to the file picker without uploading', () => {
|
||||
it('shows the idle state', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
|
||||
await clickButton('Choose another file')
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe("A newly chosen file opens a fresh crop step, not the previous file's adjustments", () => {
|
||||
it('reopens at defaults for the new file', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
await selectRatioPill('16:9')
|
||||
await selectWidthPill('800 px')
|
||||
await clickButton('Choose another file')
|
||||
|
||||
await chooseFile(fileFixture('sunset.jpg', 'image/jpeg', 2.1))
|
||||
|
||||
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
|
||||
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
|
||||
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
|
||||
cropRect: CropRect
|
||||
}
|
||||
expect(uploader.cropRect.width).toBe(3000)
|
||||
expect(uploader.cropRect.height).toBe(2000)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Upload image" uploads the cropped, resized file, not the original', () => {
|
||||
it('sends the resized file and shows resized progress', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(north_wall())
|
||||
|
||||
await clickButton('Upload image')
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
const xhr = latestXHR()
|
||||
const sentFile = xhr.sentBody?.get('file') as File
|
||||
expect(sentFile.size).toBe(430080)
|
||||
expect(vi.mocked(renderCroppedImage)).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
expect.objectContaining({
|
||||
outputWidth: 1600,
|
||||
outputHeight: 1200,
|
||||
rect: expect.objectContaining({ width: 4032, height: 3024 }),
|
||||
}),
|
||||
)
|
||||
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
|
||||
|
||||
xhr.fireProgress(180 * 1024, 420 * 1024)
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 43%')
|
||||
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeOutputSize,
|
||||
estimateOutputBytes,
|
||||
formatEstimatedSize,
|
||||
rectForRatio,
|
||||
rotateRectBySteps,
|
||||
rotatedDimensions,
|
||||
wholeRect,
|
||||
} from '../imageCrop'
|
||||
|
||||
describe('rectForRatio', () => {
|
||||
it('centers a 16:9 crop inside a 4032x3024 image', () => {
|
||||
const rect = rectForRatio('16:9', 4032, 3024)
|
||||
expect(rect).toEqual({ x: 0, y: 378, width: 4032, height: 2268 })
|
||||
})
|
||||
|
||||
it('centers a 1:1 crop inside a 4032x3024 image', () => {
|
||||
const rect = rectForRatio('1:1', 4032, 3024)
|
||||
expect(rect).toEqual({ x: 504, y: 0, width: 3024, height: 3024 })
|
||||
})
|
||||
|
||||
it('returns the whole image for Original', () => {
|
||||
expect(rectForRatio('Original', 4032, 3024)).toEqual(wholeRect(4032, 3024))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeOutputSize', () => {
|
||||
it('never upscales past the crop width', () => {
|
||||
expect(computeOutputSize(640, 640, 1600)).toEqual({ width: 640, height: 640 })
|
||||
})
|
||||
|
||||
it('caps to the width preset when the crop is larger', () => {
|
||||
expect(computeOutputSize(4032, 3024, 1600)).toEqual({ width: 1600, height: 1200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateOutputBytes', () => {
|
||||
it('estimates 420 KB for the default 1600x1200 output', () => {
|
||||
expect(formatEstimatedSize(estimateOutputBytes(1600, 1200))).toBe('420 KB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateRectBySteps', () => {
|
||||
it('rotates a whole-image rect 90deg clockwise into the new bounds', () => {
|
||||
const rotated = rotateRectBySteps(wholeRect(4032, 3024), 4032, 3024, 1)
|
||||
expect(rotated).toEqual({ x: 0, y: 0, width: 3024, height: 4032 })
|
||||
})
|
||||
|
||||
it('round-trips back to the original after four steps', () => {
|
||||
const rect = { x: 42, y: 34, width: 500, height: 300 }
|
||||
const rotated = rotateRectBySteps(rect, 4032, 3024, 4)
|
||||
expect(rotated).toEqual(rect)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotatedDimensions', () => {
|
||||
it('swaps dimensions for a 90deg rotation', () => {
|
||||
expect(rotatedDimensions(4032, 3024, 90)).toEqual({ width: 3024, height: 4032 })
|
||||
})
|
||||
|
||||
it('keeps dimensions for a 180deg rotation', () => {
|
||||
expect(rotatedDimensions(4032, 3024, 180)).toEqual({ width: 4032, height: 3024 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
export interface CropRect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export const ASPECT_RATIOS = ['Original', 'Free', '16:9', '4:3', '1:1'] as const
|
||||
export type AspectRatioLabel = (typeof ASPECT_RATIOS)[number]
|
||||
|
||||
export const WIDTH_PRESETS = ['Original', '1600 px', '1200 px', '800 px'] as const
|
||||
export type WidthPresetLabel = (typeof WIDTH_PRESETS)[number]
|
||||
|
||||
const FIXED_RATIO_VALUES: Partial<Record<AspectRatioLabel, number>> = {
|
||||
'16:9': 16 / 9,
|
||||
'4:3': 4 / 3,
|
||||
'1:1': 1,
|
||||
}
|
||||
|
||||
const WIDTH_PRESET_PIXELS: Record<WidthPresetLabel, number | null> = {
|
||||
Original: null,
|
||||
'1600 px': 1600,
|
||||
'1200 px': 1200,
|
||||
'800 px': 800,
|
||||
}
|
||||
|
||||
export function widthPresetPixels(label: WidthPresetLabel): number | null {
|
||||
return WIDTH_PRESET_PIXELS[label]
|
||||
}
|
||||
|
||||
export function ratioValueFor(
|
||||
ratio: AspectRatioLabel,
|
||||
boundsWidth: number,
|
||||
boundsHeight: number,
|
||||
): number | null {
|
||||
if (ratio === 'Free') {
|
||||
return null
|
||||
}
|
||||
if (ratio === 'Original') {
|
||||
return boundsWidth / boundsHeight
|
||||
}
|
||||
return FIXED_RATIO_VALUES[ratio] ?? null
|
||||
}
|
||||
|
||||
export function wholeRect(width: number, height: number): CropRect {
|
||||
return { x: 0, y: 0, width, height }
|
||||
}
|
||||
|
||||
export function clampRect(rect: CropRect, boundsWidth: number, boundsHeight: number): CropRect {
|
||||
const width = Math.round(Math.min(Math.max(rect.width, 1), boundsWidth))
|
||||
const height = Math.round(Math.min(Math.max(rect.height, 1), boundsHeight))
|
||||
const x = Math.round(Math.min(Math.max(rect.x, 0), boundsWidth - width))
|
||||
const y = Math.round(Math.min(Math.max(rect.y, 0), boundsHeight - height))
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
export function rectForRatio(
|
||||
ratio: AspectRatioLabel,
|
||||
boundsWidth: number,
|
||||
boundsHeight: number,
|
||||
): CropRect {
|
||||
if (ratio === 'Original') {
|
||||
return wholeRect(boundsWidth, boundsHeight)
|
||||
}
|
||||
const value = FIXED_RATIO_VALUES[ratio]
|
||||
if (!value) {
|
||||
return wholeRect(boundsWidth, boundsHeight)
|
||||
}
|
||||
const boundsRatio = boundsWidth / boundsHeight
|
||||
let width: number
|
||||
let height: number
|
||||
if (value > boundsRatio) {
|
||||
width = boundsWidth
|
||||
height = Math.round(width / value)
|
||||
} else {
|
||||
height = boundsHeight
|
||||
width = Math.round(height * value)
|
||||
}
|
||||
return {
|
||||
x: Math.round((boundsWidth - width) / 2),
|
||||
y: Math.round((boundsHeight - height) / 2),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveRect(
|
||||
rect: CropRect,
|
||||
dx: number,
|
||||
dy: number,
|
||||
boundsWidth: number,
|
||||
boundsHeight: number,
|
||||
): CropRect {
|
||||
return clampRect({ ...rect, x: rect.x + dx, y: rect.y + dy }, boundsWidth, boundsHeight)
|
||||
}
|
||||
|
||||
export function resizeRect(
|
||||
rect: CropRect,
|
||||
dw: number,
|
||||
dh: number,
|
||||
boundsWidth: number,
|
||||
boundsHeight: number,
|
||||
lockedRatio: number | null,
|
||||
): CropRect {
|
||||
let width = rect.width + dw
|
||||
let height = rect.height + dh
|
||||
if (lockedRatio) {
|
||||
if (dw !== 0) {
|
||||
height = Math.round(width / lockedRatio)
|
||||
} else if (dh !== 0) {
|
||||
width = Math.round(height * lockedRatio)
|
||||
}
|
||||
}
|
||||
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
|
||||
}
|
||||
|
||||
export function resizeRectTo(
|
||||
rect: CropRect,
|
||||
width: number,
|
||||
height: number,
|
||||
boundsWidth: number,
|
||||
boundsHeight: number,
|
||||
): CropRect {
|
||||
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
|
||||
}
|
||||
|
||||
function rotateRectClockwiseOnce(rect: CropRect, width: number, height: number): CropRect {
|
||||
return { x: height - rect.y - rect.height, y: rect.x, width: rect.height, height: rect.width }
|
||||
}
|
||||
|
||||
export function rotateRectBySteps(
|
||||
rect: CropRect,
|
||||
width: number,
|
||||
height: number,
|
||||
steps: number,
|
||||
): CropRect {
|
||||
let r = rect
|
||||
let w = width
|
||||
let h = height
|
||||
const n = ((steps % 4) + 4) % 4
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
r = rotateRectClockwiseOnce(r, w, h)
|
||||
;[w, h] = [h, w]
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
export function rotatedDimensions(width: number, height: number, rotation: number) {
|
||||
const turns = (((rotation / 90) % 4) + 4) % 4
|
||||
return turns % 2 === 1 ? { width: height, height: width } : { width, height }
|
||||
}
|
||||
|
||||
export function nextRotation(rotation: number): number {
|
||||
return (rotation + 90) % 360
|
||||
}
|
||||
|
||||
export function computeOutputSize(
|
||||
cropWidth: number,
|
||||
cropHeight: number,
|
||||
targetWidth: number | null,
|
||||
) {
|
||||
const target = targetWidth === null ? cropWidth : targetWidth
|
||||
const width = Math.min(target, cropWidth)
|
||||
const height = Math.round((cropHeight * width) / cropWidth)
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const ESTIMATED_BYTES_PER_PIXEL = (420 * 1024) / (1600 * 1200)
|
||||
|
||||
export function estimateOutputBytes(width: number, height: number): number {
|
||||
return Math.round(width * height * ESTIMATED_BYTES_PER_PIXEL)
|
||||
}
|
||||
|
||||
export function formatEstimatedSize(bytes: number): string {
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1000) {
|
||||
return `${Math.round(kb)} KB`
|
||||
}
|
||||
return `${(kb / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export interface NaturalImageSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export async function readNaturalImageSize(file: File): Promise<NaturalImageSize> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const size = { width: bitmap.width, height: bitmap.height }
|
||||
bitmap.close()
|
||||
return size
|
||||
}
|
||||
|
||||
export interface RenderCropOptions {
|
||||
rect: CropRect
|
||||
rotation: number
|
||||
naturalWidth: number
|
||||
naturalHeight: number
|
||||
outputWidth: number
|
||||
outputHeight: number
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export async function renderCroppedImage(file: File, options: RenderCropOptions): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const rotated = rotatedDimensions(options.naturalWidth, options.naturalHeight, options.rotation)
|
||||
const rotatedCanvas = document.createElement('canvas')
|
||||
rotatedCanvas.width = rotated.width
|
||||
rotatedCanvas.height = rotated.height
|
||||
const rotatedCtx = rotatedCanvas.getContext('2d')
|
||||
if (!rotatedCtx) {
|
||||
throw new Error('canvas rendering is not supported')
|
||||
}
|
||||
rotatedCtx.translate(rotated.width / 2, rotated.height / 2)
|
||||
rotatedCtx.rotate((options.rotation * Math.PI) / 180)
|
||||
rotatedCtx.drawImage(bitmap, -options.naturalWidth / 2, -options.naturalHeight / 2)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = options.outputWidth
|
||||
canvas.height = options.outputHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('canvas rendering is not supported')
|
||||
}
|
||||
ctx.drawImage(
|
||||
rotatedCanvas,
|
||||
options.rect.x,
|
||||
options.rect.y,
|
||||
options.rect.width,
|
||||
options.rect.height,
|
||||
0,
|
||||
0,
|
||||
options.outputWidth,
|
||||
options.outputHeight,
|
||||
)
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
return
|
||||
}
|
||||
reject(new Error('failed to render the cropped image'))
|
||||
}, options.mimeType)
|
||||
})
|
||||
}
|
||||
@@ -42,3 +42,19 @@ export function rejectionTitle(filename: string) {
|
||||
export function sizeRejectionText(filename: string, bytes: number) {
|
||||
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
|
||||
}
|
||||
|
||||
export function formatWholeKB(bytes: number): string {
|
||||
const kb = bytes / 1024
|
||||
const rounded = Math.round(kb * 10) / 10
|
||||
if (rounded === 0) {
|
||||
return '0.0'
|
||||
}
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||
}
|
||||
|
||||
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
|
||||
if (totalBytes < 1024 * 1024) {
|
||||
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
|
||||
}
|
||||
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
|
||||
}
|
||||
@@ -10,6 +10,15 @@ import RootComponent from '@/App.vue'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
|
||||
}
|
||||
})
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static instances: FakeXMLHttpRequest[] = []
|
||||
|
||||
@@ -195,8 +204,8 @@ describe("Opening the uploader from the toolbar's image button shows the idle st
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropping a valid file onto the editor opens the uploader already uploading it', () => {
|
||||
it('opens uploading, not idle, and sends the file', async () => {
|
||||
describe('Dropping a valid file onto the editor opens the uploader already showing the crop step', () => {
|
||||
it('opens the crop step, and confirming it sends the resized file', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
@@ -209,8 +218,12 @@ describe('Dropping a valid file onto the editor opens the uploader already uploa
|
||||
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Add an image')
|
||||
expect(document.body.textContent).toContain('Crop and resize')
|
||||
expect(document.body.textContent).not.toContain('Drop an image here')
|
||||
|
||||
findButton('Upload image')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(latestXHR().url).toBe('/v1/article/12/image')
|
||||
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
|
||||
@@ -235,6 +248,9 @@ describe('A successful upload inserts the image at the cursor and marks the draf
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
await flushPromises()
|
||||
|
||||
findButton('Upload image')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
latestXHR().resolve(
|
||||
201,
|
||||
|
||||
+163
-4
@@ -209,6 +209,10 @@ function mib(value: number) {
|
||||
return Math.round(value * 1024 * 1024)
|
||||
}
|
||||
|
||||
function kb(value: number) {
|
||||
return Math.round(value * 1024)
|
||||
}
|
||||
|
||||
async function openImageUploader(page: Page) {
|
||||
await page.getByRole('button', { name: 'Image', exact: true }).first().click()
|
||||
await page.getByRole('dialog').getByText('Add an image').waitFor()
|
||||
@@ -222,6 +226,130 @@ async function chooseImageFile(page: Page, filename: string, mimeType: string, s
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseRasterImageFile(
|
||||
page: Page,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
sizeBytes: number,
|
||||
) {
|
||||
await page.evaluate(
|
||||
async ({ filename, mimeType, naturalWidth, naturalHeight, sizeBytes }) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = naturalWidth
|
||||
canvas.height = naturalHeight
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.fillStyle = '#d8d0c3'
|
||||
ctx.fillRect(0, 0, naturalWidth, naturalHeight)
|
||||
ctx.fillStyle = '#8f8373'
|
||||
ctx.fillRect(0, naturalHeight * 0.68, naturalWidth, naturalHeight * 0.32)
|
||||
ctx.fillStyle = '#f1ece3'
|
||||
ctx.strokeStyle = '#c4b9a8'
|
||||
ctx.lineWidth = naturalWidth * 0.015
|
||||
ctx.fillRect(
|
||||
naturalWidth * 0.56,
|
||||
naturalHeight * 0.14,
|
||||
naturalWidth * 0.22,
|
||||
naturalHeight * 0.42,
|
||||
)
|
||||
ctx.strokeRect(
|
||||
naturalWidth * 0.56,
|
||||
naturalHeight * 0.14,
|
||||
naturalWidth * 0.22,
|
||||
naturalHeight * 0.42,
|
||||
)
|
||||
const blob = await new Promise<Blob>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b!), mimeType, 0.85),
|
||||
)
|
||||
const file = new File([blob], filename, { type: mimeType })
|
||||
Object.defineProperty(file, 'size', { value: sizeBytes, configurable: true })
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
input.files = dataTransfer.files
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
},
|
||||
{ filename, mimeType, naturalWidth, naturalHeight, sizeBytes },
|
||||
)
|
||||
}
|
||||
|
||||
async function openCropStep(
|
||||
page: Page,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
sizeBytes: number,
|
||||
) {
|
||||
await openImageUploader(page)
|
||||
await chooseRasterImageFile(page, filename, mimeType, naturalWidth, naturalHeight, sizeBytes)
|
||||
await page.getByRole('dialog').getByText('Crop and resize').waitFor()
|
||||
}
|
||||
|
||||
async function selectAspectRatio(page: Page, ratio: string) {
|
||||
await page
|
||||
.getByRole('radiogroup', { name: 'Aspect ratio' })
|
||||
.getByRole('radio', { name: ratio, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
async function resizeCropFrameTo(
|
||||
page: Page,
|
||||
naturalWidth: number,
|
||||
targetWidthPx: number,
|
||||
targetHeightPx: number,
|
||||
) {
|
||||
const frame = page.locator('[data-testid="crop-frame"]')
|
||||
const handle = page.locator('[data-testid="crop-handle"]').nth(3)
|
||||
async function currentSize() {
|
||||
const box = (await frame.boundingBox())!
|
||||
const stageWidth = await frame.evaluate(
|
||||
(el) => el.parentElement!.getBoundingClientRect().width,
|
||||
)
|
||||
const scale = stageWidth / naturalWidth
|
||||
return {
|
||||
width: Math.round(box.width / scale),
|
||||
height: Math.round(box.height / scale),
|
||||
scale,
|
||||
}
|
||||
}
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
const frameBox = (await frame.boundingBox())!
|
||||
const handleBox = (await handle.boundingBox())!
|
||||
const { width: currentWidth, height: currentHeight, scale } = await currentSize()
|
||||
if (
|
||||
Math.abs(currentWidth - targetWidthPx) <= 30 &&
|
||||
Math.abs(currentHeight - targetHeightPx) <= 30
|
||||
) {
|
||||
break
|
||||
}
|
||||
const startX = handleBox.x + handleBox.width / 2
|
||||
const startY = handleBox.y + handleBox.height / 2
|
||||
const targetX = frameBox.x + targetWidthPx * scale
|
||||
const targetY = frameBox.y + targetHeightPx * scale
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(targetX, targetY, { steps: 1 })
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
for (let guard = 0; guard < 60; guard += 1) {
|
||||
const { width: currentWidth, height: currentHeight } = await currentSize()
|
||||
if (currentWidth === targetWidthPx && currentHeight === targetHeightPx) {
|
||||
return
|
||||
}
|
||||
if (currentWidth !== targetWidthPx) {
|
||||
await frame.press(currentWidth > targetWidthPx ? 'Shift+ArrowLeft' : 'Shift+ArrowRight')
|
||||
await page.waitForTimeout(120)
|
||||
continue
|
||||
}
|
||||
await frame.press(currentHeight > targetHeightPx ? 'Shift+ArrowUp' : 'Shift+ArrowDown')
|
||||
await page.waitForTimeout(120)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockUploadedImage(page: Page) {
|
||||
const isDark = await page.evaluate(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
@@ -957,16 +1085,47 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await openImageUploader(page)
|
||||
},
|
||||
},
|
||||
'Upload: crop and resize': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
|
||||
},
|
||||
},
|
||||
'Upload: crop to a ratio': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
|
||||
await selectAspectRatio(page, '16:9')
|
||||
},
|
||||
},
|
||||
'Upload: crop narrower than the width': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
|
||||
await selectAspectRatio(page, '1:1')
|
||||
await resizeCropFrameTo(page, 4032, 640, 640)
|
||||
await page.getByText('The crop is 640 px wide').waitFor()
|
||||
},
|
||||
},
|
||||
'Upload: in progress': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openImageUploader(page)
|
||||
await freezeUploadProgress(page, mib(2.4), mib(3.9))
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await page.getByText('north-wall.jpg 62%').waitFor()
|
||||
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
|
||||
await freezeUploadProgress(page, kb(180), kb(420))
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
|
||||
await page.getByText('north-wall.jpg 43%').waitFor()
|
||||
},
|
||||
},
|
||||
'Upload: rejected': {
|
||||
|
||||
Reference in new issue
Block a user