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;
}
}