components_context_menu.js


/**
 * Base class for all positioned context menus.
 *
 * Handles root element creation, DOM mounting, viewport-clamped positioning,
 * outside-click dismissal, and a shared item factory.
 *
 * Subclass usage:
 *   1. Call `super(title, onDismiss)` at the top of the subclass constructor.
 *   2. Build content onto `this.root`.
 *   3. Call `this._mount(x, y)` at the end of the subclass constructor.
 *   4. Override `close()` if extra cleanup is needed, calling `super.close()`.
 *
 * Stacking usage:
 *   primaryMenu.stack(secondaryMenu)
 *   Merges secondaryMenu's title and items below primaryMenu's content. The
 *   combined result is a single mounted menu. Returns `this` for chaining.
 */
export class ContextMenu {
    /**
     * @param {string} title - Label shown at the top of the menu identifying what was right-clicked.
     * @param {function(): void} [onDismiss] - Called when the menu is dismissed via outside click.
     * @param {{message: string, href: (string|undefined)}|null} [info] - When provided, an info-widget icon is
     *   prepended to the title row. `message` is the hover tooltip text; `href` opens a docs page on click.
     */
    constructor(title, onDismiss, info = null) {
        this._onDismiss = onDismiss ?? (() => {});
        this._childMenus = [];
        this.root = document.createElement('div');
        this.root.className = 'ctx-menu';

        const titleEl = document.createElement('div');
        titleEl.className = 'ctx-title';
        if (info) {
            const widget = document.createElement('info-widget');
            widget.setAttribute('message', info.message);
            if (info.href) widget.setAttribute('href', info.href);
            titleEl.appendChild(widget);
        }
        titleEl.appendChild(document.createTextNode(title));
        this.root.appendChild(titleEl);
    }

    /**
     * Appends the menu to the document body, positions it at (x, y), and
     * starts listening for outside clicks. Call this at the end of the subclass
     * constructor once all content has been built.
     * @param {number} x - Preferred left position in viewport pixels.
     * @param {number} y - Preferred top position in viewport pixels.
     */
    _mount(x, y) {
        document.body.appendChild(this.root);
        this.#position(x, y);
        this.#bindOutsideClick();
    }

    /** Removes the menu from the DOM. Override to add cleanup; call super.close(). */
    close() {
        this.root.remove();
        for (const child of this._childMenus) child.close();
    }

    /**
     * Merges `childMenu` below this menu as a stacked section. The child's title
     * and items are appended to this root separated by a divider. The child is
     * detached from the DOM and its lifecycle (close, dismiss) is taken over by
     * this menu. Returns `this` for chaining.
     * @param {ContextMenu} childMenu - A mounted ContextMenu instance to stack below this one.
     * @returns {ContextMenu} this
     */
    stack(childMenu) {
        childMenu.root.remove();

        this.root.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep ctx-sep--before-header' }));
        while (childMenu.root.firstChild) {
            this.root.appendChild(childMenu.root.firstChild);
        }

        this._childMenus.push(childMenu);

        const prevDismiss = this._onDismiss;
        this._onDismiss = () => { prevDismiss(); childMenu._onDismiss(); };

        // Re-clamp now that the menu is taller.
        requestAnimationFrame(() => {
            const r    = this.root.getBoundingClientRect();
            const left = parseFloat(this.root.style.left);
            const top  = parseFloat(this.root.style.top);
            if (left + r.width  > window.innerWidth)  this.root.style.left = (window.innerWidth  - r.width  - 8) + 'px';
            if (top  + r.height > window.innerHeight) this.root.style.top  = (window.innerHeight - r.height - 8) + 'px';
        });

        return this;
    }

    /**
     * Creates a standard menu item element.
     * @param {string} iconHtml - Inner HTML for the icon cell (character or SVG/icon span).
     * @param {string} label - Display label text.
     * @param {string|null} kbd - Keyboard shortcut hint; pass null to omit.
     * @param {function|null} onClick - Click handler; pass null for disabled items.
     * @param {string|null} [disabledReason] - If set, the item is dimmed and shows this as a tooltip.
     * @returns {HTMLElement}
     */
    makeItem(iconHtml, label, kbd, onClick, disabledReason = null) {
        const item = document.createElement('div');
        item.className = 'ctx-item';

        const iconSpan = document.createElement('span');
        iconSpan.className = 'ctx-icon';
        iconSpan.innerHTML = iconHtml;

        const inner = document.createElement('span');
        inner.className = 'ctx-item-inner';
        inner.textContent = label;

        item.appendChild(iconSpan);
        item.appendChild(inner);

        if (kbd != null) {
            const kbdSpan = document.createElement('span');
            kbdSpan.className = 'ctx-kbd';
            kbdSpan.textContent = kbd;
            item.appendChild(kbdSpan);
        }

        if (disabledReason) {
            item.title = disabledReason;
            item.style.opacity = '0.4';
            item.style.cursor = 'default';
        } else if (onClick) {
            item.addEventListener('click', onClick);
        }

        return item;
    }

    /**
     * Positions the menu at (x, y), then clamps it to stay within the viewport.
     * @param {number} x - Preferred left position in viewport pixels.
     * @param {number} y - Preferred top position in viewport pixels.
     */
    #position(x, y) {
        this.root.style.left = x + 'px';
        this.root.style.top  = y + 'px';
        requestAnimationFrame(() => {
            const r = this.root.getBoundingClientRect();
            if (x + r.width  > window.innerWidth)  this.root.style.left = (window.innerWidth  - r.width  - 8) + 'px';
            if (y + r.height > window.innerHeight) this.root.style.top  = (window.innerHeight - r.height - 8) + 'px';
        });
    }

    /** Registers a mousedown listener that dismisses the menu on outside clicks. */
    #bindOutsideClick() {
        setTimeout(() => {
            const outside = (e) => {
                if (!document.body.contains(this.root)) {
                    // Root was stacked into another menu — deactivate silently.
                    document.removeEventListener('mousedown', outside);
                    return;
                }
                if (!this.root.contains(e.target)) {
                    this.close();
                    this._onDismiss();
                    document.removeEventListener('mousedown', outside);
                }
            };
            document.addEventListener('mousedown', outside);
        }, 0);
    }
}