components_selection_context_menu.js


/**
 * A context menu for word-selection operations.
 *
 * Shown when the user right-clicks on a word that belongs to an active word
 * selection. Provides "Add link" and "Search text" actions.
 *
 * @example
 * const menu = new SelectionContextMenu(event.clientX, event.clientY, {
 *   onAddLink:          () => { handleHyperlink(); },
 *   onSearchText:       () => { handleSearch(); },
 *   onGenerateLiveQuote: () => { handleEmbed(); },  // null to hide
 *   onDismiss:          () => { menu.close(); },
 * });
 *
 * // Later:
 * menu.close();
 */
export class SelectionContextMenu {
    /**
     * @param {number} x - Preferred left position in viewport pixels.
     * @param {number} y - Preferred top position in viewport pixels.
     * @param {object} callbacks - Callback functions for menu actions.
     * @param {function(): void} [callbacks.onAddLink]             - Called when "Add link" is clicked.
     * @param {function(): void} [callbacks.onSearchText]          - Called when "Search text" is clicked.
     * @param {function(): void|null} [callbacks.onGenerateLiveQuote] - Called when "Generate Live Quote" is clicked. Pass null to hide the item.
     * @param {function(): void} [callbacks.onDismiss]             - Called when the menu is dismissed.
     */
    constructor(x, y, { onAddLink, onSearchText, onGenerateLiveQuote, onDismiss } = {}) {
        this._onAddLink             = onAddLink             ?? (() => {});
        this._onSearchText          = onSearchText          ?? (() => {});
        this._onGenerateLiveQuote   = onGenerateLiveQuote   ?? null;
        this._onDismiss             = onDismiss             ?? (() => {});

        this.root = document.createElement('div');
        this.root.className = 'ctx-menu';

        this.#buildControls();

        // Prevent mousedown from clearing the native text selection before
        // click callbacks have a chance to fire.
        this.root.addEventListener('mousedown', (e) => e.preventDefault());

        document.body.appendChild(this.root);
        this.#position(x, y);
        this.#bindOutsideClick();
    }

    /** Removes the menu from the DOM. */
    close() {
        this.root.remove();
    }

    /** Builds and appends the menu items to the root element. */
    #buildControls() {
        const controls = document.createElement('div');
        controls.className = 'ctx-controls';

        controls.appendChild(this.#makeItem(
            '<span class="icon icon-plus" style="width:14px;height:14px;"></span>',
            'Add link',
            '⇧K',
            () => { this._onDismiss(); this._onAddLink(); },
        ));

        controls.appendChild(this.#makeItem(
            '<span class="icon icon-crosshair" style="width:14px;height:14px;"></span>',
            'Search text',
            '⇧F',
            () => { this._onDismiss(); this._onSearchText(); },
        ));

        if (this._onGenerateLiveQuote !== null) {
            controls.appendChild(this.#makeItem(
                '<span style="font-size:0.85rem;line-height:1;letter-spacing:-0.05em">«»</span>',
                'Generate Live Quote',
                '⇧Q',
                () => { this._onDismiss(); this._onGenerateLiveQuote(); },
            ));
        }

        this.root.appendChild(controls);
    }

    /**
     * @param {string} iconHtml - Inner HTML for the icon cell.
     * @param {string} label    - Display label.
     * @param {string} kbd      - Keyboard shortcut hint text.
     * @param {function} onClick - Called when the item is clicked.
     * @returns {HTMLElement} The constructed menu item element.
     */
    #makeItem(iconHtml, label, kbd, onClick) {
        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;

        const kbdSpan = document.createElement('span');
        kbdSpan.className = 'ctx-kbd';
        kbdSpan.textContent = kbd;

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

        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 (!this.root.contains(e.target)) {
                    this._onDismiss();
                    document.removeEventListener('mousedown', outside);
                }
            };
            document.addEventListener('mousedown', outside);
        }, 0);
    }
}