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