components_split_popup.js


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

/**
 * Popup UI for splitting a transcript segment at a chosen word boundary and time.
 * Manages its own DOM, event listeners, waveform rendering, and playback state.
 */
export class SplitPopup {

    /**
     * Creates and displays a SplitPopup.
     * @param {number} segIdx - Index of the segment in segments
     * @param {Element|null} anchorEl - Element to position the popup near
     * @param {object} project - The active project instance
     * @param {object} workspace - The workspace controller instance
     * @param {object} [callbacks={}] - Callback functions for split actions
     * @param {function} [callbacks.onSplit] - Called with (segA, segB) when the split is confirmed
     * @param {function} [callbacks.onCancel] - Called when the popup is cancelled or dismissed
     */
    constructor(segIdx, anchorEl, project, workspace, callbacks={}) {
        this.segIdx = segIdx;
        this.activeProject = project;
        this.workspace = workspace;
        
        this.segment = this.activeProject.transcript().segments[segIdx];
        this.hasValidWords = !!(this.segment.words?.length && !this.segment.wordsStale);
        if (this.hasValidWords) {
            this.wordItems = this.segment.words;
            this.words = this.wordItems.map(w => w.word.trimStart());
        } else {
            this.words = this.segment.text.trim().split(/\s+/);
        }
        this.wordBoundary = Math.max(1, Math.ceil(this.words.length / 2));
        this.timeFrac = this.hasValidWords
            ? (this.wordItems[this.wordBoundary].start - this.segment.start) / (this.segment.end - this.segment.start)
            : 0.5;
        this.segPlaying = false;
        this.rafPlayhead = null;
        this.waveDragging = false;

        this._onSplit = callbacks.onSplit ?? (() => {});
        this._onCancel = callbacks.onCancel ?? (() => {});

        this.popup = this.#buildPopup();
        document.body.appendChild(this.popup);
        this.#bindGlobalEvents();
        this.#position(anchorEl);

        requestAnimationFrame(() => {
            if (!this.hasValidWords && this.activeProject.hasWaveform) {
                this.#drawWaveStrip();
                this.#updateCursor();
            }
        });
    }

    /**
     * Returns the current split preview state for use by the waveform panel.
     * @returns {{segIdx: number, timeFrac: number}}
     */
    get previewFrac() {
        if (this.hasValidWords) {
            const t = this.wordItems[this.wordBoundary].start;
            return { segIdx: this.segIdx, timeFrac: (t - this.segment.start) / (this.segment.end - this.segment.start) };
        }
        return { segIdx: this.segIdx, timeFrac: this.timeFrac };
    }

    // ── DOM Construction ──────────────────────────────────────────────────

    /**
     * Builds the full popup DOM and returns the root element.
     * @returns {HTMLElement}
     */
    #buildPopup() {
        const popup = document.createElement('div');
        popup.className = 'split-popup';

        const lbl = document.createElement('div');
        lbl.className = 'split-popup-label';
        lbl.textContent = 'Split segment';
        popup.appendChild(lbl);

        this.textRow = document.createElement('div');
        this.textRow.className = 'split-text-row';
        popup.appendChild(this.textRow);
        this.#rebuildTextRow();

        const waveform = this.#buildWaveform();
        if (!this.hasValidWords && this.activeProject.hasWaveform) {
            popup.appendChild(waveform);
        }
        popup.appendChild(this.#buildActions());

        return popup;
    }

    /**
     * Builds the waveform strip, cursor, time label, and playhead elements.
     * @returns {HTMLElement}
     */
    #buildWaveform() {
        this.waveWrap = document.createElement('div');
        this.waveWrap.className = 'split-wave-wrap';

        this.waveCanvas = document.createElement('canvas');
        this.waveCanvas.width = 300;
        this.waveCanvas.height = 52;
        this.waveWrap.appendChild(this.waveCanvas);

        this.cursor = document.createElement('div');
        this.cursor.className = 'split-wave-cursor';
        this.waveWrap.appendChild(this.cursor);

