feat(image-modal): enhance gesture handling and improve pinch zoom functionality
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user