diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts index 3dfd1ff..e8d84f7 100644 --- a/src/app/core/services/modal.service.ts +++ b/src/app/core/services/modal.service.ts @@ -2,16 +2,19 @@ import { InjectionToken, Injectable, Type, signal } from '@angular/core'; import { Observable, Subject, map } from 'rxjs'; import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component'; import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component'; +import { ImageModalComponent } from '../../shared/components/image-modal/image-modal.component'; import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component'; import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component'; export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced'; +export type ModalPresentation = 'dialog' | 'fullscreen-media'; export interface ModalConfig { title?: string; data?: TData; size?: ModalSize; + presentation?: ModalPresentation; closeOnBackdrop?: boolean; closeOnEscape?: boolean; showCloseButton?: boolean; @@ -62,6 +65,25 @@ export interface QrModalConfig extends Omit, 'data' | ' ticket: string; } +export interface ImageModalData { + src: string; + alt: string; + initialZoom: number; + minZoom: number; + maxZoom: number; +} + +export interface ImageModalConfig extends Omit< + ModalConfig, + 'data' | 'presentation' | 'size' +> { + src: string; + alt: string; + initialZoom?: number; + minZoom?: number; + maxZoom?: number; +} + export interface ActiveModalState { component: Type; config: NormalizedModalConfig; @@ -195,6 +217,33 @@ export class ModalService { }); } + openImage(config: ImageModalConfig): Observable { + return this.openImageRef(config).afterClosed$.pipe(map(() => undefined)); + } + + openImageRef(config: ImageModalConfig): ModalRef { + const { src, alt, initialZoom = 1, minZoom = 1, maxZoom = 4, ...modalConfig } = config; + const normalizedMinZoom = Math.max(0.1, minZoom); + const normalizedMaxZoom = Math.max(normalizedMinZoom, maxZoom); + const normalizedInitialZoom = Math.min( + normalizedMaxZoom, + Math.max(normalizedMinZoom, initialZoom), + ); + + return this.open(ImageModalComponent, { + ...modalConfig, + size: 'full', + presentation: 'fullscreen-media', + data: { + src, + alt, + initialZoom: normalizedInitialZoom, + minZoom: normalizedMinZoom, + maxZoom: normalizedMaxZoom, + }, + }); + } + private close(ref: ModalRef, result?: TResult): void { if (this.activeModalState()?.ref !== ref) { return; diff --git a/src/app/shared/components/image-modal/image-modal.component.html b/src/app/shared/components/image-modal/image-modal.component.html new file mode 100644 index 0000000..6a601db --- /dev/null +++ b/src/app/shared/components/image-modal/image-modal.component.html @@ -0,0 +1,52 @@ +
+
+ @if (!imageFailed()) { + + } @else { + + } +
+ +
+ + + +
+
diff --git a/src/app/shared/components/image-modal/image-modal.component.scss b/src/app/shared/components/image-modal/image-modal.component.scss new file mode 100644 index 0000000..ca62222 --- /dev/null +++ b/src/app/shared/components/image-modal/image-modal.component.scss @@ -0,0 +1,124 @@ +:host { + display: flex; + min-height: 0; + flex: 1; +} + +.image-modal { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; + background: #111111; +} + +.image-modal__viewport { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + align-items: center; + justify-content: center; + overflow: hidden; + cursor: zoom-in; + touch-action: none; + user-select: none; +} + +.image-modal__viewport--zoomed { + cursor: grab; +} + +.image-modal__viewport--zoomed:active { + cursor: grabbing; +} + +.image-modal__image { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + transform-origin: center; + transition: transform 120ms ease-out; + will-change: transform; + -webkit-user-drag: none; +} + +.image-modal__viewport:active .image-modal__image { + transition: none; +} + +.image-modal__error { + margin: 1rem; + color: #ffffff; + text-align: center; +} + +.image-modal__controls { + position: absolute; + right: 50%; + bottom: max(1.25rem, env(safe-area-inset-bottom)); + z-index: 1; + display: flex; + align-items: center; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 2rem; + background: rgba(20, 20, 20, 0.78); + box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3); + transform: translateX(50%); + backdrop-filter: blur(0.5rem); +} + +.image-modal__control, +.image-modal__zoom { + display: inline-flex; + height: 2.75rem; + align-items: center; + justify-content: center; + border: 0; + color: #ffffff; + background: transparent; +} + +.image-modal__control { + width: 2.75rem; + font-size: 1.5rem; +} + +.image-modal__zoom { + min-width: 4rem; + padding: 0 0.5rem; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} + +.image-modal__control:hover:not(:disabled), +.image-modal__zoom:hover { + background: rgba(255, 255, 255, 0.12); +} + +.image-modal__control:focus-visible, +.image-modal__zoom:focus-visible { + outline: 2px solid #ffffff; + outline-offset: -3px; +} + +.image-modal__control:disabled { + opacity: 0.35; +} + +@media (max-width: 576px) { + .image-modal__controls { + bottom: max(1rem, env(safe-area-inset-bottom)); + } +} + +@media (prefers-reduced-motion: reduce) { + .image-modal__image { + transition: none; + } +} diff --git a/src/app/shared/components/image-modal/image-modal.component.ts b/src/app/shared/components/image-modal/image-modal.component.ts new file mode 100644 index 0000000..a1f65ed --- /dev/null +++ b/src/app/shared/components/image-modal/image-modal.component.ts @@ -0,0 +1,231 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; + +import { ImageModalData, MODAL_DATA } from '../../../core/services/modal.service'; + +interface Point { + x: number; + y: 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 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 transform = computed( + () => `translate3d(${this.offsetX()}px, ${this.offsetY()}px, 0) scale(${this.zoom()})`, + ); + protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`); + + 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; + + 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(); + 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.dragStart = point; + this.dragOffset = { x: this.offsetX(), y: this.offsetY() }; + } else if (this.pointers.size === 2) { + this.hadMultiplePointers = true; + 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; + } + + 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.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(); + } + } + + protected onPointerUp(event: PointerEvent): void { + const wasTouch = event.pointerType === 'touch'; + const tapPoint = this.pointers.get(event.pointerId); + this.pointers.delete(event.pointerId); + const viewport = this.viewport().nativeElement; + if (viewport.hasPointerCapture?.(event.pointerId)) { + viewport.releasePointerCapture(event.pointerId); + } + + if (wasTouch && tapPoint && !this.gestureMoved && !this.hadMultiplePointers) { + const now = Date.now(); + if (now - this.lastTapAt < 300) { + this.toggleZoom(tapPoint.x, tapPoint.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.clampOffset(); + } + } + + protected onImageLoad(): void { + this.imageFailed.set(false); + this.clampOffset(); + } + + private beginPinch(): void { + const [first, second] = [...this.pointers.values()]; + const midpoint = this.midpoint(first, second); + const rect = this.viewport().nativeElement.getBoundingClientRect(); + + 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(), + }; + } + + 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(); + + 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); + 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 viewport = this.viewport().nativeElement; + const image = this.image()?.nativeElement; + if (!image) { + 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); + + 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); + } +} diff --git a/src/app/shared/components/modal-host/modal-host.component.html b/src/app/shared/components/modal-host/modal-host.component.html index 57f3e48..bea49e7 100644 --- a/src/app/shared/components/modal-host/modal-host.component.html +++ b/src/app/shared/components/modal-host/modal-host.component.html @@ -2,6 +2,7 @@ +