workspace_panels_transcript_panel.js

import { ConfirmDialog } from "../components/confirm_dialog.js";
import { TranscribeDialog } from "../components/transcribe_dialog.js";
import { LOCAL_MODE } from "../utilities/constants.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";
import { ContextMenu } from "../components/context_menu.js";
import { EffectContextMenu } from "../components/effect_context_menu.js";
import { LinkContextMenu } from "../components/link_context_menu.js";
import { SectionContextMenu } from "../components/section_context_menu.js";
import { StaleContextMenu } from "../components/stale_context_menu.js";
import { SpeakerContextMenu } from "../components/speaker_context_menu.js";
import { ApplyEffectDialog } from "../components/apply_effect_dialog.js";
import { formatTime, formatTimeMs } from "../utilities/tools.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 {
    #elapsedTimer = null;

    /**
     * @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);

        // Sidebar resize drag
        this.sectionsSidebarResizer.addEventListener('pointerdown', (e) => {
            e.preventDefault();
            const startX     = e.clientX;
            const startWidth = this.sectionsSidebar.offsetWidth;
            this.sectionsSidebarResizer.classList.add('dragging');
            this.sectionsSidebarResizer.setPointerCapture(e.pointerId);

            const onMove = (e) => {
                const w = Math.max(60, Math.min(280, startWidth + e.clientX - startX));
                this.sectionsSidebar.style.width = w + 'px';
            };
            const onUp = () => {
                this.sectionsSidebarResizer.classList.remove('dragging');
                this.sectionsSidebarResizer.removeEventListener('pointermove', onMove);
                this.sectionsSidebarResizer.removeEventListener('pointerup', onUp);
            };
            this.sectionsSidebarResizer.addEventListener('pointermove', onMove);
            this.sectionsSidebarResizer.addEventListener('pointerup', onUp);
        });

        // 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
        // Shift+N — insert a note at the current playhead position
        // Shift+M — mute selected words
        // Shift+E — apply effect to selected words
        window.addEventListener('keydown', (e) => {
            if (!e.shiftKey) return;
            const tag = document.activeElement?.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
            if (e.key === 'K') { e.preventDefault(); this.#handleHyperlinkShortcut(); }
            if (e.key === 'F') { e.preventDefault(); this.#searchWordSelection(); }
            if (e.key === 'N' && !this.workspace.isReadOnly()) { e.preventDefault(); this.#openInsertNoteDialog(); }
            if (e.key === 'Q' && !this.workspace.isLocalMode() && !this.workspace.isReadOnly()) {
                const selTarget = this.#getNativeSelection();
                if (selTarget) { e.preventDefault(); this.workspace.openEmbedDialog(selTarget); }
            }
            if ((e.key === 'M' || e.key === 'E') && !this.workspace.isReadOnly() && !this.workspace.isLocalMode()) {
                const selTarget = this.#getNativeSelection();
                if (!selTarget) return;
                const wordSpans = this.#getSelectedWordSpans(selTarget);
                if (wordSpans.length === 0) return;
                e.preventDefault();
                if (e.key === 'M') this.#muteSelection(selTarget, wordSpans);
                if (e.key === 'E') this.#openApplyEffectDialog(selTarget, wordSpans);
            }
        }, { 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.sectionsSidebar     = this.root.querySelector('#sectionsSidebar');
        this.sectionsSidebarResizer = this.root.querySelector('#sectionsSidebarResizer');
        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) {
        clearInterval(this.#elapsedTimer);
        this.#elapsedTimer = null;
        this.transcribeProgress.style.display = 'none';

        this.activeProject = project;
        this._chainColorMap = {};
        const server = project.activeServer;
        if (server?.isConnected) {
            server.getEffectChains().then(chains => {
                this._chainColorMap = Object.fromEntries(chains.map(c => [c.id, c.color]));
                const hasChainRanges = project.effects?.ranges?.some(r => r.chain);
                if (hasChainRanges) this.renderTranscript();
            }).catch(() => {});
        }
        this.transcribeBtn.disabled = true;

        this.#applyAccessMode();

        // Re-attach in-panel progress UI if this project is already transcribing
        const manager = this.workspace.transcriptionManager;
        if (manager?.isActive(project.projectId)) {
            this.onTranscriptionUpdate(project.projectId);
        } else 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 note insertion dialog at the current playhead/selected-segment timecode.
     * Falls back to the end of the transcript if no position is available.
     */
    #openInsertNoteDialog() {
        if (!this.activeProject) return;
        const segs = this.activeProject.transcript().segments;
        const activeIdx = this.previouslyActiveSegment ?? -1;
        const selectedIdx = this.previouslySelectedSegment ?? -1;
        const refIdx = activeIdx >= 0 ? activeIdx : selectedIdx;
        const timecode = refIdx >= 0 && segs[refIdx] ? segs[refIdx].start : 0;
        this.insertNoteAtTimecode(timecode);
    }

    /**
     * Inserts a blank editor note at the given timecode and immediately focuses
     * its pill for inline text entry. Blurring without typing cancels and removes it.
     * @param {number} timecode - The timecode (in seconds) at which to insert the note.
     */
    insertNoteAtTimecode(timecode) {
        if (!this.activeProject) return;
        const note = { timecode, type: 'editor_note', text: '', public: false };
        this.activeProject.notes.push(note);
        this.activeProject.notes.sort((a, b) => a.timecode - b.timecode);
        const idx = this.activeProject.notes.indexOf(note);
        this.#saveNotes();
        this.renderTranscript();
        const inner = this.transcriptBody?.querySelector(`.transcript-note[data-note-index="${idx}"] .transcript-note__inner`);
        if (inner) inner.focus();
    }

    /** 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,
            isLocal: LOCAL_MODE,
            onConfirm: (opts) => this.#startTranscription(opts),
        });
    }

    /**
     * Initiates a transcription job via the TranscriptionManager so it persists
     * across project switches. Progress updates arrive via onTranscriptionUpdate().
     * @param {object} opts - options from TranscribeDialog
     */
    #startTranscription(opts = {}) {
        const server = this.activeProject?.activeServer;
        const manager = this.workspace.transcriptionManager;
        if (!server?.isConnected || !this.activeProject?.projectId || !manager) return;

        const projectId = this.activeProject.projectId;
        manager.start(projectId, server, opts);
    }

    /**
     * Called by App whenever the TranscriptionManager emits an update for this
     * panel's active project. Updates the in-panel progress UI.
     * @param {string} projectId - the project whose transcription state changed
     */
    onTranscriptionUpdate(projectId) {
        if (this.activeProject?.projectId !== projectId) return;
        const manager = this.workspace.transcriptionManager;
        if (!manager) return;

        const state = manager.getState(projectId);
        if (!state) {
            clearInterval(this.#elapsedTimer);
            this.#elapsedTimer = null;
            this.transcribeProgress.style.display = 'none';
            this.transcribeBtn.disabled = false;
            return;
        }

        this.transcribeBtn.disabled = true;
        this.transcribeProgress.style.display = 'flex';
        this.transcribeProgressBar.style.width = `${Math.round(state.fraction * 100)}%`;
        this.transcribeStatus.textContent = state.status;

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

    /**
    * 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;
        this.#updateSidebarActive(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() {
        clearInterval(this.#elapsedTimer);
        this.#elapsedTimer = null;
        if (this.transcribeProgress) this.transcribeProgress.style.display = 'none';

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

        const { items, hasSections } = this.activeProject.transcript().toRenderSequence(this.activeProject.notes);
        items.forEach(item => {
            if (item.type === 'sectionBreak') {
                this.transcriptBody.appendChild(this.#renderSectionBreak(item));
            } else if (item.type === 'note') {
                this.transcriptBody.appendChild(this.#renderNote(item));
            } else {
                this.transcriptBody.appendChild(this.#renderSpeakerBlock(item));
            }
        });

        this.#renderSectionsSidebar(hasSections ? items.filter(i => i.type === 'sectionBreak') : []);

        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';
        blockEl.dataset.firstSegStart = block.paragraphs[0].segments[0].start;

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

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

        if (!this.workspace.isReadOnly()) {
            const speaker = this.activeProject.speakers()[block.speaker];
            labelEl.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.workspace.closeCtxMenu();
                const swatchEl = document.querySelector(`.speaker-row-swatch[data-speaker-id="${speaker?.id}"]`);
                this.workspace.activeCtxMenu = new SpeakerContextMenu(e.clientX, e.clientY, speaker, {
                    onSetColor:   () => this.workspace.speakersPanel.openHuePicker(speaker, swatchEl ?? nameSpan),
                    onChangeName: () => this.workspace.speakersPanel.makeSpeakerEditable(nameSpan, speaker),
                    onRemove:     () => this.workspace.speakersPanel.deleteSpeaker(speaker?.id),
                    onDismiss:    () => this.workspace.closeCtxMenu(),
                });
            });
        }

        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';
            if (paragraph.off_the_record) paraRow.classList.add('para-row--otr');
            paraRow.dataset.firstSegStart = paragraph.segments[0].start;

            // 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 for options';
            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';

            if (paragraph.off_the_record) {
                const otrLabel = document.createElement('span');
                otrLabel.className = 'para-otr-label';
                otrLabel.textContent = 'Off The Record';
                paraEl.appendChild(otrLabel);
            }

            // 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 section break divider with an editable name and a remove button.
     * @param {{ number: number, name: string, beforeSegStart: number }} item - section break render item from toRenderSequence()
     * @returns {HTMLElement}
     */
    #renderSectionBreak({ number, name, beforeSegStart }) {
        const el = document.createElement('div');
        el.className = 'section-break';
        el.dataset.beforeSegStart = beforeSegStart;

        if (!this.workspace.isReadOnly()) {
            const gripEl = document.createElement('span');
            gripEl.className = 'section-break__grip';
            gripEl.textContent = '⠿';
            gripEl.addEventListener('pointerdown', (e) => this.#startSectionBreakDrag(e, el, beforeSegStart));
            gripEl.addEventListener('click', e => e.stopPropagation());
            el.appendChild(gripEl);
        }

        const numEl = document.createElement('span');
        numEl.className = 'section-break__num';
        numEl.textContent = `SECTION ${number}`;

        const nameEl = document.createElement('span');
        nameEl.className = 'section-break__name';
        nameEl.dataset.placeholder = `Section ${number}`;
        nameEl.textContent = name;

        if (!this.workspace.isReadOnly()) {
            nameEl.addEventListener('click', (e) => {
                e.stopPropagation();
                nameEl.contentEditable = 'true';
                nameEl.focus();
            });
            nameEl.addEventListener('blur', () => {
                nameEl.contentEditable = 'false';
                const transcript = this.activeProject.transcript();
                const entry = transcript.sectionBreaks.find(b => b.beforeSegStart === beforeSegStart);
                if (entry && entry.name !== nameEl.textContent) {
                    entry.name = nameEl.textContent;
                    this.activeProject.markTranscriptDirty();
                }
            });
            nameEl.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') { e.preventDefault(); nameEl.blur(); }
            });
        }

        const left = document.createElement('div');
        left.className = 'section-break__left';
        left.appendChild(numEl);
        left.appendChild(nameEl);

        el.appendChild(left);

        const timeEl = document.createElement('span');
        timeEl.className = 'section-break__time';
        timeEl.textContent = formatTime(beforeSegStart);
        el.appendChild(timeEl);

        let removeBtn = null;
        if (!this.workspace.isReadOnly()) {
            removeBtn = document.createElement('button');
            removeBtn.className = 'section-break__remove';
            removeBtn.textContent = '×';
            removeBtn.title = 'Remove section break';
            removeBtn.addEventListener('click', () => {
                this.workspace._removeSectionBreakWithHistory(beforeSegStart);
            });
            el.appendChild(removeBtn);
        }

        el.addEventListener('click', (e) => {
            if (nameEl.contains(e.target) || removeBtn?.contains(e.target)) return;
            const transcript = this.activeProject.transcript();
            const segIdx = beforeSegStart === 0
                ? 0
                : transcript.segments.findIndex(s => s.start === beforeSegStart);
            if (segIdx >= 0) {
                this.#selectSegment(segIdx);
                this.onSegmentSelect(segIdx);
            }
        });

        if (!this.workspace.isReadOnly()) {
            el.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.workspace.closeCtxMenu();
                this.workspace.activeCtxMenu = new SectionContextMenu(e.clientX, e.clientY, {
                    number,
                    name,
                    onEditName: () => {
                        nameEl.contentEditable = 'true';
                        nameEl.focus();
                        const range = document.createRange();
                        range.selectNodeContents(nameEl);
                        window.getSelection().removeAllRanges();
                        window.getSelection().addRange(range);
                    },
                    onRemove: () => this.workspace._removeSectionBreakWithHistory(beforeSegStart),
                    onDismiss: () => this.workspace.closeCtxMenu(),
                });
            });
        }

        return el;
    }

    /**
     * Renders an inline note element.
     * - Chip (left margin): click opens type-picker popup.
     * - Inner pill: contenteditable — edit text directly, Enter/Escape to finish,
     *   blur saves; clearing all text deletes the note.
     * Parens and uppercase formatting are handled entirely by CSS so the raw text
     * is always what is stored and what the user edits.
     * @param {object} root0 - The note data object.
     * @param {string} root0.noteType - The note type ('stage_direction', 'speech_label', or 'editor_note').
     * @param {string} root0.text - The note text content.
     * @param {boolean} root0.public - Whether the note is publicly visible.
     * @param {number} root0.timecode - The timecode (in seconds) associated with the note.
     * @param {number} root0.index - The note's index in the notes array.
     * @returns {HTMLElement} The constructed note element.
     */
    #renderNote({ noteType, text, public: isPublic, timecode, index }) {
        const el = document.createElement('div');
        el.className = `transcript-note transcript-note--${noteType.replace(/_/g, '-')}`;
        el.dataset.noteIndex = index;

        if (!this.workspace.isReadOnly()) {
            const chip = document.createElement('span');
            chip.className = 'transcript-note__chip';
            const typeLabels = { stage_direction: 'STAGE', speech_label: 'SPEECH', editor_note: 'NOTE' };
            chip.textContent = typeLabels[noteType] ?? noteType.toUpperCase();
            chip.addEventListener('click', (e) => {
                e.stopPropagation();
                const r = chip.getBoundingClientRect();
                this.#openNoteTypePopup(r.left, r.bottom + 4, index);
            });
            el.appendChild(chip);

            el.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.#openNoteTypePopup(e.clientX, e.clientY, index);
            });
        }

        const inner = document.createElement('span');
        inner.className = 'transcript-note__inner';
        inner.textContent = text;

        if (!this.workspace.isReadOnly()) {
            inner.contentEditable = 'true';
            inner.spellcheck = false;

            inner.addEventListener('click', (e) => e.stopPropagation());
            inner.addEventListener('mousedown', (e) => { if (e.button === 2) e.preventDefault(); });

            inner.addEventListener('keydown', (e) => {
                e.stopPropagation();
                if (e.key === 'Enter')  { e.preventDefault(); inner.blur(); }
                if (e.key === 'Escape') { inner.textContent = text; inner.blur(); }
            });

            inner.addEventListener('blur', () => {
                const newText = inner.textContent.trim();
                if (!newText) {
                    const previousNotes = this.activeProject.notes.map(n => ({ ...n }));
                    this.activeProject.notes.splice(index, 1);
                    this.#saveNotes();
                    this.renderTranscript();
                    if (text) this.#pushNotesHistory('Delete note', previousNotes);
                } else if (newText !== text) {
                    const previousNotes = this.activeProject.notes.map(n => ({ ...n }));
                    this.activeProject.notes[index] = { timecode, type: noteType, text: newText, public: isPublic };
                    this.#saveNotes();
                    this.#pushNotesHistory(text ? 'Edit note' : 'Add note', previousNotes);
                }
            });
        }

        el.appendChild(inner);
        return el;
    }

    /**
     * Opens a small type-picker popup at the given viewport position.
     * Lets the user switch type, toggle presentation visibility, or delete.
     * @param {number} x - Viewport x position for the popup.
     * @param {number} y - Viewport y position for the popup.
     * @param {number} noteIndex - Index of the note in the notes array.
     */
    #openNoteTypePopup(x, y, noteIndex) {
        document.querySelector('.note-type-popup')?.remove();

        const note = this.activeProject?.notes?.[noteIndex];
        if (!note) return;

        const NOTE_TYPES = [
            { value: 'stage_direction', label: 'Stage direction' },
            { value: 'speech_label',    label: 'Speech label'    },
            { value: 'editor_note',     label: 'Editor note'     },
        ];

        const popup = document.createElement('div');
        popup.className = 'note-type-popup';

        NOTE_TYPES.forEach(t => {
            const item = document.createElement('div');
            item.className = 'note-type-popup__item' + (t.value === note.type ? ' active' : '');

            const swatch = document.createElement('span');
            swatch.className = `note-type-popup__swatch note-type-popup__swatch--${t.value.replace(/_/g, '-')}`;
            item.appendChild(swatch);
            item.appendChild(document.createTextNode(t.label));

            item.addEventListener('mousedown', (e) => {
                e.preventDefault();
                popup.remove();
                if (t.value !== note.type) {
                    const previousNotes = this.activeProject.notes.map(n => ({ ...n }));
                    note.type = t.value;
                    note.public = t.value !== 'editor_note';
                    this.#saveNotes();
                    this.renderTranscript();
                    this.#pushNotesHistory('Change note type', previousNotes);
                }
            });
            popup.appendChild(item);
        });

        const sep1 = document.createElement('div');
        sep1.className = 'note-type-popup__sep';
        popup.appendChild(sep1);

        const pubItem = document.createElement('div');
        pubItem.className = 'note-type-popup__item note-type-popup__item--toggle';
        const pubSwatch = document.createElement('span');
        pubSwatch.className = 'note-type-popup__check';
        pubSwatch.textContent = note.public ? '✓' : '';
        pubItem.appendChild(pubSwatch);
        pubItem.appendChild(document.createTextNode('Visible in presentation'));
        pubItem.addEventListener('mousedown', (e) => {
            e.preventDefault();
            const previousNotes = this.activeProject.notes.map(n => ({ ...n }));
            note.public = !note.public;
            pubSwatch.textContent = note.public ? '✓' : '';
            this.#saveNotes();
            this.#pushNotesHistory('Toggle note visibility', previousNotes);
        });
        popup.appendChild(pubItem);

        const sep2 = document.createElement('div');
        sep2.className = 'note-type-popup__sep';
        popup.appendChild(sep2);

        const deleteItem = document.createElement('div');
        deleteItem.className = 'note-type-popup__item note-type-popup__item--delete';
        deleteItem.textContent = 'Delete note';
        deleteItem.addEventListener('mousedown', (e) => {
            e.preventDefault();
            popup.remove();
            const previousNotes = this.activeProject.notes.map(n => ({ ...n }));
            this.activeProject.notes.splice(noteIndex, 1);
            this.#saveNotes();
            this.renderTranscript();
            this.#pushNotesHistory('Delete note', previousNotes);
        });
        popup.appendChild(deleteItem);

        popup.style.top  = `${y}px`;
        popup.style.left = `${x}px`;
        document.body.appendChild(popup);
        requestAnimationFrame(() => {
            const r = popup.getBoundingClientRect();
            if (r.right  > window.innerWidth)  popup.style.left = (window.innerWidth  - r.width  - 8) + 'px';
            if (r.bottom > window.innerHeight) popup.style.top  = (window.innerHeight - r.height - 8) + 'px';
        });

        const close = (e) => {
            if (!popup.contains(e.target)) {
                popup.remove();
                document.removeEventListener('mousedown', close, true);
            }
        };
        requestAnimationFrame(() => document.addEventListener('mousedown', close, true));
    }

    /** Persists the current notes array to the server. */
    #saveNotes() {
        const server = this.activeProject?.activeServer;
        if (server?.isConnected && this.activeProject?.projectId) {
            server.saveNotes(this.activeProject.projectId, this.activeProject.notes)
                  .catch(e => console.error('Failed to save notes:', e));
        }
    }

    /**
     * Pushes an undo/redo history entry for a notes change.
     * @param {string} label - Display label for the history entry.
     * @param {Array} previousNotes - Snapshot of notes before the change.
     */
    #pushNotesHistory(label, previousNotes) {
        const nextNotes = this.activeProject.notes.map(n => ({ ...n }));
        this.workspace.history.push({
            label, dirtyFlags: [],
            undo: () => {
                this.activeProject.notes = previousNotes;
                this.#saveNotes();
                this.renderTranscript();
            },
            redo: () => {
                this.activeProject.notes = nextNotes;
                this.#saveNotes();
                this.renderTranscript();
            },
        });
    }

    /**
     * Builds the sections sidebar. Shows it when sectionBreaks is non-empty,
     * hides it otherwise.
     * @param {Array} sectionBreaks - sectionBreak items from toRenderSequence()
     */
    #renderSectionsSidebar(sectionBreaks) {
        this.sectionsSidebar.innerHTML = '';
        this.sectionsSidebar.classList.toggle('visible', sectionBreaks.length > 0);
        if (!sectionBreaks.length) return;

        const header = document.createElement('div');
        header.className = 'sections-sidebar__header';
        header.textContent = 'SECTIONS';
        this.sectionsSidebar.appendChild(header);

        for (const { number, name, beforeSegStart } of sectionBreaks) {
            const item = document.createElement('div');
            item.className = 'section-nav-item';
            item.dataset.beforeSegStart = beforeSegStart;

            const numEl = document.createElement('div');
            numEl.className = 'section-nav-item__num';
            numEl.textContent = `§${number}`;

            const nameEl = document.createElement('div');
            nameEl.className = 'section-nav-item__name';
            nameEl.textContent = name || `Section ${number}`;

            item.appendChild(numEl);
            item.appendChild(nameEl);

            item.addEventListener('click', () => {
                const breakEl = this.transcriptBody.querySelector(`.section-break[data-before-seg-start="${beforeSegStart}"]`);
                breakEl?.scrollIntoView({ block: 'start', behavior: 'smooth' });

                const transcript = this.activeProject.transcript();
                const segIdx = beforeSegStart === 0
                    ? 0
                    : transcript.segments.findIndex(s => s.start === beforeSegStart);
                if (segIdx >= 0) {
                    this.#selectSegment(segIdx);
                    this.onSegmentSelect(segIdx);
                }
            });

            if (!this.workspace.isReadOnly()) {
                item.addEventListener('contextmenu', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    this.workspace.closeCtxMenu();
                    this.workspace.activeCtxMenu = new SectionContextMenu(e.clientX, e.clientY, {
                        number,
                        name,
                        onEditName: () => {
                            const breakEl = this.transcriptBody.querySelector(`.section-break[data-before-seg-start="${beforeSegStart}"]`);
                            breakEl?.scrollIntoView({ block: 'start', behavior: 'smooth' });
                            const nameInBreak = breakEl?.querySelector('.section-break__name');
                            if (nameInBreak) {
                                nameInBreak.contentEditable = 'true';
                                nameInBreak.focus();
                                const range = document.createRange();
                                range.selectNodeContents(nameInBreak);
                                window.getSelection().removeAllRanges();
                                window.getSelection().addRange(range);
                            }
                        },
                        onRemove: () => this.workspace._removeSectionBreakWithHistory(beforeSegStart),
                        onDismiss: () => this.workspace.closeCtxMenu(),
                    });
                });
            }

            this.sectionsSidebar.appendChild(item);
        }

        this.#updateSidebarActive(this.previouslyActiveSegment);
    }

    /**
     * Highlights the sidebar item for the section containing the given segment.
     * @param {number} segmentIdx - index into the transcript segments array, or -1/null to clear
     */
    #updateSidebarActive(segmentIdx) {
        const items = this.sectionsSidebar.querySelectorAll('.section-nav-item');
        if (!items.length) return;

        let activeBSS = null;
        if (segmentIdx != null && segmentIdx >= 0 && this.activeProject) {
            const seg = this.activeProject.transcript().segments[segmentIdx];
            if (seg) {
                const breaks = [...this.activeProject.transcript().sectionBreaks]
                    .sort((a, b) => a.beforeSegStart - b.beforeSegStart);
                for (const sb of breaks) {
                    if (sb.beforeSegStart <= seg.start) activeBSS = sb.beforeSegStart;
                    else break;
                }
            }
        }

        for (const item of items) {
            const bss = parseFloat(item.dataset.beforeSegStart);
            item.classList.toggle('active', activeBSS !== null && bss === activeBSS);
        }
    }

    /**
     * Handles pointer-based drag to move a section break to a new paragraph boundary.
     * @param {PointerEvent} e - the initiating pointerdown event
     * @param {HTMLElement} sectionBreakEl - the section break element being dragged
     * @param {number} beforeSegStart - start time of the section break's current position
     */
    #startSectionBreakDrag(e, sectionBreakEl, beforeSegStart) {
        e.preventDefault();
        sectionBreakEl.setPointerCapture(e.pointerId);
        sectionBreakEl.classList.add('dragging');

        const indicator = document.createElement('div');
        indicator.className = 'section-drop-indicator';
        this.transcriptBody.appendChild(indicator);

        let dropTarget = null;
        let didMove   = false;

        const onMove = (e) => {
            didMove = true;
            const bodyRect  = this.transcriptBody.getBoundingClientRect();
            const cursorY   = e.clientY - bodyRect.top + this.transcriptBody.scrollTop;
            const paras = [...this.transcriptBody.querySelectorAll('.para-row[data-first-seg-start]')];

            // Find first paragraph whose vertical midpoint is below the cursor
            let targetPara = null;
            for (const para of paras) {
                const r   = para.getBoundingClientRect();
                const mid = r.top - bodyRect.top + this.transcriptBody.scrollTop + r.height / 2;
                if (cursorY < mid) { targetPara = para; break; }
            }

            // Can't drop before the first paragraph
            if (!targetPara || targetPara === paras[0]) {
                indicator.style.display = 'none';
                dropTarget = null;
                return;
            }

            const targetStart = parseFloat(targetPara.dataset.firstSegStart);
            if (targetStart === beforeSegStart) {
                indicator.style.display = 'none';
                dropTarget = null;
                return;
            }

            const paraTop = targetPara.getBoundingClientRect().top - bodyRect.top + this.transcriptBody.scrollTop;
            indicator.style.display = 'block';
            indicator.style.top = (paraTop - 5) + 'px';
            dropTarget = targetStart;
        };

        const onUp = () => {
            sectionBreakEl.classList.remove('dragging');
            indicator.remove();
            sectionBreakEl.removeEventListener('pointermove', onMove);
            sectionBreakEl.removeEventListener('pointerup',   onUp);
            if (didMove && dropTarget !== null) {
                this.workspace._moveSectionBreakWithHistory(beforeSegStart, dropTarget);
            }
        };

        sectionBreakEl.addEventListener('pointermove', onMove);
        sectionBreakEl.addEventListener('pointerup',   onUp);
    }

    /**
    * 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';
            badge.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.workspace.closeCtxMenu();
                this.workspace.activeCtxMenu = new StaleContextMenu(e.clientX, e.clientY, {
                    onRetranscribe: () => this.retranscribeSegments([idx]),
                    onRevertAndRetranscribe: () => this.revertAndRetranscribeSegments([idx]),
                    onDismiss: () => this.workspace.closeCtxMenu(),
                });
            });
            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 selectedWordSpans = this.#getSelectedWordSpans(selTarget);
                const canMute = !this.workspace.isReadOnly() && !this.workspace.isLocalMode();
                const menu = new SelectionContextMenu(e.clientX, e.clientY, {
                    onAddLink:    () => { menu.close(); this.#openHyperlinkDialogForTarget(selTarget); },
                    onSearchText: () => { menu.close(); this.#searchWithTarget(selTarget); },
                    onAddNote: this.workspace.isReadOnly() ? null : () => {
                        menu.close();
                        const segs = this.activeProject.transcript().segments;
                        const t = segs[selTarget.segIdxStart]?.start ?? 0;
                        this.insertNoteAtTimecode(t);
                    },
                    // null in LOCAL_MODE or when selection spans an off-the-record paragraph
                    onGenerateLiveQuote: this.workspace.isLocalMode() || this.#selectionHasOtr(selTarget) ? null
                        : () => { menu.close(); this.workspace.openEmbedDialog(selTarget); },
                    onMute: canMute
                        ? () => { menu.close(); this.#muteSelection(selTarget, selectedWordSpans); }
                        : null,
                    onApplyEffect: canMute && selectedWordSpans.length > 0
                        ? () => { menu.close(); this.#openApplyEffectDialog(selTarget, selectedWordSpans); }
                        : null,
                    applyEffectDisabledReason: canMute && selectedWordSpans.length === 0
                        ? 'Requires word-level transcription'
                        : null,
                    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, 10);
            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, 10);
                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;
            const oldWords = seg.words ? seg.words.map(w => ({ ...w })) : null;
            const oldWordsStale = seg.wordsStale;
            seg.text = newText;
            const newTokens = newText.trim().split(/\s+/);
            if (seg.words?.length && seg.words.length === newTokens.length) {
                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) {
                seg.words = null;
                seg.wordsStale = true;
            }
            this.activeProject.markTranscriptDirty();
            const hl = document.querySelector(`.t-seg[data-idx="${segIdx}"] .t-seg-hl`);
            if (hl) this.#renderHlContent(hl, segIdx, newText, seg.words);
            this.workspace.history.push({
                label: 'Replace text', dirtyFlags: ['transcript'],
                undo: () => { seg.text = oldText; seg.words = oldWords; seg.wordsStale = oldWordsStale; },
                redo: () => {
                    seg.text = newText;
                    const redoTokens = newText.trim().split(/\s+/);
                    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();
        }
        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);
                if (newText === oldText) return null;
                const oldWords = seg.words ? seg.words.map(w => ({ ...w })) : null;
                const oldWordsStale = seg.wordsStale;
                return { segIdx, seg, oldText, newText, oldWords, oldWordsStale };
            })
            .filter(Boolean);

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

        changes.forEach(({ seg, newText, segIdx, oldWords }) => {
            seg.text = newText;
            const newTokens = newText.trim().split(/\s+/);
            if (seg.words?.length && seg.words.length === newTokens.length) {
                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) {
                seg.words = null;
                seg.wordsStale = true;
            }
            const hl = document.querySelector(`.t-seg[data-idx="${segIdx}"] .t-seg-hl`);
            if (hl) this.#renderHlContent(hl, segIdx, newText, seg.words);
        });
        this.activeProject.markTranscriptDirty();

        this.workspace.history.push({
            label: 'Replace all', dirtyFlags: ['transcript'],
            undo: () => { changes.forEach(({ seg, oldText, oldWords, oldWordsStale }) => { seg.text = oldText; seg.words = oldWords; seg.wordsStale = oldWordsStale; }); },
            redo: () => {
                changes.forEach(({ seg, newText, oldWords }) => {
                    seg.text = newText;
                    const redoTokens = newText.trim().split(/\s+/);
                    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();
        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) {
        this.#closeParaSpeakerDialog();

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

        const titleEl = document.createElement('div');
        titleEl.className = 'ctx-title';
        const titleWidget = document.createElement('info-widget');
        titleWidget.setAttribute('message', 'Change the speaker or off-the-record status for this paragraph.');
        titleWidget.setAttribute('href', '/docs');
        titleEl.appendChild(titleWidget);
        titleEl.appendChild(document.createTextNode('Paragraph'));
        popup.appendChild(titleEl);

        const firstSeg = paragraph.segments[0];
        const lastSeg  = paragraph.segments[paragraph.segments.length - 1];
        const duration = lastSeg.end - firstSeg.start;
        const words    = paragraph.segments.flatMap(s => s.text.trim().split(/\s+/).filter(Boolean));
        const wps      = duration > 0 ? (words.length / duration).toFixed(1) : '—';
        const info     = document.createElement('div');
        info.className = 'ctx-info';
        [
            ['START',     formatTimeMs(firstSeg.start)],
            ['END',       formatTimeMs(lastSeg.end)],
            ['DURATION',  duration.toFixed(2) + 's'],
            ['SEGMENTS',  String(paragraph.segments.length)],
            ['WORDS',     words.length + '  (' + wps + '/s)'],
        ].forEach(([k, v]) => {
            const key = document.createElement('span');
            key.className = 'ctx-info-key'; key.textContent = k;
            const val = document.createElement('span');
            val.className = 'ctx-info-val'; val.textContent = v;
            info.appendChild(key);
            info.appendChild(val);
        });
        popup.appendChild(info);

        const controlsBlock = document.createElement('div');
        controlsBlock.className = 'ctx-controls';

        if (!this.workspace.isReadOnly()) {
            // Off-the-record button
            const isOtr = !!paragraph.off_the_record;
            const otrItem = document.createElement('div');
            otrItem.className = 'ctx-item';
            otrItem.innerHTML = `<span class="ctx-item-inner">${isOtr ? 'Unset Off The Record' : 'Set Off The Record'}</span>`;
            otrItem.addEventListener('click', (e) => {
                e.stopPropagation();
                const transcript = this.activeProject.transcript();
                const paraIdx = transcript.paragraphs.indexOf(paragraph);
                const prevValue = paragraph.off_the_record ?? false;
                const newValue = !prevValue;
                transcript.setParaOffTheRecord(paraIdx, newValue);
                this.activeProject.markTranscriptDirty();
                this.workspace.history.push({
                    label: newValue ? 'Mark as Off The Record' : 'Unmark as Off The Record',
                    dirtyFlags: ['transcript'],
                    undo: () => { transcript.setParaOffTheRecord(paraIdx, prevValue); this.renderTranscript(); },
                    redo: () => { transcript.setParaOffTheRecord(paraIdx, newValue); this.renderTranscript(); },
                });
                this.workspace._updateUndoRedoButtons();
                this.#closeParaSpeakerDialog();
                this.renderTranscript();
            });
            controlsBlock.appendChild(otrItem);

            // Change speaker item — clicking expands the speaker list
            const changeSpeakerItem = document.createElement('div');
            changeSpeakerItem.className = 'ctx-item';
            changeSpeakerItem.innerHTML = '<span class="ctx-item-inner">Change paragraph speaker</span>';
            let speakerListOpen = false;
            changeSpeakerItem.addEventListener('click', () => {
                if (speakerListOpen) return;
                speakerListOpen = true;
                changeSpeakerItem.style.color = 'var(--accent)';
                controlsBlock.style.display = 'none';

                const speakerSelector = document.createElement('div');
                speakerSelector.className = 'ctx-speaker-list';

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

                    speakerSelector.appendChild(item);
                });

                popup.appendChild(speakerSelector);

                const mr = popup.getBoundingClientRect();
                if (mr.bottom > window.innerHeight) {
                    popup.style.top = (window.innerHeight - mr.height - 8) + 'px';
                }
            });
            controlsBlock.appendChild(changeSpeakerItem);
        }

        // Zoom to paragraph — always available
        const zoomItem = document.createElement('div');
        zoomItem.className = 'ctx-item';
        zoomItem.innerHTML = '<span class="ctx-icon">⌕</span><span class="ctx-item-inner">Zoom to paragraph</span>';
        zoomItem.addEventListener('click', (e) => {
            e.stopPropagation();
            this.#closeParaSpeakerDialog();
            this.onParagraphZoom(paragraph);
        });
        controlsBlock.appendChild(zoomItem);

        popup.appendChild(controlsBlock);
        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}
     */
    /**
     * Returns true if any segment in the selection range belongs to an off-the-record paragraph.
     * @param {object} selTarget - resolved selection from #getNativeSelection
     * @returns {boolean}
     */
    #selectionHasOtr(selTarget) {
        const transcript = this.activeProject.transcript();
        const start = selTarget.segIdxStart ?? selTarget.segIdx ?? 0;
        const end   = selTarget.segIdxEnd   ?? selTarget.segIdx ?? 0;
        for (let i = start; i <= end; i++) {
            const seg = transcript.segments[i];
            if (!seg) continue;
            const para = transcript.paragraphs.find(p => p.segments.includes(seg));
            if (para?.off_the_record) return true;
        }
        return false;
    }

    /**
     * Returns the resolved selection target from the current window selection, or null if there is no valid transcript selection.
     * @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, 10);
        const endSegIdx   = parseInt(endSeg.dataset.idx, 10);
        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);
        const segStart = this.activeProject.transcript().segments[segIdx]?.start;
        this.activeProject.annotations.hyperlinks[id] = { url, name: name || null, description: description || null, editorNotes: editorNotes || null, segmentIdx: segIdx, segmentStart: segStart ?? null, 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, seg.words); }
        }

        // 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, seg.words); }
                }
                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, seg.words); }
        }
        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);

        hl.textContent = '';

        if (!links.length && !words?.length) {
            hl.textContent = text;
            return;
        }

        // Builds a .t-seg-link span with all its event listeners.
        const makeLinkSpan = (link) => {
            const span = document.createElement('span');
            span.className = 't-seg-link';
            span.dataset.linkId = link.id;
            span.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                const linkMenu = new LinkContextMenu(e.clientX, e.clientY, {
                    onEdit:    () => this.#editHyperlink(link.id, link.url, link.name, link.description, link.editorNotes),
                    onCopy:    () => navigator.clipboard.writeText(link.url),
                    onRemove:  () => this.#removeHyperlink(link.id, segIdx),
                    onDismiss: () => this.workspace.closeCtxMenu(),
                });
                this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, segIdx, null, 'Segment', linkMenu);
            });
            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');
                }
            });
            return span;
        };

        // Links only (no word-level data): simple char-range rendering.
        if (links.length && !words?.length) {
            let pos = 0;
            for (const link of links) {
                if (link.charStart > pos) {
                    hl.appendChild(document.createTextNode(text.slice(pos, link.charStart)));
                }
                const span = makeLinkSpan(link);
                span.textContent = text.slice(link.charStart, link.charEnd);
                hl.appendChild(span);
                pos = link.charEnd;
            }
            if (pos < text.length) {
                hl.appendChild(document.createTextNode(text.slice(pos)));
            }
            return;
        }

        // Words exist (with or without links): word-level rendering.
        // When links are present, word spans are placed inside .t-seg-link containers
        // so both effect decorations and link styling coexist on the same words.
        const effectRanges = this.activeProject?.effects?.ranges ?? [];

        // Track char position through the text so we can map words to link ranges.
        let charPos = 0;
        let activeLinkSpan = null;
        let activeLink = null;

        const flushLink = () => {
            if (activeLinkSpan) {
                hl.appendChild(activeLinkSpan);
                activeLinkSpan = null;
                activeLink = null;
            }
        };

        const containerAt = (pos) => {
            if (!links.length) return hl;
            const link = links.find(l => pos >= l.charStart && pos < l.charEnd) ?? null;
            if (link !== activeLink) {
                flushLink();
                if (link) {
                    activeLink = link;
                    activeLinkSpan = makeLinkSpan(link);
                }
            }
            return activeLinkSpan ?? hl;
        };

        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])) {
                containerAt(charPos).appendChild(document.createTextNode(leading));
                charPos += leading.length;
            }

            const wordSpan = document.createElement('span');
            wordSpan.className = 't-word';
            wordSpan.dataset.wstart = w.start;
            wordSpan.dataset.wend = w.end;
            wordSpan.textContent = trimmed;

            const effectRange = effectRanges.find(r => w.start >= r.start && w.end <= r.end + 0.01);
            if (effectRange) {
                if (effectRange.type === 'mute') {
                    wordSpan.classList.add('t-word--muted');
                    wordSpan.dataset.muteRangeId = effectRange.id;
                    wordSpan.addEventListener('contextmenu', (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        if (!this.workspace.isReadOnly() && !this.workspace.isLocalMode()) {
                            const lm = this.#buildLinkMenuForEl(e.currentTarget, e.clientX, e.clientY, segIdx);
                            this.#showUnmuteMenu(e.clientX, e.clientY, effectRange.id, segIdx, lm);
                        }
                    });
                } else if (effectRange.chain) {
                    wordSpan.classList.add('t-word--effected');
                    const color = this._chainColorMap?.[effectRange.chain] ?? '#6fa8dc';
                    wordSpan.style.setProperty('--effect-color', color);
                    wordSpan.dataset.effectRangeId = effectRange.id;
                    wordSpan.addEventListener('contextmenu', (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        if (!this.workspace.isReadOnly() && !this.workspace.isLocalMode()) {
                            const lm = this.#buildLinkMenuForEl(e.currentTarget, e.clientX, e.clientY, segIdx);
                            this.#showEffectContextMenu(e.clientX, e.clientY, effectRange.id, segIdx, lm);
                        }
                    });
                }
            }

            containerAt(charPos).appendChild(wordSpan);
            charPos += trimmed.length;
        }

        flushLink();
        hl.dataset.wordWrapped = '1';
    }

    /**
     * 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 [];
        const seg = this.activeProject?.transcript()?.segments[segIdx];
        return Object.entries(hyperlinks)
            .filter(([, h]) => {
                // Primary match: exact index. Secondary: segmentStart timestamp (repairs stale indices).
                if (h.segmentIdx === segIdx) return true;
                if (h.segmentStart != null && seg != null && h.segmentStart === seg.start) {
                    h.segmentIdx = segIdx; // repair stale index in-place
                    return true;
                }
                return false;
            })
            .map(([id, h]) => {
                let charStart = h.charStart ?? 0;
                let charEnd   = h.charEnd   ?? text.length;
                // Drop links whose char offsets exceed the text length (stale from a prior format).
                if (charStart >= text.length) return null;
                charEnd = Math.min(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 !== null && h.charStart < h.charEnd)
            .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);
        }
    }

    /**
     * Discards user edits to the given segments and replaces their text and word
     * timestamps with a fresh Whisper transcription (no text constraint).
     * @param {number[]} segIndices - indices into the active project's transcript segments
     */
    async revertAndRetranscribeSegments(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;

        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)}%`; };

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

                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 {
                    // Omit text so Whisper produces a fresh transcription.
                    ({ results } = await server.retranscribeSegments(
                        this.activeProject.projectId,
                        [{ start: seg.start, end: seg.end, idx: segIdx }],
                        'medium',
                    ));
                } finally {
                    clearInterval(segTicker);
                }

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

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

                const newText  = result.text ?? seg.text;
                const newWords = result.words ?? [];

                seg.text       = newText;
                seg.words      = newWords;
                seg.wordsStale = undefined;

                this.workspace.history.push({
                    label: 'Revert and retranscribe', dirtyFlags: ['transcript'],
                    undo: () => { segments[segIdx].text = oldText; segments[segIdx].words = oldWords; segments[segIdx].wordsStale = oldWordsStale; },
                    redo: () => { segments[segIdx].text = newText; segments[segIdx].words = newWords; 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 revert-retranscription failed:', e);
            setStatus(`Error: ${e.message}`);
            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;
    }

    // ── Modulation / mute helpers ─────────────────────────────────────────────

    /**
     * Returns the .t-word spans that intersect the current native selection.
     * Returns an empty array when no word spans are in the selection.
     * @param {object} selTarget - resolved selection from #getNativeSelection
     * @returns {HTMLElement[]}
     */
    #getSelectedWordSpans(selTarget) {
        const sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) return [];
        const range = sel.getRangeAt(0);
        const container = selTarget.segIdx != null
            ? this.transcriptBody.querySelector(`.t-seg[data-idx="${selTarget.segIdx}"] .t-seg-hl`)
            : this.transcriptBody;
        if (!container) return [];
        return Array.from(container.querySelectorAll('.t-word[data-wstart]'))
            .filter(span => range.intersectsNode(span));
    }

    /**
     * Applies a mute range covering the selection. Uses word timestamps when
     * available; falls back to the segment's start/end times otherwise.
     * @param {object} selTarget - resolved selection from #getNativeSelection
     * @param {HTMLElement[]} wordSpans - word spans within the selection (may be empty)
     */
    async #openApplyEffectDialog(selTarget, wordSpans) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected) return;

        // Compute start/end upfront so the preview callback can use them
        let start, end;
        if (wordSpans.length > 0) {
            start = Math.min(...wordSpans.map(s => parseFloat(s.dataset.wstart)));
            end   = Math.max(...wordSpans.map(s => parseFloat(s.dataset.wend)));
        } else {
            const segIdx = selTarget.segIdx ?? selTarget.segIdxStart;
            const seg    = this.activeProject?.transcript()?.segments?.[segIdx];
            if (!seg) return;
            start = seg.start;
            end   = seg.end;
        }

        let chains;
        try {
            chains = await server.getEffectChains();
        } catch (e) {
            console.error('Failed to load effect chains:', e);
            return;
        }
        if (!chains?.length) return;

        const projectId = this.activeProject.projectId;

        new ApplyEffectDialog(chains, {
            fetchPreview: ({ chainId, controls }) =>
                server.previewEffect(projectId, start, end, chainId, controls),
            onApply: ({ chainId, controls }) => {
                this.#applyEffectChain(start, end, chainId, controls);
            },
        });
    }

    /**
     * Saves a new effect range to the server and updates local state.
     * @param {number} start - Start time in seconds.
     * @param {number} end - End time in seconds.
     * @param {string} chainId - Effect chain ID.
     * @param {object} controls - Control parameter values keyed by control ID.
     */
    async #applyEffectChain(start, end, chainId, controls) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        if (!this.activeProject.effects) this.activeProject.effects = { ranges: [] };
        const existingRanges = this.activeProject.effects.ranges ?? [];
        if (existingRanges.some(r => r.chain === chainId && Math.abs(r.start - start) < 0.001 && Math.abs(r.end - end) < 0.001)) return;
        const newRange  = { start, end, chain: chainId, controls };
        const newRanges = [...existingRanges, newRange];
        const previousRanges = existingRanges;
        const projectId = this.activeProject.projectId;

        try {
            const result = await server.setEffects(projectId, newRanges);
            this.activeProject.effects      = result.effects ?? { ranges: newRanges };
            this.activeProject.effectsReady = !result.processing;
            this.renderTranscript();
            if (result.processing) {
                this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
            } else {
                this.workspace.waveformPanel?._updateEffectButtons();
            }
            const resultRanges = this.activeProject.effects.ranges;
            this.workspace.history.push({
                label: 'Apply effect', dirtyFlags: [],
                undo: async () => {
                    const r = await server.setEffects(projectId, previousRanges);
                    this.activeProject.effects = r.effects ?? { ranges: previousRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
                redo: async () => {
                    const r = await server.setEffects(projectId, resultRanges);
                    this.activeProject.effects = r.effects ?? { ranges: resultRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
            });
        } catch (e) {
            console.error('Failed to apply effect:', e);
        }
    }

    /**
     * Opens the ApplyEffectDialog pre-populated with the existing range's chain and controls.
     * @param {string} rangeId - ID of the effect range to edit.
     */
    async #openEditEffectDialog(rangeId) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected) return;

        const range = (this.activeProject.effects?.ranges ?? []).find(r => r.id === rangeId);
        if (!range) return;

        let chains;
        try {
            chains = await server.getEffectChains();
        } catch (e) {
            console.error('Failed to load effect chains:', e);
            return;
        }
        if (!chains?.length) return;

        const projectId = this.activeProject.projectId;

        new ApplyEffectDialog(chains, {
            title:           'Edit Effect',
            initialChainId:  range.chain,
            initialControls: range.controls ?? {},
            fetchPreview: ({ chainId, controls }) =>
                server.previewEffect(projectId, range.start, range.end, chainId, controls),
            onApply: ({ chainId, controls }) => {
                this.#editEffectChain(rangeId, chainId, controls);
            },
        });
    }

    /**
     * Replaces the chain and controls on an existing effect range and saves to the server.
     * @param {string} rangeId - ID of the effect range to update.
     * @param {string} chainId - New effect chain ID.
     * @param {object} controls - Updated control parameter values.
     */
    async #editEffectChain(rangeId, chainId, controls) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        const previousRanges = this.activeProject.effects?.ranges ?? [];
        const newRanges = previousRanges.map(r =>
            r.id === rangeId ? { ...r, chain: chainId, controls } : r
        );
        const projectId = this.activeProject.projectId;

        try {
            const result = await server.setEffects(projectId, newRanges);
            this.activeProject.effects      = result.effects ?? { ranges: newRanges };
            this.activeProject.effectsReady = !result.processing;
            this.renderTranscript();
            if (result.processing) {
                this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
            } else {
                this.workspace.waveformPanel?.setEffectsRendering(false);
            }
            this.workspace.history.push({
                label: 'Edit effect', dirtyFlags: [],
                undo: async () => {
                    const r = await server.setEffects(projectId, previousRanges);
                    this.activeProject.effects = r.effects ?? { ranges: previousRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
                redo: async () => {
                    const r = await server.setEffects(projectId, newRanges);
                    this.activeProject.effects = r.effects ?? { ranges: newRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
            });
        } catch (e) {
            console.error('Failed to edit effect:', e);
        }
    }

    /**
     * Adds a mute range covering the selected words (or the full segment as fallback).
     * @param {object} selTarget - Selection target descriptor with segIdx info.
     * @param {HTMLElement[]} wordSpans - Word span elements whose data-wstart/wend define the range.
     */
    async #muteSelection(selTarget, wordSpans) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        let start, end;
        if (wordSpans.length > 0) {
            start = Math.min(...wordSpans.map(s => parseFloat(s.dataset.wstart)));
            end   = Math.max(...wordSpans.map(s => parseFloat(s.dataset.wend)));
        } else {
            // No word-level data — use the segment's time boundaries as fallback
            const segIdx = selTarget.segIdx ?? selTarget.segIdxStart;
            const seg = this.activeProject?.transcript()?.segments?.[segIdx];
            if (!seg) return;
            start = seg.start;
            end   = seg.end;
        }

        if (!this.activeProject.effects) this.activeProject.effects = { ranges: [] };
        const existingRanges = this.activeProject.effects.ranges ?? [];
        if (existingRanges.some(r => r.type === 'mute' && Math.abs(r.start - start) < 0.001 && Math.abs(r.end - end) < 0.001)) return;
        const newRange = { start, end, type: 'mute' };
        const newRanges = [...existingRanges, newRange];
        const previousRanges = existingRanges;
        const projectId = this.activeProject.projectId;

        try {
            const result = await server.setEffects(projectId, newRanges);
            // Server assigns an id — update local state with the id-annotated ranges
            this.activeProject.effects = result.effects ?? { ranges: newRanges };
            this.activeProject.effectsReady = !result.processing;
            this.renderTranscript();
            if (result.processing) {
                this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
            } else {
                this.workspace.waveformPanel?._updateEffectButtons();
            }
            const resultRanges = this.activeProject.effects.ranges;
            this.workspace.history.push({
                label: 'Mute', dirtyFlags: [],
                undo: async () => {
                    const r = await server.setEffects(projectId, previousRanges);
                    this.activeProject.effects = r.effects ?? { ranges: previousRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
                redo: async () => {
                    const r = await server.setEffects(projectId, resultRanges);
                    this.activeProject.effects = r.effects ?? { ranges: resultRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
            });
        } catch(e) {
            console.error('Failed to save effects:', e);
        }
    }

    /**
     * Removes a mute range by id, saves to server, and notifies the waveform panel.
     * @param {string} rangeId - ID of the effect range to remove.
     */
    async #unmuteRange(rangeId) {
        const server = this.activeProject?.activeServer;
        if (!server?.isConnected || !this.activeProject?.projectId) return;

        const previousRanges = this.activeProject.effects?.ranges ?? [];
        const newRanges = previousRanges.filter(r => r.id !== rangeId);
        const projectId = this.activeProject.projectId;

        try {
            const result = await server.setEffects(projectId, newRanges);
            this.activeProject.effects = result.effects ?? { ranges: newRanges };
            this.activeProject.effectsReady = !result.processing;
            this.renderTranscript();
            if (result.processing) {
                this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
            } else {
                this.workspace.waveformPanel?.setEffectsRendering(false);
            }
            this.workspace.history.push({
                label: 'Unmute', dirtyFlags: [],
                undo: async () => {
                    const r = await server.setEffects(projectId, previousRanges);
                    this.activeProject.effects = r.effects ?? { ranges: previousRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
                redo: async () => {
                    const r = await server.setEffects(projectId, newRanges);
                    this.activeProject.effects = r.effects ?? { ranges: newRanges };
                    this.activeProject.effectsReady = !r.processing;
                    this.renderTranscript();
                    if (r.processing) this.workspace.waveformPanel?.setEffectsRendering(true, server, projectId);
                    else this.workspace.waveformPanel?.setEffectsRendering(false);
                },
            });
        } catch(e) {
            console.error('Failed to remove effect:', e);
        }
    }

    /**
     * Shows a context menu with a single "Unmute" action, optionally stacking
     * an extra menu (e.g. a LinkContextMenu) and the segment menu below it.
     * @param {number} x - Horizontal position (px) for the menu.
     * @param {number} y - Vertical position (px) for the menu.
     * @param {string} rangeId - ID of the mute range to unmute.
     * @param {number} segIdx - Segment index, used to open the segment menu below.
     * @param {ContextMenu|null} [extraMenu] - Optional menu to stack below the unmute section.
     */
    #showUnmuteMenu(x, y, rangeId, segIdx, extraMenu = null) {
        this.workspace.closeCtxMenu();
        this._unmuteMenu = null;

        const unmuteMenu = new ContextMenu('Mute', () => this.workspace.closeCtxMenu());
        unmuteMenu.root.addEventListener('mousedown', e => e.preventDefault());

        const controls = document.createElement('div');
        controls.className = 'ctx-controls';
        controls.appendChild(unmuteMenu.makeItem(
            '<span style="font-size:0.85rem;line-height:1;">🔊</span>',
            'Unmute', null,
            () => { this.workspace.closeCtxMenu(); this.#unmuteRange(rangeId); },
        ));
        unmuteMenu.root.appendChild(controls);
        unmuteMenu._mount(x, y);

        if (extraMenu) unmuteMenu.stack(extraMenu);
        this.workspace.openSegmentCtxMenu(x, y, segIdx, null, 'Segment', unmuteMenu);
        this._unmuteMenu = unmuteMenu;
    }

    /**
     * Shows a context menu with "Edit effect" and "Remove effect" actions,
     * optionally stacking an extra menu (e.g. a LinkContextMenu) below the effect section.
     * @param {number} x - Horizontal position (px) for the menu.
     * @param {number} y - Vertical position (px) for the menu.
     * @param {string} rangeId - ID of the effect range to act on.
     * @param {number} segIdx - Index of the segment containing the effect range.
     * @param {ContextMenu|null} [extraMenu] - Optional menu to stack below the effect section.
     */
    #showEffectContextMenu(x, y, rangeId, segIdx, extraMenu = null) {
        this.workspace.closeCtxMenu();
        this._removeEffectMenu = null;
        const effectMenu = new EffectContextMenu(x, y, {
            onEdit:    () => this.#openEditEffectDialog(rangeId),
            onRemove:  () => this.#unmuteRange(rangeId),
            onDismiss: () => { this.workspace.closeCtxMenu(); this._removeEffectMenu = null; },
        });
        if (extraMenu) effectMenu.stack(extraMenu);
        this.workspace.openSegmentCtxMenu(x, y, segIdx, null, 'Segment', effectMenu);
        this._removeEffectMenu = effectMenu;
    }

    /**
     * Returns a LinkContextMenu for the link ancestor of `el`, or null if `el`
     * is not inside a .t-seg-link span.
     * @param {Element} el - Element to check for a link ancestor.
     * @param {number} x - Pointer X coordinate for menu positioning.
     * @param {number} y - Pointer Y coordinate for menu positioning.
     * @param {number} segIdx - Index of the transcript segment containing `el`.
     * @returns {LinkContextMenu|null}
     */
    #buildLinkMenuForEl(el, x, y, segIdx) {
        const linkSpan = el.closest('.t-seg-link');
        if (!linkSpan) return null;
        const linkId = linkSpan.dataset.linkId;
        const linkData = this.activeProject?.annotations?.hyperlinks?.[linkId];
        if (!linkData) return null;
        return new LinkContextMenu(x, y, {
            onEdit:    () => this.#editHyperlink(linkId, linkData.url, linkData.name, linkData.description, linkData.editorNotes),
            onCopy:    () => navigator.clipboard.writeText(linkData.url),
            onRemove:  () => this.#removeHyperlink(linkId, segIdx),
            onDismiss: () => this.workspace.closeCtxMenu(),
        });
    }

}