components_segment_context_menu.js


import { formatTimeMs } from "../utilities/tools.js"

/**
 * A context menu for transcript segment operations.
 *
 * Reads segment and speaker data directly from `AppState` using the provided
 * segment index, then renders a positioned dropdown containing segment metadata
 * and action items for changing speaker, splitting, editing, and merging.
 * Actions are decoupled from application logic via callbacks supplied by the caller.
 *
 * @example
 * const menu = new SegmentContextMenu(event.clientX, event.clientY, segIdx, {
 *   onChangeSpeaker: (id) => { changeSpeaker(segIdx, id); menu.close(); },
 *   onSplit:         () => { enterSplitMode(segIdx, anchor); },
 *   onEdit:          () => { makeSegmentEditable(segIdx); },
 *   onMergePrev:     () => { ... },
 *   onMergeNext:     () => { ... },
 * });
 *
 * // Later:
 * menu.close();
 *
 * @param {number} x      - Preferred left position in viewport pixels.
 * @param {number} y      - Preferred top position in viewport pixels.
 * @param {number} segIdx - Index into `AppState.transcript.loadedSegments`.
 * @param {object} callbacks
 *
 * @param {function(string): void} callbacks.onChangeSpeaker
 *   Called with the selected speaker `id` when a speaker row is clicked.
 * @param {function(): void} callbacks.onSplit
 *   Called when the Split Segment item is clicked. Omit or pass `null` to
 *   suppress the item (e.g. when the segment has only one word).
 * @param {function(): void} callbacks.onEdit
 *   Called when the Edit Text item is clicked.
 * @param {function(): void|null} callbacks.onMergePrev
 *   Called when Merge With Previous is clicked. Pass `null` to suppress the item.
 * @param {function(): void|null} callbacks.onMergeNext
 *   Called when Merge With Next is clicked. Pass `null` to suppress the item.
 * @param {function(): void|null} callbacks.onAddLink
 *   Called when Add Link is clicked. Pass `null` to suppress the item.
 * @param {function(): void|null} callbacks.onSplitParagraph
 *   Called when Split Paragraph Here is clicked. Pass `null` to suppress the item.
 * @param {function(): void|null} callbacks.onMergeParagraphPrev
 *   Called when Merge Paragraph with Previous is clicked. Pass `null` to suppress the item.
 * @param {function(): void|null} callbacks.onZoom
 *   Called when Zoom to Sentence is clicked. Pass `null` to suppress the item.
 */
export class SegmentContextMenu {
    /**
     * @param {number} x - Preferred left position in viewport pixels.
     * @param {number} y - Preferred top position in viewport pixels.
     * @param {number} segIdx - Index into the active project's transcript segments.
     * @param {object} project - The active project instance.
     * @param {object} callbacks - Callback functions for menu actions.
     * @param {function(string): void} callbacks.onChangeSpeaker - Called with the selected speaker id when a speaker row is clicked.
     * @param {function(): void|null} callbacks.onSplit - Called when Split Segment is clicked; pass null to suppress the item.
     * @param {function(): void} callbacks.onEdit - Called when Edit Text is clicked.
     * @param {function(): void|null} callbacks.onMergePrev - Called when Merge With Previous is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onMergeNext - Called when Merge With Next is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onAddLink - Called when Add Link is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onRemoveLink - Called when Remove Link is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onCopyLink - Called when Copy Link is clicked; pass null to suppress.
     * @param {function(): void} callbacks.onDismiss - Called when the menu is dismissed via outside click.
     * @param {function(): void|null} callbacks.onSplitParagraph - Called when Split Paragraph Here is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onMergeParagraphPrev - Called when Merge Paragraph with Previous is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onZoom - Called when Zoom to Sentence is clicked; pass null to suppress.
     * @param {function(): void|null} callbacks.onRetranscribe - Called when Retranscribe Sentence is clicked; pass null to suppress (show only when word timestamps are stale).
     * @param {boolean} [callbacks.readOnly=false] - When true, speaker-change and edit actions are suppressed.
     */
    constructor(x, y, segIdx, project, { onChangeSpeaker, onSplit, onEdit, onMergePrev, onMergeNext, onAddLink, onDismiss, onEditLink, onRemoveLink, onCopyLink, onSplitParagraph, onMergeParagraphPrev, onZoom, onRetranscribe, readOnly = false }) {
        this.activeProject = project;
        this.segIdx = segIdx;
        this.seg = this.activeProject.transcript().segments[segIdx];
        this.readOnly = readOnly;

        this.onChangeSpeaker = onChangeSpeaker ?? (() => {});
        this.onSplit = onSplit;
        this.onEdit = onEdit ?? (() => {});
        this.onMergePrev = onMergePrev;
        this.onMergeNext = onMergeNext;
        this.onAddLink = onAddLink ?? null;
        this.onDismiss = onDismiss ?? (() => {});
        this.onEditLink = onEditLink ?? null;
        this.onRemoveLink = onRemoveLink ?? null;
        this.onCopyLink = onCopyLink ?? null;
        this.onSplitParagraph = onSplitParagraph ?? null;
        this.onMergeParagraphPrev = onMergeParagraphPrev ?? null;
        this.onZoom = onZoom ?? null;
        this.onRetranscribe = onRetranscribe ?? null;

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

        if (this.onEditLink || this.onRemoveLink || this.onCopyLink) this.#buildLinkBlock();
        this.#buildInfoBlock();
        this.#buildControlsBlock();

        this._onDismiss = onDismiss;// ?? (() => {});
        
        document.body.appendChild(this.root);
        this.#position(x, y);
        this.#bindOutsideClick();
        this.#setupListeners();
    }

