import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, signal, viewChild, } from '@angular/core'; import { ImageModalData, MODAL_DATA, ModalRef } from '../../../core/services/modal.service'; const SWIPE_DISMISS_DISTANCE = 100; const SWIPE_DIRECTION_RATIO = 1.25; interface Point { x: number; 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', styleUrl: './image-modal.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) 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(); protected readonly zoom = signal(this.data.initialZoom); protected readonly offsetX = signal(0); protected readonly offsetY = signal(0); 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)`); private dragStart: Point | null = null; private dragOffset: Point = { x: 0, y: 0 }; private pinchDistance = 0; private pinchZoom = 1; private pinchLocal: Point = { x: 0, y: 0 }; private pointerDownAt: Point | null = null; private gestureMoved = false; 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)); } protected zoomOut(): void { this.setZoomAt(Math.max(this.data.minZoom, this.zoom() - 0.5)); } protected reset(): void { this.zoom.set(this.data.initialZoom); this.offsetX.set(0); this.offsetY.set(0); } protected onWheel(event: WheelEvent): void { event.preventDefault(); const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15; this.setZoomAt(this.zoom() * factor, event.clientX, event.clientY); } protected onDoubleClick(event: MouseEvent): void { this.toggleZoom(event.clientX, event.clientY); } protected onPointerDown(event: PointerEvent): void { if (event.pointerType === 'mouse' && event.button !== 0) { return; } 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); this.pointerDownAt = point; this.gestureMoved = false; if (this.pointers.size === 1) { this.swipeSettling.set(false); this.dragStart = point; 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) { this.hadMultiplePointers = true; this.swipeStart = null; this.swipeOffsetY.set(0); this.beginPinch(); } } protected onPointerMove(event: PointerEvent): void { if (!this.pointers.has(event.pointerId)) { return; } const point = { x: event.clientX, y: event.clientY }; this.pointers.set(event.pointerId, point); if (this.pointerDownAt && this.distance(this.pointerDownAt, point) > 4) { this.gestureMoved = true; } 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 && endPoint !== null && this.isSwipeDown(this.swipeStart, endPoint); this.pointers.delete(event.pointerId); const viewport = this.viewport().nativeElement; if (viewport.hasPointerCapture?.(event.pointerId)) { viewport.releasePointerCapture(event.pointerId); } if (shouldDismiss) { this.resetGesture(); this.modalRef.dismiss('swipe'); return; } if (wasTouch && endPoint && !this.gestureMoved && !this.hadMultiplePointers) { const now = Date.now(); if (now - this.lastTapAt < 300) { this.toggleZoom(endPoint.x, endPoint.y); this.lastTapAt = 0; } else { this.lastTapAt = now; } } if (this.pointers.size === 1) { const remaining = [...this.pointers.values()][0]; this.dragStart = remaining; this.dragOffset = { x: this.offsetX(), y: this.offsetY() }; } else if (this.pointers.size === 0) { this.dragStart = null; 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); } this.clampOffset(); } } 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 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 - (geometry.viewportLeft + geometry.viewportWidth / 2) - this.offsetX()) / this.zoom(), y: (midpoint.y - (geometry.viewportTop + geometry.viewportHeight / 2) - this.offsetY()) / this.zoom(), }; } private toggleZoom(clientX?: number, clientY?: number): void { const target = this.zoom() > this.data.minZoom + 0.01 ? this.data.minZoom : Math.min(this.data.maxZoom, Math.max(2, this.data.minZoom)); this.setZoomAt(target, clientX, clientY); } 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 && 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); this.offsetY.set(pointY - localY * nextZoom); } this.zoom.set(nextZoom); if (nextZoom <= this.data.minZoom) { this.offsetX.set(0); this.offsetY.set(0); } this.clampOffset(); } private clampZoom(value: number): number { return Math.min(this.data.maxZoom, Math.max(this.data.minZoom, value)); } private clampOffset(): void { const geometry = this.geometry; if (!geometry) { return; } 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()))); } private midpoint(first: Point, second: Point): Point { return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; } private distance(first: Point, second: Point): number { 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.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; } }