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
+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