feat(image-modal): enhance gesture handling and improve pinch zoom functionality

This commit is contained in:
2026-09-02 14:52:55 -03:00
parent 63b01f366a
commit 2462f25f2f
4 changed files with 178 additions and 50 deletions

View File

@@ -1,8 +1,8 @@
<div
class="image-modal"
[class.image-modal--gesturing]="gestureActive()"
[class.image-modal--settling]="swipeSettling()"
[style.transform]="swipeTransform()"
[style.opacity]="swipeOpacity()"
>
<div
#viewport
@@ -22,6 +22,7 @@
[src]="data.src"
[alt]="data.alt"
[style.transform]="transform()"
decoding="async"
draggable="false"
(load)="onImageLoad()"
(error)="imageFailed.set(true)"

View File

@@ -14,13 +14,14 @@
overflow: hidden;
background: black;
transform-origin: center top;
will-change: transform, opacity;
}
.image-modal--settling {
transition:
transform 180ms ease-out,
opacity 180ms ease-out;
transition: transform 180ms ease-out;
}
.image-modal--gesturing {
will-change: transform;
}
.image-modal__viewport {
@@ -51,10 +52,14 @@
object-fit: contain;
transform-origin: center;
transition: transform 120ms ease-out;
will-change: transform;
-webkit-user-drag: none;
}
.image-modal--gesturing .image-modal__image {
transition: none;
will-change: transform;
}
.image-modal__viewport:active .image-modal__image {
transition: none;
}
@@ -122,6 +127,15 @@
@media (max-width: 576px) {
.image-modal__controls {
bottom: max(1rem, env(safe-area-inset-bottom));
background: rgba(20, 20, 20, 0.94);
backdrop-filter: none;
}
}
@media (pointer: coarse) {
.image-modal__controls {
background: rgba(20, 20, 20, 0.94);
backdrop-filter: none;
}
}

View File

@@ -76,7 +76,7 @@ describe('ImageModalComponent', () => {
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<void> {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}

View File

@@ -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<ImageModalData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
private readonly destroyRef = inject(DestroyRef);
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
private readonly image = viewChild<ElementRef<HTMLImageElement>>('image');
private readonly pointers = new Map<number, Point>();
@@ -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;
}
}