components_selection_context_menu.js


import { ContextMenu } from "./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
 *   onMute:             () => { handleMute(); },    // null to hide
 *   onApplyEffect:      () => { handleEffect(); },  // null to hide
 *   onDismiss:          () => { menu.close(); },
 * });
 *
 * // Later:
 * menu.close();
 */
export class SelectionContextMenu extends ContextMenu {
    /**
     * @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|null} [callbacks.onMute]             - Called when "Mute" is clicked. Pass null to hide the item.
     * @param {function(): void|null} [callbacks.onApplyEffect]             - Called when "Apply effect" is clicked. Pass null to hide the item.
     * @param {string|null} [callbacks.applyEffectDisabledReason]           - Tooltip/reason shown when apply effect is disabled. Pass null if not disabled.
     * @param {function(): void|null} [callbacks.onAddNote]                 - Called when "Add note" is clicked. Pass null to hide the item.
     * @param {function(): void} [callbacks.onDismiss]                      - Called when the menu is dismissed.
     * @param {{message: string, href: (string|undefined)}|null} [callbacks.info] - Info widget config passed to the base ContextMenu.
     */
    constructor(x, y, { onAddLink, onSearchText, onGenerateLiveQuote, onMute, onApplyEffect, applyEffectDisabledReason, onAddNote, onDismiss, info = { message: 'Perform actions on the currently selected words.', href: '/docs' } } = {}) {
        super('Selection', onDismiss, info);

        this._onAddLink                   = onAddLink             ?? (() => {});
        this._onSearchText                = onSearchText          ?? (() => {});
        this._onGenerateLiveQuote         = onGenerateLiveQuote   ?? null;
        this._onMute                      = onMute                ?? null;
        this._onApplyEffect               = onApplyEffect         ?? null;
        this._applyEffectDisabledReason   = applyEffectDisabledReason ?? null;
        this._onAddNote                   = onAddNote             ?? null;

        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());

        this._mount(x, y);
    }

    /** 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(); },
            ));
        }

        if (this._onMute !== null) {
            controls.appendChild(this.makeItem(
                '<span style="font-size:0.85rem;line-height:1;">🔇</span>',
                'Mute',
                '⇧M',
                () => { this._onDismiss(); this._onMute(); },
            ));
        }

        if (this._onApplyEffect !== null || this._applyEffectDisabledReason) {
            controls.appendChild(this.makeItem(
                '<span style="font-size:0.85rem;line-height:1;">✦</span>',
                'Apply effect',
                '⇧E',
                this._applyEffectDisabledReason ? null : () => { this._onDismiss(); this._onApplyEffect(); },
                this._applyEffectDisabledReason,
            ));
        }

        if (this._onAddNote !== null) {
            controls.appendChild(this.makeItem(
                '✎',
                'Add note',
                '⇧N',
                () => { this._onDismiss(); this._onAddNote(); },
            ));
        }

        this.root.appendChild(controls);
    }

}