        this.timeLabel = document.createElement('div');
        this.timeLabel.className = 'split-time-label';
        this.waveWrap.appendChild(this.timeLabel);

        this.playhead = document.createElement('div');
        this.playhead.style.cssText = 'position:absolute;top:0;bottom:0;width:1px;background:var(--accent-strong);pointer-events:none;display:none;';
        this.waveWrap.appendChild(this.playhead);

        this.waveWrap.addEventListener('mousedown', (e) => {
            e.preventDefault();
            this.waveDragging = true;
            this.#setTimeFracFromEvent(e);
        });

        return this.waveWrap;
    }

    /**
     * Builds the action buttons row (play, cancel, split).
     * @returns {HTMLElement}
     */
    #buildActions() {
        const actions = document.createElement('div');
        actions.className = 'split-actions';

        this.playBtn = document.createElement('button');
        this.playBtn.className = 'split-btn split-btn-cancel';
        this.playBtn.style.cssText = 'margin-right:auto;min-width:2.2rem;';
        this.playBtn.textContent = '▶';
        this.playBtn.title = 'Play segment';
        this.playBtn.addEventListener('click', () => this.#togglePlayback());
        if (this.hasValidWords || !this.activeProject.hasWaveform) {
            this.playBtn.style.display = 'none';
        }

        const cancelBtn = document.createElement('button');
        cancelBtn.className = 'split-btn split-btn-cancel';
        cancelBtn.textContent = 'Cancel';
        cancelBtn.addEventListener('click', () => {
            this.#stopPlayback();
            this.destroy();
            this._onCancel();
//            this.splitPreviewFrac = null;
//            drawRegions();
        });

        const splitBtn = document.createElement('button');
        splitBtn.className = 'split-btn split-btn-confirm';
        splitBtn.textContent = '⋮ Split';
        splitBtn.addEventListener('click', () => this.#confirmSplit());

        actions.appendChild(this.playBtn);
        actions.appendChild(cancelBtn);
        actions.appendChild(splitBtn);

        return actions;
    }

    // ── Text Row ──────────────────────────────────────────────────────────

    /**
     * Rebuilds the word spans and draggable divider in the text row.
     */
    #rebuildTextRow() {
        this.textRow.innerHTML = '';
        this.words.forEach((word, wi) => {
            if (wi === this.wordBoundary) {
                this.textRow.appendChild(this.#buildDivider());
            }
            const sp = document.createElement('span');
            sp.className = 'split-word ' + (wi < this.wordBoundary ? 'left' : 'right');
            sp.textContent = word;
            sp.addEventListener('click', () => {
                this.wordBoundary = Math.max(1, Math.min(this.words.length - 1, wi === 0 ? 1 : wi));
                this.#rebuildTextRow();
            });
            this.textRow.appendChild(sp);
        });
    }

    /**
     * Builds the draggable divider span used to adjust the word boundary.
     * @returns {HTMLElement}
     */
    #buildDivider() {
        const div = document.createElement('span');
        div.className = 'split-divider';
        div.textContent = '┆';

        let dDragging = false, dStartX = 0, dStartB = 0;

        div.addEventListener('mousedown', (e) => {
            e.preventDefault();
            e.stopPropagation();
            dDragging = true;
            dStartX = e.clientX;
            dStartB = this.wordBoundary;
        });

        const onMove = (e) => {
            if (!dDragging) return;
            const delta = Math.round((e.clientX - dStartX) / 28);
            const nb = Math.max(1, Math.min(this.words.length - 1, dStartB + delta));
            if (nb !== this.wordBoundary) {
                this.wordBoundary = nb;
                this.#rebuildTextRow();
            }
        };

        const onUp = () => { dDragging = false; };

        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onUp);

        return div;
    }

    // ── Waveform ──────────────────────────────────────────────────────────

    /**
     * Draws the waveform slice for the current segment onto the canvas,
     * coloring peaks left/right of the split point differently.
     */
    #drawWaveStrip() {
        const W = this.waveCanvas.width, H = this.waveCanvas.height;
        const ctx = this.waveCanvas.getContext('2d');
        ctx.clearRect(0, 0, W, H);

        if (!this.activeProject.waveform().peaks?.[0] || !this.activeProject.waveform().duration) return;

        const peaks = this.activeProject.waveform().peaks[0];
        const startFrac = this.segment.start / this.activeProject.waveform().duration;
        const endFrac   = this.segment.end   / this.activeProject.waveform().duration;
        const peakStart = Math.floor(startFrac * peaks.length);
        const peakEnd   = Math.ceil(endFrac   * peaks.length);
        const segPeaks  = peaks.slice(peakStart, peakEnd);

        for (let x = 0; x < W; x++) {
            const pi = Math.floor((x / W) * segPeaks.length);
            const amp = Math.min(segPeaks[pi] * H * 0.9, H / 2);
            ctx.fillStyle = (x / W < this.timeFrac) ? '#dde8ff' : '#4a4a60';
            ctx.fillRect(x, H / 2 - amp, 1, Math.max(amp * 2, 1));
        }
    }

    /**
     * Updates the cursor and time label positions to reflect the current timeFrac,
     * and triggers a region redraw.
     */
    #updateCursor() {
        const W = this.waveWrap.clientWidth || 300;
        const px = this.timeFrac * W;
        this.cursor.style.left = px + 'px';
        this.timeLabel.style.left = px + 'px';
        this.timeLabel.textContent = formatTimeMs(this.segment.start + this.timeFrac * (this.segment.end - this.segment.start));
