thi-ng/umbrella

View on GitHub
packages/wasm-api-dom/src/dom.ts

Summary

Maintainability
A
0 mins
Test Coverage
import type { Maybe, NumOrString } from "@thi.ng/api";
import { adaptDPI } from "@thi.ng/canvas";
import { assert } from "@thi.ng/errors/assert";
import {
    WasmStringSlice,
    type IWasmAPI,
    type ReadonlyWasmString,
    type WasmBridge,
    type WasmType,
    type WasmTypeBase,
} from "@thi.ng/wasm-api";
import { ObjectIndex } from "@thi.ng/wasm-api/object-index";
import {
    $CreateCanvasOpts,
    $CreateElementOpts,
    $Event,
    $WindowInfo,
    AttribType,
    EventType,
    NS_PREFIXES,
    type CreateElementOpts,
    type EventBody,
    type WasmDomExports,
    type WasmDomImports,
    type Event as WasmEvent,
} from "./api.js";

/**
 * Hidden property for managed DOM elements to track IDs of attached WASM event
 * listeners
 */
const __listeners = "__wasm_listeners";

interface WasmElement extends Element {
    [__listeners]: Set<number>;
}

/**
 * Map of JS event name regexps to {@link EventType} enums and {@link EventBody}
 * field names
 */
const EVENT_MAP: [
    RegExp,
    Maybe<Exclude<keyof EventBody, keyof WasmTypeBase>>,
    EventType
][] = [
    [/^drag(end|enter|leave|over|start)|drop$/, "drag", EventType.DRAG],
    [/^blur|focus(in|out)?$/, undefined, EventType.FOCUS],
    [/^change|(before)?input$/, "input", EventType.INPUT],
    [/^key(down|press|up)$/, "key", EventType.KEY],
    [
        /^(dbl)?click|contextmenu|mouse(down|enter|leave|move|out|over|up)$/,
        "mouse",
        EventType.MOUSE,
    ],
    [
        /^(got|lost)pointercapture|pointer(cancel|down|enter|leave|move|out|over|up)$/,
        "pointer",
        EventType.POINTER,
    ],
    [/^scroll$/, "scroll", EventType.SCROLL],
    [/^touch(cancel|end|move|start)$/, "touch", EventType.TOUCH],
    [/^wheel$/, "touch", EventType.WHEEL],
];

/** @internal */
interface WasmListener {
    ctx: number;
    name: string;
    event: WasmEvent;
    fn: EventListener;
}

export class WasmDom implements IWasmAPI<WasmDomExports> {
    static readonly id = "dom";

    readonly id = WasmDom.id;

    parent!: WasmBridge<WasmDomExports>;
    $Event!: WasmType<WasmEvent>;
    $CreateElementOpts!: WasmType<CreateElementOpts>;

    elements = new ObjectIndex<Element>({ name: "elements" });
    listeners: Record<number, WasmListener> = {};

    protected currEvent: Event | null = null;
    protected currDataTransfer: DataTransfer | null = null;

    async init(parent: WasmBridge<WasmDomExports>) {
        this.parent = parent;
        if (parent.exports._dom_init) {
            parent.exports._dom_init();
        } else {
            parent.logger.warn("DOM module unused, skipping auto-init...");
        }
        this.elements.add(document.head);
        this.elements.add(document.body);
        this.$Event = $Event(this.parent);
        this.$CreateElementOpts = $CreateElementOpts(this.parent);
        return true;
    }