    /** Registers a keydown listener that maps digit keys to speaker selection when the speaker list is open. */
    #setupListeners() {
        this._keydownHandler = (e) => {
            if (!this.speakerListOpen || this.segIdx < 0) return;
            const num = parseInt(e.key);
            if (num >= 1 && num <= 9) {
                const speaker = Object.values(this.activeProject.speakers())[num - 1];
                if (speaker && this.seg?.speaker !== speaker.id) {
                    this.onChangeSpeaker(speaker.id);
                    this._onDismiss();
                }
            }
        };
        document.addEventListener('keydown', this._keydownHandler);
    }
    
    /** Removes the context menu from the DOM and resets internal state. */
    close() {
        this.root.remove();
        document.removeEventListener('keydown', this._keydownHandler);

        this.speakerListOpen = false;
        this.segIdx = -1;
    }

    /** Builds the link-action section shown at the top when a link was right-clicked. */
    #buildLinkBlock() {
        const controls = document.createElement('div');
        controls.className = 'ctx-controls';

        if (this.onEditLink) {
            const editItem = document.createElement('div');
            editItem.className = 'ctx-item';
            editItem.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Edit link</span>';
            editItem.addEventListener('click', () => this.onEditLink());
            controls.appendChild(editItem);
        }

        if (this.onCopyLink) {
            const copyItem = document.createElement('div');
            copyItem.className = 'ctx-item';
            copyItem.innerHTML = '<span class="ctx-icon">⧉</span><span class="ctx-item-inner">Copy link</span>';
            copyItem.addEventListener('click', () => { this.onCopyLink(); this._onDismiss(); });
            controls.appendChild(copyItem);
        }

        if (this.onRemoveLink) {
            const removeItem = document.createElement('div');
            removeItem.className = 'ctx-item ctx-item--danger';
            removeItem.innerHTML = '<span class="ctx-icon"><span class="icon icon-close" style="width:12px;height:12px;"></span></span><span class="ctx-item-inner">Remove link</span>';
            removeItem.addEventListener('click', () => this.onRemoveLink());
            controls.appendChild(removeItem);
        }

        this.root.appendChild(controls);
        this.root.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
    }

    /** Builds the segment info block showing start, end, duration, and word count. */
    #buildInfoBlock() {
        const { seg } = this;
        if (!seg) {
            return;
        }
        
        const duration = seg.end - seg.start;
        const words = seg.text.trim().split(/\s+/).filter(Boolean);
        const wps = duration > 0 ? (words.length / duration).toFixed(1) : '—';
        
        const info = document.createElement('div');
        info.className = 'ctx-info';
        
        const infoElements = [
            ['START', formatTimeMs(seg.start)],
            ['END', formatTimeMs(seg.end)],
            ['DURATION', duration.toFixed(2) + 's'],
            ['WORDS', words.length + '  (' + wps + '/s)'],
        ];
        
        infoElements.forEach(([k, v]) => {
            const key = document.createElement('span');
            key.className = 'ctx-info-key'; key.textContent = k;
            const val = document.createElement('span');
            val.className = 'ctx-info-val'; val.textContent = v;
            info.appendChild(key);
            info.appendChild(val);
        });
        this.root.appendChild(info);
    }

    /** Builds the controls block containing all action items. */
    #buildControlsBlock() {
        const controls = document.createElement('div');
        controls.className = 'ctx-controls';
        this.controlsBlock = controls;

        if (!this.readOnly) {
            this.#buildChangeSpeaker();

            // If onSplit has been hooked up, build its item
            if (this.onSplit) {
                this.#buildSplitItem();
            }

            this.#buildEditItem();

            if (this.onAddLink) {
                this.#buildAddLinkItem();
            }

            // TODO: (issue-12) Merge allows you to merge with a segment in a different speaker.  Should this be allowed?
            if (this.onMergePrev || this.onMergeNext) {
                this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));

                if (this.onMergePrev) {
                    this.#buildMergeItem('Merge with previous', '⇧A', this.onMergePrev);
                }
                if (this.onMergeNext) {
                    this.#buildMergeItem('Merge with next', '⇧D', this.onMergeNext);
                }
            }

            if (this.onSplitParagraph || this.onMergeParagraphPrev) {
                this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));

                if (this.onSplitParagraph) {
                    this.#buildParagraphItem('Split paragraph here', '¶', this.onSplitParagraph);
                }
                if (this.onMergeParagraphPrev) {
                    this.#buildParagraphItem('Merge paragraph with previous', '⇑¶', this.onMergeParagraphPrev);
                }
            }
        }

        if (this.onZoom) {
            if (!this.readOnly) {
                this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
            }
            this.#buildZoomItem();
        }

        if (this.onRetranscribe) {
            this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
            const item = document.createElement('div');
            item.className = 'ctx-item';
            item.innerHTML = '<span class="ctx-icon">⟳</span><span class="ctx-item-inner">Retranscribe sentence</span>';
            item.addEventListener('click', () => this.onRetranscribe());
            this.controlsBlock.appendChild(item);
        }

        this.root.appendChild(this.controlsBlock);

    }

    /** Builds and appends the speaker selection list, replacing the controls block. */
    #buildSpeakerSelector() {
        const speakerSelector = document.createElement('div');
        speakerSelector.className = 'ctx-speaker-list';

        Object.values(this.activeProject.speakers()).forEach((speaker, i) => {
            const speakerItem = document.createElement('div');
            speakerItem.className = 'ctx-speaker-item';

            const swatch = document.createElement('span');
            swatch.className = 'ctx-speaker-swatch';
            swatch.style.background = speaker.hue;

            const label = document.createElement('span');
            label.style.flex = '1';
            label.textContent = speaker.name;
            label.style.color = speaker.hue;

            const numHint = document.createElement('span');
            numHint.className = 'ctx-kbd';
            numHint.textContent = i + 1;

            speakerItem.appendChild(swatch);
            speakerItem.appendChild(label);
            speakerItem.appendChild(numHint);

            if (this.seg?.speaker === speaker.id) {
                speakerItem.style.opacity = '0.4';
                speakerItem.style.cursor = 'default';
            } else {
                speakerItem.addEventListener('click', (e) => {
                    e.stopPropagation();
                    this.onChangeSpeaker(speaker.id);
                });
            }
            speakerSelector.appendChild(speakerItem);
        });

        this.root.appendChild(speakerSelector);
    }

    /** Builds the "Change speaker" menu item. Clicking it expands the speaker list. */
    #buildChangeSpeaker() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon"><span class="icon icon-diamond" style="width:14px;height:14px;"></span></span><span class="ctx-item-inner">Change speaker</span><span class="ctx-kbd">C</span>';

        this.speakerListOpen = false;
        item.addEventListener('click', () => {
            if (this.speakerListOpen) {
                return;
            }

            this.speakerListOpen = true;
            item.style.color = 'var(--accent)';

            // hide the controls
            this.controlsBlock.style.display = 'none';
            // build the speaker selection list
            this.#buildSpeakerSelector();

            const mr = this.root.getBoundingClientRect();
            if (mr.bottom > window.innerHeight) {
                this.root.style.top = (window.innerHeight - mr.height - 8) + 'px';
            }
        });
        
        this.controlsBlock.appendChild(item);
    }
    
    /** Builds the "Split segment" menu item. */
    #buildSplitItem() {
        const onSplit = this.onSplit;
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">⋮</span><span class="ctx-item-inner">Split segment</span><span class="ctx-kbd">S</span>';
        item.addEventListener('click', onSplit);
        this.controlsBlock.appendChild(item);
    }
    
    /** Builds the "Edit text" menu item. */
    #buildEditItem() {
        const onEdit = this.onEdit;
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Edit text</span><span class="ctx-kbd">E</span>';
        item.addEventListener('click', onEdit);
        this.controlsBlock.appendChild(item);
    }
    
    /** Builds the "Add link" menu item. */
    #buildAddLinkItem() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon"><span class="icon icon-plus" style="width:14px;height:14px;"></span></span><span class="ctx-item-inner">Add link</span><span class="ctx-kbd">⇧K</span>';
        item.addEventListener('click', () => this.onAddLink());
        this.controlsBlock.appendChild(item);
    }

    /**
    * Builds a paragraph action menu item (split or merge paragraph).
    * @param {string} label - display label
    * @param {string} icon - icon character
    * @param {function} onAction - callback invoked on click
    */
    #buildParagraphItem(label, icon, onAction) {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = `<span class="ctx-icon">${icon}</span><span class="ctx-item-inner">${label}</span>`;
        item.addEventListener('click', onAction);
        this.controlsBlock.appendChild(item);
    }

    /**
    * Builds a merge menu item.
    * @param {string} label - display label (e.g. "Merge with previous")
    * @param {string} kbd - keyboard shortcut hint text
    * @param {function} onMerge - callback invoked on click
    */
    #buildMergeItem(label, kbd, onMerge) {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = `<span class="ctx-icon">⇔</span><span class="ctx-item-inner">${label}</span><span class="ctx-kbd">${kbd}</span>`;
        item.addEventListener('click', onMerge);
        this.controlsBlock.appendChild(item);
    }
    
    /** Builds the "Zoom to sentence" menu item. */
    #buildZoomItem() {
        const item = document.createElement('div');
        item.className = 'ctx-item';
        item.innerHTML = '<span class="ctx-icon">⌕</span><span class="ctx-item-inner">Zoom to sentence</span>';
        item.addEventListener('click', () => this.onZoom());
        this.controlsBlock.appendChild(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);
    }
}