workspace_panels_transcript_panel.js

import { ConfirmDialog } from "../components/confirm_dialog.js";
import { TranscribeDialog } from "../components/transcribe_dialog.js";
import { ExportPanel } from "../components/export_panel.js";
import { exportFile } from "../utilities/export.js";
import { HyperlinkDialog } from "../components/hyperlink_dialog.js";
import { SelectionContextMenu } from "../components/selection_context_menu.js";

/**
 * Panel that renders the active transcript as speaker blocks, paragraphs, and
 * clickable word-level segments. Handles segment selection, hover, inline text
 * editing, CSV loading, and CSV export.
 */
export class TranscriptPanel {
    /**
     * @param {object} workspace - the Workspace controller instance
     * @param {object} callbacks - callback functions for transcript interactions
     * @param {function} callbacks.onSegmentHover - called with segment index when a segment is hovered
     * @param {function} callbacks.onSegmentSelect - called with segment index when a segment is selected
     * @param {function} callbacks.onSegmentZoom - called with segment index when a segment is double-clicked
     * @param {function} callbacks.onSearchChanged - called with a Set of matching segment indices (or null to clear)
     * @param {function} callbacks.onParagraphHover - called with paragraph index when a paragraph handle is hovered
     * @param {function} callbacks.onParagraphZoom - called with paragraph index when a paragraph handle is double-clicked
     */
    constructor(workspace, { onSegmentHover, onSegmentSelect, onSegmentZoom, onSearchChanged, onParagraphHover, onParagraphZoom }) {
        this.workspace = workspace;

        this.onSegmentHover = onSegmentHover ?? (() => {})
        this.onSegmentSelect = onSegmentSelect ?? (() => {})
        this.onSegmentZoom = onSegmentZoom ?? (() => {})
        this.onSearchChanged = onSearchChanged ?? (() => {})
        this.onParagraphHover = onParagraphHover ?? (() => {})
        this.onParagraphZoom = onParagraphZoom ?? (() => {})

        this.previouslyHoveredSegment = null;
        this.previouslySelectedSegment = null;
        this.previouslyActiveSegment = null;

        // Search state
        this.searchQuery = '';
        this.searchSpeaker = '';
        this.searchMatchIndices = [];
        this.searchFocusedIdx = -1;

        this.#getElements();
        this.#setupListeners();

    }

    /** Sets up the CSV file input, save button, transcript-body background click handler, and search bar. */
    #setupListeners() {
        new ResizeObserver(([entry]) => {
            this.root.classList.toggle('header-compact', entry.contentRect.width < 420);
        }).observe(this.root);

        // Deselect on clicking transcript background (one-time, not per-render)
        this.transcriptBody.addEventListener('click', (e) => {
            if (!e.target.closest('.t-seg[data-idx]')) {
                this.#selectSegment(-1);
                this.workspace.closeCtxMenu();
            }
        });

        // Shift key tracking (used to suppress segment click while shift-selecting text).
        window.addEventListener('keydown', (e) => {
            if (e.key !== 'Shift') return;
            this.shiftHeld = true;
        }, { capture: true });
        window.addEventListener('keyup', (e) => {
            if (e.key !== 'Shift') return;
            this.shiftHeld = false;
        }, { capture: true });

        // Ctrl key tracking — drives link hover styles via a body class and
        // shows a URL tooltip when hovering a link with Ctrl held.
        window.addEventListener('keydown', (e) => {
            if (e.key !== 'Control' && e.key !== 'Meta') return;
            document.body.classList.add('ctrl-held');
            if (this._hoveredLinkUrl) {
                clearTimeout(this._linkTooltipTimer);
                this.#showLinkTooltip(this._hoveredLinkUrl, this._hoveredLinkName, this._hoveredLinkDesc, this._hoveredLinkNotes);
            }
            if (this._mouseSelectingActive) this.#updateSelectionOverlay();
        }, { capture: true });
        window.addEventListener('keyup', (e) => {
            if (e.key !== 'Control' && e.key !== 'Meta') return;
            document.body.classList.remove('ctrl-held');
            this.#hideLinkTooltip();
            if (this._mouseSelectingActive) this.#updateSelectionOverlay();
        }, { capture: true });
        window.addEventListener('blur', () => {
            document.body.classList.remove('ctrl-held');
            this.#hideLinkTooltip();
            if (this._mouseSelectingActive) this.#updateSelectionOverlay();
        });

        // Track cursor position for link tooltip placement.
        document.addEventListener('mousemove', (e) => { this._mouseX = e.clientX; this._mouseY = e.clientY; });

        // Track whether the user is actively dragging a selection.
        this.transcriptBody.addEventListener('mousedown', () => {
            this._mouseSelectingActive = true;
        });
        document.addEventListener('mouseup', () => {
            if (this._mouseSelectingActive && document.body.classList.contains('ctrl-held')) {
                this.#snapSelectionToWords();
            }
            this._mouseSelectingActive = false;
        });

        // Drive the custom selection overlay whenever the native selection changes.
        document.addEventListener('selectionchange', () => this.#updateSelectionOverlay());

        // Track whether the cursor is actually over a highlight rect (not just over
        // a selected segment's non-selected text) for the hover brightening effect.
        this.transcriptBody.addEventListener('mousemove', (e) => {
            const rects = this._selOverlay.querySelectorAll('.transcript-sel-rect');
            if (!rects.length) return;
            const over = Array.from(rects).some(el => {
                const r = el.getBoundingClientRect();
                return e.clientX >= r.left && e.clientX <= r.right &&
                       e.clientY >= r.top  && e.clientY <= r.bottom;
            });
            this._selOverlay.classList.toggle('hovered', over);
        });
        this.transcriptBody.addEventListener('mouseleave', () => {
            this._selOverlay.classList.remove('hovered');
        });

        // Shift+K — add hyperlink to native text selection or selected segment
        // Shift+F — populate search bar from native text selection
        window.addEventListener('keydown', (e) => {
            if (!e.shiftKey) return;
            const tag = document.activeElement?.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA') return;
            if (e.key === 'K') { e.preventDefault(); this.#handleHyperlinkShortcut(); }
            if (e.key === 'F') { e.preventDefault(); this.#searchWordSelection(); }
            if (e.key === 'Q' && !this.workspace.isLocalMode() && !this.workspace.isReadOnly()) {
                const selTarget = this.#getNativeSelection();
                if (selTarget) { e.preventDefault(); this.workspace.openEmbedDialog(selTarget); }
            }
        }, { capture: true });

        // Search bar listeners
        let searchDebounce = null;
        this.searchInput.addEventListener('input', () => {
            clearTimeout(searchDebounce);
            searchDebounce = setTimeout(() => this.#runSearch(), 150);
        });
        this.searchClearBtn.addEventListener('click', () => this.clearSearch());
        this.searchInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); this.#stepMatch(1); }
            if (e.key === 'Escape') { e.preventDefault(); this.clearSearch(); }
            e.stopPropagation();
        });
        this.searchSpeakerFilter.addEventListener('change', () => this.#runSearch());
        this.searchPrev.addEventListener('click', () => this.#stepMatch(-1));
        this.searchNext.addEventListener('click', () => this.#stepMatch(1));

        // Replace bar toggle
        this.replaceToggle.addEventListener('click', () => {
            const isOpen = this.replaceRow.style.display !== 'none';
            this.replaceRow.style.display = isOpen ? 'none' : 'flex';
            this.replaceToggle.classList.toggle('active', !isOpen);
            if (!isOpen) this.replaceInput.focus();
        });

        // Replace input keyboard
        this.replaceInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); this.#replaceOne(); }
            if (e.key === 'Escape') {
                e.preventDefault();
                this.replaceRow.style.display = 'none';
                this.replaceToggle.classList.remove('active');
            }
            e.stopPropagation();
        });

        this.replaceOneBtn.addEventListener('click', () => this.#replaceOne());
        this.replaceAllBtn.addEventListener('click', () => this.#replaceAll());

        this.csvInput.addEventListener('change', (e) => {
            const doLoad = () => {
                // get the file from the input dialog
                const file = e.target.files[0];
                if (!file) {
                    return;
                }

                // create the reader
                const reader = new FileReader();
                // once file is read, load it into the project
                reader.onload = (ev) => {
                    if (file.name.endsWith('.json')) {
                        this.activeProject.loadTranscriptJSON(JSON.parse(ev.target.result));
                    } else {
                        this.activeProject.loadTranscriptCSV(ev.target.result);
                    }
                };

                // trigger the file read
                reader.readAsText(file);
            };

            if (this.activeProject?.hasTranscript) {
                new ConfirmDialog('Overwrite transcript?', {
                    onConfirm: doLoad,
                    onDismiss: () => { e.target.value = ''; }
                }, 'Loading a new file will replace the current transcript.');
            } else {
                doLoad();
            }
        });

        this.exportBtn.addEventListener('click', (e) => {
            this.#openExportPanel();
        });

        this.deleteTranscriptBtn.addEventListener('click', () => {
            new ConfirmDialog('Delete transcript?', {
                onConfirm: async () => {
                    const server = this.activeProject?.activeServer;
                    if (server?.isConnected && this.activeProject?.projectId) {
                        await server.deleteTranscript(this.activeProject.projectId);
                    }
                    this.activeProject.hasTranscript = false;
                    this.activeProject.local.transcript = null;
                    this.activeProject.server.transcript = null;
                    this.activeProject.transcriptDirty = false;
                    this.clearTranscriptPanel();
                },
            }, 'This will permanently delete the transcript from the server.');
        });

        this.transcribeBtn.addEventListener('click', () => this.#openTranscribeDialog());
        this.retranscribeStalBtn.addEventListener('click', () => {
            const segs = this.activeProject?.transcript()?.segments ?? [];
            const staleIndices = segs.map((s, i) => s.wordsStale ? i : null).filter(i => i !== null);
            if (staleIndices.length) this.retranscribeSegments(staleIndices);
        });

        this.transcribeBtnWrap.addEventListener('mouseenter', () => this.#showLimitTooltip());
        this.transcribeBtnWrap.addEventListener('mouseleave', () => this.#hideLimitTooltip());

        window.addEventListener('account-drawer-closed', () => {
            if (this._limitTip === null) return;
            const server = this.activeProject?.activeServer;
            if (!server) return;
            server.refreshUser().catch(() => {}).finally(() => this.#pollAudioReady());
        });
    }

    /** Binds panel DOM elements to instance properties. */
    #getElements() {
        this.root = document.getElementById('transcriptPanel');
        this.transcriptBody = this.root.querySelector('#transcriptBody');
        this.transcriptEmpty = this.root.querySelector('#transcriptEmpty');
        this.csvInput = this.root.querySelector('#csvInput')
        this.csvLabel = this.root.querySelector('.transcript-drop');
        this.exportBtn = this.root.querySelector('#exportBtn');
        this.deleteTranscriptBtn = this.root.querySelector('#deleteTranscriptBtn');
        this.retranscribeStalBtn  = this.root.querySelector('#retranscribeStalBtn');
        this.staleSentenceCount   = this.root.querySelector('#staleSentenceCount');
        this.transcribeBtn     = this.root.querySelector('#transcribeBtn');
        this.transcribeBtnWrap = this.root.querySelector('#transcribeBtnWrap');
        this.transcribeProgress = this.root.querySelector('#transcribeProgress');
        this.transcribeProgressBar = this.root.querySelector('#transcribeProgressBar');
        this.transcribeStatus = this.root.querySelector('#transcribeStatus');
        this.transcribeElapsed = this.root.querySelector('#transcribeElapsed');

        // Search bar elements
        this.transcriptSearch = this.root.querySelector('#transcriptSearch');
        this.searchInputWrap = this.root.querySelector('.search-input-wrap');
        this.searchInput = this.root.querySelector('#searchInput');
        this.searchClearBtn = this.root.querySelector('#searchClearBtn');
        this.searchSpeakerFilter = this.root.querySelector('#searchSpeakerFilter');
        this.searchCount = this.root.querySelector('#searchCount');
        this.searchPrev = this.root.querySelector('#searchPrev');
        this.searchNext = this.root.querySelector('#searchNext');

        // Replace bar elements
        this.replaceToggle = this.root.querySelector('#replaceToggle');
        this.replaceRow = this.root.querySelector('#replaceRow');
        this.replaceInput = this.root.querySelector('#replaceInput');
        this.replaceOneBtn = this.root.querySelector('#replaceOneBtn');
        this.replaceAllBtn = this.root.querySelector('#replaceAllBtn');

        // Overlay layer for custom-styled native-selection highlight.
        // Sits inside transcriptBody so z-index: -1 places it behind text but
        // above the panel background (transcriptBody is an isolation context).
        this._selOverlay = document.createElement('div');
        this._selOverlay.className = 'transcript-sel-overlay';
        this.transcriptBody.appendChild(this._selOverlay);
    }

    /**
    * Loads transcript data from the given project and renders it.
    * Also begins polling for audio readiness to enable the Transcribe button.
    * @param {Project} project - the project to load transcript data from
    */
    loadFromProject(project) {
        this.activeProject = project;
        this.transcribeBtn.disabled = true;

        this.#applyAccessMode();

        if (!this.activeProject.localOnly && !this.workspace.isReadOnly()) {
            this.#pollAudioReady();
        }

        if (this.activeProject.hasTranscript) {
            try {
                if (this.activeProject.transcript().segments.length) {
                    this.renderTranscript();
                }
            } catch(e) {
                console.warn('Could not load transcript:', e);
            }
        }

    }

    /** Applies or removes read-only mode on the panel's edit controls. */
    #applyAccessMode() {
        const readOnly = this.workspace.isReadOnly();
        if (this.transcribeBtn) this.transcribeBtn.style.display = readOnly ? 'none' : '';
        if (this.csvLabel) this.csvLabel.style.display = readOnly ? 'none' : '';
        if (this.replaceToggle) this.replaceToggle.style.display = readOnly ? 'none' : '';
        if (readOnly && this.replaceRow) {
            this.replaceRow.style.display = 'none';
            this.replaceToggle?.classList.remove('active');
        }
    }

    /** Public entry point called once the waveform has fully loaded for a server project. */
    startPollingAudioReady() {
        if (!this.workspace.isReadOnly()) this.#pollAudioReady();
    }

    /**
     * Polls the server until audio.mp3 is ready, then enables the Transcribe button.
     * Stops polling after 20 attempts (~60 s) or when audio is confirmed ready.
     */
    async #pollAudioReady() {
        this.transcribeBtn.disabled = true;
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        const projectId = this.activeProject.projectId;
        const maxAttempts = 20;
        for (let i = 0; i < maxAttempts; i++) {
            try {
                const ready = await server.checkAudioReady(projectId);
                if (ready) {
                    // Check per-file duration limit before enabling the button
                    const features   = server.backendUser?.subscription_tier?.features;
                    const maxMins    = features?.max_audio_mins ?? null;
                    const duration   = this.activeProject?.waveform?.()?.duration
                                    ?? this.activeProject?.waveform?.(false)?.duration
                                    ?? -1;
                    if (maxMins !== null && duration > 0 && duration / 60 > maxMins) {
                        this._limitTip = `Audio is ${(duration / 60).toFixed(1)} min — exceeds your plan's ${maxMins}-minute per-file limit. Upgrade your plan to transcribe longer files.`;
                        // button stays disabled
                        return;
                    }
                    // Clear any limit tooltip and enable the button
                    this._limitTip = null;
                    this.transcribeBtn.disabled = false;
                    return;
                }
            } catch { /* server unreachable — stop polling */ return; }
            await new Promise(r => setTimeout(r, 3000));
            // Stop if the project changed while we were waiting
            if (this.activeProject?.projectId !== projectId) return;
        }
    }

    /** Shows a custom tooltip on the transcribe button wrapper. */
    #showLimitTooltip() {
        if (this._limitTooltipEl) return;
        const msg = this._limitTip
            ?? (this.transcribeBtn.disabled ? 'Waiting for audio to be ready…' : 'Transcribe audio');
        const tip = document.createElement('div');
        tip.className = 'info-widget-tooltip';
        tip.textContent = msg;
        document.body.appendChild(tip);
        this._limitTooltipEl = tip;

        const rect = this.transcribeBtnWrap.getBoundingClientRect();
        const tipW = tip.offsetWidth;
        const tipH = tip.offsetHeight;
        const gap  = 6;

        const top  = (rect.bottom + tipH + gap <= window.innerHeight)
            ? rect.bottom + gap
            : rect.top - tipH - gap;
        const left = Math.min(rect.left, window.innerWidth - tipW - 8);

        tip.style.top  = `${top}px`;
        tip.style.left = `${left}px`;
    }

    /** Removes the limit tooltip from the DOM. */
    #hideLimitTooltip() {
        this._limitTooltipEl?.remove();
        this._limitTooltipEl = null;
    }

    /** Opens the transcription options dialog before starting a job. */
    #openTranscribeDialog() {
        const waveform = this.activeProject?.waveform?.();
        const audioDuration = waveform?.duration ?? -1;
        const speakers = this.activeProject?.speakers?.() ?? {};
        const hasVoiceSamples = Object.values(speakers).some(s => s.sample);
        const server = this.activeProject?.activeServer;
        const allowedModels = server?.backendUser?.subscription_tier?.features?.whisper_models ?? null;

        new TranscribeDialog({
            audioDuration,
            hasVoiceSamples,
            allowedModels,
            onConfirm: (opts) => this.#startTranscription(opts),
        });
    }

