Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c7ee404
chore: wip
nelsonlaidev Sep 26, 2025
8e9b0dc
refactor: correct type
nelsonlaidev Sep 26, 2025
46a1ddd
chore: add css for example
nelsonlaidev Sep 26, 2025
4c8e548
Merge branch 'main' into feat/image-cropper
nelsonlaidev Sep 28, 2025
afe6f7b
chore: update lockfile
nelsonlaidev Sep 28, 2025
1e34ce9
feat: basic crop and resize functionalities
nelsonlaidev Sep 29, 2025
298a4b8
fix: add missing props
nelsonlaidev Sep 29, 2025
67f35e2
refactor: switch to use pixel unit
nelsonlaidev Sep 29, 2025
a9591b3
feat: allow custom aspect ratio
nelsonlaidev Sep 29, 2025
4ef2bba
fix: add missing props
nelsonlaidev Sep 29, 2025
671ee91
refactor: move some code to utils
nelsonlaidev Sep 29, 2025
0e6939b
feat: allow shift key to maintain aspect ratio
nelsonlaidev Sep 29, 2025
f0e45b5
refactor: clean up
nelsonlaidev Sep 29, 2025
bf30d75
refactor: clean up
nelsonlaidev Sep 29, 2025
ffc7993
Merge branch 'main' into feat/image-cropper
nelsonlaidev Sep 29, 2025
e879feb
Merge branch 'main' into feat/image-cropper
nelsonlaidev Oct 2, 2025
16e3f34
chore: update lockfile
nelsonlaidev Oct 2, 2025
ae21fb3
refactor: default toolbar to visualizer
nelsonlaidev Oct 2, 2025
90e3c6a
feat: add zoom functionality
nelsonlaidev Oct 2, 2025
6cbc497
feat: pinch-to-zoom
nelsonlaidev Oct 4, 2025
81be29b
feat: allow dragging the image after zooming
nelsonlaidev Oct 4, 2025
2c428ca
Merge branch 'main' into feat/image-cropper
nelsonlaidev Oct 5, 2025
6d5a900
refactor: update example and css to have bigger hitbox
nelsonlaidev Oct 5, 2025
8969dc4
feat: add zoom related props
nelsonlaidev Oct 5, 2025
c441e6b
feat: setZoom API
nelsonlaidev Oct 5, 2025
347ff77
refactor: revert no new line in package.json
nelsonlaidev Oct 5, 2025
14aca56
refactor: move some functions to utils file
nelsonlaidev Oct 5, 2025
8c10fe0
feat: rotation
nelsonlaidev Oct 5, 2025
e40344e
refactor: controlled image cropper example
nelsonlaidev Oct 5, 2025
4232243
refactor: use api to control zoom and rotation in image cropper example
nelsonlaidev Oct 7, 2025
c8753a6
refactor: reduce code complexity
nelsonlaidev Oct 7, 2025
b6f0282
refactor: remove logic for panning temporarily
nelsonlaidev Oct 7, 2025
4fad881
fix: no overflow when zooming out
nelsonlaidev Oct 7, 2025
5522990
fix: pan correctly when the image is rotated
nelsonlaidev Oct 7, 2025
3c9aaec
refactor: update default zoom step
nelsonlaidev Oct 7, 2025
a4c55ea
refactor: only clear necessary context
nelsonlaidev Oct 7, 2025
10fe38e
refactor: remove unnecessary context
nelsonlaidev Oct 7, 2025
04128bd
feat: allow setting sensitivity for mobile when zooming
nelsonlaidev Oct 7, 2025
b4a6349
fix: type error
nelsonlaidev Oct 9, 2025
cb54315
fix: can't pan while pinching
nelsonlaidev Oct 9, 2025
f55d2d2
feat: add fixed cropper
nelsonlaidev Oct 9, 2025
bde84b5
Merge branch 'main' into feat/image-cropper
nelsonlaidev Oct 9, 2025
da4620e
chore: update lockfile
nelsonlaidev Oct 9, 2025
60b5e33
fix: type error
nelsonlaidev Oct 9, 2025
8d2efe0
feat: smart default crop area
nelsonlaidev Oct 9, 2025
cb13756
test: add e2e tests for image cropper
nelsonlaidev Oct 10, 2025
ece3d36
refactor: replace minCropSize with minWidth and minHeight
nelsonlaidev Oct 10, 2025
4681c81
feat: add maxWidth and maxHeight for crop area
nelsonlaidev Oct 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions e2e/image-cropper.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { test, expect } from "@playwright/test"
import { ImageCropperModel } from "./models/image-cropper.model"

