From 71ef09b413d7fb53cc66034d11d1b26d30e413c5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 2 Sep 2026 14:52:55 -0300 Subject: [PATCH] feat(image-modal): enhance gesture handling and improve pinch zoom functionality --- .../image-modal/image-modal.component.html | 3 +- .../image-modal/image-modal.component.scss | 24 ++- .../image-modal/image-modal.component.spec.ts | 13 +- .../image-modal/image-modal.component.ts | 188 ++++++++++++++---- 4 files changed, 178 insertions(+), 50 deletions(-) diff --git a/src/app/shared/components/image-modal/image-modal.component.html b/src/app/shared/components/image-modal/image-modal.component.html index 4d6c426..aa13fd8 100644 --- a/src/app/shared/components/image-modal/image-modal.component.html +++ b/src/app/shared/components/image-modal/image-modal.component.html @@ -1,8 +1,8 @@
{ expect(zoomOut.disabled).toBe(true); }); - it('supports pinch zoom through touch pointer events', () => { + it('supports pinch zoom through touch pointer events', async () => { const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement; const zoom = fixture.nativeElement.querySelector( '[aria-label="Restablecer zoom"]', @@ -85,17 +85,19 @@ describe('ImageModalComponent', () => { viewport.dispatchEvent(pointerEvent('pointerdown', 1, 100, 100)); viewport.dispatchEvent(pointerEvent('pointerdown', 2, 200, 100)); viewport.dispatchEvent(pointerEvent('pointermove', 2, 250, 100)); + await renderNextFrame(); fixture.detectChanges(); expect(zoom.textContent).toContain('150%'); }); - it('dismisses with a downward swipe while the image is at its base zoom', () => { + it('dismisses with a downward swipe while the image is at its base zoom', async () => { const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement; const modal = fixture.nativeElement.querySelector('.image-modal') as HTMLElement; viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100)); viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230)); + await renderNextFrame(); fixture.detectChanges(); expect(modal.style.transform).toBe('translate3d(0, 130px, 0)'); @@ -106,12 +108,13 @@ describe('ImageModalComponent', () => { expect(modalRef.dismiss).toHaveBeenCalledWith('swipe'); }); - it('returns smoothly to its position when the swipe is too short', () => { + it('returns smoothly to its position when the swipe is too short', async () => { const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement; const modal = fixture.nativeElement.querySelector('.image-modal') as HTMLElement; viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100)); viewport.dispatchEvent(pointerEvent('pointermove', 1, 150, 160)); + await renderNextFrame(); fixture.detectChanges(); expect(modal.style.transform).toBe('translate3d(0, 60px, 0)'); @@ -156,3 +159,7 @@ function pointerEvent(type: string, pointerId: number, clientX: number, clientY: }); return event; } + +async function renderNextFrame(): Promise { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); +} diff --git a/src/app/shared/components/image-modal/image-modal.component.ts b/src/app/shared/components/image-modal/image-modal.component.ts index 6ae8029..b344d76 100644 --- a/src/app/shared/components/image-modal/image-modal.component.ts +++ b/src/app/shared/components/image-modal/image-modal.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, computed, inject, @@ -18,6 +19,15 @@ interface Point { y: number; } +interface ViewerGeometry { + imageHeight: number; + imageWidth: number; + viewportHeight: number; + viewportLeft: number; + viewportTop: number; + viewportWidth: number; +} + @Component({ selector: 'app-image-modal', templateUrl: './image-modal.component.html', @@ -27,6 +37,7 @@ interface Point { export class ImageModalComponent { protected readonly data = inject(MODAL_DATA); private readonly modalRef = inject>(ModalRef); + private readonly destroyRef = inject(DestroyRef); private readonly viewport = viewChild.required>('viewport'); private readonly image = viewChild>('image'); private readonly pointers = new Map(); @@ -37,14 +48,12 @@ export class ImageModalComponent { protected readonly imageFailed = signal(false); protected readonly swipeOffsetY = signal(0); protected readonly swipeSettling = signal(false); + protected readonly gestureActive = signal(false); protected readonly transform = computed( () => `translate3d(${this.offsetX()}px, ${this.offsetY()}px, 0) scale(${this.zoom()})`, ); protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`); protected readonly swipeTransform = computed(() => `translate3d(0, ${this.swipeOffsetY()}px, 0)`); - protected readonly swipeOpacity = computed(() => - Math.max(0.65, 1 - (this.swipeOffsetY() / 400) * 0.35), - ); private dragStart: Point | null = null; private dragOffset: Point = { x: 0, y: 0 }; @@ -56,6 +65,12 @@ export class ImageModalComponent { private hadMultiplePointers = false; private lastTapAt = 0; private swipeStart: Point | null = null; + private geometry: ViewerGeometry | null = null; + private animationFrameId: number | null = null; + + constructor() { + this.destroyRef.onDestroy(() => this.cancelGestureFrame()); + } protected zoomIn(): void { this.setZoomAt(Math.min(this.data.maxZoom, this.zoom() + 0.5)); @@ -87,6 +102,10 @@ export class ImageModalComponent { } event.preventDefault(); + if (this.pointers.size === 0) { + this.refreshGeometry(); + this.gestureActive.set(true); + } this.viewport().nativeElement.setPointerCapture?.(event.pointerId); const point = { x: event.clientX, y: event.clientY }; this.pointers.set(event.pointerId, point); @@ -119,38 +138,21 @@ export class ImageModalComponent { this.gestureMoved = true; } - if (this.pointers.size === 2) { - const [first, second] = [...this.pointers.values()]; - const distance = this.distance(first, second); - const midpoint = this.midpoint(first, second); - const nextZoom = this.clampZoom(this.pinchZoom * (distance / this.pinchDistance)); - const rect = this.viewport().nativeElement.getBoundingClientRect(); - - this.zoom.set(nextZoom); - this.offsetX.set(midpoint.x - (rect.left + rect.width / 2) - this.pinchLocal.x * nextZoom); - this.offsetY.set(midpoint.y - (rect.top + rect.height / 2) - this.pinchLocal.y * nextZoom); - this.clampOffset(); - return; - } - - if (this.pointers.size === 1 && this.swipeStart) { - const deltaX = Math.abs(point.x - this.swipeStart.x); - const deltaY = point.y - this.swipeStart.y; - this.swipeOffsetY.set(deltaY > 0 && deltaY >= deltaX ? deltaY : 0); - return; - } - - if (this.pointers.size === 1 && this.dragStart && this.zoom() > this.data.minZoom) { - this.offsetX.set(this.dragOffset.x + point.x - this.dragStart.x); - this.offsetY.set(this.dragOffset.y + point.y - this.dragStart.y); - this.clampOffset(); - } + this.scheduleGestureFrame(); } protected onPointerUp(event: PointerEvent): void { const wasTouch = event.pointerType === 'touch'; const trackedPoint = this.pointers.get(event.pointerId); const endPoint = trackedPoint ? { x: event.clientX, y: event.clientY } : null; + if (endPoint) { + this.pointers.set(event.pointerId, endPoint); + if (this.animationFrameId !== null) { + this.flushGestureFrame(); + } else { + this.applyPointerMovement(); + } + } const shouldDismiss = event.type === 'pointerup' && wasTouch && @@ -187,6 +189,7 @@ export class ImageModalComponent { this.pointerDownAt = null; this.hadMultiplePointers = false; this.swipeStart = null; + this.gestureActive.set(false); if (this.swipeOffsetY() > 0) { this.swipeSettling.set(true); this.swipeOffsetY.set(0); @@ -197,19 +200,27 @@ export class ImageModalComponent { protected onImageLoad(): void { this.imageFailed.set(false); + this.refreshGeometry(); this.clampOffset(); } private beginPinch(): void { const [first, second] = [...this.pointers.values()]; const midpoint = this.midpoint(first, second); - const rect = this.viewport().nativeElement.getBoundingClientRect(); + const geometry = this.geometry ?? this.refreshGeometry(); + if (!geometry) { + return; + } this.pinchDistance = Math.max(1, this.distance(first, second)); this.pinchZoom = this.zoom(); this.pinchLocal = { - x: (midpoint.x - (rect.left + rect.width / 2) - this.offsetX()) / this.zoom(), - y: (midpoint.y - (rect.top + rect.height / 2) - this.offsetY()) / this.zoom(), + x: + (midpoint.x - (geometry.viewportLeft + geometry.viewportWidth / 2) - this.offsetX()) / + this.zoom(), + y: + (midpoint.y - (geometry.viewportTop + geometry.viewportHeight / 2) - this.offsetY()) / + this.zoom(), }; } @@ -224,11 +235,11 @@ export class ImageModalComponent { private setZoomAt(value: number, clientX?: number, clientY?: number): void { const nextZoom = this.clampZoom(value); const currentZoom = this.zoom(); + const geometry = this.refreshGeometry(); - if (clientX !== undefined && clientY !== undefined && currentZoom > 0) { - const rect = this.viewport().nativeElement.getBoundingClientRect(); - const pointX = clientX - (rect.left + rect.width / 2); - const pointY = clientY - (rect.top + rect.height / 2); + if (clientX !== undefined && clientY !== undefined && currentZoom > 0 && geometry) { + const pointX = clientX - (geometry.viewportLeft + geometry.viewportWidth / 2); + const pointY = clientY - (geometry.viewportTop + geometry.viewportHeight / 2); const localX = (pointX - this.offsetX()) / currentZoom; const localY = (pointY - this.offsetY()) / currentZoom; this.offsetX.set(pointX - localX * nextZoom); @@ -248,14 +259,13 @@ export class ImageModalComponent { } private clampOffset(): void { - const viewport = this.viewport().nativeElement; - const image = this.image()?.nativeElement; - if (!image) { + const geometry = this.geometry; + if (!geometry) { return; } - const maxX = Math.max(0, (image.offsetWidth * this.zoom() - viewport.clientWidth) / 2); - const maxY = Math.max(0, (image.offsetHeight * this.zoom() - viewport.clientHeight) / 2); + const maxX = Math.max(0, (geometry.imageWidth * this.zoom() - geometry.viewportWidth) / 2); + const maxY = Math.max(0, (geometry.imageHeight * this.zoom() - geometry.viewportHeight) / 2); this.offsetX.set(Math.min(maxX, Math.max(-maxX, this.offsetX()))); this.offsetY.set(Math.min(maxY, Math.max(-maxY, this.offsetY()))); @@ -280,10 +290,106 @@ export class ImageModalComponent { } private resetGesture(): void { + this.cancelGestureFrame(); this.pointers.clear(); this.dragStart = null; this.pointerDownAt = null; this.swipeStart = null; this.hadMultiplePointers = false; + this.gestureActive.set(false); + } + + private scheduleGestureFrame(): void { + if (this.animationFrameId !== null) { + return; + } + + this.animationFrameId = requestAnimationFrame(() => { + this.animationFrameId = null; + this.applyPointerMovement(); + }); + } + + private flushGestureFrame(): void { + if (this.animationFrameId === null) { + return; + } + + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + this.applyPointerMovement(); + } + + private cancelGestureFrame(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + } + + private applyPointerMovement(): void { + if (this.pointers.size === 2) { + const [first, second] = [...this.pointers.values()]; + const geometry = this.geometry; + if (!geometry) { + return; + } + + const distance = this.distance(first, second); + const midpoint = this.midpoint(first, second); + const nextZoom = this.clampZoom(this.pinchZoom * (distance / this.pinchDistance)); + + this.zoom.set(nextZoom); + this.offsetX.set( + midpoint.x - + (geometry.viewportLeft + geometry.viewportWidth / 2) - + this.pinchLocal.x * nextZoom, + ); + this.offsetY.set( + midpoint.y - + (geometry.viewportTop + geometry.viewportHeight / 2) - + this.pinchLocal.y * nextZoom, + ); + this.clampOffset(); + return; + } + + if (this.pointers.size !== 1) { + return; + } + + const point = [...this.pointers.values()][0]; + if (this.swipeStart) { + const deltaX = Math.abs(point.x - this.swipeStart.x); + const deltaY = point.y - this.swipeStart.y; + this.swipeOffsetY.set(deltaY > 0 && deltaY >= deltaX ? deltaY : 0); + return; + } + + if (this.dragStart && this.zoom() > this.data.minZoom) { + this.offsetX.set(this.dragOffset.x + point.x - this.dragStart.x); + this.offsetY.set(this.dragOffset.y + point.y - this.dragStart.y); + this.clampOffset(); + } + } + + private refreshGeometry(): ViewerGeometry | null { + const viewport = this.viewport().nativeElement; + const image = this.image()?.nativeElement; + if (!image) { + this.geometry = null; + return null; + } + + const rect = viewport.getBoundingClientRect(); + this.geometry = { + imageHeight: image.offsetHeight, + imageWidth: image.offsetWidth, + viewportHeight: viewport.clientHeight, + viewportLeft: rect.left, + viewportTop: rect.top, + viewportWidth: viewport.clientWidth, + }; + return this.geometry; } }