//        this.splitPreviewFrac = { segIdx: this.segIdx, timeFrac: this.timeFrac };
//        drawRegions();
    }

    /**
     * Sets timeFrac from a mouse event on the waveform, clamped to a safe margin.
     * @param {MouseEvent} e - the mouse event used to determine cursor position on the waveform
     */
    #setTimeFracFromEvent(e) {
        const rect = this.waveWrap.getBoundingClientRect();
        const margin = 0.05;
        this.timeFrac = Math.max(margin, Math.min(1 - margin, (e.clientX - rect.left) / rect.width));
        this.#drawWaveStrip();
        this.#updateCursor();
    }

    // ── Playback ──────────────────────────────────────────────────────────

    /**
     * Toggles playback of the current segment.
     */
    #togglePlayback() {
        if (!this.workspace.wavesurferInstance() || !this.activeProject.waveform().duration) return;
        if (this.segPlaying) {
            this.#stopPlayback();
        } else {
            this.workspace.wavesurferInstance().seekTo(this.segment.start / this.activeProject.waveform().duration);
            this.workspace.wavesurferInstance().play();
            this.segPlaying = true;
            this.playBtn.textContent = '⏸';
            this.rafPlayhead = requestAnimationFrame(() => this.#updatePlayhead());
        }
    }

    /**
     * Stops playback and resets playback state.
     */
    #stopPlayback() {
        if (!this.segPlaying) return;
        this.workspace.wavesurferInstance().pause();
        this.segPlaying = false;
        this.playBtn.textContent = '▶';
        this.playhead.style.display = 'none';
        cancelAnimationFrame(this.rafPlayhead);
    }

    /**
     * Animation frame callback that advances the playhead during segment playback.
     */
    #updatePlayhead() {
        if (!this.workspace.wavesurferInstance() || !this.segPlaying) return;
        const t = this.workspace.wavesurferInstance().getCurrentTime();
        if (t >= this.segment.end) {
            this.#stopPlayback();
            return;
        }
        const frac = (t - this.segment.start) / (this.segment.end - this.segment.start);
        this.playhead.style.left = (frac * (this.waveWrap.clientWidth || 300)) + 'px';
        this.playhead.style.display = '';
        this.rafPlayhead = requestAnimationFrame(() => this.#updatePlayhead());
    }

    // ── Split Execution ───────────────────────────────────────────────────

    /**
     * Executes the split, replacing the original segment with two new segments.
     */
    #confirmSplit() {
        this.#stopPlayback();
        this.#cleanup();
