396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
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<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>();
|
|
|
|
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;
|
|
}
|
|
}
|