presentation/components/step.component.ts

presentation/components/step.component.ts is a file in Bane's Lab Site. 143 lines of code and 26 definitions.

import { LABEL_SEPARATOR, LIST_SEPARATOR } from "#configuration/strings/ontology.strings";
import {
    MILLISECONDS,
    REFERENCE_CHAIN_MS,
    REFERENCE_CHAIN_PROPERTY,
    REFERENCE_CLASS,
    REFERENCE_EDGES_CLASS,
    REFERENCE_LABEL_CLASS,
    REFERENCE_RELATION_CLASS,
} from "#configuration/constants/reference.constants";
import { NEXT_STEP_LABEL, OF_STEPS_LABEL, PREVIOUS_STEP_LABEL, STEP_LABEL } from "#configuration/strings/walk.strings";
import { WALK_CELL_SELECTOR, WALK_CURRENT_CLASS, WALK_REF_ATTRIBUTE } from "#configuration/constants/anatomy.constants";
import { armLink, clampPanel, createRecordPanel, placePanel } from "#presentation/components/reference.component";
import { cellRecord, cellText, loadSnippet } from "#presentation/components/walk.component";
import { clearChildren, createElement } from "#core/factories/element.factory";
import { ANCHOR_PREFIX } from "#configuration/constants/document.constants";
import type { Disposer } from "#types/base.types";
import { OPEN_CLASS } from "#configuration/constants/element.constants";
import { WALK_PANEL_ID } from "#core/ids/anatomy.ids";
import type { WalkCellView } from "#types/anatomy.types";
import { declareStyle } from "#core/registries/style.registry";

const sharedPanel = function sharedPanel(): HTMLElement {
    const existing = document.getElementById(WALK_PANEL_ID);
    if (existing !== null) {
        return existing;
    }
    const panel = createElement("div", { attributes: { id: WALK_PANEL_ID }, className: REFERENCE_CLASS });
    declareStyle(panel, REFERENCE_CHAIN_PROPERTY, String(REFERENCE_CHAIN_MS) + MILLISECONDS);
    document.body.append(panel);
    return panel;
};

export class WalkInspector {
    private readonly panel = sharedPanel();
    private readonly polygons: ReadonlyMap<string, Element>;
    private readonly cells: readonly WalkCellView[];
    private readonly status: HTMLElement;
    private current = -1;
    private timer: ReturnType<typeof setTimeout> | null = null;
    private disarm: Disposer | null = null;

    public constructor(element: HTMLElement, status: HTMLElement, cells: readonly WalkCellView[]) {
        this.cells = cells;
        this.status = status;
        this.polygons = new Map(
            [...element.querySelectorAll(WALK_CELL_SELECTOR)].map((polygon) => [
                polygon.getAttribute(WALK_REF_ATTRIBUTE) ?? "",
                polygon,
            ]),
        );
    }

    public indexAt(target: EventTarget | null): number {
        const polygon = target instanceof Element ? target.closest(WALK_CELL_SELECTOR) : null;
        const ref = polygon?.getAttribute(WALK_REF_ATTRIBUTE) ?? null;
        return ref === null ? -1 : this.cells.findIndex((cell) => cell.ref === ref);
    }

    public schedule(action: () => void, delay: number): void {
        this.cancel();
        this.timer = setTimeout(action, delay);
    }

    public cancel(): void {
        if (this.timer !== null) {
            clearTimeout(this.timer);
            this.timer = null;
        }
        if (this.disarm !== null) {
            this.disarm();
            this.disarm = null;
        }
    }

    public show(index: number, replace: boolean): void {
        const cell = this.cells[index];
        const polygon = this.polygons.get(cell?.ref ?? "");
        if (cell === undefined || polygon === undefined) {
            return;
        }
        this.cancel();
        this.polygons.get(this.cells[this.current]?.ref ?? "")?.classList.remove(WALK_CURRENT_CLASS);
        this.current = index;
        polygon.classList.add(WALK_CURRENT_CLASS);
        this.status.textContent = cellText(cell);
        clearChildren(this.panel);
        const step = this.stepRow(index);
        this.panel.append(...createRecordPanel(cellRecord(cell)), step);
        this.panel.classList.add(OPEN_CLASS);
        if (replace) {
            placePanel(this.panel, polygon);
        }
        clampPanel(this.panel);
        void loadSnippet(cell).then((snippet) => {
            if (snippet !== null && this.current === index) {
                step.before(snippet);
                clampPanel(this.panel);
            }
        });
    }

    public hide(): void {
        this.cancel();
        this.polygons.get(this.cells[this.current]?.ref ?? "")?.classList.remove(WALK_CURRENT_CLASS);
        this.current = -1;
        this.panel.classList.remove(OPEN_CLASS);
        clearChildren(this.panel);
    }

    public isOpen(): boolean {
        return this.current !== -1;
    }

    public holds(node: EventTarget | null): boolean {
        return node instanceof Node && this.panel.contains(node);
    }

    private stepLink(label: string, index: number): HTMLAnchorElement {
        const link = createElement("a", { attributes: { href: ANCHOR_PREFIX + WALK_PANEL_ID }, text: label });
        link.addEventListener("click", (event) => {
            event.preventDefault();
            event.stopPropagation();
            this.show(index, false);
        });
        link.addEventListener("mouseenter", () => {
            this.cancel();
            this.disarm = armLink(link);
            this.timer = setTimeout(() => {
                this.show(index, false);
            }, REFERENCE_CHAIN_MS);
        });
        link.addEventListener("mouseleave", () => {
            this.cancel();
        });
        return link;
    }

    private stepRow(index: number): HTMLElement {
        const edges: (HTMLElement | string)[] = [String(index + 1) + OF_STEPS_LABEL + String(this.cells.length)];
        if (index > 0) {
            edges.push(LIST_SEPARATOR, this.stepLink(PREVIOUS_STEP_LABEL, index - 1));
        }
        if (index < this.cells.length - 1) {
            edges.push(LIST_SEPARATOR, this.stepLink(NEXT_STEP_LABEL, index + 1));
        }
        return createElement("div", {
            children: [
                createElement("span", { className: REFERENCE_LABEL_CLASS, text: STEP_LABEL + LABEL_SEPARATOR }),
                createElement("span", { children: edges, className: REFERENCE_EDGES_CLASS }),
            ],
            className: REFERENCE_RELATION_CLASS,
        });
    }
}