Skip to content

Commit a766d97

Browse files
authored
fix: make the image cropper responsive (#2805)
* fix: make the image cropper responsive * test: make e2e tests to be stable * fix: avoid failing to set default crop * refactor: use scope.getWin
1 parent 60d65ea commit a766d97

File tree

4 files changed

+259
-0
lines changed

4 files changed

+259
-0
lines changed

.changeset/smart-showers-jump.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/image-cropper": patch
3+
---
4+
5+
Make the image cropper responsive

e2e/image-cropper.e2e.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,3 +706,160 @@ test.describe("image-cropper / circle", () => {
706706
expect(newRect.width).toEqual(newRect.height)
707707
})
708708
})
709+
710+
test.describe("image-cropper / viewport resize", () => {
711+
test.beforeEach(async ({ page }) => {
712+
I = new ImageCropperModel(page)
713+
await I.goto()
714+
await I.waitForImageLoad()
715+
})
716+
717+
test("[viewport] should scale crop proportionally when viewport shrinks", async () => {
718+
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
719+
const initialCrop = await I.getSelectionRect()
720+
721+
const initialWidthRatio = initialCrop.width / initialWidth
722+
const initialHeightRatio = initialCrop.height / initialHeight
723+
724+
const newWidth = Math.floor(initialWidth * 0.8)
725+
const newHeight = Math.floor(initialHeight * 0.8)
726+
await I.resizeViewport(newWidth, newHeight)
727+
728+
const newViewport = await I.getViewportSize()
729+
const newCrop = await I.getSelectionRect()
730+
731+
expect(newViewport.width).toBe(newWidth)
732+
expect(newViewport.height).toBe(newHeight)
733+
734+
const newWidthRatio = newCrop.width / newViewport.width
735+
const newHeightRatio = newCrop.height / newViewport.height
736+
737+
expect(newWidthRatio).toBe(initialWidthRatio)
738+
expect(newHeightRatio).toBe(initialHeightRatio)
739+
})
740+
741+
test("[viewport] should scale crop proportionally when viewport grows", async () => {
742+
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
743+
const initialCrop = await I.getSelectionRect()
744+
745+
const initialWidthRatio = initialCrop.width / initialWidth
746+
const initialHeightRatio = initialCrop.height / initialHeight
747+
748+
const newWidth = Math.floor(initialWidth * 1.2)
749+
const newHeight = Math.floor(initialHeight * 1.2)
750+
await I.resizeViewport(newWidth, newHeight)
751+
752+
const newViewport = await I.getViewportSize()
753+
const newCrop = await I.getSelectionRect()
754+
755+
expect(newViewport.width).toBe(newWidth)
756+
expect(newViewport.height).toBe(newHeight)
757+
758+
const newWidthRatio = newCrop.width / newViewport.width
759+
const newHeightRatio = newCrop.height / newViewport.height
760+
761+
expect(newWidthRatio).toBe(initialWidthRatio)
762+
expect(newHeightRatio).toBe(initialHeightRatio)
763+
})
764+
765+
test("[viewport] should maintain aspect ratio during resize when aspectRatio is set", async () => {
766+
await I.controls.num("aspectRatio", "1.5")
767+
await I.wait(100)
768+
769+
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
770+
const newWidth = Math.floor(initialWidth * 0.7)
771+
const newHeight = Math.floor(initialHeight * 0.7)
772+
await I.resizeViewport(newWidth, newHeight)
773+
774+
const newCrop = await I.getSelectionRect()
775+
const newAspectRatio = newCrop.width / newCrop.height
776+
777+
expect(newAspectRatio).toBeCloseTo(1.5, 2)
778+
})
779+
780+
test("[viewport] should keep crop within bounds after resize", async () => {
781+
const initialViewport = await I.getViewportRect()
782+
const initialCrop = await I.getSelectionRect()
783+
784+
const relativeMaxX = initialViewport.width - initialCrop.width
785+
const relativeMaxY = initialViewport.height - initialCrop.height
786+
const relativeCropX = initialCrop.x - initialViewport.x
787+
const relativeCropY = initialCrop.y - initialViewport.y
788+
789+
await I.dragSelection(relativeMaxX - relativeCropX, relativeMaxY - relativeCropY)
790+
await I.wait(100)
791+
792+
const newWidth = Math.floor(initialViewport.width * 0.6)
793+
const newHeight = Math.floor(initialViewport.height * 0.6)
794+
await I.resizeViewport(newWidth, newHeight)
795+
796+
const newViewport = await I.getViewportRect()
797+
const newCrop = await I.getSelectionRect()
798+
799+
const relativeCropLeft = newCrop.x - newViewport.x
800+
const relativeCropTop = newCrop.y - newViewport.y
801+
const relativeCropRight = relativeCropLeft + newCrop.width
802+
const relativeCropBottom = relativeCropTop + newCrop.height
803+
804+
expect(relativeCropLeft).toBeGreaterThanOrEqual(0)
805+
expect(relativeCropTop).toBeGreaterThanOrEqual(0)
806+
expect(relativeCropRight).toBeLessThanOrEqual(newViewport.width)
807+
expect(relativeCropBottom).toBeLessThanOrEqual(newViewport.height)
808+
})
809+
810+
test("[viewport] should respect minSize constraints after resize", async () => {
811+
await I.controls.num("minWidth", "100")
812+
await I.controls.num("minHeight", "80")
813+
await I.wait(100)
814+
815+
const newWidth = 150
816+
const newHeight = 120
817+
await I.resizeViewport(newWidth, newHeight)
818+
819+
const newCrop = await I.getSelectionRect()
820+
821+
expect(newCrop.width).toBeGreaterThanOrEqual(100)
822+
expect(newCrop.height).toBeGreaterThanOrEqual(80)
823+
})
824+
825+
test("[viewport] should respect maxSize constraints after resize", async () => {
826+
await I.controls.num("maxWidth", "200")
827+
await I.controls.num("maxHeight", "150")
828+
await I.wait(100)
829+
830+
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
831+
832+
const newWidth = Math.floor(initialWidth * 1.5)
833+
const newHeight = Math.floor(initialHeight * 1.5)
834+
await I.resizeViewport(newWidth, newHeight)
835+
836+
const newCrop = await I.getSelectionRect()
837+
const newViewport = await I.getViewportRect()
838+
839+
const expectedMaxWidth = Math.min(200, newViewport.width)
840+
const expectedMaxHeight = Math.min(150, newViewport.height)
841+
842+
expect(newCrop.width).toBeLessThanOrEqual(expectedMaxWidth)
843+
expect(newCrop.height).toBeLessThanOrEqual(expectedMaxHeight)
844+
})
845+
846+
test("[viewport] should handle multiple sequential resizes", async () => {
847+
const initialCrop = await I.getSelectionRect()
848+
const { width: initialWidth, height: initialHeight } = await I.getViewportSize()
849+
850+
await I.resizeViewport(Math.floor(initialWidth * 0.8), Math.floor(initialHeight * 0.8))
851+
const afterShrink = await I.getSelectionRect()
852+
853+
await I.resizeViewport(Math.floor(initialWidth * 1.1), Math.floor(initialHeight * 1.1))
854+
const afterGrow = await I.getSelectionRect()
855+
856+
await I.resizeViewport(initialWidth, initialHeight)
857+
const finalCrop = await I.getSelectionRect()
858+
859+
expect(afterShrink.width).toBeLessThan(initialCrop.width)
860+
expect(afterGrow.width).toBeGreaterThan(afterShrink.width)
861+
862+
expect(finalCrop.width).toBeCloseTo(initialCrop.width, 0)
863+
expect(finalCrop.height).toBeCloseTo(initialCrop.height, 0)
864+
})
865+
})