    getImports(): WasmDomImports {
        return {
            getWindowInfo: (ptr: number) => {
                const info = $WindowInfo(this.parent).instance(ptr);
                info.innerWidth = window.innerWidth;
                info.innerHeight = window.innerHeight;
                info.dpr = window.devicePixelRatio || 1;
                info.scrollX = window.scrollX;
                info.scrollY = window.scrollY;
                info.fullscreen =
                    (document.fullscreenElement ||
                    (<any>document).webkitFullscreenElement
                        ? 1
                        : 0) |
                    (document.fullscreenEnabled ||
                    (<any>document).webkitFullscreenEnabled
                        ? 2
                        : 0);
            },

            getElementByID: (nameAddr: number) => {
                const name = this.parent.getString(nameAddr);
                let id = this.elements.find((el) => el.id === name);
                if (id === undefined) {
                    const el = document.getElementById(name);
                    return el ? this.elements.add(el) : -1;
                }
                return id;
            },

            createElement: (optsAddr: number) => {
                const create = (
                    opts: CreateElementOpts,
                    nestedParent?: number
                ) => {
                    const tagName = opts.tag.deref();
                    const ns = opts.ns.deref();
                    const el = ns
                        ? document.createElementNS(
                                NS_PREFIXES[ns] || ns,
                                tagName
                          )
                        : document.createElement(tagName);
                    const id = this.elements.add(el);
                    this.initElement(id, el, opts, nestedParent);
                    if (opts.children.length > 0) {
                        for (let child of opts.children) {
                            create(child, id);
                        }
                    }
                    return id;
                };
                return create(this.$CreateElementOpts.instance(optsAddr));
            },

            removeElement: (elementID: number) => {
                assert(elementID > 1, "can't remove reserved element");
                const el = this.elements.get(elementID, false);
                if (!el) return;
                const remove = (el: Element) => {
                    const elementID = this.elements.find(
                        (x) => x === el,
                        false
                    );
                    if (elementID !== undefined) {
                        this.elements.delete(elementID, false);
                        const elementListeners = (<WasmElement>el)[__listeners];
                        if (elementListeners) {
                            for (let listenerID of elementListeners) {
                                this.removeListener(el, listenerID);
                                // WASM side cleanup
                                this.parent.exports._dom_removeListener(
                                    listenerID
                                );
                            }
                        }
                    }
                    el.parentNode?.removeChild(el);
                    /* eslint-disable-next-line no-useless-spread -- shallow copy required here */
                    for (let child of [...el.children]) remove(child);
                };
                remove(el);
            },

            createCanvas: (optsAddr: number) => {
                const opts = $CreateCanvasOpts(this.parent).instance(optsAddr);
                const el = document.createElement("canvas");
                adaptDPI(el, opts.width, opts.height, opts.dpr);
                const id = this.elements.add(el);
                this.initElement(id, el, opts);
                return id;
            },

            setCanvasSize: (
                elementID: number,
                width: number,
                height: number,
                dpr: number
            ) =>
                adaptDPI(
                    <HTMLCanvasElement>this.elements.get(elementID),
                    width,
                    height,
                    dpr
                ),

            setStringAttrib: (elementID: number, name: number, val: number) =>
                this.setAttrib(elementID, name, this.parent.getString(val)),

            setNumericAttrib: (elementID: number, name: number, val: number) =>
                this.setAttrib(elementID, name, val),

            _setBooleanAttrib: (
                elementID: number,
                nameAddr: number,
                val: number
            ) => {
                const el = this.elements.get(elementID);
                const name = this.parent.getString(nameAddr);
                if (name in el) {
                    // @ts-ignore
                    el[name] = !!val;
                } else {
                    val ? el.setAttribute(name, "") : el.removeAttribute(name);
                }
            },

            _getStringAttrib: (
                elementID: number,
                nameAddr: number,
                valAddr: number,
                maxBytes: number
            ) =>
                this.parent.setString(
                    String(this.getAttrib(elementID, nameAddr) || ""),
                    valAddr,
                    maxBytes,
                    true
                ),

            _getStringAttribAlloc: (
                elementID: number,
                nameAddr: number,
                slice: number
            ) =>
                new WasmStringSlice(this.parent, slice).setAlloc(
                    String(this.getAttrib(elementID, nameAddr) || ""),
                    true
                ),

            getNumericAttrib: (elementID: number, nameAddr: number) =>
                Number(this.getAttrib(elementID, nameAddr) || ""),

            _getBooleanAttrib: (elementID: number, nameAddr: number) =>
                ~~(this.getAttrib(elementID, nameAddr) != null),

            addClass: (elementID: number, name: number) =>
                this.elements
                    .get(elementID)
                    .classList.add(this.parent.getString(name)),

            removeClass: (elementID: number, name: number) =>
                this.elements
                    .get(elementID)
                    .classList.remove(this.parent.getString(name)),

            _addListener: (ctxID: number, name: number, listenerID: number) => {
                const ctx = ctxID < 0 ? window : this.elements.get(ctxID);
                const eventName = this.parent.getString(name);
                const eventSpec = EVENT_MAP.find(([re]) => re.test(eventName));
                const [eventBodyID, eventTypeID] = eventSpec
                    ? [eventSpec[1], eventSpec[2]]
                    : [undefined, EventType.UNKOWN];
                const hasModifiers = [
                    EventType.DRAG,
                    EventType.INPUT,
                    EventType.KEY,
                    EventType.MOUSE,
                    EventType.POINTER,
                    EventType.TOUCH,
                    EventType.WHEEL,
                ].includes(eventTypeID);
                const event = this.$Event.instance(
                    this.parent.allocate(this.$Event.size)[0]
                );
                const body = eventBodyID ? event.body[eventBodyID] : undefined;
                const fn = (e: Event) => {
                    this.currEvent = e;
                    this.parent.ensureMemory();
                    event.__bytes.fill(0);
                    const target =
                        e.target === ctx
                            ? ctxID
                            : e.target === window
                            ? -1
                            : this.elements.find((x) => x === e.target, false);
                    event.target = target !== undefined ? target : -2;
                    event.id = eventTypeID;
                    const slice = body ? body.fromEvent(<any>e) : undefined;
                    if (hasModifiers) {
                        (<any>body!).modifiers = this.encodeModifiers(
                            <KeyboardEvent>e
                        );
                    }
                    if (eventTypeID === EventType.DRAG) {
                        this.currDataTransfer = (<DragEvent>e).dataTransfer;
                    }
                    this.parent.exports._dom_callListener(
                        listenerID,
                        event.__base
                    );
                    if (slice) this.parent.free(slice);
                    this.currEvent = null;
                    this.currDataTransfer = null;
                };
                this.parent.logger.debug(
                    `ctx ${ctxID} - adding ${eventName} listener #${listenerID}`
                );
                ctx.addEventListener(eventName, fn);
                this.listeners[listenerID] = {
                    ctx: ctxID,
                    name: eventName,
                    event,
                    fn,
                };
                if (ctxID >= 0) {
                    (
                        (<WasmElement>ctx)[__listeners] ||
                        ((<WasmElement>ctx)[__listeners] = new Set())
                    ).add(listenerID);
                }
            },

            preventDefault: () => {
                this.currEvent && this.currEvent.preventDefault();
            },

            stopPropagation: () => {
                this.currEvent && this.currEvent.stopPropagation();
            },

            stopImmediatePropagation: () => {
                this.currEvent && this.currEvent.stopImmediatePropagation();
            },

            _removeListener: (listenerID: number) => {
                const listener = this.listeners[listenerID];
                assert(!!listener, `unknown listener ID: ${listenerID}`);
                const ctx =
                    listener.ctx < 0 ? window : this.elements.get(listener.ctx);
                this.removeListener(ctx, listenerID);
                if (listener.ctx >= 0) {
                    const listeners = (<WasmElement>ctx)[__listeners];
                    if (listeners.has(listenerID)) listeners.delete(listenerID);
                }
            },

            setInnerHtml: (elementID: number, body: number) => {
                this.elements.get(elementID).innerHTML =
                    this.parent.getString(body);
            },

            setInnerText: (elementID: number, body: number) => {
                (<HTMLElement>this.elements.get(elementID)).innerText =
                    this.parent.getString(body);
            },

            _requestAnimationFrame: (rafID: number) => {
                this.parent.logger.fine(`requestAnimationFrame #${rafID}`);
                requestAnimationFrame((t) =>
                    this.parent.exports._dom_callRAF(rafID, t)
                );
            },

            _requestFullscreen: async (elementID: number) => {
                if (
                    !(
                        document.fullscreenElement ||
                        (<any>document).webkitFullscreenElement
                    )
                ) {
                    const el =
                        elementID <= 1
                            ? document.documentElement
                            : this.elements.get(elementID);
                    const method =
                        el.requestFullscreen ||
                        (<any>el).webkitRequestFullscreen;
                    await method.bind(el)();
                    this.parent.exports._dom_fullscreenChanged();
                }
            },

            _exitFullscreen: async () => {
                if (
                    document.fullscreenElement ||
                    (<any>document).webkitFullscreenElement
                ) {
                    const method =
                        document.exitFullscreen ||
                        (<any>document).webkitExitFullscreen;
                    await method.bind(document)();
                    this.parent.exports._dom_fullscreenChanged();
                }
            },
        };
    }