let I: ImageCropperModel

test.describe("image-cropper / resizable", () => {
test.beforeEach(async ({ page }) => {
I = new ImageCropperModel(page)
await I.goto()
await I.waitForImageLoad()
})

test("should have no accessibility violation", async () => {
await I.checkAccessibility()
})

test("should display image and crop selection", async () => {
await I.seeImageVisible()
await I.seeSelectionVisible()
})

test("[pointer] should move crop selection by dragging", async () => {
const initialRect = await I.getSelectionRect()

await I.dragSelection(50, 30)

await I.seeSelectionPosition(initialRect.x + 50, initialRect.y + 30)

await I.seeSelectionSize(initialRect.width, initialRect.height)
})

test("[pointer] should resize crop selection using corner handle", async () => {
const initialRect = await I.getSelectionRect()

await I.dragHandle("bottom-right", 10, 15)

await I.seeSelectionSize(initialRect.width + 10, initialRect.height + 15)
})

test("[pointer] should resize crop selection using side handle", async () => {
const initialRect = await I.getSelectionRect()

await I.dragHandle("right", 20, 0)

await I.seeSelectionSize(initialRect.width + 20, initialRect.height)
})

test("[pointer] should resize crop selection using top handle", async () => {
const initialRect = await I.getSelectionRect()

await I.dragHandle("top", 0, -20)

await I.seeSelectionSize(initialRect.width, initialRect.height + 20)
})

test("[zoom] should zoom in using wheel", async () => {
await I.zoomWithWheel(-100)
await I.wait(100)

const transform = await I.getImageTransform()
const { scaleX, scaleY } = await I.getScaleFromMatrix(transform)

expect(scaleX).toBe(1.1)
expect(scaleY).toBe(1.1)
})

test("[zoom] should zoom out using wheel", async () => {
await I.zoomWithWheel(-100)
await I.wait(100)

await I.zoomWithWheel(100)
await I.wait(100)

const transform = await I.getImageTransform()
const { scaleX, scaleY } = await I.getScaleFromMatrix(transform)

expect(scaleX).toBe(1)
expect(scaleY).toBe(1)
})

test("[pan] should pan image when dragging overlay", async () => {
await I.zoomWithWheel(-100)
await I.wait(100)

await I.panImage(50, 30)
await I.wait(100)

const transform = await I.getImageTransform()
const { translateX, translateY } = await I.getTranslateFromMatrix(transform)

expect(translateX).toBe(25)
expect(translateY).toBe(15)
})

test("[aspectRatio] should maintain aspect ratio when resizing with constraint", async () => {
await I.controls.num("aspectRatio", "1")
await I.wait(100)

await I.dragHandle("bottom-right", 60, 60)
await I.wait(100)

const newRect = await I.getSelectionRect()

const aspectRatio = newRect.width / newRect.height
const expectedRatio = 1

expect(aspectRatio).toBe(expectedRatio)
})

test("[rotation] should rotate image", async () => {
await I.rotationSlider.fill("45")

const transform = await I.getImageTransform()
const rotation = await I.getRotationFromMatrix(transform)

expect(rotation).toBe(45)
})

test("[keyboard + pointer] should lock aspect ratio with shift key during resize", async () => {
const initialRect = await I.getSelectionRect()
const initialAspectRatio = initialRect.width / initialRect.height

await I.dragHandle("bottom-right", -60, -100, { shift: true })
await I.wait(100)

const newRect = await I.getSelectionRect()
const newAspectRatio = newRect.width / newRect.height

expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 2)
})

test("[minSize] should respect minimum crop size", async () => {
await I.controls.num("minWidth", "80")
await I.controls.num("minHeight", "60")
await I.wait(100)

await I.dragHandle("bottom-right", -500, -500)
await I.wait(100)

const { width, height } = await I.getSelectionRect()

expect(width).toBe(80)
expect(height).toBe(60)
})

test("[maxSize] should respect maximum crop size", async () => {
await I.controls.num("maxWidth", "200")
await I.controls.num("maxHeight", "150")
await I.wait(100)

await I.dragHandle("bottom-right", 500, 500)
await I.wait(100)

const selectionRect = await I.getSelectionRect()
const viewportRect = await I.getViewportRect()

const expectedWidth = Math.min(200, viewportRect.width)
const expectedHeight = Math.min(150, viewportRect.height)

expect(selectionRect.width).toBe(expectedWidth)
expect(selectionRect.height).toBe(expectedHeight)
})

test("[zoom] should allow programmatic zoom changes", async () => {
await I.zoomSlider.fill("2")

const transform = await I.getImageTransform()
const { scaleX, scaleY } = await I.getScaleFromMatrix(transform)

expect(scaleX).toBe(2)
expect(scaleY).toBe(2)
})
})