e2e/models/image-cropper.model.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,20 @@ export class ImageCropperModel extends Model {
298298
async clickShrink() {
299299
await this.shrinkButton.click()
300300
}
301+
302+
async resizeViewport(width: number, height: number) {
303+
await this.viewport.evaluate(
304+
(el, { width, height }) => {
305+
el.style.width = `${width}px`
306+
el.style.height = `${height}px`
307+
},
308+
{ width, height },
309+
)
310+
await this.wait(100)
311+
}
312+
313+
async getViewportSize() {
314+
const rect = await this.getViewportRect()
315+
return { width: rect.width, height: rect.height }
316+
}
301317
}

packages/machines/image-cropper/src/image-cropper.machine.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,15 @@ export const machine = createMachine<ImageCropperSchema>({
162162
guard: "canResizeCrop",
163163
actions: ["resizeCrop"],
164164
},
165+
VIEWPORT_RESIZE: {
166+
actions: ["handleViewportResize"],
167+
},
165168
},
166169

167170
states: {
168171
idle: {
169172
entry: ["checkImageStatus"],
173+
effects: ["trackViewportResize"],
170174
on: {
171175
SET_NATURAL_SIZE: {
172176
actions: ["setNaturalSize"],
@@ -722,6 +726,67 @@ export const machine = createMachine<ImageCropperSchema>({
722726

723727
context.set("crop", nextCrop)
724728
},
729+
730+
handleViewportResize({ context, prop, scope, send }) {
731+
const viewportEl = dom.getViewportEl(scope)
732+
if (!viewportEl) return
733+
734+
const newViewportRect = viewportEl.getBoundingClientRect()
735+
if (newViewportRect.height <= 0 || newViewportRect.width <= 0) return
736+
737+
const oldViewportRect = context.get("viewportRect")
738+
739+
if (oldViewportRect.width === newViewportRect.width && oldViewportRect.height === newViewportRect.height) {
740+
return
741+
}
742+
743+
context.set("viewportRect", newViewportRect)
744+
745+
const oldCrop = context.get("crop")
746+
747+
if (oldViewportRect.width === 0 || oldViewportRect.height === 0) {
748+
if (oldCrop.width === 0 || oldCrop.height === 0) {
749+
send({ type: "SET_DEFAULT_CROP", src: "viewport-resize" })
750+
return
751+
}
752+
}
753+
754+
const cropShape = prop("cropShape")
755+
const aspectRatio = resolveCropAspectRatio(cropShape, prop("aspectRatio"))
756+
const { minSize, maxSize } = getCropSizeLimits(prop)
757+
758+
const scaleX = newViewportRect.width / oldViewportRect.width
759+
const scaleY = newViewportRect.height / oldViewportRect.height
760+
761+
let newCrop = {
762+
x: oldCrop.x * scaleX,
763+
y: oldCrop.y * scaleY,
764+
width: oldCrop.width * scaleX,
765+
height: oldCrop.height * scaleY,
766+
}
767+
768+
const constrainedCrop = computeResizeCrop({
769+
cropStart: newCrop,
770+
handlePosition: "bottom-right",
771+
delta: { x: 0, y: 0 },
772+
viewportRect: newViewportRect,
773+
minSize,
774+
maxSize,
775+
aspectRatio,
776+
})
777+
778+
const maxX = Math.max(0, newViewportRect.width - constrainedCrop.width)
779+
const maxY = Math.max(0, newViewportRect.height - constrainedCrop.height)
780+
781+
const finalCrop = {
782+
x: clampValue(constrainedCrop.x, 0, maxX),
783+
y: clampValue(constrainedCrop.y, 0, maxY),
784+
width: constrainedCrop.width,
785+
height: constrainedCrop.height,
786+
}
787+
788+
context.set("crop", finalCrop)
789+
},
725790
},
726791

727792
effects: {
@@ -745,6 +810,22 @@ export const machine = createMachine<ImageCropperSchema>({
745810
cleanups.forEach((cleanup) => cleanup())
746811
}
747812
},
813+
814+
trackViewportResize({ scope, send }) {
815+
const viewportEl = dom.getViewportEl(scope)
816+
if (!viewportEl) return
817+
818+
const win = scope.getWin()
819+
const resizeObserver = new win.ResizeObserver(() => {
820+
send({ type: "VIEWPORT_RESIZE", src: "resize" })
821+
})
822+
823+
resizeObserver.observe(viewportEl)
824+
825+
return () => {
826+
resizeObserver.disconnect()
827+
}
828+
},
748829
},
749830
},
750831
})

0 commit comments

Comments
 (0)