    protected initElement(
        elementID: number,
        el: Element,
        opts: Pick<
            Readonly<CreateElementOpts>,
            "attribs" | "class" | "id" | "index" | "parent"
        > &
            Partial<{
                html: ReadonlyWasmString;
                text: ReadonlyWasmString;
            }>,
        nestedParent?: number
    ) {
        const { id, attribs, class: $class, index } = opts;
        if (id.length) el.setAttribute("id", id.deref());
        if ($class.length) el.setAttribute("class", $class.deref());
        if (opts.html?.length) {
            el.innerHTML = opts.html.deref();
        } else if (opts.text?.length) {
            (<HTMLElement>el).innerText = opts.text.deref();
        }
        if (attribs && attribs.length) {
            for (let attr of attribs) {
                const name = attr.name.deref();
                if (attr.kind === AttribType.EVENT) {
                    const listenerAddr = attr.value.event.__base;
                    const listenerID =
                        this.parent.exports._dom_addListener(listenerAddr);
                    this.getImports()._addListener(
                        elementID,
                        attr.name.addr,
                        listenerID
                    );
                } else if (attr.kind === AttribType.FLAG) {
                    attr.value.flag && el.setAttribute(name, "");
                } else {
                    el.setAttribute(
                        name,
                        attr.kind === AttribType.STR
                            ? attr.value.str.deref()
                            : String(attr.value.num)
                    );
                }
            }
        }
        const parent = nestedParent != undefined ? nestedParent : opts.parent;
        if (parent >= 0) {
            const parentEl = this.elements.get(parent);
            index < 0
                ? parentEl.appendChild(el)
                : parentEl.insertBefore(el, parentEl.childNodes[index]);
        }
    }

    protected encodeModifiers(e: KeyboardEvent) {
        return (
            (e.shiftKey ? 1 : 0) |
            (e.ctrlKey ? 2 : 0) |
            (e.altKey ? 4 : 0) |
            (e.metaKey ? 8 : 0)
        );
    }

    protected getAttrib(elementID: number, nameAddr: number) {
        const el = this.elements.get(elementID);
        const name = this.parent.getString(nameAddr);
        return name in el ? el[<keyof Element>name] : el.getAttribute(name);
    }

    protected setAttrib(
        elementID: number,
        nameAddr: number,
        value: NumOrString
    ) {
        const el = this.elements.get(elementID);
        const name = this.parent.getString(nameAddr);
        return name in el
            ? // @ts-ignore
              (el[name] = value)
            : el.setAttribute(name, String(value));
    }

    protected removeListener(ctx: Window | Element, listenerID: number) {
        const listener = this.listeners[listenerID];
        assert(!!listener, `invalid listener ID ${listenerID}`);
        this.parent.logger.debug(`removing event listener #${listenerID}`);
        delete this.listeners[listenerID];
        ctx.removeEventListener(listener.name, listener.fn);
        this.parent.free([listener.event.__base, this.$Event.size]);
    }
}