presentation/components/stage.component.ts

presentation/components/stage.component.ts is a file in Bane's Lab Site. 131 lines of code and 30 definitions.

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 { declareStyle } from "#core/registries/style.registry";
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<number, Point>();
    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));
        declareStyle(this.stage, PANEL_X_PROPERTY, String(this.view.x) + PIXEL);
        declareStyle(this.stage, PANEL_Y_PROPERTY, String(this.view.y) + PIXEL);
        declareStyle(this.stage, 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;
    }
}