import { MOUSE_POINTER, PANEL_DRAGGING_CLASS, PANEL_MAX_SCALE, PANEL_POINTER_PAIR, PANEL_REVEAL_MARGIN_PX, PANEL_SCALE_PROPERTY, PANEL_WHEEL_ZOOM_RATE, PANEL_X_PROPERTY, PANEL_Y_PROPERTY, } from "#configuration/constants/panel.constants"; import { ORIGIN_VIEW, boundedView, centerOf, contentExtentOf, distanceBetween, extentOf, fittedView, midpointOf, zoomedView, } from "#core/analyzers/panel.analyzer"; import type { PanelMode, PanelView, PanelPoint as Point } from "#types/panel.types"; import { PIXEL } from "#configuration/constants/element.constants"; import { observeArming } from "#core/observers/panel.observer"; const PRIMARY_BUTTON = 0; export class PanelState { public view: PanelView = ORIGIN_VIEW; private readonly element: HTMLElement; private readonly stage: HTMLElement; private readonly zoom: Element; private readonly mode: PanelMode; private readonly pointers = new Map(); private pinchDistance = 0; private readonly armed: () => boolean; public constructor(element: HTMLElement, stage: HTMLElement, mode: PanelMode) { this.element = element; this.stage = stage; this.zoom = stage.firstElementChild ?? stage; this.mode = mode; this.armed = observeArming(element); } public apply(next: PanelView): void { this.view = boundedView(next, extentOf(this.element), contentExtentOf(this.zoom, this.view.scale)); this.stage.style.setProperty(PANEL_X_PROPERTY, String(this.view.x) + PIXEL); this.stage.style.setProperty(PANEL_Y_PROPERTY, String(this.view.y) + PIXEL); this.stage.style.setProperty(PANEL_SCALE_PROPERTY, String(this.view.scale)); } public reset(): void { if (this.mode === "origin") { this.apply(ORIGIN_VIEW); return; } const ceiling = this.mode === "fill" ? PANEL_MAX_SCALE : 1; this.apply(fittedView(extentOf(this.element), contentExtentOf(this.zoom, this.view.scale), ceiling)); } public zoomBy(factor: number, anchor: Point): void { this.apply(zoomedView(this.view, factor, anchor)); } public zoomAtCenter(factor: number): void { this.zoomBy(factor, centerOf(extentOf(this.element))); } public reveal(top: number): void { this.apply({ ...this.view, y: PANEL_REVEAL_MARGIN_PX - top * this.view.scale }); } public press(event: PointerEvent): void { if (event.pointerType === MOUSE_POINTER && (event.button !== PRIMARY_BUTTON || !this.armed())) { return; } this.pointers.set(event.pointerId, this.local(event)); this.element.setPointerCapture(event.pointerId); this.element.classList.add(PANEL_DRAGGING_CLASS); this.pinchDistance = this.pairDistance(); event.preventDefault(); } public move(event: PointerEvent): void { const previous = this.pointers.get(event.pointerId); if (previous === undefined) { return; } const current = this.local(event); this.pointers.set(event.pointerId, current); event.preventDefault(); if (this.pointers.size >= PANEL_POINTER_PAIR) { this.pinch(); return; } this.apply({ ...this.view, x: this.view.x + current.x - previous.x, y: this.view.y + current.y - previous.y }); } public release(event: PointerEvent): void { this.pointers.delete(event.pointerId); this.pinchDistance = this.pairDistance(); if (this.pointers.size === 0) { this.element.classList.remove(PANEL_DRAGGING_CLASS); } } public wheel(event: WheelEvent): void { if (!this.armed()) { return; } if (event.ctrlKey || event.metaKey) { this.zoomBy(Math.exp(-event.deltaY * PANEL_WHEEL_ZOOM_RATE), this.local(event)); event.preventDefault(); return; } this.apply({ ...this.view, x: this.view.x - event.deltaX, y: this.view.y - event.deltaY }); event.preventDefault(); } private local(event: MouseEvent): Point { const rect = this.element.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; } private pair(): [Point, Point] | null { const [first, second] = [...this.pointers.values()]; return first !== undefined && second !== undefined ? [first, second] : null; } private pairDistance(): number { const pair = this.pair(); return pair === null ? 0 : distanceBetween(pair[0], pair[1]); } private pinch(): void { const pair = this.pair(); if (pair === null || this.pinchDistance === 0) { return; } const now = distanceBetween(pair[0], pair[1]); this.zoomBy(now / this.pinchDistance, midpointOf(pair[0], pair[1])); this.pinchDistance = now; } }