feat(image-modal): add swipe-to-dismiss functionality for zoomed images
This commit is contained in:
parent
ebae063a0f
commit
7d52e11bca
|
|
@ -7,7 +7,7 @@ import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.comp
|
||||||
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
||||||
|
|
||||||
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
|
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced' | 'swipe';
|
||||||
export type ModalPresentation = 'dialog' | 'fullscreen-media';
|
export type ModalPresentation = 'dialog' | 'fullscreen-media';
|
||||||
|
|
||||||
export interface ModalConfig<TData = unknown> {
|
export interface ModalConfig<TData = unknown> {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import '@angular/compiler';
|
import '@angular/compiler';
|
||||||
import { ComponentFixture, TestBed, getTestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, getTestBed } from '@angular/core/testing';
|
||||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ImageModalData, MODAL_DATA } from '../../../core/services/modal.service';
|
import { ImageModalData, MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
|
||||||
import { ImageModalComponent } from './image-modal.component';
|
import { ImageModalComponent } from './image-modal.component';
|
||||||
|
|
||||||
describe('ImageModalComponent', () => {
|
describe('ImageModalComponent', () => {
|
||||||
let fixture: ComponentFixture<ImageModalComponent>;
|
let fixture: ComponentFixture<ImageModalComponent>;
|
||||||
|
const modalRef = { dismiss: vi.fn() };
|
||||||
const data: ImageModalData = {
|
const data: ImageModalData = {
|
||||||
src: '/images/producto.webp',
|
src: '/images/producto.webp',
|
||||||
alt: 'Vista frontal del producto',
|
alt: 'Vista frontal del producto',
|
||||||
|
|
@ -25,9 +26,13 @@ describe('ImageModalComponent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
modalRef.dismiss.mockReset();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ImageModalComponent],
|
imports: [ImageModalComponent],
|
||||||
providers: [{ provide: MODAL_DATA, useValue: data }],
|
providers: [
|
||||||
|
{ provide: MODAL_DATA, useValue: data },
|
||||||
|
{ provide: ModalRef, useValue: modalRef },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ImageModalComponent);
|
fixture = TestBed.createComponent(ImageModalComponent);
|
||||||
|
|
@ -85,6 +90,30 @@ describe('ImageModalComponent', () => {
|
||||||
expect(zoom.textContent).toContain('150%');
|
expect(zoom.textContent).toContain('150%');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dismisses with a downward swipe while the image is at its base zoom', () => {
|
||||||
|
const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement;
|
||||||
|
|
||||||
|
viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100));
|
||||||
|
viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230));
|
||||||
|
viewport.dispatchEvent(pointerEvent('pointerup', 1, 155, 230));
|
||||||
|
|
||||||
|
expect(modalRef.dismiss).toHaveBeenCalledWith('swipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dismiss with a downward gesture while the image is zoomed', () => {
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const viewport = element.querySelector('.image-modal__viewport') as HTMLElement;
|
||||||
|
const zoomIn = element.querySelector('[aria-label="Acercar"]') as HTMLButtonElement;
|
||||||
|
zoomIn.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100));
|
||||||
|
viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230));
|
||||||
|
viewport.dispatchEvent(pointerEvent('pointerup', 1, 155, 230));
|
||||||
|
|
||||||
|
expect(modalRef.dismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a fallback when the image cannot be loaded', () => {
|
it('shows a fallback when the image cannot be loaded', () => {
|
||||||
const image = fixture.nativeElement.querySelector('img') as HTMLImageElement;
|
const image = fixture.nativeElement.querySelector('img') as HTMLImageElement;
|
||||||
image.dispatchEvent(new Event('error'));
|
image.dispatchEvent(new Event('error'));
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import {
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { ImageModalData, MODAL_DATA } from '../../../core/services/modal.service';
|
import { ImageModalData, MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
|
||||||
|
|
||||||
|
const SWIPE_DISMISS_DISTANCE = 100;
|
||||||
|
const SWIPE_DIRECTION_RATIO = 1.25;
|
||||||
|
|
||||||
interface Point {
|
interface Point {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -23,6 +26,7 @@ interface Point {
|
||||||
})
|
})
|
||||||
export class ImageModalComponent {
|
export class ImageModalComponent {
|
||||||
protected readonly data = inject<ImageModalData>(MODAL_DATA);
|
protected readonly data = inject<ImageModalData>(MODAL_DATA);
|
||||||
|
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
|
||||||
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
|
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
|
||||||
private readonly image = viewChild<ElementRef<HTMLImageElement>>('image');
|
private readonly image = viewChild<ElementRef<HTMLImageElement>>('image');
|
||||||
private readonly pointers = new Map<number, Point>();
|
private readonly pointers = new Map<number, Point>();
|
||||||
|
|
@ -45,6 +49,7 @@ export class ImageModalComponent {
|
||||||
private gestureMoved = false;
|
private gestureMoved = false;
|
||||||
private hadMultiplePointers = false;
|
private hadMultiplePointers = false;
|
||||||
private lastTapAt = 0;
|
private lastTapAt = 0;
|
||||||
|
private swipeStart: Point | null = null;
|
||||||
|
|
||||||
protected zoomIn(): void {
|
protected zoomIn(): void {
|
||||||
this.setZoomAt(Math.min(this.data.maxZoom, this.zoom() + 0.5));
|
this.setZoomAt(Math.min(this.data.maxZoom, this.zoom() + 0.5));
|
||||||
|
|
@ -85,8 +90,11 @@ export class ImageModalComponent {
|
||||||
if (this.pointers.size === 1) {
|
if (this.pointers.size === 1) {
|
||||||
this.dragStart = point;
|
this.dragStart = point;
|
||||||
this.dragOffset = { x: this.offsetX(), y: this.offsetY() };
|
this.dragOffset = { x: this.offsetX(), y: this.offsetY() };
|
||||||
|
this.swipeStart =
|
||||||
|
event.pointerType === 'touch' && this.zoom() <= this.data.minZoom + 0.01 ? point : null;
|
||||||
} else if (this.pointers.size === 2) {
|
} else if (this.pointers.size === 2) {
|
||||||
this.hadMultiplePointers = true;
|
this.hadMultiplePointers = true;
|
||||||
|
this.swipeStart = null;
|
||||||
this.beginPinch();
|
this.beginPinch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,17 +134,26 @@ export class ImageModalComponent {
|
||||||
|
|
||||||
protected onPointerUp(event: PointerEvent): void {
|
protected onPointerUp(event: PointerEvent): void {
|
||||||
const wasTouch = event.pointerType === 'touch';
|
const wasTouch = event.pointerType === 'touch';
|
||||||
const tapPoint = this.pointers.get(event.pointerId);
|
const trackedPoint = this.pointers.get(event.pointerId);
|
||||||
|
const endPoint = trackedPoint ? { x: event.clientX, y: event.clientY } : null;
|
||||||
|
const shouldDismiss =
|
||||||
|
wasTouch && endPoint !== null && this.isSwipeDown(this.swipeStart, endPoint);
|
||||||
this.pointers.delete(event.pointerId);
|
this.pointers.delete(event.pointerId);
|
||||||
const viewport = this.viewport().nativeElement;
|
const viewport = this.viewport().nativeElement;
|
||||||
if (viewport.hasPointerCapture?.(event.pointerId)) {
|
if (viewport.hasPointerCapture?.(event.pointerId)) {
|
||||||
viewport.releasePointerCapture(event.pointerId);
|
viewport.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasTouch && tapPoint && !this.gestureMoved && !this.hadMultiplePointers) {
|
if (shouldDismiss) {
|
||||||
|
this.resetGesture();
|
||||||
|
this.modalRef.dismiss('swipe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasTouch && endPoint && !this.gestureMoved && !this.hadMultiplePointers) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastTapAt < 300) {
|
if (now - this.lastTapAt < 300) {
|
||||||
this.toggleZoom(tapPoint.x, tapPoint.y);
|
this.toggleZoom(endPoint.x, endPoint.y);
|
||||||
this.lastTapAt = 0;
|
this.lastTapAt = 0;
|
||||||
} else {
|
} else {
|
||||||
this.lastTapAt = now;
|
this.lastTapAt = now;
|
||||||
|
|
@ -151,6 +168,7 @@ export class ImageModalComponent {
|
||||||
this.dragStart = null;
|
this.dragStart = null;
|
||||||
this.pointerDownAt = null;
|
this.pointerDownAt = null;
|
||||||
this.hadMultiplePointers = false;
|
this.hadMultiplePointers = false;
|
||||||
|
this.swipeStart = null;
|
||||||
this.clampOffset();
|
this.clampOffset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -228,4 +246,22 @@ export class ImageModalComponent {
|
||||||
private distance(first: Point, second: Point): number {
|
private distance(first: Point, second: Point): number {
|
||||||
return Math.hypot(second.x - first.x, second.y - first.y);
|
return Math.hypot(second.x - first.x, second.y - first.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSwipeDown(start: Point | null, end: Point): boolean {
|
||||||
|
if (!start || this.hadMultiplePointers || this.zoom() > this.data.minZoom + 0.01) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = Math.abs(end.x - start.x);
|
||||||
|
const deltaY = end.y - start.y;
|
||||||
|
return deltaY >= SWIPE_DISMISS_DISTANCE && deltaY >= deltaX * SWIPE_DIRECTION_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetGesture(): void {
|
||||||
|
this.pointers.clear();
|
||||||
|
this.dragStart = null;
|
||||||
|
this.pointerDownAt = null;
|
||||||
|
this.swipeStart = null;
|
||||||
|
this.hadMultiplePointers = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue