components_project_context_menu.js

/**
 * A context menu for project management operations (rename, new, duplicate,
 * delete, make-local, upload). When no project is provided only the "New
 * project" item is shown.
 */
export class ProjectContextMenu {
    /**
    * @param {number} x - preferred left position in viewport pixels
    * @param {number} y - preferred top position in viewport pixels
    * @param {object|null} project - project the menu acts on, or null for a general menu
    * @param {object} callbacks - callback functions for each menu action
    * @param {function} [callbacks.onDelete] - called when "Delete" is clicked
    * @param {function} [callbacks.onDuplicate] - called when "Duplicate" is clicked
    * @param {function} [callbacks.onNew] - called when "New project" is clicked
    * @param {function} [callbacks.onOpen] - called when "Open local project" is clicked
    * @param {function} [callbacks.onRename] - called when "Rename" is clicked
    * @param {function|null} [callbacks.onUpload] - called when "Upload changes" is clicked; omit to hide item
    * @param {function|null} [callbacks.onDownload] - called when "Download project" is clicked; omit to hide item
    * @param {function|null} [callbacks.onRevert] - called when "Revert changes" is clicked; omit to hide item
    * @param {function|null} [callbacks.onMoveToFolder] - called when "Move to folder" is clicked; omit to hide item
    * @param {function|null} [callbacks.onNewFolder] - called when "New folder" is clicked; omit to hide item
    * @param {function|null} [callbacks.onPresentation] - called when "Open Presentation" is clicked; omit to hide item
    * @param {function|null} [callbacks.onShare] - called when "Share…" is clicked; omit to hide item
    * @param {function|null} [callbacks.onCompress] - called when "Compress…" is clicked; omit to hide item
    * @param {function} [callbacks.onDismiss] - called when the menu is dismissed via outside click
    */
    constructor(x, y, project, { onDelete, onDuplicate, onNew, onOpen, onRename, onUpload, onDownload, onRevert, onMoveToFolder, onNewFolder, onPresentation, onShare, onCompress, onDismiss }) {
        this.project = project;

        this._onDelete = onDelete;
        this._onDuplicate = onDuplicate ?? (() => {});
        this._onNew = onNew ?? (() => {});
        this._onOpen = onOpen ?? (() => {});
        this._onRename = onRename;
        this._onUpload = onUpload;
        this._onDownload = onDownload;
        this._onRevert = onRevert;
        this._onMoveToFolder = onMoveToFolder;
        this._onNewFolder = onNewFolder;
        this._onPresentation = onPresentation;
        this._onShare = onShare;
        this._onCompress = onCompress;
        this._onDismiss = onDismiss ?? (() => {});

        this.root = document.createElement('div');
        this.root.className = 'ctx-menu';
        
//        this.#buildInfoBlock();
        this.#buildControlsBlock();
        
        document.body.appendChild(this.root);
        this.#position(x, y);
        this.#bindOutsideClick();
    }
    
    /** Removes the context menu from the DOM. */
    close() {
        this.root.remove();
    }

    /** Builds the controls block with the appropriate action items for the current project. */
    #buildControlsBlock() {
        const controls = document.createElement('div');
        controls.className = 'ctx-controls';
        this.controlsBlock = controls;

