presentation/components/surface.component.ts

presentation/components/surface.component.ts is a file in Bane's Lab Site. 104 lines of code and 21 definitions.

import {
    CAMERA_MS,
    SURFACE_CAMERA_PROPERTY,
    SURFACE_CHANGED_CLASS,
    SURFACE_DOCUMENT_CLASS,
    SURFACE_LINE_CLASS,
    SURFACE_NAME_CLASS,
    SURFACE_OFFSET_PROPERTY,
    SURFACE_PANE_CLASS,
    SURFACE_TOGGLE_CLASS,
    SURFACE_TYPING_CLASS,
    SURFACE_VENUE_CLASS,
    SURFACE_VENUE_ROWS_PROPERTY,
    SURFACE_VIEWPORT_CLASS,
    VENUE_ROWS,
} from "#configuration/constants/surface.constants";
import { HALF, PIXEL } from "#configuration/constants/element.constants";
import { ICON_BASE_CLASS, PAUSE_ICON, PLAY_ICON } from "#configuration/icons/element.icons";
import { PAUSE_LABEL, PLAY_LABEL, SURFACE_PANE_LABEL } from "#configuration/strings/surface.strings";
import { MILLISECONDS } from "#configuration/constants/reference.constants";
import { SPACE } from "#configuration/constants/document.constants";
import type { SurfaceLine } from "#types/surface.types";
import { createElement } from "#core/factories/element.factory";
import { declareStyle } from "#core/registries/style.registry";
import { indentOf } from "#core/converters/surface.converter";

const lineRow = function lineRow(text: string): HTMLElement {
    return createElement("span", { className: SURFACE_LINE_CLASS, text });
};

export const createPlaybackButton = function createPlaybackButton(paused: boolean, act: () => void): HTMLButtonElement {
    const label = paused ? PLAY_LABEL : PAUSE_LABEL;
    const button = createElement("button", {
        attributes: { "aria-label": label, title: label, type: "button" },
        children: [createElement("i", { className: `${ICON_BASE_CLASS} ${paused ? PLAY_ICON : PAUSE_ICON}` })],
        className: SURFACE_TOGGLE_CLASS,
    });
    button.addEventListener("click", act);
    return button;
};

export class VenuePane {
    public readonly element: HTMLElement;
    private readonly viewport: HTMLElement;
    private readonly content: HTMLElement;
    private rows: HTMLElement[] = [];
    private offset = 0;

    public constructor(surface: string) {
        this.content = createElement("code", { className: SURFACE_DOCUMENT_CLASS });
        this.viewport = createElement("pre", { children: [this.content], className: SURFACE_VIEWPORT_CLASS });
        this.element = createElement("section", {
            attributes: { "aria-label": SURFACE_PANE_LABEL },
            children: [createElement("p", { className: SURFACE_NAME_CLASS, text: surface }), this.viewport],
            className: `${SURFACE_PANE_CLASS} ${SURFACE_VENUE_CLASS}`,
        });
        declareStyle(this.element, SURFACE_VENUE_ROWS_PROPERTY, String(VENUE_ROWS));
        declareStyle(this.element, SURFACE_CAMERA_PROPERTY, String(CAMERA_MS) + MILLISECONDS);
    }

    public show(lines: readonly string[]): void {
        this.rows = lines.map(lineRow);
        this.content.replaceChildren(...this.rows);
    }

    public insert(at: number, line: SurfaceLine): void {
        const row = lineRow(SPACE.repeat(indentOf(line.text)));
        row.classList.add(SURFACE_TYPING_CLASS);
        row.classList.toggle(SURFACE_CHANGED_CLASS, line.changed);
        const next = this.rows[at];
        if (next === undefined) {
            this.content.append(row);
        } else {
            next.before(row);
        }
        this.rows.splice(at, 0, row);
        this.focus(at);
    }

    public type(at: number, text: string, done: boolean): void {
        const row = this.rows[at];
        if (row !== undefined) {
            row.textContent = text;
            row.classList.toggle(SURFACE_TYPING_CLASS, !done);
        }
        this.focus(at);
    }

    public remove(at: number): void {
        this.rows[at]?.remove();
        this.rows.splice(at, 1);
        this.focus(Math.max(0, at - 1));
    }

    public mark(lines: readonly SurfaceLine[]): void {
        for (const [index, row] of this.rows.entries()) {
            row.classList.toggle(SURFACE_CHANGED_CLASS, lines[index]?.changed === true);
        }
    }

    public focus(at: number): void {
        const row = this.rows[at];
        if (row === undefined) {
            return;
        }
        const room = this.viewport.clientHeight;
        const centered = row.offsetTop + row.offsetHeight / HALF - room / HALF;
        const next = Math.round(Math.min(Math.max(centered, 0), Math.max(this.content.offsetHeight - room, 0)));
        if (next !== this.offset) {
            this.offset = next;
            declareStyle(this.content, SURFACE_OFFSET_PROPERTY, String(-next) + PIXEL);
        }
    }
}