test.describe("image-cropper / fixedCropArea", () => {
test.beforeEach(async ({ page }) => {
I = new ImageCropperModel(page)
await I.goto("/image-cropper-fixed")
await I.waitForImageLoad()
})

test("should prevent crop area from moving when fixed", async () => {
const initialRect = await I.getSelectionRect()

await I.dragSelection(50, 30)
await I.wait(100)

await I.seeSelectionPosition(initialRect.x, initialRect.y)
})
})
203 changes: 203 additions & 0 deletions e2e/models/image-cropper.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { expect, type Page } from "@playwright/test"
import { a11y, rect } from "../_utils"
import { Model } from "./model"

export class ImageCropperModel extends Model {
constructor(public page: Page) {
super(page)
}

checkAccessibility() {
return a11y(this.page)
}

goto(url = "/image-cropper") {
return this.page.goto(url)
}

get viewport() {
return this.page.locator("[data-scope='image-cropper'][data-part='viewport']")
}

get image() {
return this.page.locator("[data-scope='image-cropper'][data-part='image']")
}

get selection() {
return this.page.locator("[data-scope='image-cropper'][data-part='selection']")
}

get overlay() {
return this.page.locator("[data-scope='image-cropper'][data-part='overlay']")
}

get zoomSlider() {
return this.page.locator("input[type='range'][data-testid='zoom-slider']")
}

get rotationSlider() {
return this.page.locator("input[type='range'][data-testid='rotation-slider']")
}

getHandle(position: string) {
return this.page.locator(`[data-scope='image-cropper'][data-part='handle'][data-position='${position}']`)
}

async getSelectionRect() {
const bbox = await rect(this.selection)
return bbox
}

async getViewportRect() {
return rect(this.viewport)
}

async getImageTransform() {
return this.image.evaluate((el) => {
const style = window.getComputedStyle(el)
return style.transform
})
}

async getRotationFromMatrix(transform: string) {
if (!transform || transform === "none") return 0

const match = transform.match(/^matrix\(([^)]+)\)$/)
if (!match) return 0

const values = match[1].split(",").map(parseFloat)
const [a, b] = values

let angle = Math.atan2(b, a) * (180 / Math.PI)
if (angle < 0) angle += 360
return angle
}