    /**
     * Initiates a Modal transcription job for the active project, streaming
     * progress updates into the progress bar.  On completion, loads the
     * resulting transcript and re-renders.
     * @param {object} opts - options from TranscribeDialog
     */
    async #startTranscription(opts = {}) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        this.transcribeBtn.disabled = true;
        this.transcribeProgress.style.display = 'flex';
        this.transcribeProgressBar.style.width = '0%';
        this.transcribeStatus.textContent = 'Starting transcription…';
        this.transcribeElapsed.textContent = '0:00';

        const startTime = Date.now();
        const elapsedTimer = setInterval(() => {
            const s = Math.floor((Date.now() - startTime) / 1000);
            const mm = Math.floor(s / 60);
            const ss = String(s % 60).padStart(2, '0');
            this.transcribeElapsed.textContent = `${mm}:${ss}`;
        }, 1000);

        const projectId = this.activeProject.projectId;

        let succeeded = false;
        try {
            await server.transcribeProject(projectId, opts, (event) => {
                if (event.type === 'status') {
                    this.transcribeStatus.textContent = event.message;
                } else if (event.type === 'progress') {
                    this.transcribeProgressBar.style.width = `${Math.round(event.fraction * 100)}%`;
                } else if (event.type === 'result') {
                    succeeded = true;
                    this.transcribeProgressBar.style.width = '100%';
                    this.transcribeStatus.textContent = 'Done!';
                } else if (event.type === 'error') {
                    console.error('Transcription error event:', event.message);
                    this.transcribeStatus.textContent = `Error: ${event.message}`;
                }
            });
        } catch(e) {
            console.error('Transcription request failed:', e);
            this.transcribeStatus.textContent = `Error: ${e.message}`;
        } finally {
            clearInterval(elapsedTimer);
            this.transcribeBtn.disabled = false;
            setTimeout(() => {
                this.transcribeProgress.style.display = 'none';
            }, 2000);
        }