//        this.splitPreviewFrac = null;
//        splitPopup = null;
        this.popup.remove();

        let splitTime, textA, textB, wordsA, wordsB;
        if (this.hasValidWords) {
            wordsA = this.wordItems.slice(0, this.wordBoundary);
            wordsB = this.wordItems.slice(this.wordBoundary);
            splitTime = this.wordItems[this.wordBoundary].start;
            textA = wordsA.map(w => w.word).join('').trim() || '…';
            textB = wordsB.map(w => w.word).join('').trim() || '…';
        } else {
            splitTime = this.segment.start + this.timeFrac * (this.segment.end - this.segment.start);
            textA = this.words.slice(0, this.wordBoundary).join(' ') || '…';
            textB = this.words.slice(this.wordBoundary).join(' ') || '…';
        }
        const stale = this.segment.wordsStale ? { wordsStale: true } : {};
        const segA = {
            start: this.segment.start,
            end: splitTime,
            speaker: this.segment.speaker,
            text: textA,
            ...(wordsA ? { words: wordsA } : {}),
            ...stale
        };
        const segB = {
            start: splitTime,
            end: this.segment.end,
            speaker: this.segment.speaker,
            text: textB,
            ...(wordsB ? { words: wordsB } : {}),
            ...stale
        };

        this._onSplit(segA, segB);
//        drawRegions();
    }

    // ── Positioning ───────────────────────────────────────────────────────

    /**
     * Positions the popup near the anchor element, adjusting for viewport edges.
     * anchorEl may be a DOM Element or a plain {left, top, bottom} rect object.
     * @param {Element|{left:number,top:number,bottom:number}|null} anchorEl - element or rect to anchor the popup near
     */
    #position(anchorEl) {
        const ar = anchorEl
            ? (typeof anchorEl.getBoundingClientRect === 'function'
                ? anchorEl.getBoundingClientRect()
                : anchorEl)
            : { left: window.innerWidth / 2 - 160, bottom: window.innerHeight / 2, top: window.innerHeight / 2 };

        const popupH = this.popup.offsetHeight || 220;
        let left = ar.left;
        let top;

        if (ar.bottom > window.innerHeight) {
            // Segment end is off-screen: center the popup vertically
            top = Math.max(4, (window.innerHeight - popupH) / 2);
        } else {
            top = ar.bottom + 6;
            if (top + popupH > window.innerHeight) top = ar.top - popupH - 6;
        }

        if (left + 320 > window.innerWidth) left = window.innerWidth - 328;
        if (left < 4) left = 4;

        this.popup.style.left = left + 'px';
        this.popup.style.top  = top  + 'px';
    }

    // ── Event Management ──────────────────────────────────────────────────

    /**
     * Binds window-level mouse and keyboard event listeners.
     */
    #bindGlobalEvents() {
        this._onWaveMove = (e) => {
            if (!this.waveDragging) return;
            this.#setTimeFracFromEvent(e);
        };
        this._onWaveUp = () => { this.waveDragging = false; };

        window.addEventListener('mousemove', this._onWaveMove);
        window.addEventListener('mouseup',   this._onWaveUp);

        setTimeout(() => {
            this._outsideClick = (e) => {
                if (this.popup && !this.popup.contains(e.target)) {
                    this.#stopPlayback();
                    this.#cleanup();
                    this._onCancel();
//                    this.splitPreviewFrac = null;
                    document.removeEventListener('mousedown', this._outsideClick);
                }
            };
            document.addEventListener('mousedown', this._outsideClick);
        }, 0);
    }

    /**
     * Removes all window-level event listeners added by this instance.
     */
    #cleanup() {
        window.removeEventListener('mousemove', this._onWaveMove);
        window.removeEventListener('mouseup',   this._onWaveUp);
    }

    /**
     * Fully removes the popup from the DOM and cleans up all listeners and state.
     */
    destroy() {
        this.#stopPlayback();
        this.#cleanup();
        if (this._outsideClick) {
            document.removeEventListener('mousedown', this._outsideClick);
        }
        this.popup.remove();
    }
}