async getScaleFromMatrix(transform: string) {
if (!transform || transform === "none") {
return { scaleX: 1, scaleY: 1 }
}

const match = transform.match(/^matrix\(([^)]+)\)$/)
if (!match) return { scaleX: 1, scaleY: 1 }

const values = match[1].split(",").map(parseFloat)
const [a, b, c, d] = values

const scaleX = Math.sqrt(a * a + b * b)
const scaleY = Math.sqrt(c * c + d * d)

return { scaleX, scaleY }
}

async getTranslateFromMatrix(transform: string) {
if (!transform || transform === "none") {
return { translateX: 0, translateY: 0 }
}

const match = transform.match(/^matrix\(([^)]+)\)$/)
if (!match) return { translateX: 0, translateY: 0 }

const values = match[1].split(",").map(parseFloat)
const [, , , , e, f] = values

return { translateX: e, translateY: f }
}

async dragSelection(deltaX: number, deltaY: number) {
const selectionBox = await rect(this.selection)
const startX = selectionBox.midX
const startY = selectionBox.midY

await this.page.mouse.move(startX, startY)
await this.page.mouse.down()
await this.page.mouse.move(startX + deltaX, startY + deltaY)
await this.page.mouse.up()
}

async dragHandle(position: string, deltaX: number, deltaY: number, options?: { shift?: boolean }) {
const handle = this.getHandle(position)
const handleBox = await rect(handle)

await this.page.mouse.move(handleBox.midX, handleBox.midY)

if (options?.shift) {
await this.page.keyboard.down("Shift")
}

await this.page.mouse.down()
await this.page.mouse.move(handleBox.midX + deltaX, handleBox.midY + deltaY, { steps: 10 })
await this.page.mouse.up()

if (options?.shift) {
await this.page.keyboard.up("Shift")
}
}

async panImage(deltaX: number, deltaY: number) {
// Click on overlay (outside selection) to trigger pan
const viewportBox = await rect(this.viewport)
const selectionBox = await rect(this.selection)

// Click in top-left corner of viewport, outside selection
const startX = viewportBox.x + 10
const startY = viewportBox.y + 10

// Make sure we're not clicking on the selection
const isOutsideSelection = startX < selectionBox.x || startY < selectionBox.y

if (!isOutsideSelection) {
// If selection is in top-left, use bottom-right instead
const altX = viewportBox.maxX - 10
const altY = viewportBox.maxY - 10

await this.page.mouse.move(altX, altY)
await this.page.mouse.down()
await this.page.mouse.move(altX + deltaX, altY + deltaY)
await this.page.mouse.up()
} else {
await this.page.mouse.move(startX, startY)
await this.page.mouse.down()
await this.page.mouse.move(startX + deltaX, startY + deltaY)
await this.page.mouse.up()
}
}

async zoomWithWheel(deltaY: number, point?: { x: number; y: number }) {
const viewportBox = await rect(this.viewport)
const x = point?.x ?? viewportBox.midX
const y = point?.y ?? viewportBox.midY

await this.page.mouse.move(x, y)
await this.page.mouse.wheel(0, deltaY)
}

async seeSelectionPosition(expectedX: number, expectedY: number) {
const bbox = await this.getSelectionRect()
expect(bbox.x).toBe(expectedX)
expect(bbox.y).toBe(expectedY)
}

async seeSelectionSize(expectedWidth: number, expectedHeight: number) {
const bbox = await this.getSelectionRect()
expect(bbox.width).toBe(expectedWidth)
expect(bbox.height).toBe(expectedHeight)
}

async seeSelectionVisible() {
await expect(this.selection).toBeVisible()
}

async seeImageVisible() {
await expect(this.image).toBeVisible()
}

async waitForImageLoad() {
await this.image.evaluate((img: HTMLImageElement) => {
if (img.complete) return
return new Promise((resolve) => {
img.onload = () => resolve(true)
})
})
}
}
Loading
Loading