        // If there is no project selected, just add the "New" button
        if (this.project) {
            if (this._onRename) this.controlsBlock.appendChild(this.#buildRename());
            this.controlsBlock.appendChild(this.#buildDuplicate());
            if (this._onDelete) this.controlsBlock.appendChild(this.#buildDelete());

            if (this._onUpload) {
                this.controlsBlock.appendChild(this.#buildUpload());
            }

            if (this._onDownload) {
                this.controlsBlock.appendChild(this.#buildDownload());
            }

            if (this._onRevert) {
                this.controlsBlock.appendChild(this.#buildRevert());
            }

            if (this._onMoveToFolder) {
                this.controlsBlock.appendChild(this.#buildMoveToFolder());
            }

            if (this._onCompress) {
                this.controlsBlock.appendChild(this.#buildCompress());
            }

            if (this._onPresentation || this._onShare) {
                this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
                if (this._onPresentation) this.controlsBlock.appendChild(this.#buildPresentation());
                if (this._onShare)        this.controlsBlock.appendChild(this.#buildShare());
            }

            const projectId = this.project.id ?? this.project.projectId;
            if (projectId) {
                this.controlsBlock.appendChild(this.#buildIdLine(projectId));
            }

            this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
            this.controlsBlock.appendChild(this.#buildNew());
            this.controlsBlock.appendChild(this.#buildOpen());
            if (this._onNewFolder) {
                this.controlsBlock.appendChild(this.#buildNewFolder());
            }

        } else {
            this.controlsBlock.appendChild(this.#buildNew());
            this.controlsBlock.appendChild(this.#buildOpen());
            if (this._onNewFolder) {
                this.controlsBlock.appendChild(this.#buildNewFolder());
            }
        }

        this.root.appendChild(this.controlsBlock);

    }

    /** @returns {HTMLElement} the "Rename" menu item */
    #buildRename() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Rename</span>';
        item.addEventListener('click', this._onRename);
        return item;
    }

    /** @returns {HTMLElement} the "Open local project" menu item */
    #buildOpen() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon"><span class="icon icon-open-project" style="width:14px;height:14px;"></span></span><span class="ctx-item-inner">Open local project</span>';
        item.addEventListener('click', this._onOpen);
        return item;
    }

    /** @returns {HTMLElement} the "New project" menu item */
    #buildNew() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">+</span><span class="ctx-item-inner">New project</span>';
        item.addEventListener('click', this._onNew);
        return item;
    }

    /** @returns {HTMLElement} the "Duplicate" menu item */
    #buildDuplicate() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">⧉</span><span class="ctx-item-inner">Duplicate</span>';
        item.addEventListener('click', this._onDuplicate);
        return item;
    }

    /** @returns {HTMLElement} the "Delete" menu item */
    #buildDelete() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">🗑</span><span class="ctx-item-inner">Delete</span>';
        item.addEventListener('click', this._onDelete);
        return item;
    }

    /** @returns {HTMLElement} the "Upload changes" menu item */
    #buildUpload() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">⇧</span><span class="ctx-item-inner">Upload changes</span>';
        item.addEventListener('click', this._onUpload);
        return item;
    }

    /** @returns {HTMLElement} the "Download project" menu item */
    #buildDownload() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">⇩</span><span class="ctx-item-inner">Download project</span>';
        item.addEventListener('click', this._onDownload);
        return item;
    }

    /** @returns {HTMLElement} the "Revert changes" menu item */
    #buildRevert() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">↺</span><span class="ctx-item-inner">Revert changes</span>';
        item.title = 'Discard all changes since the last save';
        item.addEventListener('click', this._onRevert);
        return item;
    }

    /** @returns {HTMLElement} the "New folder" menu item */
    #buildNewFolder() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon"><span class="icon icon-new-folder" style="width:13px;height:13px;"></span></span><span class="ctx-item-inner">New folder</span>';
        item.addEventListener('click', this._onNewFolder);
        return item;
    }

    /**
     * @param {string} id - The project ID to display.
     * @returns {HTMLElement} a dim ID display line
     */
    #buildIdLine(id) {
        const el = document.createElement('div');
        el.className = 'ctx-id-line';
        el.textContent = id;
        return el;
    }

    /** @returns {HTMLElement} the "Open Presentation" menu item */
    #buildPresentation() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">&#9654;</span><span class="ctx-item-inner">Open Presentation</span>';
        item.addEventListener('click', this._onPresentation);
        return item;
    }

    /** @returns {HTMLElement} the "Share" menu item */
    #buildShare() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">&#x1F517;</span><span class="ctx-item-inner">Share…</span>';
        item.addEventListener('click', this._onShare);
        return item;
    }

    /** @returns {HTMLElement} the "Compress…" menu item */
    #buildCompress() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">⊜</span><span class="ctx-item-inner">Compress…</span>';
        item.title = 'Remove the original audio file, keeping only the generated MP3';
        item.addEventListener('click', this._onCompress);
        return item;
    }

    /** @returns {HTMLElement} the "Move to folder" menu item */
    #buildMoveToFolder() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon"><span class="icon icon-folder" style="width:13px;height:13px;"></span></span><span class="ctx-item-inner">Move to folder…</span>';
        item.title = 'Move this project to a different folder';
        item.addEventListener('click', this._onMoveToFolder);
        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 mr = this.root.getBoundingClientRect();
            if (x + mr.width  > window.innerWidth)  {
                this.root.style.left = (window.innerWidth  - mr.width  - 8) + 'px';
            }
            if (y + mr.height > window.innerHeight) {
                this.root.style.top  = (window.innerHeight - mr.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);
    }
}