Skip to content

Commit 62cc85d

Browse files
authored
fix(image-cropper): the controlled zoom prop is not functioning as expected (#2824)
fix: the controlled `zoom` prop is not functioning as expected
1 parent 0324b42 commit 62cc85d

File tree

2 files changed

+75
-68
lines changed

2 files changed

+75
-68
lines changed

.changeset/all-rice-create.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+
Fix issues with the controlled `zoom` prop not functioning as expected

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

Lines changed: 70 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ export const machine = createMachine<ImageCropperSchema>({
110110
})),
111111
zoom: bindable<number>(() => ({
112112
defaultValue: prop("defaultZoom"),
113-
value: prop("zoom"),
114113
onChange(zoom) {
115114
prop("onZoomChange")?.({ zoom })
116115
},
@@ -184,6 +183,18 @@ export const machine = createMachine<ImageCropperSchema>({
184183
isImageReady: ({ context }) => isVisibleRect(context.get("naturalSize")),
185184
},
186185

186+
watch({ track, context, prop, send }) {
187+
track([() => prop("zoom")], () => {
188+
const propZoom = prop("zoom")
189+
if (propZoom === undefined) return
190+
191+
const currentZoom = context.get("zoom")
192+
if (propZoom === currentZoom) return
193+
194+
send({ type: "SET_ZOOM", zoom: propZoom, src: "prop" })
195+
})
196+
},
197+
187198
states: {
188199
idle: {
189200
entry: ["checkImageStatus"],
@@ -515,110 +526,101 @@ export const machine = createMachine<ImageCropperSchema>({
515526
updateZoom({ context, event, prop }) {
516527
let { delta, point, zoom: targetZoom, scale, panDelta } = event
517528

529+
const crop = context.get("crop")
530+
const currentZoom = context.get("zoom")
531+
const currentOffset = context.get("offset")
532+
const rotation = context.get("rotation")
533+
const viewportRect = context.get("viewportRect")
534+
const naturalSize = context.get("naturalSize")
535+
const fixedCropArea = prop("fixedCropArea")
536+
518537
// If no point is specified, zoom based on the center of the crop area
519538
if (!point) {
520-
const crop = context.get("crop")
521539
point = getCenterPoint(crop)
522540
}
523541

524542
const step = Math.abs(prop("zoomStep"))
525543
const sensitivity = Math.max(0, prop("zoomSensitivity"))
526544
const [minZoom, maxZoom] = [prop("minZoom"), prop("maxZoom")]
527-
const currentZoom = context.get("zoom")
528-
const viewportRect = context.get("viewportRect")
529545

530-
let nextZoom
531-
532-
if (typeof targetZoom === "number") {
533-
nextZoom = clampValue(targetZoom, minZoom, maxZoom)
534-
} else if (event.trigger === "touch" && typeof scale === "number") {
535-
const minScale = 0.5
536-
const maxScale = 2
537-
const clampedScale = clampValue(scale, minScale, maxScale)
538-
const smoothing = sensitivity > 0 ? Math.pow(clampedScale, sensitivity) : clampedScale
539-
nextZoom = clampValue(currentZoom * smoothing, minZoom, maxZoom)
540-
} else if (typeof delta === "number") {
541-
const direction = Math.sign(delta) < 0 ? 1 : -1
542-
nextZoom = clampValue(currentZoom + step * direction, minZoom, maxZoom)
543-
} else {
544-
return
545-
}
546+
const calculateNextZoom = (): number | null => {
547+
if (typeof targetZoom === "number") {
548+
return clampValue(targetZoom, minZoom, maxZoom)
549+
}
546550

547-
// Only pan if there's a pan delta from pinch movement
548-
if (nextZoom === currentZoom && panDelta) {
549-
const currentOffset = context.get("offset")
550-
const rotation = context.get("rotation")
551+
if (event.trigger === "touch" && typeof scale === "number") {
552+
const minScale = 0.5
553+
const maxScale = 2
554+
const clampedScale = clampValue(scale, minScale, maxScale)
555+
const smoothing = sensitivity > 0 ? Math.pow(clampedScale, sensitivity) : clampedScale
556+
return clampValue(currentZoom * smoothing, minZoom, maxZoom)
557+
}
558+
559+
if (typeof delta === "number") {
560+
const direction = Math.sign(delta) < 0 ? 1 : -1
561+
return clampValue(currentZoom + step * direction, minZoom, maxZoom)
562+
}
563+
564+
return null
565+
}
551566

552-
const nextOffset = clampOffset({
553-
zoom: currentZoom,
567+
const applyClampedOffset = (zoom: number, offset: Point): Point => {
568+
return clampOffset({
569+
zoom,
554570
rotation,
555571
viewportSize: viewportRect,
556-
offset: addPoints(currentOffset, panDelta),
557-
fixedCropArea: prop("fixedCropArea"),
558-
crop: context.get("crop"),
559-
naturalSize: context.get("naturalSize"),
572+
offset,
573+
fixedCropArea,
574+
crop,
575+
naturalSize,
560576
})
577+
}
561578

579+
const nextZoom = calculateNextZoom()
580+
if (nextZoom === null) return
581+
582+
// Handle pan-only update
583+
if (nextZoom === currentZoom && panDelta) {
584+
const nextOffset = applyClampedOffset(currentZoom, addPoints(currentOffset, panDelta))
562585
context.set("offset", nextOffset)
563586
return
564587
}
565588

566589
if (nextZoom === currentZoom) return
567590

568-
const { width: vpW, height: vpH } = viewportRect
591+
const { width: viewportWidth, height: viewportHeight } = viewportRect
569592
const { x: centerX, y: centerY } = getViewportCenter(viewportRect)
570593

571-
const currentOffset = context.get("offset")
572-
573-
const ratio = nextZoom / currentZoom
574-
let nextOffset = {
575-
x: (1 - ratio) * (point.x - centerX) + ratio * currentOffset.x,
576-
y: (1 - ratio) * (point.y - centerY) + ratio * currentOffset.y,
594+
const zoomRatio = nextZoom / currentZoom
595+
let nextOffset: Point = {
596+
x: (1 - zoomRatio) * (point.x - centerX) + zoomRatio * currentOffset.x,
597+
y: (1 - zoomRatio) * (point.y - centerY) + zoomRatio * currentOffset.y,
577598
}
578599

579600
// Apply pan delta from pinch movement if provided
580601
if (panDelta) {
581-
nextOffset = addPoints(nextOffset, panDelta)
582-
583-
const rotation = context.get("rotation")
584-
nextOffset = clampOffset({
585-
zoom: nextZoom,
586-
rotation,
587-
viewportSize: viewportRect,
588-
offset: nextOffset,
589-
fixedCropArea: prop("fixedCropArea"),
590-
crop: context.get("crop"),
591-
naturalSize: context.get("naturalSize"),
592-
})
602+
nextOffset = applyClampedOffset(nextZoom, addPoints(nextOffset, panDelta))
593603
} else if (nextZoom < currentZoom) {
594-
if (prop("fixedCropArea")) {
595-
const rotation = context.get("rotation")
596-
nextOffset = clampOffset({
597-
zoom: nextZoom,
598-
rotation,
599-
viewportSize: viewportRect,
600-
offset: nextOffset,
601-
fixedCropArea: true,
602-
crop: context.get("crop"),
603-
naturalSize: context.get("naturalSize"),
604-
})
604+
// Handle zoom out - clamp offset to keep image within bounds
605+
if (fixedCropArea) {
606+
nextOffset = applyClampedOffset(nextZoom, nextOffset)
605607
} else {
606-
const imgSize = context.get("naturalSize")
607-
const { width: scaledW, height: scaledH } = scaleSize(imgSize, nextZoom)
608+
// Manual clamping for non-fixed crop area
609+
const { width: scaledImageWidth, height: scaledImageHeight } = scaleSize(naturalSize, nextZoom)
608610

609-
if (scaledW <= vpW) {
611+
if (scaledImageWidth <= viewportWidth) {
610612
nextOffset.x = 0
611613
} else {
612-
const minX = vpW - centerX - scaledW / 2
613-
const maxX = scaledW / 2 - centerX
614+
const minX = viewportWidth - centerX - scaledImageWidth / 2
615+
const maxX = scaledImageWidth / 2 - centerX
614616
nextOffset.x = Math.max(minX, Math.min(maxX, nextOffset.x))
615617
}
616618

617-
if (scaledH <= vpH) {
619+
if (scaledImageHeight <= viewportHeight) {
618620
nextOffset.y = 0
619621
} else {
620-
const minY = vpH - centerY - scaledH / 2
621-
const maxY = scaledH / 2 - centerY
622+
const minY = viewportHeight - centerY - scaledImageHeight / 2
623+
const maxY = scaledImageHeight / 2 - centerY
622624
nextOffset.y = Math.max(minY, Math.min(maxY, nextOffset.y))
623625
}
624626
}

0 commit comments

Comments
 (0)