        // Only reload if transcription actually produced a result
        if (succeeded && this.activeProject?.projectId === projectId) {
            try {
                const transcriptResult = await server.getTranscript(projectId);
                if (transcriptResult.contentType?.includes('application/json')) {
                    this.activeProject.loadTranscriptJSON(JSON.parse(transcriptResult.text));
                } else {
                    this.activeProject.loadTranscriptCSV(transcriptResult.text);
                }
                this.renderTranscript();
            } catch(e) {
                console.error('Failed to reload transcript after transcription:', e);
            }
        }
    }

    /**
    * Applies the hover CSS class to the segment at segmentIdx and removes it from the previous one.
    * @param {number} segmentIdx - segment index, or -1 to clear
    */
    setHoveredSegment(segmentIdx) {
        // Clear old hover from transcript
        if (this.previouslyHoveredSegment >= 0) {
            const old = document.querySelector(`.t-seg[data-idx="${this.previouslyHoveredSegment}"]`);
            if (old) {
                old.classList.remove('hovered');
            }
        }

        // Apply new hover to transcript
        if (segmentIdx >= 0) {
            const el = document.querySelector(`.t-seg[data-idx="${segmentIdx}"]`);
            if (el) {
                el.classList.add('hovered');
            }
        }

        this.previouslyHoveredSegment = segmentIdx;
    }

    /**
    * Applies the selected CSS class to segmentIdx and removes it from the previous selection.
    * @param {number} segmentIdx - segment index, or -1 to clear
    */
    setSelectedSegment(segmentIdx) {
        // remove the selected class from the previously selected segment
        if (this.previouslySelectedSegment >= 0) {
            const old = document.querySelector(`.t-seg[data-idx="${this.previouslySelectedSegment}"]`);
            if (old) {
                old.classList.remove('selected');
            }
        }

        // add the selected class to the newly selected segment
        if (segmentIdx >= 0) {
            const el = document.querySelector(`.t-seg[data-idx="${segmentIdx}"]`);
            if (el) el.classList.add('selected');
        }
        this.previouslySelectedSegment = segmentIdx;
    }

    /**
    * Marks the segment at segmentIdx as active (scrolls it into view) and removes
    * the active class from the previous one. Called as the playhead moves through audio.
    * @param {number} segmentIdx - segment index, or -1 to clear
    */
    setActiveSegment(segmentIdx) {
        if (this.previouslyActiveSegment != -1) {
            const old = document.querySelector(`.t-seg[data-idx="${this.previouslyActiveSegment}"]`);
            if (old) {
                old.classList.remove('active');
            }
        }

        if (segmentIdx != -1) {
            const el = document.querySelector(`.t-seg[data-idx="${segmentIdx}"]`);
            if (el) {
                el.classList.add('active');
                this.#smoothScrollTo(el, 160);
            }
        }
        this.previouslyActiveSegment = segmentIdx;
    }

    /**
    * Highlights the word span (if any) that contains the given playback time within
    * the currently active segment. Clears any previously highlighted word.
    * @param {number} time - current playback time in seconds
    */
    setActiveWord(time) {
        // Clear previous active word
        const prev = this.transcriptBody.querySelector('.t-word.active-word');
        if (prev) prev.classList.remove('active-word');

        if (this.previouslyActiveSegment < 0) return;
        const segEl = this.transcriptBody.querySelector(`.t-seg[data-idx="${this.previouslyActiveSegment}"]`);
        if (!segEl) return;

        const wordSpans = segEl.querySelectorAll('.t-word');
        for (const span of wordSpans) {
            const wstart = parseFloat(span.dataset.wstart);
            const wend   = parseFloat(span.dataset.wend);
            if (time >= wstart && time < wend) {
                span.classList.add('active-word');
                break;
            }
        }
    }

    /**
    * Updates hover state and fires the onSegmentHover callback.
    * @param {number} segmentIdx - segment index to hover, or -1 to clear
    */
    #hoverSegment(segmentIdx) {
        this.setHoveredSegment(segmentIdx);
        this.onSegmentHover(segmentIdx);
    }

    /**
    * Updates selection state and fires the onSegmentSelect callback.
    * @param {number} segmentIdx - segment index to select, or -1 to clear
    */
    #selectSegment(segmentIdx) {
        this.setSelectedSegment(segmentIdx);
        this.onSegmentSelect(segmentIdx);
    }

    /** Clears the transcript body and resets the save button and empty state. */
    clearTranscriptPanel() {
        // Clear the transcript body
        if (this.transcriptBody) {
            this.transcriptBody.innerHTML = '';
            this.transcriptBody.appendChild(this._selOverlay);
        }
        // Show the transcriptEmpty element
        if (this.transcriptEmpty) {
            this.transcriptEmpty.style.display = 'flex';
        }
        if (this.exportBtn) this.exportBtn.style.display = 'none';
        if (this.deleteTranscriptBtn) this.deleteTranscriptBtn.style.display = 'none';
        if (this.retranscribeStalBtn) this.retranscribeStalBtn.style.display = 'none';
        // Hide search bar (and replace row) and clear search state
        if (this.transcriptSearch) {
            this.transcriptSearch.style.display = 'none';
        }
        if (this.replaceRow) {
            this.replaceRow.style.display = 'none';
            this.replaceToggle?.classList.remove('active');
        }
        this.clearSearch();
    }

    /** Fully re-renders the transcript from the active transcript's speaker blocks. */
    renderTranscript() {
        // build the transcript base
        this.#closeParaSpeakerDialog();
        if (this.transcriptEmpty) this.transcriptEmpty.style.display = 'none';
        if (this.transcriptSearch) this.transcriptSearch.style.display = 'flex';
        if (this.exportBtn) this.exportBtn.style.display = '';
        if (this.deleteTranscriptBtn) this.deleteTranscriptBtn.style.display = this.workspace.isReadOnly() ? 'none' : '';
        if (this.retranscribeStalBtn && !this.workspace.isReadOnly()) {
            const staleCount = this.activeProject.transcript().segments.filter(s => s.wordsStale).length;
            this.retranscribeStalBtn.style.display = staleCount > 0 ? '' : 'none';
            if (this.staleSentenceCount) this.staleSentenceCount.textContent = staleCount;
        }
        this.transcriptBody.innerHTML = '';
        this._selOverlay.innerHTML = '';
        this.transcriptBody.appendChild(this._selOverlay);

        this.activeProject.transcript().speakerBlocks.forEach(block => {
            const blockElement = this.#renderSpeakerBlock(block);
            this.transcriptBody.appendChild(blockElement);
        });

        this.populateSpeakerFilter();

        // Re-apply search highlights if a search is active
        if (this.searchQuery || this.searchSpeaker) {
            this.#runSearch();
        }
    }

    /**
    * Renders a speaker block (speaker label + paragraphs) as a DOM element.
    * @param {object} block - speaker block with speaker id and paragraphs array
    * @returns {HTMLElement}
    */
    #renderSpeakerBlock(block) {
        const blockEl = document.createElement('div');
        blockEl.className = 'speaker-block';

        // Centered editable speaker label
        const labelEl = document.createElement('div');
        labelEl.className = 'speaker-label';

        let nameSpan = this.workspace.speakersPanel.makeSpeakerNameSpan(block.speaker);
        labelEl.appendChild(nameSpan);
        blockEl.appendChild(labelEl);

        // render each paragraph in the block
        const speaker = this.activeProject.speakers()[block.speaker];
        block.paragraphs.forEach(paragraph => {
            const paraRow = document.createElement('div');
            paraRow.className = 'para-row';

            // Colored handle bar
            const handle = document.createElement('div');
            handle.className = 'para-handle';
            handle.style.background = speaker?.hue ?? 'var(--muted)';
            handle.title = this.workspace.isReadOnly() ? 'Click to jump' : 'Click to jump · Right-click to change speaker';
            handle.addEventListener('click', (e) => {
                e.stopPropagation();
                const firstIdx = this.activeProject.transcript().segments.indexOf(paragraph.segments[0]);
                this.#selectSegment(firstIdx);
                this.onSegmentSelect(firstIdx);
            });
            handle.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.#openParaSpeakerDialog(paragraph, e.clientX, e.clientY);
            });
            handle.addEventListener('mouseenter', () => this.onParagraphHover(paragraph));
            handle.addEventListener('mouseleave', () => this.onParagraphHover(null));
            handle.addEventListener('dblclick', (e) => { e.stopPropagation(); this.onParagraphZoom(paragraph); });

            const paraEl = document.createElement('p');
            paraEl.className = 'transcript-para';

            // render each segment in the paragraph
            paragraph.segments.forEach((segment) => {
                const renderedSegment = this.#renderSegment(segment);
                paraEl.appendChild(renderedSegment);
            });

            paraRow.appendChild(handle);
            paraRow.appendChild(paraEl);
            blockEl.appendChild(paraRow);
        });

        return blockEl;
    }

    /**
    * Renders a single segment and returns the rendered element
    * @param {object} segment - A segment object to be rendered
    * @returns {HTMLElement} the rendered segment span element
    */
    #renderSegment(segment) {
        const idx = this.activeProject.transcript().segments.indexOf(segment);
        const span = document.createElement('span');
        span.className = 't-seg';
        span.dataset.idx = idx;
        const hl = document.createElement('span');
        hl.className = 't-seg-hl';
        this.#renderHlContent(hl, idx, segment.text, segment.words);
        span.appendChild(hl);
        if (segment.wordsStale) {
            span.classList.add('t-seg--words-stale');
            const badge = document.createElement('span');
            badge.className = 't-seg-stale-badge';
            badge.title = 'Word timestamps are outdated \u2014 retranscription needed for word highlighting';
            badge.textContent = '\u26a0';
            span.appendChild(badge);
        }
        span.appendChild(document.createTextNode(' '));
        let clickTimer = null;
        span.addEventListener('click', (e) => {
            e.stopPropagation();
            if (this.shiftHeld) return;
            if (!window.getSelection().isCollapsed) return;
            clearTimeout(clickTimer);
            this.onSegmentSelect(idx);
            clickTimer = setTimeout(() => { this.#selectSegment(idx); }, 220);
        });
        span.addEventListener('dblclick', (e) => {
            e.stopPropagation();
            clearTimeout(clickTimer);
            this.#selectSegment(idx);
            this.onSegmentZoom(idx);
            if (!this.workspace.isReadOnly()) {
                this.makeSegmentEditable(idx);
            }
        });
        span.addEventListener('contextmenu', (e) => {
            e.preventDefault();
            // Capture native selection now — clicking the menu will clear it.
            const selTarget = this.#getNativeSelection();
            if (selTarget) {
                this.workspace.closeCtxMenu();
                const menu = new SelectionContextMenu(e.clientX, e.clientY, {
                    onAddLink:    () => { menu.close(); this.#openHyperlinkDialogForTarget(selTarget); },
                    onSearchText: () => { menu.close(); this.#searchWithTarget(selTarget); },
                    // null in LOCAL_MODE — SelectionContextMenu hides items with null callbacks
                    onGenerateLiveQuote: this.workspace.isLocalMode() ? null
                        : () => { menu.close(); this.workspace.openEmbedDialog(selTarget); },
                    onDismiss:    () => menu.close(),
                });
                return;
            }
            this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, idx);
        });
        span.addEventListener('mouseenter', () => {
            this.#hoverSegment(idx);
            if (segment.wordsStale) {
                this._staleTooltipTimer = setTimeout(() => this.#showStaleTooltip(), 600);
            }
        });
        span.addEventListener('mouseleave', () => {
            this.#hoverSegment(-1);
            clearTimeout(this._staleTooltipTimer);
            this.#hideStaleTooltip();
        });
        return span;
    }

    /**
     * Opens the hyperlink dialog for the current native text selection if one
     * exists, otherwise adds a link covering the entire segment text.
     * @param {number} segIdx - index of the segment that was right-clicked
     */
    openAddLinkDialog(segIdx) {
        if (this.workspace.isReadOnly()) return;
        if (!this.activeProject?.hasTranscript) return;
        const target = this.#getNativeSelection() ?? (() => {
            const seg = this.activeProject.transcript().segments[segIdx];
            return seg ? { segIdx, charStart: 0, charEnd: seg.text.length } : null;
        })();
        if (!target) return;
        this.#openHyperlinkDialogForTarget(target);
    }

    /**
    * Turns a transcript segment span into a contentEditable field.
    * Commits the edit on Enter or blur; cancels (restores original text) on Escape.
    * The span text is normalised (trailing space stripped) while editing, then
    * restored with a trailing space on commit.
    * Keyboard events are stopped from propagating so playback shortcuts don't fire.
    * @param {number} segIdx - index into loadedSegments
    */
    makeSegmentEditable(segIdx) {
        if (this.workspace.isReadOnly()) return;
        const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
        if (!el || el.isContentEditable) return;

        const seg = this.activeProject.transcript().segments[segIdx];
        el.contentEditable = 'true';
        el.classList.add('editing');
        el.classList.remove('selected');
        // Keep the existing .t-seg-hl structure intact so link highlights remain
        // visible during editing. textContent is read on commit.
        el.focus();

        // Place cursor at end of the hl span (before the trailing space text node)
        const editHl = el.querySelector('.t-seg-hl') ?? el;
        const range = document.createRange();
        range.selectNodeContents(editHl);
        range.collapse(false);
        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);

        const restoreSpan = (text) => {
          el.textContent = '';
          const hl = document.createElement('span');
          hl.className = 't-seg-hl';
          el.appendChild(hl);
          el.appendChild(document.createTextNode(' '));
          this.#renderHlContent(hl, segIdx, text);
        };

        let committed = false;
        const commit = () => {
          if (committed) return;
          committed = true;
          const newText = (el.querySelector('.t-seg-hl')?.textContent ?? el.textContent).trim();
          if (newText && newText !== seg.text) {
            const oldText = seg.text;
            const oldWords = seg.words ? seg.words.map(w => ({ ...w })) : null;
            const oldWordsStale = seg.wordsStale;

            const newTokens = newText.trim().split(/\s+/);
            if (seg.words?.length && seg.words.length === newTokens.length) {
                // Same word count — update word text in-place, keep timestamps.
                // Preserve leading whitespace that Whisper stores on each word (e.g. " Hello").
                newTokens.forEach((token, i) => {
                    const orig = seg.words[i].word;
                    const leading = orig.slice(0, orig.length - orig.trimStart().length);
                    seg.words[i].word = leading + token;
                });
                seg.wordsStale = undefined;
            } else if (seg.words?.length) {
                // Word count changed — clear words so the edited text renders correctly,
                // and set wordsStale so the badge and retranscription option appear.
                seg.words = null;
                seg.wordsStale = true;
            }
            // else: seg.words is null/empty — no change needed

            this.#updateAnnotationsAfterEdit(segIdx, seg.text, newText);
            seg.text = newText;
            this.activeProject.markTranscriptDirty();
            this.workspace.history.push({
                label: 'Edit segment text', dirtyFlags: ['transcript'],
                undo: () => {
                    this.#updateAnnotationsAfterEdit(segIdx, newText, oldText);
                    seg.text = oldText;
                    seg.words = oldWords;
                    seg.wordsStale = oldWordsStale;
                },
                redo: () => {
                    const redoTokens = newText.trim().split(/\s+/);
                    this.#updateAnnotationsAfterEdit(segIdx, oldText, newText);
                    seg.text = newText;
                    if (oldWords?.length && oldWords.length === redoTokens.length) {
                        seg.words = redoTokens.map((token, i) => {
                            const orig = oldWords[i].word;
                            const leading = orig.slice(0, orig.length - orig.trimStart().length);
                            return { ...oldWords[i], word: leading + token };
                        });
                        seg.wordsStale = undefined;
                    } else if (oldWords?.length) {
                        seg.words = null;
                        seg.wordsStale = true;
                    }
                },
            });
            this.workspace._updateUndoRedoButtons();
          }
          el.contentEditable = 'false';
          el.classList.remove('editing');
          restoreSpan(seg.text);
          this.#selectSegment(-1);
        }

        el.addEventListener('keydown', (e) => {
          if (e.key === 'Enter') { e.preventDefault(); commit(); }
          if (e.key === 'Escape') {
            committed = true; // prevent blur from re-committing
            el.contentEditable = 'false';
            el.classList.remove('editing');
            restoreSpan(seg.text);
            this.#selectSegment(-1);
          }
          if (e.key === 'Tab') {
            e.preventDefault();
            const segs = this.activeProject.transcript().segments;
            const nextIdx = e.shiftKey ? segIdx - 1 : segIdx + 1;
            commit();
            if (nextIdx >= 0 && nextIdx < segs.length) {
              this.#selectSegment(nextIdx);
              this.makeSegmentEditable(nextIdx);
            }
          }
          e.stopPropagation(); // don't trigger play/skip shortcuts
        });
        el.addEventListener('blur', commit, { once: true });
    }

    // ── Search ────────────────────────────────────────────────────────────────

    /**
     * Populates the speaker filter dropdown from the active project's speakers.
     * Preserves the current selection when possible.
     */
    populateSpeakerFilter() {
        if (!this.activeProject?.hasSpeakers) return;
        const currentValue = this.searchSpeakerFilter.value;
        this.searchSpeakerFilter.innerHTML = '<option value="">All speakers</option>';
        Object.values(this.activeProject.speakers()).forEach(speaker => {
            const opt = document.createElement('option');
            opt.value = speaker.id;
            opt.textContent = speaker.name;
            this.searchSpeakerFilter.appendChild(opt);
        });
        if (currentValue) this.searchSpeakerFilter.value = currentValue;
    }

    /**
     * Clears the search bar, removes all highlights, and notifies the workspace.
     */
    clearSearch() {
        this.searchQuery = '';
        this.searchSpeaker = '';
        this.searchMatchIndices = [];
        this.searchFocusedIdx = -1;
        if (this.searchInput) this.searchInput.value = '';
        if (this.searchInputWrap) this.searchInputWrap.classList.remove('has-value');
        if (this.searchSpeakerFilter) this.searchSpeakerFilter.value = '';
        if (this.searchCount) this.searchCount.textContent = '';
        if (this.searchPrev) this.searchPrev.disabled = true;
        if (this.searchNext) this.searchNext.disabled = true;
        this.#clearHighlights();
        this.onSearchChanged(null);
    }

    /** Computes matches, updates highlights and counter, scrolls to the focused match. */
    #runSearch() {
        const query = this.searchInput.value.trim();
        const speaker = this.searchSpeakerFilter.value;

        if (!query && !speaker) {
            this.clearSearch();
            return;
        }

        this.searchQuery = query;
        this.searchSpeaker = speaker;

        const segments = this.activeProject?.transcript()?.segments ?? [];
        this.searchMatchIndices = [];
        segments.forEach((seg, idx) => {
            const textMatch = !query || seg.text.toLowerCase().includes(query.toLowerCase());
            const speakerMatch = !speaker || seg.speaker === speaker;
            if (textMatch && speakerMatch) this.searchMatchIndices.push(idx);
        });

        if (this.searchMatchIndices.length === 0) {
            this.searchFocusedIdx = -1;
        } else if (this.searchFocusedIdx < 0 || this.searchFocusedIdx >= this.searchMatchIndices.length) {
            this.searchFocusedIdx = 0;
        }

        this.searchInputWrap?.classList.toggle('has-value', this.searchInput.value.length > 0);
        this.#updateCounter();
        this.#applyHighlights();
        if (this.searchFocusedIdx >= 0) this.#scrollToFocused();
        this.onSearchChanged(new Set(this.searchMatchIndices));
    }

    /**
     * Steps to the next (+1) or previous (-1) match.
     * @param {number} dir - direction to step: +1 for next, -1 for previous
     */
    #stepMatch(dir) {
        if (this.searchMatchIndices.length === 0) return;
        this.searchFocusedIdx = (this.searchFocusedIdx + dir + this.searchMatchIndices.length) % this.searchMatchIndices.length;
        this.#updateFocusedClass();
        this.#updateCounter();
        this.#scrollToFocused();
    }

    /** Swaps the search-focused class to the current focused match without rebuilding all highlights. */
    #updateFocusedClass() {
        document.querySelectorAll('.t-seg.search-focused').forEach(el => el.classList.remove('search-focused'));
        if (this.searchFocusedIdx >= 0 && this.searchMatchIndices.length > 0) {
            const focusedSegIdx = this.searchMatchIndices[this.searchFocusedIdx];
            const el = document.querySelector(`.t-seg[data-idx="${focusedSegIdx}"]`);
            if (el) el.classList.add('search-focused');
        }
    }

    /** Applies search-match / search-focused classes and keyword <mark> highlighting to the DOM. */
    #applyHighlights() {
        this.#clearHighlights();
        const matchSet = new Set(this.searchMatchIndices);
        const focusedSegIdx = this.searchFocusedIdx >= 0 ? this.searchMatchIndices[this.searchFocusedIdx] : -1;

        document.querySelectorAll('.t-seg').forEach(el => {
            const idx = parseInt(el.dataset.idx);
            if (!matchSet.has(idx)) return;

            el.classList.add('search-match');
            if (idx === focusedSegIdx) el.classList.add('search-focused');

            if (this.searchQuery) {
                const hl = el.querySelector('.t-seg-hl');
                if (hl) {
                    const seg = this.activeProject.transcript().segments[idx];
                    this.#highlightText(hl, seg.text, this.searchQuery);
                }
            }
        });
    }

    /** Removes all search highlight classes and restores hyperlink-aware content in .t-seg-hl spans. */
    #clearHighlights() {
        document.querySelectorAll('.t-seg.search-match, .t-seg.search-focused').forEach(el => {
            el.classList.remove('search-match', 'search-focused');
            const hl = el.querySelector('.t-seg-hl');
            if (hl) {
                const idx = parseInt(el.dataset.idx);
                const seg = this.activeProject?.transcript()?.segments[idx];
                if (seg) this.#renderHlContent(hl, idx, seg.text, seg.words);
            }
        });
    }

    /**
     * Wraps matched substrings in <mark class="search-hl"> elements inside a .t-seg-hl span.
     * @param {HTMLElement} hl - the .t-seg-hl span element to populate
     * @param {string} text - the full segment text
     * @param {string} query - the search query to highlight
     */
    #highlightText(hl, text, query) {
        hl.textContent = '';
        const lowerText = text.toLowerCase();
        const lowerQuery = query.toLowerCase();
        let lastIdx = 0;
        let idx;
        while ((idx = lowerText.indexOf(lowerQuery, lastIdx)) !== -1) {
            if (idx > lastIdx) hl.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
            const mark = document.createElement('mark');
            mark.className = 'search-hl';
            mark.textContent = text.slice(idx, idx + query.length);
            hl.appendChild(mark);
            lastIdx = idx + query.length;
        }
        if (lastIdx < text.length) hl.appendChild(document.createTextNode(text.slice(lastIdx)));
    }

    /** Updates the match counter display and prev/next button disabled state. */
    #updateCounter() {
        const total = this.searchMatchIndices.length;
        const current = total > 0 ? this.searchFocusedIdx + 1 : 0;
        this.searchCount.textContent = total > 0 ? `${current} / ${total}` : 'no matches';
        this.searchPrev.disabled = total === 0;
        this.searchNext.disabled = total === 0;
        this.replaceOneBtn.disabled = total === 0;
        this.replaceAllBtn.disabled = total === 0;
    }

    // ── Replace ───────────────────────────────────────────────────────────────

    /**
     * Replaces all occurrences of the search query within the currently focused
     * match segment, then steps to the next match.
     */
    #replaceOne() {
        if (this.workspace.isReadOnly()) return;
        if (!this.searchQuery || this.searchMatchIndices.length === 0 || this.searchFocusedIdx < 0) return;
        const replaceWith = this.replaceInput.value;
        const segIdx = this.searchMatchIndices[this.searchFocusedIdx];
        const seg = this.activeProject.transcript().segments[segIdx];
        const regex = new RegExp(this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
        const newText = seg.text.replace(regex, replaceWith);
        if (newText !== seg.text) {
            const oldText = seg.text;
            seg.text = newText;
            this.activeProject.markTranscriptDirty();
            const hl = document.querySelector(`.t-seg[data-idx="${segIdx}"] .t-seg-hl`);
            if (hl) hl.textContent = newText;
            this.workspace.history.push({
                label: 'Replace text', dirtyFlags: ['transcript'],
                undo: () => { seg.text = oldText; },
                redo: () => { seg.text = newText; },
            });
            this.workspace._updateUndoRedoButtons();
        }
        this.#runSearch();
    }

    /**
     * Replaces all occurrences of the search query across every matching segment.
     */
    #replaceAll() {
        if (this.workspace.isReadOnly()) return;
        if (!this.searchQuery || this.searchMatchIndices.length === 0) return;
        const replaceWith = this.replaceInput.value;
        const regex = new RegExp(this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
        const segs = this.activeProject.transcript().segments;

        // Pre-compute all replacements before mutating
        const changes = this.searchMatchIndices
            .map(segIdx => {
                const seg = segs[segIdx];
                const oldText = seg.text;
                const newText = seg.text.replace(regex, replaceWith);
                return newText !== oldText ? { segIdx, seg, oldText, newText } : null;
            })
            .filter(Boolean);

        if (!changes.length) { this.#runSearch(); return; }

        changes.forEach(({ seg, newText, segIdx }) => {
            seg.text = newText;
            const hl = document.querySelector(`.t-seg[data-idx="${segIdx}"] .t-seg-hl`);
            if (hl) hl.textContent = newText;
        });
        this.activeProject.markTranscriptDirty();

        this.workspace.history.push({
            label: 'Replace all', dirtyFlags: ['transcript'],
            undo: () => { changes.forEach(({ seg, oldText }) => { seg.text = oldText; }); },
            redo: () => { changes.forEach(({ seg, newText }) => { seg.text = newText; }); },
        });
        this.workspace._updateUndoRedoButtons();
        this.#runSearch();
    }


    /** Scrolls the transcript to the focused match and selects it (moving the playhead). */
    #scrollToFocused() {
        const segIdx = this.searchMatchIndices[this.searchFocusedIdx];
        if (segIdx == null) return;
        const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
        if (el) this.#smoothScrollTo(el, 160);
        this.#selectSegment(segIdx);
    }

    /**
     * Opens a floating speaker-picker popup to reassign all segments in a paragraph.
     * @param {object} paragraph - the paragraph data object whose speaker to reassign
     * @param {number} x - horizontal position (px) for the popup
     * @param {number} y - vertical position (px) for the popup
     */
    #openParaSpeakerDialog(paragraph, x, y) {
        if (this.workspace.isReadOnly()) return;
        this.#closeParaSpeakerDialog();

        const popup = document.createElement('div');
        popup.className = 'para-speaker-popup';
        popup.style.left = x + 'px';
        popup.style.top = y + 'px';

        const header = document.createElement('div');
        header.className = 'para-speaker-popup-header';
        header.textContent = 'Change paragraph speaker';
        popup.appendChild(header);

        Object.values(this.activeProject.speakers()).forEach(speaker => {
            const item = document.createElement('div');
            item.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;

            item.appendChild(swatch);
            item.appendChild(label);

            if (speaker.id === paragraph.speaker) {
                item.style.opacity = '0.4';
                item.style.cursor = 'default';
            } else {
                item.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const oldSpeakerId = paragraph.speaker;
                    const newSpeakerId = speaker.id;
                    const transcript = this.activeProject.transcript();
                    const affectedIndices = paragraph.segments.map(s => transcript.segments.indexOf(s));
                    this.activeProject.changeParagraphSpeaker(paragraph, newSpeakerId);
                    this.workspace.history.push({
                        label: 'Reassign paragraph speaker', dirtyFlags: ['transcript'],
                        undo: () => {
                            affectedIndices.forEach(i => { transcript.segments[i].speaker = oldSpeakerId; });
                            transcript.buildTranscript();
                        },
                        redo: () => {
                            affectedIndices.forEach(i => { transcript.segments[i].speaker = newSpeakerId; });
                            transcript.buildTranscript();
                        },
                    });
                    this.workspace._updateUndoRedoButtons();
                    this.#closeParaSpeakerDialog();
                });
            }

            popup.appendChild(item);
        });

        document.body.appendChild(popup);
        this._paraDialog = popup;

        // Nudge into viewport if needed
        requestAnimationFrame(() => {
            const rect = popup.getBoundingClientRect();
            if (rect.right > window.innerWidth) popup.style.left = (window.innerWidth - rect.width - 8) + 'px';
            if (rect.bottom > window.innerHeight) popup.style.top = (window.innerHeight - rect.height - 8) + 'px';
        });

        // Close on outside click
        setTimeout(() => {
            this._paraDialogOutsideHandler = (e) => {
                if (!popup.contains(e.target)) this.#closeParaSpeakerDialog();
            };
            document.addEventListener('click', this._paraDialogOutsideHandler);
        }, 0);
    }

    /** Closes the paragraph speaker popup if open. */
    #closeParaSpeakerDialog() {
        if (this._paraDialog) {
            this._paraDialog.remove();
            this._paraDialog = null;
        }
        if (this._paraDialogOutsideHandler) {
            document.removeEventListener('click', this._paraDialogOutsideHandler);
            this._paraDialogOutsideHandler = null;
        }
    }

    /**
     * Smoothly scrolls the transcript body to center the given element, completing in `duration` ms.
     * @param {HTMLElement} el - the element to scroll into view
     * @param {number} duration - scroll animation duration in milliseconds
     */
    #smoothScrollTo(el, duration) {
        const container = this.transcriptBody;
        const containerRect = container.getBoundingClientRect();
        const elRect = el.getBoundingClientRect();
        const offset = elRect.top - containerRect.top - containerRect.height / 2 + elRect.height / 2;
        const start = container.scrollTop;
        const startTime = performance.now();
        const step = (now) => {
            const t = Math.min((now - startTime) / duration, 1);
            const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
            container.scrollTop = start + offset * ease;
            if (t < 1) requestAnimationFrame(step);
        };
        requestAnimationFrame(step);
    }

    /** Opens the ExportPanel dialog for the active project. */
    #openExportPanel() {
        new ExportPanel(this.activeProject.transcript(), this.activeProject.speakers(), {
            defaultTitle: this.activeProject.projectName,
            onExport: ({ fileType, style, filename, text }) => {
                exportFile(fileType, filename, text, style, this.activeProject.speakers());
            },
            onDismiss: () => {},
        });
    }

    // ── Hyperlinks ────────────────────────────────────────────────────────────

    /**
     * Reads the browser's native text selection and maps it onto transcript
     * segment indices and character offsets.
     *
     * Returns `{ segIdx, charStart, charEnd }` for a single-segment selection,
     * `{ segIdxStart, charStart, segIdxEnd, charEnd }` for a cross-segment one,
     * or `null` if there is no non-collapsed selection within the transcript.
     * @returns {{ segIdx: number, charStart: number, charEnd: number }|{ segIdxStart: number, charStart: number, segIdxEnd: number, charEnd: number }|null}
     */
    #getNativeSelection() {
        const sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;

        const range = sel.getRangeAt(0);
        const startSeg = range.startContainer.parentElement?.closest('.t-seg[data-idx]');
        const endSeg   = range.endContainer.parentElement?.closest('.t-seg[data-idx]');
        if (!startSeg || !endSeg) return null;

        const startSegIdx = parseInt(startSeg.dataset.idx);
        const endSegIdx   = parseInt(endSeg.dataset.idx);
        const startHl = startSeg.querySelector('.t-seg-hl');
        const endHl   = endSeg.querySelector('.t-seg-hl');
        if (!startHl || !endHl) return null;

        const charStart = this.#getCharOffsetInHl(startHl, range.startContainer, range.startOffset);
        const charEnd   = this.#getCharOffsetInHl(endHl,   range.endContainer,   range.endOffset);

        if (startSegIdx === endSegIdx) {
            if (charStart === charEnd) return null;
            return { segIdx: startSegIdx, charStart: Math.min(charStart, charEnd), charEnd: Math.max(charStart, charEnd) };
        }

        const realStart = Math.min(startSegIdx, endSegIdx);
        const realEnd   = Math.max(startSegIdx, endSegIdx);
        const forward   = startSegIdx <= endSegIdx;
        return {
            segIdxStart: realStart,
            charStart:   forward ? charStart : charEnd,
            segIdxEnd:   realEnd,
            charEnd:     forward ? charEnd   : charStart,
        };
    }

    /**
     * Redraws the custom selection overlay to match the current native text
     * selection, giving the highlight rounded corners. Clears the overlay when
     * no transcript text is selected.
     */
    #updateSelectionOverlay() {
        const overlay = this._selOverlay;
        overlay.innerHTML = '';
        this.transcriptBody.querySelectorAll('.t-seg--in-selection')
            .forEach(el => el.classList.remove('t-seg--in-selection'));

        const sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;

        const rawRange = sel.getRangeAt(0);
        // Only paint if the selection is within the transcript body.
        if (!this.transcriptBody?.contains(rawRange.commonAncestorContainer)) return;

        // While the user is actively dragging with Ctrl held, snap to word boundaries
        // for the visual display. On mouseup the actual selection is also snapped.
        const range = (this._mouseSelectingActive && document.body.classList.contains('ctrl-held'))
            ? this.#wordSnapRange(rawRange)
            : rawRange;

        // Walk text nodes only — avoids double-counting rects from nested inline
        // spans (e.g. .t-seg-hl + its .t-seg-word children both appearing in
        // range.getClientRects() when an entire segment is selected).
        const root = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
            ? range.commonAncestorContainer.parentElement
            : range.commonAncestorContainer;
        // Convert a viewport DOMRect to transcriptBody-local coords (accounts for scroll).
        const bodyRect   = this.transcriptBody.getBoundingClientRect();
        const scrollLeft = this.transcriptBody.scrollLeft;
        const scrollTop  = this.transcriptBody.scrollTop;
        const toLocal = (r) => ({
            left:   r.left   - bodyRect.left + scrollLeft,
            top:    r.top    - bodyRect.top  + scrollTop,
            right:  r.right  - bodyRect.left + scrollLeft,
            bottom: r.bottom - bodyRect.top  + scrollTop,
            height: r.height,
        });

        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
        const contentRects = [];
        const connectorRects = [];
        const selectedSegEls = new Set();
        let node;
        while ((node = walker.nextNode())) {
            if (!range.intersectsNode(node)) continue;
            const inHl  = node.parentElement?.closest('.t-seg-hl');
            const inSeg = !inHl && node.parentElement?.closest('.t-seg[data-idx]');
            if (!inHl && !inSeg) continue;
            if (inHl) selectedSegEls.add(inHl.closest('.t-seg[data-idx]'));
            const sub = document.createRange();
            sub.setStart(node, node === range.startContainer ? range.startOffset : 0);
            sub.setEnd(node,   node === range.endContainer   ? range.endOffset   : node.length);
            for (const r of sub.getClientRects()) {
                if (r.width > 0) (inHl ? contentRects : connectorRects).push(toLocal(r));
            }
        }
        selectedSegEls.forEach(el => el?.classList.add('t-seg--in-selection'));

        // Merge content rects that sit on the same line to eliminate gaps between word spans.
        const merged = [];
        for (const r of contentRects.sort((a, b) => a.top - b.top || a.left - b.left)) {
            const prev = merged.at(-1);
            if (prev && Math.abs(r.top - prev.top) < 2 && r.left <= prev.right + 1) {
                prev.right  = Math.max(prev.right,  r.right);
                prev.bottom = Math.max(prev.bottom, r.bottom);
            } else {
                merged.push({ left: r.left, top: r.top, right: r.right, bottom: r.bottom });
            }
        }

        for (const r of merged) {
            const div = document.createElement('div');
            div.className = 'transcript-sel-rect';
            div.style.left   = r.left + 'px';
            div.style.top    = r.top  + 'px';
            div.style.width  = (r.right  - r.left) + 'px';
            div.style.height = (r.bottom - r.top)  + 'px';
            overlay.appendChild(div);
        }

        // Render inter-segment space connectors at quarter height, vertically centred,
        // clipped against adjacent content rects so they don't overlap.
        for (const c of connectorRects) {
            let left  = c.left;
            let right = c.right;
            for (const m of merged) {
                if (Math.abs(m.top - c.top) > 4) continue;
                if (m.right > left && m.right <= right) left  = m.right;
                if (m.left  < right && m.left  >= left) right = m.left;
            }
            if (right <= left) continue;
            const h   = c.height * 0.5;
            const div = document.createElement('div');
            div.className = 'transcript-sel-rect transcript-sel-connector';
            div.style.left   = (left  + 1) + 'px';
            div.style.top    = (c.top + c.height * 0.25) + 'px';
            div.style.width  = (right - left - 2) + 'px';
            div.style.height = h + 'px';
            overlay.appendChild(div);
        }
    }

    /**
     * Returns a clone of `range` with start snapped to the beginning of its
     * word and end snapped to the end of its word.
     * @param {Range} range - the DOM range to snap
     * @returns {Range}
     */
    #wordSnapRange(range) {
        const r = range.cloneRange();
        if (r.startContainer.nodeType === Node.TEXT_NODE) {
            const text = r.startContainer.textContent;
            let i = r.startOffset;
            while (i > 0 && !/\s/.test(text[i - 1])) i--;
            r.setStart(r.startContainer, i);
        }
        if (r.endContainer.nodeType === Node.TEXT_NODE) {
            const text = r.endContainer.textContent;
            let i = r.endOffset;
            while (i < text.length && !/\s/.test(text[i])) i++;
            r.setEnd(r.endContainer, i);
        }
        return r;
    }

    /** Commits a word-snapped version of the current selection to the browser. */
    #snapSelectionToWords() {
        const sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
        const snapped = this.#wordSnapRange(sel.getRangeAt(0));
        sel.removeAllRanges();
        sel.addRange(snapped);
    }

    /**
     * Handles Shift+K: opens the hyperlink dialog for the current native text
     * selection, falling back to the selected segment when nothing is selected.
     * Cross-segment selections prompt the user to merge the segments first.
     */
    #handleHyperlinkShortcut() {
        if (this.workspace.isReadOnly()) return;
        if (!this.activeProject?.hasTranscript) return;

        // 1. Native text selection
        let target = this.#getNativeSelection();

        // 2. Whole selected segment
        if (!target && this.previouslySelectedSegment >= 0) {
            const seg = this.activeProject.transcript().segments[this.previouslySelectedSegment];
            target = { segIdx: this.previouslySelectedSegment, charStart: 0, charEnd: seg.text.length };
        }

        if (!target) return;
        this.#openHyperlinkDialogForTarget(target);
    }

    /**
     * Fetches the page title for a URL via the server proxy, using the current auth token.
     * @param {string} url - the URL to fetch the title for
     * @returns {Promise<{accessible: boolean, title: string|null}>}
     */
    async #fetchTitle(url) {
        const token = await this.workspace.getToken();
        const headers = token ? { 'X-Auth-Token': token } : {};
        const res = await fetch(`/api/fetch-page-title?url=${encodeURIComponent(url)}`, { headers });
        if (!res.ok) return { accessible: false, title: null };
        return await res.json();
    }

    /**
     * Opens the hyperlink dialog (with cross-segment merge confirmation if needed)
     * for a pre-resolved target `{ segIdx, charStart, charEnd }` or
     * `{ segIdxStart, charStart, segIdxEnd, charEnd }`.
     * @param {object} target - resolved selection target from #getNativeSelection or a whole-segment fallback
     */
    #openHyperlinkDialogForTarget(target) {
        // Cross-segment: confirm merge before proceeding
        if (target.segIdxStart != null) {
            const segs = this.activeProject.transcript().segments;
            const speaker = segs[target.segIdxStart]?.speaker;
            const sameSpeaker = Array.from(
                { length: target.segIdxEnd - target.segIdxStart },
                (_, i) => segs[target.segIdxStart + 1 + i]
            ).every(s => s?.speaker === speaker);

            if (!sameSpeaker) {
                new ConfirmDialog(
                    'Cannot add link across segments',
                    { onConfirm: () => {} },
                    'The selected segments belong to different speakers and cannot be merged.',
                    'OK', ''
                );
                return;
            }

            new ConfirmDialog(
                'Merge segments to add link?',
                {
                    onConfirm: () => {
                        new HyperlinkDialog({
                            fetchTitle: (url) => this.#fetchTitle(url),
                            onSave: ({ url, name, description, editorNotes }) => this.#addHyperlinkCrossSegment(target, url, name, description, editorNotes),
                        });
                    },
                    onDismiss: () => {},
                },
                'This selection spans multiple segments. They will be automatically merged before the link is added.'
            );
            return;
        }

        new HyperlinkDialog({
            fetchTitle: (url) => this.#fetchTitle(url),
            onSave: ({ url, name, description, editorNotes }) => {
                this.#addHyperlink(target.segIdx, target.charStart, target.charEnd, url, name, description, editorNotes);
            },
        });
    }

    /** Variant that accepts a pre-captured target (used when selection was read before a menu click cleared it).
     * @param {object} target - resolved selection target from #getNativeSelection
     */
    #searchWithTarget(target) {
        if (!this.activeProject?.hasTranscript) return;
        this.#doSearch(target);
    }

    /** Handles Shift+F: populates the search bar with the current native text selection. Cross-segment selections are joined with a space. */
    #searchWordSelection() {
        if (!this.activeProject?.hasTranscript) return;
        const target = this.#getNativeSelection();
        if (!target) return;
        this.#doSearch(target);
    }

    /**
     * Populates the search bar with the text from the given selection target and runs the search.
     * @param {object} target - resolved selection target from #getNativeSelection
     */
    #doSearch(target) {
        const segs = this.activeProject.transcript().segments;
        let selectedText;
        if (target.segIdx != null) {
            selectedText = segs[target.segIdx]?.text.slice(target.charStart, target.charEnd) ?? '';
        } else {
            const parts = [];
            for (let i = target.segIdxStart; i <= target.segIdxEnd; i++) {
                const seg = segs[i];
                if (!seg) return;
                if (i === target.segIdxStart) parts.push(seg.text.slice(target.charStart));
                else if (i === target.segIdxEnd) parts.push(seg.text.slice(0, target.charEnd));
                else parts.push(seg.text);
            }
            selectedText = parts.join(' ');
        }
        if (!selectedText) return;
        this.searchInput.value = selectedText;
        this.searchSpeakerFilter.value = '';
        this.#runSearch();
        this.searchInput.focus();
    }

    /**
     * Returns the character offset of a DOM position within a .t-seg-hl element.
     * @param {HTMLElement} hlEl - the .t-seg-hl span
     * @param {Node} container - the DOM node of the cursor position
     * @param {number} offset - the offset within that node
     * @returns {number} character offset within the element's text content
     */
    #getCharOffsetInHl(hlEl, container, offset) {
        const r = document.createRange();
        r.setStart(hlEl, 0);
        r.setEnd(container, offset);
        return r.toString().length;
    }

    /**
     * Updates hyperlink charStart/charEnd offsets for a segment after its text
     * is edited. Uses a common-prefix/suffix diff to determine where the edit
     * occurred and shifts offsets accordingly.
     *
     * Rules:
     *  - Whole-segment link (0..oldLen): expands to cover the new full length.
     *  - Edit entirely before link: shift both offsets by the length delta.
     *  - Edit entirely after link: no change.
     *  - Edit overlaps link: link is dropped (offsets are no longer meaningful).
     *
     * Saves annotations to the server if anything changed.
     * @param {number} segIdx - index of the edited segment
     * @param {string} oldText - the segment text before the edit
     * @param {string} newText - the segment text after the edit
     */
    #updateAnnotationsAfterEdit(segIdx, oldText, newText) {
        const hyperlinks = this.activeProject?.annotations?.hyperlinks;
        if (!hyperlinks) return;

        // Compute the boundaries of the changed region in the old text.
        let prefixLen = 0;
        while (prefixLen < oldText.length && prefixLen < newText.length &&
               oldText[prefixLen] === newText[prefixLen]) prefixLen++;

        let oldEditEnd = oldText.length;
        let newEditEnd = newText.length;
        while (oldEditEnd > prefixLen && newEditEnd > prefixLen &&
               oldText[oldEditEnd - 1] === newText[newEditEnd - 1]) {
            oldEditEnd--;
            newEditEnd--;
        }
        // Edited region in old text: [prefixLen, oldEditEnd)
        const delta = newText.length - oldText.length;

        let changed = false;
        for (const [id, link] of Object.entries(hyperlinks)) {
            if (link.segmentIdx !== segIdx) continue;
            const cs = link.charStart ?? 0;
            const ce = link.charEnd   ?? oldText.length;

            // Whole-segment link — keep it covering the full new text.
            if (cs === 0 && ce === oldText.length) {
                link.charEnd = newText.length;
                changed = true;
                continue;
            }

            // Edit is entirely after the link — no change needed.
            if (prefixLen >= ce) continue;

            // Edit is entirely before the link — shift both offsets.
            if (oldEditEnd <= cs) {
                link.charStart = cs + delta;
                link.charEnd   = ce + delta;
                changed = true;
                continue;
            }

            // Edit overlaps the link — offsets are no longer valid, drop it.
            delete hyperlinks[id];
            changed = true;
        }

        if (changed) {
            const server = this.activeProject?.activeServer;
            if (server?.isConnected && this.activeProject?.projectId) {
                server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations)
                      .catch(e => console.error('Failed to save annotations:', e));
            }
        }
    }

    /**
     * Adds a hyperlink annotation to the project and re-renders the affected segment.
     * @param {number} segIdx - segment index
     * @param {number} charStart - start character offset
     * @param {number} charEnd - end character offset
     * @param {string} url - hyperlink URL
     * @param {string|null} name - optional display name
     * @param {string|null} description - optional public-facing description shown in tooltips
     * @param {string|null} editorNotes - optional private notes visible only in edit mode
     */
    async #addHyperlink(segIdx, charStart, charEnd, url, name, description = null, editorNotes = null) {
        if (!this.activeProject.annotations) this.activeProject.annotations = { hyperlinks: {} };

        // Trim leading/trailing whitespace from the selection.
        const segText = this.activeProject.transcript().segments[segIdx]?.text ?? '';
        while (charStart < charEnd && /\s/.test(segText[charStart])) charStart++;
        while (charEnd > charStart && /\s/.test(segText[charEnd - 1])) charEnd--;
        if (charStart >= charEnd) return;

        this.#resolveHyperlinkOverlaps(segIdx, charStart, charEnd);
        const id = 'hl_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
        this.activeProject.annotations.hyperlinks[id] = { url, name: name || null, description: description || null, editorNotes: editorNotes || null, segmentIdx: segIdx, charStart, charEnd };

        if (!this._suppressHistory) {
            const savedLink = { ...this.activeProject.annotations.hyperlinks[id] };
            this.workspace.history.push({
                label: 'Add hyperlink', dirtyFlags: ['annotations'],
                undo: () => { delete this.activeProject.annotations.hyperlinks[id]; },
                redo: () => { this.activeProject.annotations.hyperlinks[id] = { ...savedLink }; },
            });
            this.workspace._updateUndoRedoButtons();
        }

        // Re-render the segment
        const el  = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
        const seg = this.activeProject.transcript().segments[segIdx];
        if (el && seg) {
            const hl = el.querySelector('.t-seg-hl');
            if (hl) { delete hl.dataset.wordWrapped; this.#renderHlContent(hl, segIdx, seg.text); }
        }

        // Persist to server
        const server = this.activeProject?.activeServer;
        if (server?.isConnected && this.activeProject?.projectId) {
            try {
                await server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations);
            } catch(e) {
                console.error('Failed to save annotations:', e);
            }
        }
    }

    /**
     * Merges all segments from target.segIdxStart to target.segIdxEnd, then adds a
     * hyperlink spanning the selected text in the resulting merged segment.
     * @param {{ segIdxStart: number, charStart: number, segIdxEnd: number, charEnd: number }} target - cross-segment selection range
     * @param {string} url - hyperlink URL
     * @param {string|null} name - optional display name
     * @param {string|null} description - optional public-facing description shown in tooltips
     * @param {string|null} editorNotes - optional private notes visible only in edit mode
     */
    async #addHyperlinkCrossSegment({ segIdxStart, charStart, segIdxEnd, charEnd }, url, name, description = null, editorNotes = null) {
        const transcript = this.activeProject.transcript();
        const segs = transcript.segments;

        // Snapshot for undo — save segments and annotations before any mutation
        const savedSegs = segs.slice(segIdxStart, segIdxEnd + 1).map(s => ({ ...s }));
        const savedAnnotations = JSON.parse(JSON.stringify(this.activeProject.annotations ?? {}));

        // Calculate the offset of the end segment's text within the merged result.
        // mergeSegments joins with a space: (a.text + ' ' + b.text).trim()
        let offset = 0;
        for (let i = segIdxStart; i < segIdxEnd; i++) {
            offset += segs[i].text.length + 1; // +1 for the joining space
        }
        const mergedCharEnd = offset + charEnd;

        // Merge all segments into segIdxStart (bypasses _mergeWithHistory intentionally)
        const mergeCount = segIdxEnd - segIdxStart;
        for (let i = 0; i < mergeCount; i++) {
            this.activeProject.mergeSegments(segIdxStart, segIdxStart + 1);
        }

        // Suppress per-link history — we'll push one compound command below
        this._suppressHistory = true;
        await this.#addHyperlink(segIdxStart, charStart, mergedCharEnd, url, name, description, editorNotes);
        this._suppressHistory = false;

        // Snapshot annotations after (includes the new link and any resolved overlaps)
        const snapshotAfterAnnotations = JSON.parse(JSON.stringify(this.activeProject.annotations ?? {}));

        this.workspace.history.push({
            label: 'Add hyperlink', dirtyFlags: ['transcript'],
            undo: () => {
                transcript.segments.splice(segIdxStart, 1, ...savedSegs);
                transcript.buildTranscript();
                this.activeProject.annotations = savedAnnotations;
            },
            redo: async () => {
                for (let i = 0; i < mergeCount; i++) {
                    this.activeProject.mergeSegments(segIdxStart, segIdxStart + 1);
                }
                this.activeProject.annotations = JSON.parse(JSON.stringify(snapshotAfterAnnotations));
            },
        });
        this.workspace._updateUndoRedoButtons();
    }

    /**
     * Resolves conflicts between a new link range [newStart, newEnd) and any
     * existing links on the same segment, mutating annotations in place:
     *
     *  - Existing link fully consumed by new range → deleted.
     *  - Existing link partially overlaps left edge  → charEnd trimmed to newStart.
     *  - Existing link partially overlaps right edge → charStart trimmed to newEnd.
     *  - Existing link fully contains new range      → split into two links
     *    (left portion and right portion keep the old URL/name).
     * @param {number} segIdx - index of the segment whose links are being resolved
     * @param {number} newStart - start character offset of the new link range
     * @param {number} newEnd - end character offset of the new link range
     */
    #resolveHyperlinkOverlaps(segIdx, newStart, newEnd) {
        const hyperlinks = this.activeProject.annotations.hyperlinks;
        const segText = this.activeProject.transcript().segments[segIdx]?.text ?? '';
        const toAdd = [];

        for (const [id, link] of Object.entries(hyperlinks)) {
            if (link.segmentIdx !== segIdx) continue;
            // Normalise legacy null values before any numeric comparison.
            if (link.charStart === null) link.charStart = 0;
            if (link.charEnd   === null) link.charEnd   = segText.length;
            const cs = link.charStart;
            const ce = link.charEnd;

            // No overlap
            if (ce <= newStart || cs >= newEnd) continue;

            // Existing entirely consumed by new range → remove
            if (cs >= newStart && ce <= newEnd) {
                delete hyperlinks[id];
                continue;
            }

            // Existing fully contains new range → split into left + right portions
            if (cs < newStart && ce > newEnd) {
                // Left portion: [cs, newStart)
                // Right portion: [newEnd, ce)
                link.charEnd = newStart;
                if (newEnd < ce) {
                    toAdd.push({ url: link.url, name: link.name, segmentIdx: segIdx, charStart: newEnd, charEnd: ce });
                }
                continue;
            }

            // Partial overlap on the left side of the new range → trim charEnd
            if (cs < newStart) {
                link.charEnd = newStart;
                continue;
            }

            // Partial overlap on the right side of the new range → trim charStart
            if (ce > newEnd) {
                link.charStart = newEnd;
                continue;
            }
        }

        // Add split right-hand portions
        for (const link of toAdd) {
            const id = 'hl_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
            hyperlinks[id] = link;
        }
    }

    /**
     * Opens the hyperlink dialog pre-filled with the existing values for editing.
     * @param {string} linkId - the annotation key of the hyperlink to edit
     * @param {string} url - current URL value to pre-fill
     * @param {string|null} name - current display name to pre-fill
     * @param {string|null} description - current description to pre-fill
     * @param {string|null} editorNotes - current editor notes to pre-fill
     */
    #editHyperlink(linkId, url, name, description, editorNotes) {
        this.workspace.closeCtxMenu();
        new HyperlinkDialog({
            fetchTitle: (url) => this.#fetchTitle(url),
            initialUrl:          url,
            initialName:         name ?? '',
            initialDescription:  description ?? '',
            initialEditorNotes:  editorNotes ?? '',
            onSave: ({ url: newUrl, name: newName, description: newDesc, editorNotes: newNotes }) => {
                const link = this.activeProject.annotations?.hyperlinks?.[linkId];
                if (!link) return;
                const before = { ...link };
                link.url         = newUrl;
                link.name        = newName  || null;
                link.description = newDesc  || null;
                link.editorNotes = newNotes || null;
                const after = { ...link };
                const segIdx = link.segmentIdx;
                const el  = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
                const seg = this.activeProject.transcript().segments[segIdx];
                if (el && seg) {
                    const hl = el.querySelector('.t-seg-hl');
                    if (hl) { delete hl.dataset.wordWrapped; this.#renderHlContent(hl, segIdx, seg.text); }
                }
                const server = this.activeProject?.activeServer;
                if (server?.isConnected && this.activeProject?.projectId) {
                    server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations)
                          .catch(e => console.error('Failed to save annotations:', e));
                }
                const hyperlinks = this.activeProject.annotations?.hyperlinks;
                this.workspace.history.push({
                    label: 'Edit hyperlink', dirtyFlags: ['annotations'],
                    undo: () => { if (hyperlinks?.[linkId]) hyperlinks[linkId] = { ...before }; },
                    redo: () => { if (hyperlinks?.[linkId]) hyperlinks[linkId] = { ...after }; },
                });
                this.workspace._updateUndoRedoButtons();
            },
        });
    }

    /**
     * Removes a hyperlink annotation and re-renders the affected segment.
     * @param {string} linkId - the annotation key of the hyperlink to remove
     * @param {number} segIdx - index of the segment to re-render after removal
     */
    #removeHyperlink(linkId, segIdx) {
        this.workspace.closeCtxMenu();
        const hyperlinks = this.activeProject?.annotations?.hyperlinks;
        if (!hyperlinks?.[linkId]) return;
        const saved = { ...hyperlinks[linkId] };
        delete hyperlinks[linkId];
        const el  = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
        const seg = this.activeProject.transcript().segments[segIdx];
        if (el && seg) {
            const hl = el.querySelector('.t-seg-hl');
            if (hl) { delete hl.dataset.wordWrapped; this.#renderHlContent(hl, segIdx, seg.text); }
        }
        const server = this.activeProject?.activeServer;
        if (server?.isConnected && this.activeProject?.projectId) {
            server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations)
                  .catch(e => console.error('Failed to save annotations:', e));
        }
        this.workspace.history.push({
            label: 'Remove hyperlink', dirtyFlags: ['annotations'],
            undo: () => { if (this.activeProject.annotations?.hyperlinks) this.activeProject.annotations.hyperlinks[linkId] = saved; },
            redo: () => { delete this.activeProject.annotations?.hyperlinks?.[linkId]; },
        });
        this.workspace._updateUndoRedoButtons();
    }

    /**
     * Populates a .t-seg-hl element with text, wrapping any hyperlinked ranges in
     * .t-seg-link spans. If no hyperlinks exist and the segment has word data,
     * renders each word as a .t-word span for playback highlighting.
     * @param {HTMLElement} hl - the .t-seg-hl span to populate
     * @param {number} segIdx - segment index (used to look up hyperlinks)
     * @param {string} text - the segment's text content
     * @param {object[]|null} [words] - optional word-level data for word spans
     */
    #renderHlContent(hl, segIdx, text, words = null) {
        const links = this.#getHyperlinksForSegment(segIdx, text);
        if (!links.length && words?.length) {
            hl.textContent = '';
            for (let wi = 0; wi < words.length; wi++) {
                const w = words[wi];
                const trimmed = w.word.trimStart();
                const leading = w.word.slice(0, w.word.length - trimmed.length);
                // Skip the leading space of the first word if seg.text has no leading space —
                // Whisper sometimes adds one as a tokenisation artefact, and rendering it as a
                // text node shifts every character offset by 1, causing spurious trim detection.
                if (leading && (wi > 0 || text[0] === leading[0])) {
                    hl.appendChild(document.createTextNode(leading));
                }
                const wordSpan = document.createElement('span');
                wordSpan.className = 't-word';
                wordSpan.dataset.wstart = w.start;
                wordSpan.dataset.wend = w.end;
                wordSpan.textContent = trimmed;
                hl.appendChild(wordSpan);
            }
            hl.dataset.wordWrapped = '1';
            return;
        }
        if (!links.length) {
            hl.textContent = text;
            return;
        }

        hl.textContent = '';
        let pos = 0;
        for (const link of links) {
            if (link.charStart > pos) {
                hl.appendChild(document.createTextNode(text.slice(pos, link.charStart)));
            }
            const span = document.createElement('span');
            span.className = 't-seg-link';
            span.dataset.linkId = link.id;
            span.textContent = text.slice(link.charStart, link.charEnd);
            span.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, segIdx, null, {
                    onEdit:   () => this.#editHyperlink(link.id, link.url, link.name, link.description, link.editorNotes),
                    onRemove: () => this.#removeHyperlink(link.id, segIdx),
                    onCopy:   () => navigator.clipboard.writeText(link.url),
                });
            });
            span.addEventListener('mouseenter', () => {
                this._hoveredLinkUrl   = link.url;
                this._hoveredLinkName  = link.name;
                this._hoveredLinkDesc  = link.description;
                this._hoveredLinkNotes = link.editorNotes;
                if (document.body.classList.contains('ctrl-held')) {
                    this.#showLinkTooltip(link.url, link.name, link.description, link.editorNotes);
                } else {
                    this._linkTooltipTimer = setTimeout(() => this.#showLinkTooltip(link.url, link.name, link.description, link.editorNotes), 800);
                }
            });
            span.addEventListener('mouseleave', () => {
                this._hoveredLinkUrl   = null;
                this._hoveredLinkName  = null;
                this._hoveredLinkDesc  = null;
                this._hoveredLinkNotes = null;
                clearTimeout(this._linkTooltipTimer);
                this.#hideLinkTooltip();
            });
            span.addEventListener('click', (e) => {
                if (e.ctrlKey || e.metaKey) {
                    e.stopPropagation();
                    const href = /^https?:\/\//i.test(link.url) ? link.url : 'https://' + link.url;
                    window.open(href, '_blank', 'noopener,noreferrer');
                }
            });
            hl.appendChild(span);
            pos = link.charEnd;
        }
        if (pos < text.length) {
            hl.appendChild(document.createTextNode(text.slice(pos)));
        }
    }

    /**
     * Returns hyperlinks for a segment, sorted by start position, with null
     * charStart/charEnd normalised to cover the full segment text.
     * @param {number} segIdx - segment index
     * @param {string} text - segment text (used as fallback for legacy null extents)
     * @returns {object[]}
     */
    #getHyperlinksForSegment(segIdx, text) {
        const hyperlinks = this.activeProject?.annotations?.hyperlinks;
        if (!hyperlinks) return [];
        return Object.entries(hyperlinks)
            .filter(([, h]) => h.segmentIdx === segIdx)
            .map(([id, h]) => {
                let charStart = h.charStart ?? 0;
                let charEnd   = h.charEnd   ?? text.length;
                // Trim leading/trailing whitespace at display time so the link
                // span never begins or ends on a space character.
                while (charStart < charEnd && /\s/.test(text[charStart])) charStart++;
                while (charEnd > charStart && /\s/.test(text[charEnd - 1])) charEnd--;
                return { id, url: h.url, name: h.name, description: h.description ?? null, editorNotes: h.editorNotes ?? null, charStart, charEnd };
            })
            .filter(h => h.charStart < h.charEnd) // drop links that are all whitespace
            .sort((a, b) => a.charStart - b.charStart);
    }

    /**
     * Shows a tooltip near the cursor with link metadata and editor notes.
     * @param {string} url - the hyperlink URL
     * @param {string|null} name - optional display name for the link
     * @param {string|null} description - optional description text
     * @param {string|null} editorNotes - optional editor notes (only shown when not in read-only mode)
     */
    #showLinkTooltip(url, name, description, editorNotes) {
        this.#hideLinkTooltip();
        const el = document.createElement('div');
        el.className = 'info-widget-tooltip link-tooltip';

        if (name) {
            const nameEl = document.createElement('div');
            nameEl.className = 'link-tooltip-name';
            nameEl.textContent = name;
            el.appendChild(nameEl);
        }

        const urlEl = document.createElement('div');
        urlEl.className = 'link-tooltip-url';
        urlEl.textContent = url;
        el.appendChild(urlEl);

        if (description) {
            const descEl = document.createElement('div');
            descEl.className = 'link-tooltip-desc';
            descEl.textContent = description;
            el.appendChild(descEl);
        }

        if (editorNotes && !this.workspace.isReadOnly()) {
            const sep = document.createElement('div');
            sep.className = 'link-tooltip-sep';
            el.appendChild(sep);

            const notesHeader = document.createElement('div');
            notesHeader.className = 'link-tooltip-notes-header';
            notesHeader.textContent = "Editor's Notes";
            el.appendChild(notesHeader);

            const notesEl = document.createElement('div');
            notesEl.className = 'link-tooltip-notes';
            notesEl.textContent = editorNotes;
            el.appendChild(notesEl);
        }

        const footer = document.createElement('div');
        footer.className = 'link-tooltip-footer';
        footer.textContent = 'Ctrl+Click to navigate ↗';
        el.appendChild(footer);

        document.body.appendChild(el);
        this._linkTooltipEl = el;

        // Position below and to the right of the cursor, clamped to viewport.
        const x = this._mouseX + 12;
        const y = this._mouseY + 16;
        el.style.left = x + 'px';
        el.style.top  = y + 'px';
        requestAnimationFrame(() => {
            const r = el.getBoundingClientRect();
            if (r.right  > window.innerWidth)  el.style.left = (window.innerWidth  - r.width  - 8) + 'px';
            if (r.bottom > window.innerHeight) el.style.top  = (this._mouseY - r.height - 4) + 'px';
        });
    }

    /**
     * Retranscribes the given segment indices one at a time, showing progress in
     * the transcription status bar. Preserves the user's edited text while
     * updating word timestamps. Pushes one undo/redo entry per segment.
     * @param {number[]} segIndices - indices into the active project's transcript segments
     */
    async retranscribeSegments(segIndices) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        const segments  = this.activeProject.transcript().segments;
        const total     = segIndices.length;
        const isBatch   = total > 1;

        // Show status bar and disable buttons
        this.transcribeBtn.disabled = true;
        if (this.retranscribeStalBtn) this.retranscribeStalBtn.disabled = true;
        this.transcribeProgress.style.display = 'flex';
        this.transcribeProgressBar.style.width = '0%';
        this.transcribeElapsed.textContent = '0:00';

        const startTime = Date.now();
        const elapsedTimer = setInterval(() => {
            const s  = Math.floor((Date.now() - startTime) / 1000);
            const mm = Math.floor(s / 60);
            const ss = String(s % 60).padStart(2, '0');
            this.transcribeElapsed.textContent = `${mm}:${ss}`;
        }, 1000);

        const setStatus = (msg) => { this.transcribeStatus.textContent = msg; };
        const setProgress = (fraction) => {
            this.transcribeProgressBar.style.width = `${Math.round(fraction * 100)}%`;
        };

        // Spinner badges
        segIndices.forEach(i => {
            const badge = document.querySelector(`.t-seg[data-idx="${i}"] .t-seg-stale-badge`);
            if (badge) { badge.textContent = ''; badge.classList.add('stale-badge--loading'); }
        });

        let succeeded = 0;
        try {
            for (let n = 0; n < total; n++) {
                const segIdx = segIndices[n];
                const seg    = segments[segIdx];
                const label  = isBatch ? ` (${n + 1}/${total})` : '';
                setStatus(`Retranscribing sentence${label}…`);
                setProgress(n / total);

                // Animate progress bar while waiting for the server response
                const segFloor  = n / total;
                const segCeil   = (n + 1) / total;
                let   segFrac   = segFloor;
                const segTicker = setInterval(() => {
                    segFrac += (segCeil - segFrac) * 0.12;
                    setProgress(segFrac);
                }, 250);

                let results;
                try {
                    ({ results } = await server.retranscribeSegments(
                        this.activeProject.projectId,
                        [{ start: seg.start, end: seg.end, idx: segIdx, text: seg.text }],
                        'medium',
                    ));
                } finally {
                    clearInterval(segTicker);
                }

                const result = results?.[0];
                if (!result) continue;

                const oldWords      = seg.words ? seg.words.map(w => ({ ...w })) : null;
                const oldWordsStale = seg.wordsStale;

                const userTokens   = seg.text.trim().split(/\s+/);
                const whisperWords = result.words ?? [];
                const mappedWords  = [];

                if (whisperWords.length === 0) {
                    // Whisper produced no word timestamps — distribute evenly so highlighting works.
                    const segDur = seg.end - seg.start;
                    userTokens.forEach((token, i) => {
                        mappedWords.push({
                            start: seg.start + segDur * (i / userTokens.length),
                            end:   seg.start + segDur * ((i + 1) / userTokens.length),
                            word:  (i === 0 ? '' : ' ') + token,
                            probability: 0,
                        });
                    });
                } else {
                    // Map Whisper words to user tokens positionally.
                    const matchCount = Math.min(whisperWords.length, userTokens.length);
                    for (let i = 0; i < matchCount; i++) {
                        const w       = whisperWords[i];
                        const leading = w.word.slice(0, w.word.length - w.word.trimStart().length);
                        mappedWords.push({ start: w.start, end: w.end, word: leading + userTokens[i], probability: w.probability });
                    }
                    // If user has more words than Whisper returned, distribute remaining
                    // time evenly between the last Whisper word's end and seg.end.
                    if (userTokens.length > whisperWords.length) {
                        const overflowCount = userTokens.length - matchCount;
                        const overflowStart = mappedWords[mappedWords.length - 1].end;
                        const overflowDur   = (seg.end - overflowStart) / overflowCount;
                        for (let i = 0; i < overflowCount; i++) {
                            mappedWords.push({
                                start: overflowStart + overflowDur * i,
                                end:   overflowStart + overflowDur * (i + 1),
                                word:  ' ' + userTokens[matchCount + i],
                                probability: 0,
                            });
                        }
                    }
                }

                seg.words      = mappedWords;
                seg.wordsStale = undefined;

                this.workspace.history.push({
                    label: 'Retranscribe sentence', dirtyFlags: ['transcript'],
                    undo: () => { segments[segIdx].words = oldWords; segments[segIdx].wordsStale = oldWordsStale; },
                    redo: () => { segments[segIdx].words = mappedWords; segments[segIdx].wordsStale = undefined; },
                });

                succeeded++;
                setProgress((n + 1) / total);
            }

            if (succeeded > 0) {
                const label = isBatch ? ` (${succeeded}/${total})` : '';
                setStatus(`Done${label}!`);
                this.activeProject.markTranscriptDirty();
                this.workspace._updateUndoRedoButtons();
                this.renderTranscript();
            }
        } catch (e) {
            console.error('Segment retranscription failed:', e);
            setStatus(`Error: ${e.message}`);
            // Restore badges for any segments that weren't completed
            segIndices.slice(succeeded).forEach(i => {
                const badge = document.querySelector(`.t-seg[data-idx="${i}"] .t-seg-stale-badge`);
                if (badge) { badge.textContent = '\u26a0'; badge.classList.remove('stale-badge--loading'); }
            });
        } finally {
            clearInterval(elapsedTimer);
            this.transcribeBtn.disabled = false;
            if (this.retranscribeStalBtn) this.retranscribeStalBtn.disabled = false;
            setTimeout(() => { this.transcribeProgress.style.display = 'none'; }, 2000);
        }
    }

    /** Shows a tooltip near the cursor explaining that word timestamps are outdated. */
    #showStaleTooltip() {
        this.#hideStaleTooltip();
        const el = document.createElement('div');
        el.className = 'info-widget-tooltip stale-tooltip';
        const msg = document.createElement('div');
        msg.className = 'stale-tooltip-msg';
        msg.textContent = 'Word timestamps are outdated.';
        el.appendChild(msg);
        const hint = document.createElement('div');
        hint.className = 'stale-tooltip-hint';
        hint.textContent = 'Right-click to retranscribe this sentence.';
        el.appendChild(hint);
        document.body.appendChild(el);
        this._staleTooltipEl = el;
        const x = this._mouseX + 12;
        const y = this._mouseY + 16;
        el.style.left = x + 'px';
        el.style.top  = y + 'px';
        requestAnimationFrame(() => {
            const r = el.getBoundingClientRect();
            if (r.right  > window.innerWidth)  el.style.left = (window.innerWidth  - r.width  - 8) + 'px';
            if (r.bottom > window.innerHeight) el.style.top  = (this._mouseY - r.height - 4) + 'px';
        });
    }

    /** Hides and removes the stale-timestamp tooltip. */
    #hideStaleTooltip() {
        this._staleTooltipEl?.remove();
        this._staleTooltipEl = null;
    }

    /** Hides and removes the active link tooltip. */
    #hideLinkTooltip() {
        clearTimeout(this._linkTooltipTimer);
        this._linkTooltipEl?.remove();
        this._linkTooltipEl = null;
    }

}