import { ConfirmDialog } from "../components/confirm_dialog.js";
import { TranscribeDialog } from "../components/transcribe_dialog.js";
import { ExportPanel } from "../components/export_panel.js";
import { exportFile } from "../utilities/export.js";
import { HyperlinkDialog } from "../components/hyperlink_dialog.js";
import { SelectionContextMenu } from "../components/selection_context_menu.js";
/**
* Panel that renders the active transcript as speaker blocks, paragraphs, and
* clickable word-level segments. Handles segment selection, hover, inline text
* editing, CSV loading, and CSV export.
*/
export class TranscriptPanel {
/**
* @param {object} workspace - the Workspace controller instance
* @param {object} callbacks - callback functions for transcript interactions
* @param {function} callbacks.onSegmentHover - called with segment index when a segment is hovered
* @param {function} callbacks.onSegmentSelect - called with segment index when a segment is selected
* @param {function} callbacks.onSegmentZoom - called with segment index when a segment is double-clicked
* @param {function} callbacks.onSearchChanged - called with a Set of matching segment indices (or null to clear)
* @param {function} callbacks.onParagraphHover - called with paragraph index when a paragraph handle is hovered
* @param {function} callbacks.onParagraphZoom - called with paragraph index when a paragraph handle is double-clicked
*/
constructor(workspace, { onSegmentHover, onSegmentSelect, onSegmentZoom, onSearchChanged, onParagraphHover, onParagraphZoom }) {
this.workspace = workspace;
this.onSegmentHover = onSegmentHover ?? (() => {})
this.onSegmentSelect = onSegmentSelect ?? (() => {})
this.onSegmentZoom = onSegmentZoom ?? (() => {})
this.onSearchChanged = onSearchChanged ?? (() => {})
this.onParagraphHover = onParagraphHover ?? (() => {})
this.onParagraphZoom = onParagraphZoom ?? (() => {})
this.previouslyHoveredSegment = null;
this.previouslySelectedSegment = null;
this.previouslyActiveSegment = null;
// Search state
this.searchQuery = '';
this.searchSpeaker = '';
this.searchMatchIndices = [];
this.searchFocusedIdx = -1;
this.#getElements();
this.#setupListeners();
}
/** Sets up the CSV file input, save button, transcript-body background click handler, and search bar. */
#setupListeners() {
new ResizeObserver(([entry]) => {
this.root.classList.toggle('header-compact', entry.contentRect.width < 420);
}).observe(this.root);
// Deselect on clicking transcript background (one-time, not per-render)
this.transcriptBody.addEventListener('click', (e) => {
if (!e.target.closest('.t-seg[data-idx]')) {
this.#selectSegment(-1);
this.workspace.closeCtxMenu();
}
});
// Shift key tracking (used to suppress segment click while shift-selecting text).
window.addEventListener('keydown', (e) => {
if (e.key !== 'Shift') return;
this.shiftHeld = true;
}, { capture: true });
window.addEventListener('keyup', (e) => {
if (e.key !== 'Shift') return;
this.shiftHeld = false;
}, { capture: true });
// Ctrl key tracking — drives link hover styles via a body class and
// shows a URL tooltip when hovering a link with Ctrl held.
window.addEventListener('keydown', (e) => {
if (e.key !== 'Control' && e.key !== 'Meta') return;
document.body.classList.add('ctrl-held');
if (this._hoveredLinkUrl) {
clearTimeout(this._linkTooltipTimer);
this.#showLinkTooltip(this._hoveredLinkUrl, this._hoveredLinkName, this._hoveredLinkDesc, this._hoveredLinkNotes);
}
if (this._mouseSelectingActive) this.#updateSelectionOverlay();
}, { capture: true });
window.addEventListener('keyup', (e) => {
if (e.key !== 'Control' && e.key !== 'Meta') return;
document.body.classList.remove('ctrl-held');
this.#hideLinkTooltip();
if (this._mouseSelectingActive) this.#updateSelectionOverlay();
}, { capture: true });
window.addEventListener('blur', () => {
document.body.classList.remove('ctrl-held');
this.#hideLinkTooltip();
if (this._mouseSelectingActive) this.#updateSelectionOverlay();
});
// Track cursor position for link tooltip placement.
document.addEventListener('mousemove', (e) => { this._mouseX = e.clientX; this._mouseY = e.clientY; });
// Track whether the user is actively dragging a selection.
this.transcriptBody.addEventListener('mousedown', () => {
this._mouseSelectingActive = true;
});
document.addEventListener('mouseup', () => {
if (this._mouseSelectingActive && document.body.classList.contains('ctrl-held')) {
this.#snapSelectionToWords();
}
this._mouseSelectingActive = false;
});
// Drive the custom selection overlay whenever the native selection changes.
document.addEventListener('selectionchange', () => this.#updateSelectionOverlay());
// Track whether the cursor is actually over a highlight rect (not just over
// a selected segment's non-selected text) for the hover brightening effect.
this.transcriptBody.addEventListener('mousemove', (e) => {
const rects = this._selOverlay.querySelectorAll('.transcript-sel-rect');
if (!rects.length) return;
const over = Array.from(rects).some(el => {
const r = el.getBoundingClientRect();
return e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom;
});
this._selOverlay.classList.toggle('hovered', over);
});
this.transcriptBody.addEventListener('mouseleave', () => {
this._selOverlay.classList.remove('hovered');
});
// Shift+K — add hyperlink to native text selection or selected segment
// Shift+F — populate search bar from native text selection
window.addEventListener('keydown', (e) => {
if (!e.shiftKey) return;
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === 'K') { e.preventDefault(); this.#handleHyperlinkShortcut(); }
if (e.key === 'F') { e.preventDefault(); this.#searchWordSelection(); }
if (e.key === 'Q' && !this.workspace.isLocalMode() && !this.workspace.isReadOnly()) {
const selTarget = this.#getNativeSelection();
if (selTarget) { e.preventDefault(); this.workspace.openEmbedDialog(selTarget); }
}
}, { capture: true });
// Search bar listeners
let searchDebounce = null;
this.searchInput.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => this.#runSearch(), 150);
});
this.searchClearBtn.addEventListener('click', () => this.clearSearch());
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); this.#stepMatch(1); }
if (e.key === 'Escape') { e.preventDefault(); this.clearSearch(); }
e.stopPropagation();
});
this.searchSpeakerFilter.addEventListener('change', () => this.#runSearch());
this.searchPrev.addEventListener('click', () => this.#stepMatch(-1));
this.searchNext.addEventListener('click', () => this.#stepMatch(1));
// Replace bar toggle
this.replaceToggle.addEventListener('click', () => {
const isOpen = this.replaceRow.style.display !== 'none';
this.replaceRow.style.display = isOpen ? 'none' : 'flex';
this.replaceToggle.classList.toggle('active', !isOpen);
if (!isOpen) this.replaceInput.focus();
});
// Replace input keyboard
this.replaceInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); this.#replaceOne(); }
if (e.key === 'Escape') {
e.preventDefault();
this.replaceRow.style.display = 'none';
this.replaceToggle.classList.remove('active');
}
e.stopPropagation();
});
this.replaceOneBtn.addEventListener('click', () => this.#replaceOne());
this.replaceAllBtn.addEventListener('click', () => this.#replaceAll());
this.csvInput.addEventListener('change', (e) => {
const doLoad = () => {
// get the file from the input dialog
const file = e.target.files[0];
if (!file) {
return;
}
// create the reader
const reader = new FileReader();
// once file is read, load it into the project
reader.onload = (ev) => {
if (file.name.endsWith('.json')) {
this.activeProject.loadTranscriptJSON(JSON.parse(ev.target.result));
} else {
this.activeProject.loadTranscriptCSV(ev.target.result);
}
};
// trigger the file read
reader.readAsText(file);
};
if (this.activeProject?.hasTranscript) {
new ConfirmDialog('Overwrite transcript?', {
onConfirm: doLoad,
onDismiss: () => { e.target.value = ''; }
}, 'Loading a new file will replace the current transcript.');
} else {
doLoad();
}
});
this.exportBtn.addEventListener('click', (e) => {
this.#openExportPanel();
});
this.deleteTranscriptBtn.addEventListener('click', () => {
new ConfirmDialog('Delete transcript?', {
onConfirm: async () => {
const server = this.activeProject?.activeServer;
if (server?.isConnected && this.activeProject?.projectId) {
await server.deleteTranscript(this.activeProject.projectId);
}
this.activeProject.hasTranscript = false;
this.activeProject.local.transcript = null;
this.activeProject.server.transcript = null;
this.activeProject.transcriptDirty = false;
this.clearTranscriptPanel();
},
}, 'This will permanently delete the transcript from the server.');
});
this.transcribeBtn.addEventListener('click', () => this.#openTranscribeDialog());
this.retranscribeStalBtn.addEventListener('click', () => {
const segs = this.activeProject?.transcript()?.segments ?? [];
const staleIndices = segs.map((s, i) => s.wordsStale ? i : null).filter(i => i !== null);
if (staleIndices.length) this.retranscribeSegments(staleIndices);
});
this.transcribeBtnWrap.addEventListener('mouseenter', () => this.#showLimitTooltip());
this.transcribeBtnWrap.addEventListener('mouseleave', () => this.#hideLimitTooltip());
window.addEventListener('account-drawer-closed', () => {
if (this._limitTip === null) return;
const server = this.activeProject?.activeServer;
if (!server) return;
server.refreshUser().catch(() => {}).finally(() => this.#pollAudioReady());
});
}
/** Binds panel DOM elements to instance properties. */
#getElements() {
this.root = document.getElementById('transcriptPanel');
this.transcriptBody = this.root.querySelector('#transcriptBody');
this.transcriptEmpty = this.root.querySelector('#transcriptEmpty');
this.csvInput = this.root.querySelector('#csvInput')
this.csvLabel = this.root.querySelector('.transcript-drop');
this.exportBtn = this.root.querySelector('#exportBtn');
this.deleteTranscriptBtn = this.root.querySelector('#deleteTranscriptBtn');
this.retranscribeStalBtn = this.root.querySelector('#retranscribeStalBtn');
this.staleSentenceCount = this.root.querySelector('#staleSentenceCount');
this.transcribeBtn = this.root.querySelector('#transcribeBtn');
this.transcribeBtnWrap = this.root.querySelector('#transcribeBtnWrap');
this.transcribeProgress = this.root.querySelector('#transcribeProgress');
this.transcribeProgressBar = this.root.querySelector('#transcribeProgressBar');
this.transcribeStatus = this.root.querySelector('#transcribeStatus');
this.transcribeElapsed = this.root.querySelector('#transcribeElapsed');
// Search bar elements
this.transcriptSearch = this.root.querySelector('#transcriptSearch');
this.searchInputWrap = this.root.querySelector('.search-input-wrap');
this.searchInput = this.root.querySelector('#searchInput');
this.searchClearBtn = this.root.querySelector('#searchClearBtn');
this.searchSpeakerFilter = this.root.querySelector('#searchSpeakerFilter');
this.searchCount = this.root.querySelector('#searchCount');
this.searchPrev = this.root.querySelector('#searchPrev');
this.searchNext = this.root.querySelector('#searchNext');
// Replace bar elements
this.replaceToggle = this.root.querySelector('#replaceToggle');
this.replaceRow = this.root.querySelector('#replaceRow');
this.replaceInput = this.root.querySelector('#replaceInput');
this.replaceOneBtn = this.root.querySelector('#replaceOneBtn');
this.replaceAllBtn = this.root.querySelector('#replaceAllBtn');
// Overlay layer for custom-styled native-selection highlight.
// Sits inside transcriptBody so z-index: -1 places it behind text but
// above the panel background (transcriptBody is an isolation context).
this._selOverlay = document.createElement('div');
this._selOverlay.className = 'transcript-sel-overlay';
this.transcriptBody.appendChild(this._selOverlay);
}
/**
* Loads transcript data from the given project and renders it.
* Also begins polling for audio readiness to enable the Transcribe button.
* @param {Project} project - the project to load transcript data from
*/
loadFromProject(project) {
this.activeProject = project;
this.transcribeBtn.disabled = true;
this.#applyAccessMode();
if (!this.activeProject.localOnly && !this.workspace.isReadOnly()) {
this.#pollAudioReady();
}
if (this.activeProject.hasTranscript) {
try {
if (this.activeProject.transcript().segments.length) {
this.renderTranscript();
}
} catch(e) {
console.warn('Could not load transcript:', e);
}
}
}
/** Applies or removes read-only mode on the panel's edit controls. */
#applyAccessMode() {
const readOnly = this.workspace.isReadOnly();
if (this.transcribeBtn) this.transcribeBtn.style.display = readOnly ? 'none' : '';
if (this.csvLabel) this.csvLabel.style.display = readOnly ? 'none' : '';
if (this.replaceToggle) this.replaceToggle.style.display = readOnly ? 'none' : '';
if (readOnly && this.replaceRow) {
this.replaceRow.style.display = 'none';
this.replaceToggle?.classList.remove('active');
}
}
/** Public entry point called once the waveform has fully loaded for a server project. */
startPollingAudioReady() {
if (!this.workspace.isReadOnly()) this.#pollAudioReady();
}
/**
* Polls the server until audio.mp3 is ready, then enables the Transcribe button.
* Stops polling after 20 attempts (~60 s) or when audio is confirmed ready.
*/
async #pollAudioReady() {
this.transcribeBtn.disabled = true;
const server = this.activeProject?.activeServer;
if (!server?.isConnected || !this.activeProject?.projectId) return;
const projectId = this.activeProject.projectId;
const maxAttempts = 20;
for (let i = 0; i < maxAttempts; i++) {
try {
const ready = await server.checkAudioReady(projectId);
if (ready) {
// Check per-file duration limit before enabling the button
const features = server.backendUser?.subscription_tier?.features;
const maxMins = features?.max_audio_mins ?? null;
const duration = this.activeProject?.waveform?.()?.duration
?? this.activeProject?.waveform?.(false)?.duration
?? -1;
if (maxMins !== null && duration > 0 && duration / 60 > maxMins) {
this._limitTip = `Audio is ${(duration / 60).toFixed(1)} min — exceeds your plan's ${maxMins}-minute per-file limit. Upgrade your plan to transcribe longer files.`;
// button stays disabled
return;
}
// Clear any limit tooltip and enable the button
this._limitTip = null;
this.transcribeBtn.disabled = false;
return;
}
} catch { /* server unreachable — stop polling */ return; }
await new Promise(r => setTimeout(r, 3000));
// Stop if the project changed while we were waiting
if (this.activeProject?.projectId !== projectId) return;
}
}
/** Shows a custom tooltip on the transcribe button wrapper. */
#showLimitTooltip() {
if (this._limitTooltipEl) return;
const msg = this._limitTip
?? (this.transcribeBtn.disabled ? 'Waiting for audio to be ready…' : 'Transcribe audio');
const tip = document.createElement('div');
tip.className = 'info-widget-tooltip';
tip.textContent = msg;
document.body.appendChild(tip);
this._limitTooltipEl = tip;
const rect = this.transcribeBtnWrap.getBoundingClientRect();
const tipW = tip.offsetWidth;
const tipH = tip.offsetHeight;
const gap = 6;
const top = (rect.bottom + tipH + gap <= window.innerHeight)
? rect.bottom + gap
: rect.top - tipH - gap;
const left = Math.min(rect.left, window.innerWidth - tipW - 8);
tip.style.top = `${top}px`;
tip.style.left = `${left}px`;
}
/** Removes the limit tooltip from the DOM. */
#hideLimitTooltip() {
this._limitTooltipEl?.remove();
this._limitTooltipEl = null;
}
/** Opens the transcription options dialog before starting a job. */
#openTranscribeDialog() {
const waveform = this.activeProject?.waveform?.();
const audioDuration = waveform?.duration ?? -1;
const speakers = this.activeProject?.speakers?.() ?? {};
const hasVoiceSamples = Object.values(speakers).some(s => s.sample);
const server = this.activeProject?.activeServer;
const allowedModels = server?.backendUser?.subscription_tier?.features?.whisper_models ?? null;
new TranscribeDialog({
audioDuration,
hasVoiceSamples,
allowedModels,
onConfirm: (opts) => this.#startTranscription(opts),
});
}
/**
* Initiates a Modal transcription job for the active project, streaming
* progress updates into the progress bar. On completion, loads the
* resulting transcript and re-renders.
* @param {object} opts - options from TranscribeDialog
*/
async #startTranscription(opts = {}) {
const server = this.activeProject?.activeServer;
if (!server?.isConnected || !this.activeProject?.projectId) return;
this.transcribeBtn.disabled = true;
this.transcribeProgress.style.display = 'flex';
this.transcribeProgressBar.style.width = '0%';
this.transcribeStatus.textContent = 'Starting transcription…';
this.transcribeElapsed.textContent = '0:00';
const startTime = Date.now();
const elapsedTimer = setInterval(() => {
const s = Math.floor((Date.now() - startTime) / 1000);
const mm = Math.floor(s / 60);
const ss = String(s % 60).padStart(2, '0');
this.transcribeElapsed.textContent = `${mm}:${ss}`;
}, 1000);
const projectId = this.activeProject.projectId;
let succeeded = false;
try {
await server.transcribeProject(projectId, opts, (event) => {
if (event.type === 'status') {
this.transcribeStatus.textContent = event.message;
} else if (event.type === 'progress') {
this.transcribeProgressBar.style.width = `${Math.round(event.fraction * 100)}%`;
} else if (event.type === 'result') {
succeeded = true;
this.transcribeProgressBar.style.width = '100%';
this.transcribeStatus.textContent = 'Done!';
} else if (event.type === 'error') {
console.error('Transcription error event:', event.message);
this.transcribeStatus.textContent = `Error: ${event.message}`;
}
});
} catch(e) {
console.error('Transcription request failed:', e);
this.transcribeStatus.textContent = `Error: ${e.message}`;
} finally {
clearInterval(elapsedTimer);
this.transcribeBtn.disabled = false;
setTimeout(() => {
this.transcribeProgress.style.display = 'none';
}, 2000);
}
// Only reload if transcription actually produced a result
if (succeeded && this.activeProject?.projectId === projectId) {
try {
const transcriptResult = await server.getTranscript(projectId);
if (transcriptResult.contentType?.includes('application/json')) {
this.activeProject.loadTranscriptJSON(JSON.parse(transcriptResult.text));
} else {
this.activeProject.loadTranscriptCSV(transcriptResult.text);
}
this.renderTranscript();
} catch(e) {
console.error('Failed to reload transcript after transcription:', e);
}
}
}
/**
* Applies the hover CSS class to the segment at segmentIdx and removes it from the previous one.
* @param {number} segmentIdx - segment index, or -1 to clear
*/
setHoveredSegment(segmentIdx) {
// Clear old hover from transcript
if (this.previouslyHoveredSegment >= 0) {
const old = document.querySelector(`.t-seg[data-idx="${this.previouslyHoveredSegment}"]`);
if (old) {
old.classList.remove('hovered');
}
}
// Apply new hover to transcript
if (segmentIdx >= 0) {
const el = document.querySelector(`.t-seg[data-idx="${segmentIdx}"]`);
if (el) {
el.classList.add('hovered');
}
}
this.previouslyHoveredSegment = segmentIdx;
}
/**
* Applies the selected CSS class to segmentIdx and removes it from the previous selection.
* @param {number} segmentIdx - segment index, or -1 to clear
*/
setSelectedSegment(segmentIdx) {
// remove the selected class from the previously selected segment
if (this.previouslySelectedSegment >= 0) {
const old = document.querySelector(`.t-seg[data-idx="${this.previouslySelectedSegment}"]`);
if (old) {
old.classList.remove('selected');
}
}
// add the selected class to the newly selected segment
if (segmentIdx >= 0) {
const el = document.querySelector(`.t-seg[data-idx="${segmentIdx}"]`);
if (el) el.classList.add('selected');
}
this.previouslySelectedSegment = segmentIdx;
}
/**
* Marks the segment at segmentIdx as active (scrolls it into view) and removes
* the active class from the previous one. Called as the playhead moves through audio.
* @param {number} segmentIdx - segment index, or -1 to clear
*/
setActiveSegment(segmentIdx) {
if (this.previouslyActiveSegment != -1) {
const old = document.querySelector(`.t-seg[data-idx="${this.previouslyActiveSegment}"]`);
if (old) {
old.classList.remove('active');
}
}
if (segmentIdx != -1) {
const el = document.querySelector(`.t-seg[data-idx="${segmentIdx}"]`);
if (el) {
el.classList.add('active');
this.#smoothScrollTo(el, 160);
}
}
this.previouslyActiveSegment = segmentIdx;
}
/**
* Highlights the word span (if any) that contains the given playback time within
* the currently active segment. Clears any previously highlighted word.
* @param {number} time - current playback time in seconds
*/
setActiveWord(time) {
// Clear previous active word
const prev = this.transcriptBody.querySelector('.t-word.active-word');
if (prev) prev.classList.remove('active-word');
if (this.previouslyActiveSegment < 0) return;
const segEl = this.transcriptBody.querySelector(`.t-seg[data-idx="${this.previouslyActiveSegment}"]`);
if (!segEl) return;
const wordSpans = segEl.querySelectorAll('.t-word');
for (const span of wordSpans) {
const wstart = parseFloat(span.dataset.wstart);
const wend = parseFloat(span.dataset.wend);
if (time >= wstart && time < wend) {
span.classList.add('active-word');
break;
}
}
}
/**
* Updates hover state and fires the onSegmentHover callback.
* @param {number} segmentIdx - segment index to hover, or -1 to clear
*/
#hoverSegment(segmentIdx) {
this.setHoveredSegment(segmentIdx);
this.onSegmentHover(segmentIdx);
}
/**
* Updates selection state and fires the onSegmentSelect callback.
* @param {number} segmentIdx - segment index to select, or -1 to clear
*/
#selectSegment(segmentIdx) {
this.setSelectedSegment(segmentIdx);
this.onSegmentSelect(segmentIdx);
}
/** Clears the transcript body and resets the save button and empty state. */
clearTranscriptPanel() {
// Clear the transcript body
if (this.transcriptBody) {
this.transcriptBody.innerHTML = '';
this.transcriptBody.appendChild(this._selOverlay);
}
// Show the transcriptEmpty element
if (this.transcriptEmpty) {
this.transcriptEmpty.style.display = 'flex';
}
if (this.exportBtn) this.exportBtn.style.display = 'none';
if (this.deleteTranscriptBtn) this.deleteTranscriptBtn.style.display = 'none';
if (this.retranscribeStalBtn) this.retranscribeStalBtn.style.display = 'none';
// Hide search bar (and replace row) and clear search state
if (this.transcriptSearch) {
this.transcriptSearch.style.display = 'none';
}
if (this.replaceRow) {
this.replaceRow.style.display = 'none';
this.replaceToggle?.classList.remove('active');
}
this.clearSearch();
}
/** Fully re-renders the transcript from the active transcript's speaker blocks. */
renderTranscript() {
// build the transcript base
this.#closeParaSpeakerDialog();
if (this.transcriptEmpty) this.transcriptEmpty.style.display = 'none';
if (this.transcriptSearch) this.transcriptSearch.style.display = 'flex';
if (this.exportBtn) this.exportBtn.style.display = '';
if (this.deleteTranscriptBtn) this.deleteTranscriptBtn.style.display = this.workspace.isReadOnly() ? 'none' : '';
if (this.retranscribeStalBtn && !this.workspace.isReadOnly()) {
const staleCount = this.activeProject.transcript().segments.filter(s => s.wordsStale).length;
this.retranscribeStalBtn.style.display = staleCount > 0 ? '' : 'none';
if (this.staleSentenceCount) this.staleSentenceCount.textContent = staleCount;
}
this.transcriptBody.innerHTML = '';
this._selOverlay.innerHTML = '';
this.transcriptBody.appendChild(this._selOverlay);
this.activeProject.transcript().speakerBlocks.forEach(block => {
const blockElement = this.#renderSpeakerBlock(block);
this.transcriptBody.appendChild(blockElement);
});
this.populateSpeakerFilter();
// Re-apply search highlights if a search is active
if (this.searchQuery || this.searchSpeaker) {
this.#runSearch();
}
}
/**
* Renders a speaker block (speaker label + paragraphs) as a DOM element.
* @param {object} block - speaker block with speaker id and paragraphs array
* @returns {HTMLElement}
*/
#renderSpeakerBlock(block) {
const blockEl = document.createElement('div');
blockEl.className = 'speaker-block';
// Centered editable speaker label
const labelEl = document.createElement('div');
labelEl.className = 'speaker-label';
let nameSpan = this.workspace.speakersPanel.makeSpeakerNameSpan(block.speaker);
labelEl.appendChild(nameSpan);
blockEl.appendChild(labelEl);
// render each paragraph in the block
const speaker = this.activeProject.speakers()[block.speaker];
block.paragraphs.forEach(paragraph => {
const paraRow = document.createElement('div');
paraRow.className = 'para-row';
// Colored handle bar
const handle = document.createElement('div');
handle.className = 'para-handle';
handle.style.background = speaker?.hue ?? 'var(--muted)';
handle.title = this.workspace.isReadOnly() ? 'Click to jump' : 'Click to jump · Right-click to change speaker';
handle.addEventListener('click', (e) => {
e.stopPropagation();
const firstIdx = this.activeProject.transcript().segments.indexOf(paragraph.segments[0]);
this.#selectSegment(firstIdx);
this.onSegmentSelect(firstIdx);
});
handle.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.#openParaSpeakerDialog(paragraph, e.clientX, e.clientY);
});
handle.addEventListener('mouseenter', () => this.onParagraphHover(paragraph));
handle.addEventListener('mouseleave', () => this.onParagraphHover(null));
handle.addEventListener('dblclick', (e) => { e.stopPropagation(); this.onParagraphZoom(paragraph); });
const paraEl = document.createElement('p');
paraEl.className = 'transcript-para';
// render each segment in the paragraph
paragraph.segments.forEach((segment) => {
const renderedSegment = this.#renderSegment(segment);
paraEl.appendChild(renderedSegment);
});
paraRow.appendChild(handle);
paraRow.appendChild(paraEl);
blockEl.appendChild(paraRow);
});
return blockEl;
}
/**
* Renders a single segment and returns the rendered element
* @param {object} segment - A segment object to be rendered
* @returns {HTMLElement} the rendered segment span element
*/
#renderSegment(segment) {
const idx = this.activeProject.transcript().segments.indexOf(segment);
const span = document.createElement('span');
span.className = 't-seg';
span.dataset.idx = idx;
const hl = document.createElement('span');
hl.className = 't-seg-hl';
this.#renderHlContent(hl, idx, segment.text, segment.words);
span.appendChild(hl);
if (segment.wordsStale) {
span.classList.add('t-seg--words-stale');
const badge = document.createElement('span');
badge.className = 't-seg-stale-badge';
badge.title = 'Word timestamps are outdated \u2014 retranscription needed for word highlighting';
badge.textContent = '\u26a0';
span.appendChild(badge);
}
span.appendChild(document.createTextNode(' '));
let clickTimer = null;
span.addEventListener('click', (e) => {
e.stopPropagation();
if (this.shiftHeld) return;
if (!window.getSelection().isCollapsed) return;
clearTimeout(clickTimer);
this.onSegmentSelect(idx);
clickTimer = setTimeout(() => { this.#selectSegment(idx); }, 220);
});
span.addEventListener('dblclick', (e) => {
e.stopPropagation();
clearTimeout(clickTimer);
this.#selectSegment(idx);
this.onSegmentZoom(idx);
if (!this.workspace.isReadOnly()) {
this.makeSegmentEditable(idx);
}
});
span.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Capture native selection now — clicking the menu will clear it.
const selTarget = this.#getNativeSelection();
if (selTarget) {
this.workspace.closeCtxMenu();
const menu = new SelectionContextMenu(e.clientX, e.clientY, {
onAddLink: () => { menu.close(); this.#openHyperlinkDialogForTarget(selTarget); },
onSearchText: () => { menu.close(); this.#searchWithTarget(selTarget); },
// null in LOCAL_MODE — SelectionContextMenu hides items with null callbacks
onGenerateLiveQuote: this.workspace.isLocalMode() ? null
: () => { menu.close(); this.workspace.openEmbedDialog(selTarget); },
onDismiss: () => menu.close(),
});
return;
}
this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, idx);
});
span.addEventListener('mouseenter', () => {
this.#hoverSegment(idx);
if (segment.wordsStale) {
this._staleTooltipTimer = setTimeout(() => this.#showStaleTooltip(), 600);
}
});
span.addEventListener('mouseleave', () => {
this.#hoverSegment(-1);
clearTimeout(this._staleTooltipTimer);
this.#hideStaleTooltip();
});
return span;
}
/**
* Opens the hyperlink dialog for the current native text selection if one
* exists, otherwise adds a link covering the entire segment text.
* @param {number} segIdx - index of the segment that was right-clicked
*/
openAddLinkDialog(segIdx) {
if (this.workspace.isReadOnly()) return;
if (!this.activeProject?.hasTranscript) return;
const target = this.#getNativeSelection() ?? (() => {
const seg = this.activeProject.transcript().segments[segIdx];
return seg ? { segIdx, charStart: 0, charEnd: seg.text.length } : null;
})();
if (!target) return;
this.#openHyperlinkDialogForTarget(target);
}
/**
* Turns a transcript segment span into a contentEditable field.
* Commits the edit on Enter or blur; cancels (restores original text) on Escape.
* The span text is normalised (trailing space stripped) while editing, then
* restored with a trailing space on commit.
* Keyboard events are stopped from propagating so playback shortcuts don't fire.
* @param {number} segIdx - index into loadedSegments
*/
makeSegmentEditable(segIdx) {
if (this.workspace.isReadOnly()) return;
const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
if (!el || el.isContentEditable) return;
const seg = this.activeProject.transcript().segments[segIdx];
el.contentEditable = 'true';
el.classList.add('editing');
el.classList.remove('selected');
// Keep the existing .t-seg-hl structure intact so link highlights remain
// visible during editing. textContent is read on commit.
el.focus();
// Place cursor at end of the hl span (before the trailing space text node)
const editHl = el.querySelector('.t-seg-hl') ?? el;
const range = document.createRange();
range.selectNodeContents(editHl);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
const restoreSpan = (text) => {
el.textContent = '';
const hl = document.createElement('span');
hl.className = 't-seg-hl';
el.appendChild(hl);
el.appendChild(document.createTextNode(' '));
this.#renderHlContent(hl, segIdx, text);
};
let committed = false;
const commit = () => {
if (committed) return;
committed = true;
const newText = (el.querySelector('.t-seg-hl')?.textContent ?? el.textContent).trim();
if (newText && newText !== seg.text) {
const oldText = seg.text;
const oldWords = seg.words ? seg.words.map(w => ({ ...w })) : null;
const oldWordsStale = seg.wordsStale;
const newTokens = newText.trim().split(/\s+/);
if (seg.words?.length && seg.words.length === newTokens.length) {
// Same word count — update word text in-place, keep timestamps.
// Preserve leading whitespace that Whisper stores on each word (e.g. " Hello").
newTokens.forEach((token, i) => {
const orig = seg.words[i].word;
const leading = orig.slice(0, orig.length - orig.trimStart().length);
seg.words[i].word = leading + token;
});
seg.wordsStale = undefined;
} else if (seg.words?.length) {
// Word count changed — clear words so the edited text renders correctly,
// and set wordsStale so the badge and retranscription option appear.
seg.words = null;
seg.wordsStale = true;
}
// else: seg.words is null/empty — no change needed
this.#updateAnnotationsAfterEdit(segIdx, seg.text, newText);
seg.text = newText;
this.activeProject.markTranscriptDirty();
this.workspace.history.push({
label: 'Edit segment text', dirtyFlags: ['transcript'],
undo: () => {
this.#updateAnnotationsAfterEdit(segIdx, newText, oldText);
seg.text = oldText;
seg.words = oldWords;
seg.wordsStale = oldWordsStale;
},
redo: () => {
const redoTokens = newText.trim().split(/\s+/);
this.#updateAnnotationsAfterEdit(segIdx, oldText, newText);
seg.text = newText;
if (oldWords?.length && oldWords.length === redoTokens.length) {
seg.words = redoTokens.map((token, i) => {
const orig = oldWords[i].word;
const leading = orig.slice(0, orig.length - orig.trimStart().length);
return { ...oldWords[i], word: leading + token };
});
seg.wordsStale = undefined;
} else if (oldWords?.length) {
seg.words = null;
seg.wordsStale = true;
}
},
});
this.workspace._updateUndoRedoButtons();
}
el.contentEditable = 'false';
el.classList.remove('editing');
restoreSpan(seg.text);
this.#selectSegment(-1);
}
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') {
committed = true; // prevent blur from re-committing
el.contentEditable = 'false';
el.classList.remove('editing');
restoreSpan(seg.text);
this.#selectSegment(-1);
}
if (e.key === 'Tab') {
e.preventDefault();
const segs = this.activeProject.transcript().segments;
const nextIdx = e.shiftKey ? segIdx - 1 : segIdx + 1;
commit();
if (nextIdx >= 0 && nextIdx < segs.length) {
this.#selectSegment(nextIdx);
this.makeSegmentEditable(nextIdx);
}
}
e.stopPropagation(); // don't trigger play/skip shortcuts
});
el.addEventListener('blur', commit, { once: true });
}
// ── Search ────────────────────────────────────────────────────────────────
/**
* Populates the speaker filter dropdown from the active project's speakers.
* Preserves the current selection when possible.
*/
populateSpeakerFilter() {
if (!this.activeProject?.hasSpeakers) return;
const currentValue = this.searchSpeakerFilter.value;
this.searchSpeakerFilter.innerHTML = '<option value="">All speakers</option>';
Object.values(this.activeProject.speakers()).forEach(speaker => {
const opt = document.createElement('option');
opt.value = speaker.id;
opt.textContent = speaker.name;
this.searchSpeakerFilter.appendChild(opt);
});
if (currentValue) this.searchSpeakerFilter.value = currentValue;
}
/**
* Clears the search bar, removes all highlights, and notifies the workspace.
*/
clearSearch() {
this.searchQuery = '';
this.searchSpeaker = '';
this.searchMatchIndices = [];
this.searchFocusedIdx = -1;
if (this.searchInput) this.searchInput.value = '';
if (this.searchInputWrap) this.searchInputWrap.classList.remove('has-value');
if (this.searchSpeakerFilter) this.searchSpeakerFilter.value = '';
if (this.searchCount) this.searchCount.textContent = '';
if (this.searchPrev) this.searchPrev.disabled = true;
if (this.searchNext) this.searchNext.disabled = true;
this.#clearHighlights();
this.onSearchChanged(null);
}
/** Computes matches, updates highlights and counter, scrolls to the focused match. */
#runSearch() {
const query = this.searchInput.value.trim();
const speaker = this.searchSpeakerFilter.value;
if (!query && !speaker) {
this.clearSearch();
return;
}
this.searchQuery = query;
this.searchSpeaker = speaker;
const segments = this.activeProject?.transcript()?.segments ?? [];
this.searchMatchIndices = [];
segments.forEach((seg, idx) => {
const textMatch = !query || seg.text.toLowerCase().includes(query.toLowerCase());
const speakerMatch = !speaker || seg.speaker === speaker;
if (textMatch && speakerMatch) this.searchMatchIndices.push(idx);
});
if (this.searchMatchIndices.length === 0) {
this.searchFocusedIdx = -1;
} else if (this.searchFocusedIdx < 0 || this.searchFocusedIdx >= this.searchMatchIndices.length) {
this.searchFocusedIdx = 0;
}
this.searchInputWrap?.classList.toggle('has-value', this.searchInput.value.length > 0);
this.#updateCounter();
this.#applyHighlights();
if (this.searchFocusedIdx >= 0) this.#scrollToFocused();
this.onSearchChanged(new Set(this.searchMatchIndices));
}
/**
* Steps to the next (+1) or previous (-1) match.
* @param {number} dir - direction to step: +1 for next, -1 for previous
*/
#stepMatch(dir) {
if (this.searchMatchIndices.length === 0) return;
this.searchFocusedIdx = (this.searchFocusedIdx + dir + this.searchMatchIndices.length) % this.searchMatchIndices.length;
this.#updateFocusedClass();
this.#updateCounter();
this.#scrollToFocused();
}
/** Swaps the search-focused class to the current focused match without rebuilding all highlights. */
#updateFocusedClass() {
document.querySelectorAll('.t-seg.search-focused').forEach(el => el.classList.remove('search-focused'));
if (this.searchFocusedIdx >= 0 && this.searchMatchIndices.length > 0) {
const focusedSegIdx = this.searchMatchIndices[this.searchFocusedIdx];
const el = document.querySelector(`.t-seg[data-idx="${focusedSegIdx}"]`);
if (el) el.classList.add('search-focused');
}
}
/** Applies search-match / search-focused classes and keyword <mark> highlighting to the DOM. */
#applyHighlights() {
this.#clearHighlights();
const matchSet = new Set(this.searchMatchIndices);
const focusedSegIdx = this.searchFocusedIdx >= 0 ? this.searchMatchIndices[this.searchFocusedIdx] : -1;
document.querySelectorAll('.t-seg').forEach(el => {
const idx = parseInt(el.dataset.idx);
if (!matchSet.has(idx)) return;
el.classList.add('search-match');
if (idx === focusedSegIdx) el.classList.add('search-focused');
if (this.searchQuery) {
const hl = el.querySelector('.t-seg-hl');
if (hl) {
const seg = this.activeProject.transcript().segments[idx];
this.#highlightText(hl, seg.text, this.searchQuery);
}
}
});
}
/** Removes all search highlight classes and restores hyperlink-aware content in .t-seg-hl spans. */
#clearHighlights() {
document.querySelectorAll('.t-seg.search-match, .t-seg.search-focused').forEach(el => {
el.classList.remove('search-match', 'search-focused');
const hl = el.querySelector('.t-seg-hl');
if (hl) {
const idx = parseInt(el.dataset.idx);
const seg = this.activeProject?.transcript()?.segments[idx];
if (seg) this.#renderHlContent(hl, idx, seg.text, seg.words);
}
});
}
/**
* Wraps matched substrings in <mark class="search-hl"> elements inside a .t-seg-hl span.
* @param {HTMLElement} hl - the .t-seg-hl span element to populate
* @param {string} text - the full segment text
* @param {string} query - the search query to highlight
*/
#highlightText(hl, text, query) {
hl.textContent = '';
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let lastIdx = 0;
let idx;
while ((idx = lowerText.indexOf(lowerQuery, lastIdx)) !== -1) {
if (idx > lastIdx) hl.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark');
mark.className = 'search-hl';
mark.textContent = text.slice(idx, idx + query.length);
hl.appendChild(mark);
lastIdx = idx + query.length;
}
if (lastIdx < text.length) hl.appendChild(document.createTextNode(text.slice(lastIdx)));
}
/** Updates the match counter display and prev/next button disabled state. */
#updateCounter() {
const total = this.searchMatchIndices.length;
const current = total > 0 ? this.searchFocusedIdx + 1 : 0;
this.searchCount.textContent = total > 0 ? `${current} / ${total}` : 'no matches';
this.searchPrev.disabled = total === 0;
this.searchNext.disabled = total === 0;
this.replaceOneBtn.disabled = total === 0;
this.replaceAllBtn.disabled = total === 0;
}
// ── Replace ───────────────────────────────────────────────────────────────
/**
* Replaces all occurrences of the search query within the currently focused
* match segment, then steps to the next match.
*/
#replaceOne() {
if (this.workspace.isReadOnly()) return;
if (!this.searchQuery || this.searchMatchIndices.length === 0 || this.searchFocusedIdx < 0) return;
const replaceWith = this.replaceInput.value;
const segIdx = this.searchMatchIndices[this.searchFocusedIdx];
const seg = this.activeProject.transcript().segments[segIdx];
const regex = new RegExp(this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const newText = seg.text.replace(regex, replaceWith);
if (newText !== seg.text) {
const oldText = seg.text;
seg.text = newText;
this.activeProject.markTranscriptDirty();
const hl = document.querySelector(`.t-seg[data-idx="${segIdx}"] .t-seg-hl`);
if (hl) hl.textContent = newText;
this.workspace.history.push({
label: 'Replace text', dirtyFlags: ['transcript'],
undo: () => { seg.text = oldText; },
redo: () => { seg.text = newText; },
});
this.workspace._updateUndoRedoButtons();
}
this.#runSearch();
}
/**
* Replaces all occurrences of the search query across every matching segment.
*/
#replaceAll() {
if (this.workspace.isReadOnly()) return;
if (!this.searchQuery || this.searchMatchIndices.length === 0) return;
const replaceWith = this.replaceInput.value;
const regex = new RegExp(this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const segs = this.activeProject.transcript().segments;
// Pre-compute all replacements before mutating
const changes = this.searchMatchIndices
.map(segIdx => {
const seg = segs[segIdx];
const oldText = seg.text;
const newText = seg.text.replace(regex, replaceWith);
return newText !== oldText ? { segIdx, seg, oldText, newText } : null;
})
.filter(Boolean);
if (!changes.length) { this.#runSearch(); return; }
changes.forEach(({ seg, newText, segIdx }) => {
seg.text = newText;
const hl = document.querySelector(`.t-seg[data-idx="${segIdx}"] .t-seg-hl`);
if (hl) hl.textContent = newText;
});
this.activeProject.markTranscriptDirty();
this.workspace.history.push({
label: 'Replace all', dirtyFlags: ['transcript'],
undo: () => { changes.forEach(({ seg, oldText }) => { seg.text = oldText; }); },
redo: () => { changes.forEach(({ seg, newText }) => { seg.text = newText; }); },
});
this.workspace._updateUndoRedoButtons();
this.#runSearch();
}
/** Scrolls the transcript to the focused match and selects it (moving the playhead). */
#scrollToFocused() {
const segIdx = this.searchMatchIndices[this.searchFocusedIdx];
if (segIdx == null) return;
const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
if (el) this.#smoothScrollTo(el, 160);
this.#selectSegment(segIdx);
}
/**
* Opens a floating speaker-picker popup to reassign all segments in a paragraph.
* @param {object} paragraph - the paragraph data object whose speaker to reassign
* @param {number} x - horizontal position (px) for the popup
* @param {number} y - vertical position (px) for the popup
*/
#openParaSpeakerDialog(paragraph, x, y) {
if (this.workspace.isReadOnly()) return;
this.#closeParaSpeakerDialog();
const popup = document.createElement('div');
popup.className = 'para-speaker-popup';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
const header = document.createElement('div');
header.className = 'para-speaker-popup-header';
header.textContent = 'Change paragraph speaker';
popup.appendChild(header);
Object.values(this.activeProject.speakers()).forEach(speaker => {
const item = document.createElement('div');
item.className = 'ctx-speaker-item';
const swatch = document.createElement('span');
swatch.className = 'ctx-speaker-swatch';
swatch.style.background = speaker.hue;
const label = document.createElement('span');
label.style.flex = '1';
label.textContent = speaker.name;
label.style.color = speaker.hue;
item.appendChild(swatch);
item.appendChild(label);
if (speaker.id === paragraph.speaker) {
item.style.opacity = '0.4';
item.style.cursor = 'default';
} else {
item.addEventListener('click', (e) => {
e.stopPropagation();
const oldSpeakerId = paragraph.speaker;
const newSpeakerId = speaker.id;
const transcript = this.activeProject.transcript();
const affectedIndices = paragraph.segments.map(s => transcript.segments.indexOf(s));
this.activeProject.changeParagraphSpeaker(paragraph, newSpeakerId);
this.workspace.history.push({
label: 'Reassign paragraph speaker', dirtyFlags: ['transcript'],
undo: () => {
affectedIndices.forEach(i => { transcript.segments[i].speaker = oldSpeakerId; });
transcript.buildTranscript();
},
redo: () => {
affectedIndices.forEach(i => { transcript.segments[i].speaker = newSpeakerId; });
transcript.buildTranscript();
},
});
this.workspace._updateUndoRedoButtons();
this.#closeParaSpeakerDialog();
});
}
popup.appendChild(item);
});
document.body.appendChild(popup);
this._paraDialog = popup;
// Nudge into viewport if needed
requestAnimationFrame(() => {
const rect = popup.getBoundingClientRect();
if (rect.right > window.innerWidth) popup.style.left = (window.innerWidth - rect.width - 8) + 'px';
if (rect.bottom > window.innerHeight) popup.style.top = (window.innerHeight - rect.height - 8) + 'px';
});
// Close on outside click
setTimeout(() => {
this._paraDialogOutsideHandler = (e) => {
if (!popup.contains(e.target)) this.#closeParaSpeakerDialog();
};
document.addEventListener('click', this._paraDialogOutsideHandler);
}, 0);
}
/** Closes the paragraph speaker popup if open. */
#closeParaSpeakerDialog() {
if (this._paraDialog) {
this._paraDialog.remove();
this._paraDialog = null;
}
if (this._paraDialogOutsideHandler) {
document.removeEventListener('click', this._paraDialogOutsideHandler);
this._paraDialogOutsideHandler = null;
}
}
/**
* Smoothly scrolls the transcript body to center the given element, completing in `duration` ms.
* @param {HTMLElement} el - the element to scroll into view
* @param {number} duration - scroll animation duration in milliseconds
*/
#smoothScrollTo(el, duration) {
const container = this.transcriptBody;
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const offset = elRect.top - containerRect.top - containerRect.height / 2 + elRect.height / 2;
const start = container.scrollTop;
const startTime = performance.now();
const step = (now) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
container.scrollTop = start + offset * ease;
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
/** Opens the ExportPanel dialog for the active project. */
#openExportPanel() {
new ExportPanel(this.activeProject.transcript(), this.activeProject.speakers(), {
defaultTitle: this.activeProject.projectName,
onExport: ({ fileType, style, filename, text }) => {
exportFile(fileType, filename, text, style, this.activeProject.speakers());
},
onDismiss: () => {},
});
}
// ── Hyperlinks ────────────────────────────────────────────────────────────
/**
* Reads the browser's native text selection and maps it onto transcript
* segment indices and character offsets.
*
* Returns `{ segIdx, charStart, charEnd }` for a single-segment selection,
* `{ segIdxStart, charStart, segIdxEnd, charEnd }` for a cross-segment one,
* or `null` if there is no non-collapsed selection within the transcript.
* @returns {{ segIdx: number, charStart: number, charEnd: number }|{ segIdxStart: number, charStart: number, segIdxEnd: number, charEnd: number }|null}
*/
#getNativeSelection() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
const startSeg = range.startContainer.parentElement?.closest('.t-seg[data-idx]');
const endSeg = range.endContainer.parentElement?.closest('.t-seg[data-idx]');
if (!startSeg || !endSeg) return null;
const startSegIdx = parseInt(startSeg.dataset.idx);
const endSegIdx = parseInt(endSeg.dataset.idx);
const startHl = startSeg.querySelector('.t-seg-hl');
const endHl = endSeg.querySelector('.t-seg-hl');
if (!startHl || !endHl) return null;
const charStart = this.#getCharOffsetInHl(startHl, range.startContainer, range.startOffset);
const charEnd = this.#getCharOffsetInHl(endHl, range.endContainer, range.endOffset);
if (startSegIdx === endSegIdx) {
if (charStart === charEnd) return null;
return { segIdx: startSegIdx, charStart: Math.min(charStart, charEnd), charEnd: Math.max(charStart, charEnd) };
}
const realStart = Math.min(startSegIdx, endSegIdx);
const realEnd = Math.max(startSegIdx, endSegIdx);
const forward = startSegIdx <= endSegIdx;
return {
segIdxStart: realStart,
charStart: forward ? charStart : charEnd,
segIdxEnd: realEnd,
charEnd: forward ? charEnd : charStart,
};
}
/**
* Redraws the custom selection overlay to match the current native text
* selection, giving the highlight rounded corners. Clears the overlay when
* no transcript text is selected.
*/
#updateSelectionOverlay() {
const overlay = this._selOverlay;
overlay.innerHTML = '';
this.transcriptBody.querySelectorAll('.t-seg--in-selection')
.forEach(el => el.classList.remove('t-seg--in-selection'));
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
const rawRange = sel.getRangeAt(0);
// Only paint if the selection is within the transcript body.
if (!this.transcriptBody?.contains(rawRange.commonAncestorContainer)) return;
// While the user is actively dragging with Ctrl held, snap to word boundaries
// for the visual display. On mouseup the actual selection is also snapped.
const range = (this._mouseSelectingActive && document.body.classList.contains('ctrl-held'))
? this.#wordSnapRange(rawRange)
: rawRange;
// Walk text nodes only — avoids double-counting rects from nested inline
// spans (e.g. .t-seg-hl + its .t-seg-word children both appearing in
// range.getClientRects() when an entire segment is selected).
const root = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentElement
: range.commonAncestorContainer;
// Convert a viewport DOMRect to transcriptBody-local coords (accounts for scroll).
const bodyRect = this.transcriptBody.getBoundingClientRect();
const scrollLeft = this.transcriptBody.scrollLeft;
const scrollTop = this.transcriptBody.scrollTop;
const toLocal = (r) => ({
left: r.left - bodyRect.left + scrollLeft,
top: r.top - bodyRect.top + scrollTop,
right: r.right - bodyRect.left + scrollLeft,
bottom: r.bottom - bodyRect.top + scrollTop,
height: r.height,
});
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const contentRects = [];
const connectorRects = [];
const selectedSegEls = new Set();
let node;
while ((node = walker.nextNode())) {
if (!range.intersectsNode(node)) continue;
const inHl = node.parentElement?.closest('.t-seg-hl');
const inSeg = !inHl && node.parentElement?.closest('.t-seg[data-idx]');
if (!inHl && !inSeg) continue;
if (inHl) selectedSegEls.add(inHl.closest('.t-seg[data-idx]'));
const sub = document.createRange();
sub.setStart(node, node === range.startContainer ? range.startOffset : 0);
sub.setEnd(node, node === range.endContainer ? range.endOffset : node.length);
for (const r of sub.getClientRects()) {
if (r.width > 0) (inHl ? contentRects : connectorRects).push(toLocal(r));
}
}
selectedSegEls.forEach(el => el?.classList.add('t-seg--in-selection'));
// Merge content rects that sit on the same line to eliminate gaps between word spans.
const merged = [];
for (const r of contentRects.sort((a, b) => a.top - b.top || a.left - b.left)) {
const prev = merged.at(-1);
if (prev && Math.abs(r.top - prev.top) < 2 && r.left <= prev.right + 1) {
prev.right = Math.max(prev.right, r.right);
prev.bottom = Math.max(prev.bottom, r.bottom);
} else {
merged.push({ left: r.left, top: r.top, right: r.right, bottom: r.bottom });
}
}
for (const r of merged) {
const div = document.createElement('div');
div.className = 'transcript-sel-rect';
div.style.left = r.left + 'px';
div.style.top = r.top + 'px';
div.style.width = (r.right - r.left) + 'px';
div.style.height = (r.bottom - r.top) + 'px';
overlay.appendChild(div);
}
// Render inter-segment space connectors at quarter height, vertically centred,
// clipped against adjacent content rects so they don't overlap.
for (const c of connectorRects) {
let left = c.left;
let right = c.right;
for (const m of merged) {
if (Math.abs(m.top - c.top) > 4) continue;
if (m.right > left && m.right <= right) left = m.right;
if (m.left < right && m.left >= left) right = m.left;
}
if (right <= left) continue;
const h = c.height * 0.5;
const div = document.createElement('div');
div.className = 'transcript-sel-rect transcript-sel-connector';
div.style.left = (left + 1) + 'px';
div.style.top = (c.top + c.height * 0.25) + 'px';
div.style.width = (right - left - 2) + 'px';
div.style.height = h + 'px';
overlay.appendChild(div);
}
}
/**
* Returns a clone of `range` with start snapped to the beginning of its
* word and end snapped to the end of its word.
* @param {Range} range - the DOM range to snap
* @returns {Range}
*/
#wordSnapRange(range) {
const r = range.cloneRange();
if (r.startContainer.nodeType === Node.TEXT_NODE) {
const text = r.startContainer.textContent;
let i = r.startOffset;
while (i > 0 && !/\s/.test(text[i - 1])) i--;
r.setStart(r.startContainer, i);
}
if (r.endContainer.nodeType === Node.TEXT_NODE) {
const text = r.endContainer.textContent;
let i = r.endOffset;
while (i < text.length && !/\s/.test(text[i])) i++;
r.setEnd(r.endContainer, i);
}
return r;
}
/** Commits a word-snapped version of the current selection to the browser. */
#snapSelectionToWords() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
const snapped = this.#wordSnapRange(sel.getRangeAt(0));
sel.removeAllRanges();
sel.addRange(snapped);
}
/**
* Handles Shift+K: opens the hyperlink dialog for the current native text
* selection, falling back to the selected segment when nothing is selected.
* Cross-segment selections prompt the user to merge the segments first.
*/
#handleHyperlinkShortcut() {
if (this.workspace.isReadOnly()) return;
if (!this.activeProject?.hasTranscript) return;
// 1. Native text selection
let target = this.#getNativeSelection();
// 2. Whole selected segment
if (!target && this.previouslySelectedSegment >= 0) {
const seg = this.activeProject.transcript().segments[this.previouslySelectedSegment];
target = { segIdx: this.previouslySelectedSegment, charStart: 0, charEnd: seg.text.length };
}
if (!target) return;
this.#openHyperlinkDialogForTarget(target);
}
/**
* Fetches the page title for a URL via the server proxy, using the current auth token.
* @param {string} url - the URL to fetch the title for
* @returns {Promise<{accessible: boolean, title: string|null}>}
*/
async #fetchTitle(url) {
const token = await this.workspace.getToken();
const headers = token ? { 'X-Auth-Token': token } : {};
const res = await fetch(`/api/fetch-page-title?url=${encodeURIComponent(url)}`, { headers });
if (!res.ok) return { accessible: false, title: null };
return await res.json();
}
/**
* Opens the hyperlink dialog (with cross-segment merge confirmation if needed)
* for a pre-resolved target `{ segIdx, charStart, charEnd }` or
* `{ segIdxStart, charStart, segIdxEnd, charEnd }`.
* @param {object} target - resolved selection target from #getNativeSelection or a whole-segment fallback
*/
#openHyperlinkDialogForTarget(target) {
// Cross-segment: confirm merge before proceeding
if (target.segIdxStart != null) {
const segs = this.activeProject.transcript().segments;
const speaker = segs[target.segIdxStart]?.speaker;
const sameSpeaker = Array.from(
{ length: target.segIdxEnd - target.segIdxStart },
(_, i) => segs[target.segIdxStart + 1 + i]
).every(s => s?.speaker === speaker);
if (!sameSpeaker) {
new ConfirmDialog(
'Cannot add link across segments',
{ onConfirm: () => {} },
'The selected segments belong to different speakers and cannot be merged.',
'OK', ''
);
return;
}
new ConfirmDialog(
'Merge segments to add link?',
{
onConfirm: () => {
new HyperlinkDialog({
fetchTitle: (url) => this.#fetchTitle(url),
onSave: ({ url, name, description, editorNotes }) => this.#addHyperlinkCrossSegment(target, url, name, description, editorNotes),
});
},
onDismiss: () => {},
},
'This selection spans multiple segments. They will be automatically merged before the link is added.'
);
return;
}
new HyperlinkDialog({
fetchTitle: (url) => this.#fetchTitle(url),
onSave: ({ url, name, description, editorNotes }) => {
this.#addHyperlink(target.segIdx, target.charStart, target.charEnd, url, name, description, editorNotes);
},
});
}
/** Variant that accepts a pre-captured target (used when selection was read before a menu click cleared it).
* @param {object} target - resolved selection target from #getNativeSelection
*/
#searchWithTarget(target) {
if (!this.activeProject?.hasTranscript) return;
this.#doSearch(target);
}
/** Handles Shift+F: populates the search bar with the current native text selection. Cross-segment selections are joined with a space. */
#searchWordSelection() {
if (!this.activeProject?.hasTranscript) return;
const target = this.#getNativeSelection();
if (!target) return;
this.#doSearch(target);
}
/**
* Populates the search bar with the text from the given selection target and runs the search.
* @param {object} target - resolved selection target from #getNativeSelection
*/
#doSearch(target) {
const segs = this.activeProject.transcript().segments;
let selectedText;
if (target.segIdx != null) {
selectedText = segs[target.segIdx]?.text.slice(target.charStart, target.charEnd) ?? '';
} else {
const parts = [];
for (let i = target.segIdxStart; i <= target.segIdxEnd; i++) {
const seg = segs[i];
if (!seg) return;
if (i === target.segIdxStart) parts.push(seg.text.slice(target.charStart));
else if (i === target.segIdxEnd) parts.push(seg.text.slice(0, target.charEnd));
else parts.push(seg.text);
}
selectedText = parts.join(' ');
}
if (!selectedText) return;
this.searchInput.value = selectedText;
this.searchSpeakerFilter.value = '';
this.#runSearch();
this.searchInput.focus();
}
/**
* Returns the character offset of a DOM position within a .t-seg-hl element.
* @param {HTMLElement} hlEl - the .t-seg-hl span
* @param {Node} container - the DOM node of the cursor position
* @param {number} offset - the offset within that node
* @returns {number} character offset within the element's text content
*/
#getCharOffsetInHl(hlEl, container, offset) {
const r = document.createRange();
r.setStart(hlEl, 0);
r.setEnd(container, offset);
return r.toString().length;
}
/**
* Updates hyperlink charStart/charEnd offsets for a segment after its text
* is edited. Uses a common-prefix/suffix diff to determine where the edit
* occurred and shifts offsets accordingly.
*
* Rules:
* - Whole-segment link (0..oldLen): expands to cover the new full length.
* - Edit entirely before link: shift both offsets by the length delta.
* - Edit entirely after link: no change.
* - Edit overlaps link: link is dropped (offsets are no longer meaningful).
*
* Saves annotations to the server if anything changed.
* @param {number} segIdx - index of the edited segment
* @param {string} oldText - the segment text before the edit
* @param {string} newText - the segment text after the edit
*/
#updateAnnotationsAfterEdit(segIdx, oldText, newText) {
const hyperlinks = this.activeProject?.annotations?.hyperlinks;
if (!hyperlinks) return;
// Compute the boundaries of the changed region in the old text.
let prefixLen = 0;
while (prefixLen < oldText.length && prefixLen < newText.length &&
oldText[prefixLen] === newText[prefixLen]) prefixLen++;
let oldEditEnd = oldText.length;
let newEditEnd = newText.length;
while (oldEditEnd > prefixLen && newEditEnd > prefixLen &&
oldText[oldEditEnd - 1] === newText[newEditEnd - 1]) {
oldEditEnd--;
newEditEnd--;
}
// Edited region in old text: [prefixLen, oldEditEnd)
const delta = newText.length - oldText.length;
let changed = false;
for (const [id, link] of Object.entries(hyperlinks)) {
if (link.segmentIdx !== segIdx) continue;
const cs = link.charStart ?? 0;
const ce = link.charEnd ?? oldText.length;
// Whole-segment link — keep it covering the full new text.
if (cs === 0 && ce === oldText.length) {
link.charEnd = newText.length;
changed = true;
continue;
}
// Edit is entirely after the link — no change needed.
if (prefixLen >= ce) continue;
// Edit is entirely before the link — shift both offsets.
if (oldEditEnd <= cs) {
link.charStart = cs + delta;
link.charEnd = ce + delta;
changed = true;
continue;
}
// Edit overlaps the link — offsets are no longer valid, drop it.
delete hyperlinks[id];
changed = true;
}
if (changed) {
const server = this.activeProject?.activeServer;
if (server?.isConnected && this.activeProject?.projectId) {
server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations)
.catch(e => console.error('Failed to save annotations:', e));
}
}
}
/**
* Adds a hyperlink annotation to the project and re-renders the affected segment.
* @param {number} segIdx - segment index
* @param {number} charStart - start character offset
* @param {number} charEnd - end character offset
* @param {string} url - hyperlink URL
* @param {string|null} name - optional display name
* @param {string|null} description - optional public-facing description shown in tooltips
* @param {string|null} editorNotes - optional private notes visible only in edit mode
*/
async #addHyperlink(segIdx, charStart, charEnd, url, name, description = null, editorNotes = null) {
if (!this.activeProject.annotations) this.activeProject.annotations = { hyperlinks: {} };
// Trim leading/trailing whitespace from the selection.
const segText = this.activeProject.transcript().segments[segIdx]?.text ?? '';
while (charStart < charEnd && /\s/.test(segText[charStart])) charStart++;
while (charEnd > charStart && /\s/.test(segText[charEnd - 1])) charEnd--;
if (charStart >= charEnd) return;
this.#resolveHyperlinkOverlaps(segIdx, charStart, charEnd);
const id = 'hl_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
this.activeProject.annotations.hyperlinks[id] = { url, name: name || null, description: description || null, editorNotes: editorNotes || null, segmentIdx: segIdx, charStart, charEnd };
if (!this._suppressHistory) {
const savedLink = { ...this.activeProject.annotations.hyperlinks[id] };
this.workspace.history.push({
label: 'Add hyperlink', dirtyFlags: ['annotations'],
undo: () => { delete this.activeProject.annotations.hyperlinks[id]; },
redo: () => { this.activeProject.annotations.hyperlinks[id] = { ...savedLink }; },
});
this.workspace._updateUndoRedoButtons();
}
// Re-render the segment
const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
const seg = this.activeProject.transcript().segments[segIdx];
if (el && seg) {
const hl = el.querySelector('.t-seg-hl');
if (hl) { delete hl.dataset.wordWrapped; this.#renderHlContent(hl, segIdx, seg.text); }
}
// Persist to server
const server = this.activeProject?.activeServer;
if (server?.isConnected && this.activeProject?.projectId) {
try {
await server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations);
} catch(e) {
console.error('Failed to save annotations:', e);
}
}
}
/**
* Merges all segments from target.segIdxStart to target.segIdxEnd, then adds a
* hyperlink spanning the selected text in the resulting merged segment.
* @param {{ segIdxStart: number, charStart: number, segIdxEnd: number, charEnd: number }} target - cross-segment selection range
* @param {string} url - hyperlink URL
* @param {string|null} name - optional display name
* @param {string|null} description - optional public-facing description shown in tooltips
* @param {string|null} editorNotes - optional private notes visible only in edit mode
*/
async #addHyperlinkCrossSegment({ segIdxStart, charStart, segIdxEnd, charEnd }, url, name, description = null, editorNotes = null) {
const transcript = this.activeProject.transcript();
const segs = transcript.segments;
// Snapshot for undo — save segments and annotations before any mutation
const savedSegs = segs.slice(segIdxStart, segIdxEnd + 1).map(s => ({ ...s }));
const savedAnnotations = JSON.parse(JSON.stringify(this.activeProject.annotations ?? {}));
// Calculate the offset of the end segment's text within the merged result.
// mergeSegments joins with a space: (a.text + ' ' + b.text).trim()
let offset = 0;
for (let i = segIdxStart; i < segIdxEnd; i++) {
offset += segs[i].text.length + 1; // +1 for the joining space
}
const mergedCharEnd = offset + charEnd;
// Merge all segments into segIdxStart (bypasses _mergeWithHistory intentionally)
const mergeCount = segIdxEnd - segIdxStart;
for (let i = 0; i < mergeCount; i++) {
this.activeProject.mergeSegments(segIdxStart, segIdxStart + 1);
}
// Suppress per-link history — we'll push one compound command below
this._suppressHistory = true;
await this.#addHyperlink(segIdxStart, charStart, mergedCharEnd, url, name, description, editorNotes);
this._suppressHistory = false;
// Snapshot annotations after (includes the new link and any resolved overlaps)
const snapshotAfterAnnotations = JSON.parse(JSON.stringify(this.activeProject.annotations ?? {}));
this.workspace.history.push({
label: 'Add hyperlink', dirtyFlags: ['transcript'],
undo: () => {
transcript.segments.splice(segIdxStart, 1, ...savedSegs);
transcript.buildTranscript();
this.activeProject.annotations = savedAnnotations;
},
redo: async () => {
for (let i = 0; i < mergeCount; i++) {
this.activeProject.mergeSegments(segIdxStart, segIdxStart + 1);
}
this.activeProject.annotations = JSON.parse(JSON.stringify(snapshotAfterAnnotations));
},
});
this.workspace._updateUndoRedoButtons();
}
/**
* Resolves conflicts between a new link range [newStart, newEnd) and any
* existing links on the same segment, mutating annotations in place:
*
* - Existing link fully consumed by new range → deleted.
* - Existing link partially overlaps left edge → charEnd trimmed to newStart.
* - Existing link partially overlaps right edge → charStart trimmed to newEnd.
* - Existing link fully contains new range → split into two links
* (left portion and right portion keep the old URL/name).
* @param {number} segIdx - index of the segment whose links are being resolved
* @param {number} newStart - start character offset of the new link range
* @param {number} newEnd - end character offset of the new link range
*/
#resolveHyperlinkOverlaps(segIdx, newStart, newEnd) {
const hyperlinks = this.activeProject.annotations.hyperlinks;
const segText = this.activeProject.transcript().segments[segIdx]?.text ?? '';
const toAdd = [];
for (const [id, link] of Object.entries(hyperlinks)) {
if (link.segmentIdx !== segIdx) continue;
// Normalise legacy null values before any numeric comparison.
if (link.charStart === null) link.charStart = 0;
if (link.charEnd === null) link.charEnd = segText.length;
const cs = link.charStart;
const ce = link.charEnd;
// No overlap
if (ce <= newStart || cs >= newEnd) continue;
// Existing entirely consumed by new range → remove
if (cs >= newStart && ce <= newEnd) {
delete hyperlinks[id];
continue;
}
// Existing fully contains new range → split into left + right portions
if (cs < newStart && ce > newEnd) {
// Left portion: [cs, newStart)
// Right portion: [newEnd, ce)
link.charEnd = newStart;
if (newEnd < ce) {
toAdd.push({ url: link.url, name: link.name, segmentIdx: segIdx, charStart: newEnd, charEnd: ce });
}
continue;
}
// Partial overlap on the left side of the new range → trim charEnd
if (cs < newStart) {
link.charEnd = newStart;
continue;
}
// Partial overlap on the right side of the new range → trim charStart
if (ce > newEnd) {
link.charStart = newEnd;
continue;
}
}
// Add split right-hand portions
for (const link of toAdd) {
const id = 'hl_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
hyperlinks[id] = link;
}
}
/**
* Opens the hyperlink dialog pre-filled with the existing values for editing.
* @param {string} linkId - the annotation key of the hyperlink to edit
* @param {string} url - current URL value to pre-fill
* @param {string|null} name - current display name to pre-fill
* @param {string|null} description - current description to pre-fill
* @param {string|null} editorNotes - current editor notes to pre-fill
*/
#editHyperlink(linkId, url, name, description, editorNotes) {
this.workspace.closeCtxMenu();
new HyperlinkDialog({
fetchTitle: (url) => this.#fetchTitle(url),
initialUrl: url,
initialName: name ?? '',
initialDescription: description ?? '',
initialEditorNotes: editorNotes ?? '',
onSave: ({ url: newUrl, name: newName, description: newDesc, editorNotes: newNotes }) => {
const link = this.activeProject.annotations?.hyperlinks?.[linkId];
if (!link) return;
const before = { ...link };
link.url = newUrl;
link.name = newName || null;
link.description = newDesc || null;
link.editorNotes = newNotes || null;
const after = { ...link };
const segIdx = link.segmentIdx;
const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
const seg = this.activeProject.transcript().segments[segIdx];
if (el && seg) {
const hl = el.querySelector('.t-seg-hl');
if (hl) { delete hl.dataset.wordWrapped; this.#renderHlContent(hl, segIdx, seg.text); }
}
const server = this.activeProject?.activeServer;
if (server?.isConnected && this.activeProject?.projectId) {
server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations)
.catch(e => console.error('Failed to save annotations:', e));
}
const hyperlinks = this.activeProject.annotations?.hyperlinks;
this.workspace.history.push({
label: 'Edit hyperlink', dirtyFlags: ['annotations'],
undo: () => { if (hyperlinks?.[linkId]) hyperlinks[linkId] = { ...before }; },
redo: () => { if (hyperlinks?.[linkId]) hyperlinks[linkId] = { ...after }; },
});
this.workspace._updateUndoRedoButtons();
},
});
}
/**
* Removes a hyperlink annotation and re-renders the affected segment.
* @param {string} linkId - the annotation key of the hyperlink to remove
* @param {number} segIdx - index of the segment to re-render after removal
*/
#removeHyperlink(linkId, segIdx) {
this.workspace.closeCtxMenu();
const hyperlinks = this.activeProject?.annotations?.hyperlinks;
if (!hyperlinks?.[linkId]) return;
const saved = { ...hyperlinks[linkId] };
delete hyperlinks[linkId];
const el = document.querySelector(`.t-seg[data-idx="${segIdx}"]`);
const seg = this.activeProject.transcript().segments[segIdx];
if (el && seg) {
const hl = el.querySelector('.t-seg-hl');
if (hl) { delete hl.dataset.wordWrapped; this.#renderHlContent(hl, segIdx, seg.text); }
}
const server = this.activeProject?.activeServer;
if (server?.isConnected && this.activeProject?.projectId) {
server.saveAnnotations(this.activeProject.projectId, this.activeProject.annotations)
.catch(e => console.error('Failed to save annotations:', e));
}
this.workspace.history.push({
label: 'Remove hyperlink', dirtyFlags: ['annotations'],
undo: () => { if (this.activeProject.annotations?.hyperlinks) this.activeProject.annotations.hyperlinks[linkId] = saved; },
redo: () => { delete this.activeProject.annotations?.hyperlinks?.[linkId]; },
});
this.workspace._updateUndoRedoButtons();
}
/**
* Populates a .t-seg-hl element with text, wrapping any hyperlinked ranges in
* .t-seg-link spans. If no hyperlinks exist and the segment has word data,
* renders each word as a .t-word span for playback highlighting.
* @param {HTMLElement} hl - the .t-seg-hl span to populate
* @param {number} segIdx - segment index (used to look up hyperlinks)
* @param {string} text - the segment's text content
* @param {object[]|null} [words] - optional word-level data for word spans
*/
#renderHlContent(hl, segIdx, text, words = null) {
const links = this.#getHyperlinksForSegment(segIdx, text);
if (!links.length && words?.length) {
hl.textContent = '';
for (let wi = 0; wi < words.length; wi++) {
const w = words[wi];
const trimmed = w.word.trimStart();
const leading = w.word.slice(0, w.word.length - trimmed.length);
// Skip the leading space of the first word if seg.text has no leading space —
// Whisper sometimes adds one as a tokenisation artefact, and rendering it as a
// text node shifts every character offset by 1, causing spurious trim detection.
if (leading && (wi > 0 || text[0] === leading[0])) {
hl.appendChild(document.createTextNode(leading));
}
const wordSpan = document.createElement('span');
wordSpan.className = 't-word';
wordSpan.dataset.wstart = w.start;
wordSpan.dataset.wend = w.end;
wordSpan.textContent = trimmed;
hl.appendChild(wordSpan);
}
hl.dataset.wordWrapped = '1';
return;
}
if (!links.length) {
hl.textContent = text;
return;
}
hl.textContent = '';
let pos = 0;
for (const link of links) {
if (link.charStart > pos) {
hl.appendChild(document.createTextNode(text.slice(pos, link.charStart)));
}
const span = document.createElement('span');
span.className = 't-seg-link';
span.dataset.linkId = link.id;
span.textContent = text.slice(link.charStart, link.charEnd);
span.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, segIdx, null, {
onEdit: () => this.#editHyperlink(link.id, link.url, link.name, link.description, link.editorNotes),
onRemove: () => this.#removeHyperlink(link.id, segIdx),
onCopy: () => navigator.clipboard.writeText(link.url),
});
});
span.addEventListener('mouseenter', () => {
this._hoveredLinkUrl = link.url;
this._hoveredLinkName = link.name;
this._hoveredLinkDesc = link.description;
this._hoveredLinkNotes = link.editorNotes;
if (document.body.classList.contains('ctrl-held')) {
this.#showLinkTooltip(link.url, link.name, link.description, link.editorNotes);
} else {
this._linkTooltipTimer = setTimeout(() => this.#showLinkTooltip(link.url, link.name, link.description, link.editorNotes), 800);
}
});
span.addEventListener('mouseleave', () => {
this._hoveredLinkUrl = null;
this._hoveredLinkName = null;
this._hoveredLinkDesc = null;
this._hoveredLinkNotes = null;
clearTimeout(this._linkTooltipTimer);
this.#hideLinkTooltip();
});
span.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey) {
e.stopPropagation();
const href = /^https?:\/\//i.test(link.url) ? link.url : 'https://' + link.url;
window.open(href, '_blank', 'noopener,noreferrer');
}
});
hl.appendChild(span);
pos = link.charEnd;
}
if (pos < text.length) {
hl.appendChild(document.createTextNode(text.slice(pos)));
}
}
/**
* Returns hyperlinks for a segment, sorted by start position, with null
* charStart/charEnd normalised to cover the full segment text.
* @param {number} segIdx - segment index
* @param {string} text - segment text (used as fallback for legacy null extents)
* @returns {object[]}
*/
#getHyperlinksForSegment(segIdx, text) {
const hyperlinks = this.activeProject?.annotations?.hyperlinks;
if (!hyperlinks) return [];
return Object.entries(hyperlinks)
.filter(([, h]) => h.segmentIdx === segIdx)
.map(([id, h]) => {
let charStart = h.charStart ?? 0;
let charEnd = h.charEnd ?? text.length;
// Trim leading/trailing whitespace at display time so the link
// span never begins or ends on a space character.
while (charStart < charEnd && /\s/.test(text[charStart])) charStart++;
while (charEnd > charStart && /\s/.test(text[charEnd - 1])) charEnd--;
return { id, url: h.url, name: h.name, description: h.description ?? null, editorNotes: h.editorNotes ?? null, charStart, charEnd };
})
.filter(h => h.charStart < h.charEnd) // drop links that are all whitespace
.sort((a, b) => a.charStart - b.charStart);
}
/**
* Shows a tooltip near the cursor with link metadata and editor notes.
* @param {string} url - the hyperlink URL
* @param {string|null} name - optional display name for the link
* @param {string|null} description - optional description text
* @param {string|null} editorNotes - optional editor notes (only shown when not in read-only mode)
*/
#showLinkTooltip(url, name, description, editorNotes) {
this.#hideLinkTooltip();
const el = document.createElement('div');
el.className = 'info-widget-tooltip link-tooltip';
if (name) {
const nameEl = document.createElement('div');
nameEl.className = 'link-tooltip-name';
nameEl.textContent = name;
el.appendChild(nameEl);
}
const urlEl = document.createElement('div');
urlEl.className = 'link-tooltip-url';
urlEl.textContent = url;
el.appendChild(urlEl);
if (description) {
const descEl = document.createElement('div');
descEl.className = 'link-tooltip-desc';
descEl.textContent = description;
el.appendChild(descEl);
}
if (editorNotes && !this.workspace.isReadOnly()) {
const sep = document.createElement('div');
sep.className = 'link-tooltip-sep';
el.appendChild(sep);
const notesHeader = document.createElement('div');
notesHeader.className = 'link-tooltip-notes-header';
notesHeader.textContent = "Editor's Notes";
el.appendChild(notesHeader);
const notesEl = document.createElement('div');
notesEl.className = 'link-tooltip-notes';
notesEl.textContent = editorNotes;
el.appendChild(notesEl);
}
const footer = document.createElement('div');
footer.className = 'link-tooltip-footer';
footer.textContent = 'Ctrl+Click to navigate ↗';
el.appendChild(footer);
document.body.appendChild(el);
this._linkTooltipEl = el;
// Position below and to the right of the cursor, clamped to viewport.
const x = this._mouseX + 12;
const y = this._mouseY + 16;
el.style.left = x + 'px';
el.style.top = y + 'px';
requestAnimationFrame(() => {
const r = el.getBoundingClientRect();
if (r.right > window.innerWidth) el.style.left = (window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight) el.style.top = (this._mouseY - r.height - 4) + 'px';
});
}
/**
* Retranscribes the given segment indices one at a time, showing progress in
* the transcription status bar. Preserves the user's edited text while
* updating word timestamps. Pushes one undo/redo entry per segment.
* @param {number[]} segIndices - indices into the active project's transcript segments
*/
async retranscribeSegments(segIndices) {
const server = this.activeProject?.activeServer;
if (!server?.isConnected || !this.activeProject?.projectId) return;
const segments = this.activeProject.transcript().segments;
const total = segIndices.length;
const isBatch = total > 1;
// Show status bar and disable buttons
this.transcribeBtn.disabled = true;
if (this.retranscribeStalBtn) this.retranscribeStalBtn.disabled = true;
this.transcribeProgress.style.display = 'flex';
this.transcribeProgressBar.style.width = '0%';
this.transcribeElapsed.textContent = '0:00';
const startTime = Date.now();
const elapsedTimer = setInterval(() => {
const s = Math.floor((Date.now() - startTime) / 1000);
const mm = Math.floor(s / 60);
const ss = String(s % 60).padStart(2, '0');
this.transcribeElapsed.textContent = `${mm}:${ss}`;
}, 1000);
const setStatus = (msg) => { this.transcribeStatus.textContent = msg; };
const setProgress = (fraction) => {
this.transcribeProgressBar.style.width = `${Math.round(fraction * 100)}%`;
};
// Spinner badges
segIndices.forEach(i => {
const badge = document.querySelector(`.t-seg[data-idx="${i}"] .t-seg-stale-badge`);
if (badge) { badge.textContent = ''; badge.classList.add('stale-badge--loading'); }
});
let succeeded = 0;
try {
for (let n = 0; n < total; n++) {
const segIdx = segIndices[n];
const seg = segments[segIdx];
const label = isBatch ? ` (${n + 1}/${total})` : '';
setStatus(`Retranscribing sentence${label}…`);
setProgress(n / total);
// Animate progress bar while waiting for the server response
const segFloor = n / total;
const segCeil = (n + 1) / total;
let segFrac = segFloor;
const segTicker = setInterval(() => {
segFrac += (segCeil - segFrac) * 0.12;
setProgress(segFrac);
}, 250);
let results;
try {
({ results } = await server.retranscribeSegments(
this.activeProject.projectId,
[{ start: seg.start, end: seg.end, idx: segIdx, text: seg.text }],
'medium',
));
} finally {
clearInterval(segTicker);
}
const result = results?.[0];
if (!result) continue;
const oldWords = seg.words ? seg.words.map(w => ({ ...w })) : null;
const oldWordsStale = seg.wordsStale;
const userTokens = seg.text.trim().split(/\s+/);
const whisperWords = result.words ?? [];
const mappedWords = [];
if (whisperWords.length === 0) {
// Whisper produced no word timestamps — distribute evenly so highlighting works.
const segDur = seg.end - seg.start;
userTokens.forEach((token, i) => {
mappedWords.push({
start: seg.start + segDur * (i / userTokens.length),
end: seg.start + segDur * ((i + 1) / userTokens.length),
word: (i === 0 ? '' : ' ') + token,
probability: 0,
});
});
} else {
// Map Whisper words to user tokens positionally.
const matchCount = Math.min(whisperWords.length, userTokens.length);
for (let i = 0; i < matchCount; i++) {
const w = whisperWords[i];
const leading = w.word.slice(0, w.word.length - w.word.trimStart().length);
mappedWords.push({ start: w.start, end: w.end, word: leading + userTokens[i], probability: w.probability });
}
// If user has more words than Whisper returned, distribute remaining
// time evenly between the last Whisper word's end and seg.end.
if (userTokens.length > whisperWords.length) {
const overflowCount = userTokens.length - matchCount;
const overflowStart = mappedWords[mappedWords.length - 1].end;
const overflowDur = (seg.end - overflowStart) / overflowCount;
for (let i = 0; i < overflowCount; i++) {
mappedWords.push({
start: overflowStart + overflowDur * i,
end: overflowStart + overflowDur * (i + 1),
word: ' ' + userTokens[matchCount + i],
probability: 0,
});
}
}
}
seg.words = mappedWords;
seg.wordsStale = undefined;
this.workspace.history.push({
label: 'Retranscribe sentence', dirtyFlags: ['transcript'],
undo: () => { segments[segIdx].words = oldWords; segments[segIdx].wordsStale = oldWordsStale; },
redo: () => { segments[segIdx].words = mappedWords; segments[segIdx].wordsStale = undefined; },
});
succeeded++;
setProgress((n + 1) / total);
}
if (succeeded > 0) {
const label = isBatch ? ` (${succeeded}/${total})` : '';
setStatus(`Done${label}!`);
this.activeProject.markTranscriptDirty();
this.workspace._updateUndoRedoButtons();
this.renderTranscript();
}
} catch (e) {
console.error('Segment retranscription failed:', e);
setStatus(`Error: ${e.message}`);
// Restore badges for any segments that weren't completed
segIndices.slice(succeeded).forEach(i => {
const badge = document.querySelector(`.t-seg[data-idx="${i}"] .t-seg-stale-badge`);
if (badge) { badge.textContent = '\u26a0'; badge.classList.remove('stale-badge--loading'); }
});
} finally {
clearInterval(elapsedTimer);
this.transcribeBtn.disabled = false;
if (this.retranscribeStalBtn) this.retranscribeStalBtn.disabled = false;
setTimeout(() => { this.transcribeProgress.style.display = 'none'; }, 2000);
}
}
/** Shows a tooltip near the cursor explaining that word timestamps are outdated. */
#showStaleTooltip() {
this.#hideStaleTooltip();
const el = document.createElement('div');
el.className = 'info-widget-tooltip stale-tooltip';
const msg = document.createElement('div');
msg.className = 'stale-tooltip-msg';
msg.textContent = 'Word timestamps are outdated.';
el.appendChild(msg);
const hint = document.createElement('div');
hint.className = 'stale-tooltip-hint';
hint.textContent = 'Right-click to retranscribe this sentence.';
el.appendChild(hint);
document.body.appendChild(el);
this._staleTooltipEl = el;
const x = this._mouseX + 12;
const y = this._mouseY + 16;
el.style.left = x + 'px';
el.style.top = y + 'px';
requestAnimationFrame(() => {
const r = el.getBoundingClientRect();
if (r.right > window.innerWidth) el.style.left = (window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight) el.style.top = (this._mouseY - r.height - 4) + 'px';
});
}
/** Hides and removes the stale-timestamp tooltip. */
#hideStaleTooltip() {
this._staleTooltipEl?.remove();
this._staleTooltipEl = null;
}
/** Hides and removes the active link tooltip. */
#hideLinkTooltip() {
clearTimeout(this._linkTooltipTimer);
this._linkTooltipEl?.remove();
this._linkTooltipEl = null;
}
}