Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/smart-showers-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/image-cropper": patch
---

Make the image cropper responsive
157 changes: 157 additions & 0 deletions e2e/image-cropper.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
let I: ImageCropperModel

test.describe("image-cropper / resizable", () => {
test.beforeEach(async ({ page }) => {

Check failure on line 7 in e2e/image-cropper.e2e.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:143:7 › image-cropper / resizable › [flip] should toggle and reset flips with actions

1) e2e/image-cropper.e2e.ts:143:7 › image-cropper / resizable › [flip] should toggle and reset flips with actions Test timeout of 30000ms exceeded while running "beforeEach" hook. 5 | 6 | test.describe("image-cropper / resizable", () => { > 7 | test.beforeEach(async ({ page }) => { | ^ 8 | I = new ImageCropperModel(page) 9 | await I.goto() 10 | await I.waitForImageLoad() at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:7:8
I = new ImageCropperModel(page)
await I.goto()
await I.waitForImageLoad()
Expand Down Expand Up @@ -241,7 +241,7 @@
await I.pressKeyWithModifiers("ArrowRight", { alt: true })

let rect = await I.getSelectionRect()
expect(rect.width).toBe(initialRect.width + 1)

Check failure on line 244 in e2e/image-cropper.e2e.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:237:7 › image-cropper / resizable › [keyboard] should resize width using Alt+Arrow keys

2) e2e/image-cropper.e2e.ts:237:7 › image-cropper / resizable › [keyboard] should resize width using Alt+Arrow keys Error: expect(received).toBe(expected) // Object.is equality Expected: 3 Received: 2 242 | 243 | let rect = await I.getSelectionRect() > 244 | expect(rect.width).toBe(initialRect.width + 1) | ^ 245 | expect(rect.x).toBe(initialRect.x) 246 | 247 | await I.pressKeyWithModifiers("ArrowLeft", { alt: true }) at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:244:24
expect(rect.x).toBe(initialRect.x)

await I.pressKeyWithModifiers("ArrowLeft", { alt: true })
Expand Down Expand Up @@ -386,7 +386,7 @@
})

test.describe("image-cropper / resize API", () => {
test.beforeEach(async ({ page }) => {

Check failure on line 389 in e2e/image-cropper.e2e.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:459:7 › image-cropper / resize API › [api] should grow selection using bottom handle

3) e2e/image-cropper.e2e.ts:459:7 › image-cropper / resize API › [api] should grow selection using bottom handle Test timeout of 30000ms exceeded while running "beforeEach" hook. 387 | 388 | test.describe("image-cropper / resize API", () => { > 389 | test.beforeEach(async ({ page }) => { | ^ 390 | I = new ImageCropperModel(page) 391 | await I.goto() 392 | await I.waitForImageLoad() at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:389:8
I = new ImageCropperModel(page)
await I.goto()
await I.waitForImageLoad()
Expand Down Expand Up @@ -562,7 +562,7 @@

const newRect = await I.getSelectionRect()

expect(newRect.width).toBe(initialRect.width + 30)

Check failure on line 565 in e2e/image-cropper.e2e.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:555:7 › image-cropper / resize API › [api] should grow selection using top-left corner handle

4) e2e/image-cropper.e2e.ts:555:7 › image-cropper / resize API › [api] should grow selection using top-left corner handle Error: expect(received).toBe(expected) // Object.is equality Expected: 32 Received: 2 563 | const newRect = await I.getSelectionRect() 564 | > 565 | expect(newRect.width).toBe(initialRect.width + 30) | ^ 566 | expect(newRect.height).toBe(initialRect.height + 30) 567 | expect(newRect.x).toBe(initialRect.x - 30) 568 | expect(newRect.y).toBe(initialRect.y - 30) at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:565:27
expect(newRect.height).toBe(initialRect.height + 30)
expect(newRect.x).toBe(initialRect.x - 30)
expect(newRect.y).toBe(initialRect.y - 30)
Expand Down Expand Up @@ -686,7 +686,7 @@
})

test.describe("image-cropper / circle", () => {
test.beforeEach(async ({ page }) => {

Check failure on line 689 in e2e/image-cropper.e2e.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:700:7 › image-cropper / circle › should keep the crop area in 1:1 aspect ratio when resizing

5) e2e/image-cropper.e2e.ts:700:7 › image-cropper / circle › should keep the crop area in 1:1 aspect ratio when resizing Test timeout of 30000ms exceeded while running "beforeEach" hook. 687 | 688 | test.describe("image-cropper / circle", () => { > 689 | test.beforeEach(async ({ page }) => { | ^ 690 | I = new ImageCropperModel(page) 691 | await I.goto("/image-cropper-circle") 692 | await I.waitForImageLoad() at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:689:8
I = new ImageCropperModel(page)
await I.goto("/image-cropper-circle")
await I.waitForImageLoad()
Expand All @@ -706,3 +706,160 @@
expect(newRect.width).toEqual(newRect.height)
})
})

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

test("[viewport] should scale crop proportionally when viewport shrinks", async () => {
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
const initialCrop = await I.getSelectionRect()

const initialWidthRatio = initialCrop.width / initialWidth
const initialHeightRatio = initialCrop.height / initialHeight

const newWidth = Math.floor(initialWidth * 0.8)
const newHeight = Math.floor(initialHeight * 0.8)
await I.resizeViewport(newWidth, newHeight)

const newViewport = await I.getViewportSize()
const newCrop = await I.getSelectionRect()

expect(newViewport.width).toBe(newWidth)
expect(newViewport.height).toBe(newHeight)

const newWidthRatio = newCrop.width / newViewport.width
const newHeightRatio = newCrop.height / newViewport.height

expect(newWidthRatio).toBe(initialWidthRatio)
expect(newHeightRatio).toBe(initialHeightRatio)
})

test("[viewport] should scale crop proportionally when viewport grows", async () => {
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
const initialCrop = await I.getSelectionRect()

const initialWidthRatio = initialCrop.width / initialWidth
const initialHeightRatio = initialCrop.height / initialHeight

const newWidth = Math.floor(initialWidth * 1.2)
const newHeight = Math.floor(initialHeight * 1.2)
await I.resizeViewport(newWidth, newHeight)

const newViewport = await I.getViewportSize()
const newCrop = await I.getSelectionRect()

expect(newViewport.width).toBe(newWidth)
expect(newViewport.height).toBe(newHeight)

const newWidthRatio = newCrop.width / newViewport.width
const newHeightRatio = newCrop.height / newViewport.height

expect(newWidthRatio).toBe(initialWidthRatio)
expect(newHeightRatio).toBe(initialHeightRatio)
})

test("[viewport] should maintain aspect ratio during resize when aspectRatio is set", async () => {
await I.controls.num("aspectRatio", "1.5")
await I.wait(100)

const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
const newWidth = Math.floor(initialWidth * 0.7)
const newHeight = Math.floor(initialHeight * 0.7)
await I.resizeViewport(newWidth, newHeight)

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

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

test("[viewport] should keep crop within bounds after resize", async () => {
const initialViewport = await I.getViewportRect()
const initialCrop = await I.getSelectionRect()

const relativeMaxX = initialViewport.width - initialCrop.width
const relativeMaxY = initialViewport.height - initialCrop.height
const relativeCropX = initialCrop.x - initialViewport.x
const relativeCropY = initialCrop.y - initialViewport.y

await I.dragSelection(relativeMaxX - relativeCropX, relativeMaxY - relativeCropY)
await I.wait(100)

const newWidth = Math.floor(initialViewport.width * 0.6)
const newHeight = Math.floor(initialViewport.height * 0.6)
await I.resizeViewport(newWidth, newHeight)

const newViewport = await I.getViewportRect()
const newCrop = await I.getSelectionRect()

const relativeCropLeft = newCrop.x - newViewport.x
const relativeCropTop = newCrop.y - newViewport.y
const relativeCropRight = relativeCropLeft + newCrop.width
const relativeCropBottom = relativeCropTop + newCrop.height

expect(relativeCropLeft).toBeGreaterThanOrEqual(0)
expect(relativeCropTop).toBeGreaterThanOrEqual(0)
expect(relativeCropRight).toBeLessThanOrEqual(newViewport.width)
expect(relativeCropBottom).toBeLessThanOrEqual(newViewport.height)
})

test("[viewport] should respect minSize constraints after resize", async () => {
await I.controls.num("minWidth", "100")
await I.controls.num("minHeight", "80")
await I.wait(100)

const newWidth = 150
const newHeight = 120
await I.resizeViewport(newWidth, newHeight)

const newCrop = await I.getSelectionRect()

expect(newCrop.width).toBeGreaterThanOrEqual(100)
expect(newCrop.height).toBeGreaterThanOrEqual(80)
})

test("[viewport] should respect maxSize constraints after resize", async () => {
await I.controls.num("maxWidth", "200")
await I.controls.num("maxHeight", "150")
await I.wait(100)

const { width: initialWidth, height: initialHeight } = await I.getViewportSize()

const newWidth = Math.floor(initialWidth * 1.5)
const newHeight = Math.floor(initialHeight * 1.5)
await I.resizeViewport(newWidth, newHeight)

const newCrop = await I.getSelectionRect()
const newViewport = await I.getViewportRect()

const expectedMaxWidth = Math.min(200, newViewport.width)
const expectedMaxHeight = Math.min(150, newViewport.height)

expect(newCrop.width).toBeLessThanOrEqual(expectedMaxWidth)
expect(newCrop.height).toBeLessThanOrEqual(expectedMaxHeight)
})

test("[viewport] should handle multiple sequential resizes", async () => {
const initialCrop = await I.getSelectionRect()
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()

await I.resizeViewport(Math.floor(initialWidth * 0.8), Math.floor(initialHeight * 0.8))
const afterShrink = await I.getSelectionRect()

await I.resizeViewport(Math.floor(initialWidth * 1.1), Math.floor(initialHeight * 1.1))
const afterGrow = await I.getSelectionRect()

await I.resizeViewport(initialWidth, initialHeight)
const finalCrop = await I.getSelectionRect()

expect(afterShrink.width).toBeLessThan(initialCrop.width)
expect(afterGrow.width).toBeGreaterThan(afterShrink.width)

expect(finalCrop.width).toBeCloseTo(initialCrop.width, 0)
expect(finalCrop.height).toBeCloseTo(initialCrop.height, 0)
})
})
16 changes: 16 additions & 0 deletions e2e/models/image-cropper.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
}

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

Check failure on line 15 in e2e/models/image-cropper.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:700:7 › image-cropper / circle › should keep the crop area in 1:1 aspect ratio when resizing

5) e2e/image-cropper.e2e.ts:700:7 › image-cropper / circle › should keep the crop area in 1:1 aspect ratio when resizing Error: page.goto: Test timeout of 30000ms exceeded. Call log: - navigating to "http://localhost:3000/image-cropper-circle", waiting until "load" at models/image-cropper.model.ts:15 13 | 14 | goto(url = "/image-cropper") { > 15 | return this.page.goto(url) | ^ 16 | } 17 | 18 | get viewport() { at ImageCropperModel.goto (/home/runner/work/zag/zag/e2e/models/image-cropper.model.ts:15:22) at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:691:13

Check failure on line 15 in e2e/models/image-cropper.model.ts

View workflow job for this annotation

GitHub Actions / End-to-end Tests (4)

e2e/image-cropper.e2e.ts:459:7 › image-cropper / resize API › [api] should grow selection using bottom handle

3) e2e/image-cropper.e2e.ts:459:7 › image-cropper / resize API › [api] should grow selection using bottom handle Error: page.goto: Test timeout of 30000ms exceeded. Call log: - navigating to "http://localhost:3000/image-cropper", waiting until "load" at models/image-cropper.model.ts:15 13 | 14 | goto(url = "/image-cropper") { > 15 | return this.page.goto(url) | ^ 16 | } 17 | 18 | get viewport() { at ImageCropperModel.goto (/home/runner/work/zag/zag/e2e/models/image-cropper.model.ts:15:22) at /home/runner/work/zag/zag/e2e/image-cropper.e2e.ts:391:13
}

get viewport() {
Expand Down Expand Up @@ -298,4 +298,20 @@
async clickShrink() {
await this.shrinkButton.click()
}

async resizeViewport(width: number, height: number) {
await this.viewport.evaluate(
(el, { width, height }) => {
el.style.width = `${width}px`
el.style.height = `${height}px`
},
{ width, height },
)
await this.wait(100)
}

async getViewportSize() {
const rect = await this.getViewportRect()
return { width: rect.width, height: rect.height }
}
}
81 changes: 81 additions & 0 deletions packages/machines/image-cropper/src/image-cropper.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,15 @@ export const machine = createMachine<ImageCropperSchema>({
guard: "canResizeCrop",
actions: ["resizeCrop"],
},
VIEWPORT_RESIZE: {
actions: ["handleViewportResize"],
},
},

states: {
idle: {
entry: ["checkImageStatus"],
effects: ["trackViewportResize"],
on: {
SET_NATURAL_SIZE: {
actions: ["setNaturalSize"],
Expand Down Expand Up @@ -722,6 +726,67 @@ export const machine = createMachine<ImageCropperSchema>({

context.set("crop", nextCrop)
},

handleViewportResize({ context, prop, scope, send }) {
const viewportEl = dom.getViewportEl(scope)
if (!viewportEl) return

const newViewportRect = viewportEl.getBoundingClientRect()
if (newViewportRect.height <= 0 || newViewportRect.width <= 0) return

const oldViewportRect = context.get("viewportRect")

if (oldViewportRect.width === newViewportRect.width && oldViewportRect.height === newViewportRect.height) {
return
}

context.set("viewportRect", newViewportRect)

const oldCrop = context.get("crop")

if (oldViewportRect.width === 0 || oldViewportRect.height === 0) {
if (oldCrop.width === 0 || oldCrop.height === 0) {
send({ type: "SET_DEFAULT_CROP", src: "viewport-resize" })
return
}
}

const cropShape = prop("cropShape")
const aspectRatio = resolveCropAspectRatio(cropShape, prop("aspectRatio"))
const { minSize, maxSize } = getCropSizeLimits(prop)

const scaleX = newViewportRect.width / oldViewportRect.width
const scaleY = newViewportRect.height / oldViewportRect.height

let newCrop = {
x: oldCrop.x * scaleX,
y: oldCrop.y * scaleY,
width: oldCrop.width * scaleX,
height: oldCrop.height * scaleY,
}

const constrainedCrop = computeResizeCrop({
cropStart: newCrop,
handlePosition: "bottom-right",
delta: { x: 0, y: 0 },
viewportRect: newViewportRect,
minSize,
maxSize,
aspectRatio,
})

const maxX = Math.max(0, newViewportRect.width - constrainedCrop.width)
const maxY = Math.max(0, newViewportRect.height - constrainedCrop.height)

const finalCrop = {
x: clampValue(constrainedCrop.x, 0, maxX),
y: clampValue(constrainedCrop.y, 0, maxY),
width: constrainedCrop.width,
height: constrainedCrop.height,
}

context.set("crop", finalCrop)
},
},

effects: {
Expand All @@ -745,6 +810,22 @@ export const machine = createMachine<ImageCropperSchema>({
cleanups.forEach((cleanup) => cleanup())
}
},

trackViewportResize({ scope, send }) {
const viewportEl = dom.getViewportEl(scope)
if (!viewportEl) return

const win = scope.getWin()
const resizeObserver = new win.ResizeObserver(() => {
send({ type: "VIEWPORT_RESIZE", src: "resize" })
})

resizeObserver.observe(viewportEl)

return () => {
resizeObserver.disconnect()
}
},
},
},
})
Loading