import { SplitPopup } from "./components/split_popup.js"
import { SegmentContextMenu } from "./components/segment_context_menu.js"
import { ConfirmDialog } from "./components/confirm_dialog.js"
import { EmbedDialog } from "./components/embed_dialog.js"
import { LiveQuotesDialog } from "./components/live_quotes_dialog.js"
import { StartPage } from "./start_page.js"
import { SpeakersPanel } from "./workspace_panels/speakers_panel.js"
import { TranscriptPanel } from "./workspace_panels/transcript_panel.js"
import { WaveformPanel } from "./workspace_panels/waveform_panel.js"
import { LOCAL_MODE } from "./utilities/constants.js"
import { HistoryManager } from "./utilities/history_manager.js"
/**
* Central workspace controller. Owns the three editing panels (speakers,
* transcript, waveform) and mediates all cross-panel interactions through
* callbacks. Manages the active project, selection state, context menus,
* and the split popup.
*/
export class Workspace {
speakersPanel;
transcriptPanel;
waveformPanel;
/**
* @param {object} callbacks - callback functions for workspace actions
* @param {function} callbacks.onNewProject - called when the user requests a new project
* @param {function} callbacks.onOpenProject - called when the user requests to open a local project
* @param {function} callbacks.onUpload - called with the active project when the upload button is clicked
* @param {function} callbacks.onProjectModified - called with the project when it becomes dirty and is a server project
* @param {function} callbacks.isServerConnected - returns true if the server is currently connected
* @param {function} [callbacks.onPresentation] - called when the user opens a project's presentation
* @param {function} [callbacks.getToken] - async function returning the current auth token string, or null
* @param {function} [callbacks.onRenderSidebar] - called when the sidebar should be re-rendered
*/
constructor({ onNewProject, onOpenProject, onUpload, onProjectModified, isServerConnected, onPresentation, getToken, onRenderSidebar }) {
this._onNewProject = onNewProject ?? (() => {});
this._onOpenProject = onOpenProject ?? (() => {});
this._onUpload = onUpload ?? (() => {});
this._onProjectModified = onProjectModified ?? null;
this._isServerConnected = isServerConnected ?? (() => false);
this.getToken = getToken ?? (() => Promise.resolve(null));
this._onRenderSidebar = onRenderSidebar ?? null;
this._onPresentation = onPresentation ?? ((project) => {
// Default: just open the presentation in a new tab if it's a server project
if (project?.projectId) window.open(`/presentation/${project.projectId}`, '_blank');
});
// Get all the document elements as members
this.#getElements();
// set the workspace empty, as nothing has been loaded
this.workspaceEmpty.style.display = "flex";
this.workspaceApp.style.display = "none";
// Initialize any primitive members
this.#initializeMembers();
// Initialize the workspace panels and their callback
this.initializeWorkspacePanels();
// Setup all event listeners
this.#setupListeners();
}
/** Constructs the three workspace panels and wires their cross-panel callbacks. */
initializeWorkspacePanels() {
// Initialize speakers panel with callbacks
this.speakersPanel = new SpeakersPanel(this, {
onSpeakerHover: (speakerId) => {
this.hoveredSpeakerId = speakerId
this.waveformPanel.drawRegions();
},
onSpeakerModified: (speaker) => {
this.waveformPanel.drawRegions();
// this.transcriptPanel.renderTranscript();
},
});
// Initialize transcript panel with callbacks
this.transcriptPanel = new TranscriptPanel(this, {
onSegmentHover: (segmentIdx) => {
// Only update if its changed for efficiency
if (this.hoveredSegmentIdx != segmentIdx) {
this.hoveredSegmentIdx = segmentIdx;
this.waveformPanel.setHoveredRegion(this.hoveredSegmentIdx);
}
},
onSegmentSelect: (segmentIdx) => {
// Only update if its changed for efficiency
if (this.selectedSegmentIdx != segmentIdx) {
this.selectedSegmentIdx = segmentIdx;
this.waveformPanel.setSelectedRegion(this.selectedSegmentIdx);
if (this.splitPopup) this.closeSplitPopup();
}
},
onSegmentZoom: (segmentIdx) => {
this.waveformPanel.zoomToRegion(segmentIdx);
},
onSearchChanged: (matchSet) => {
this.searchMatchSet = matchSet;
this.waveformPanel.drawRegions();
},
onParagraphHover: (paragraph) => {
this.hoveredParagraphIdx = paragraph
? this.activeProject.transcript().paragraphs.indexOf(paragraph)
: -1;
this.waveformPanel.drawRegions();
},
onParagraphZoom: (paragraph) => {
const idx = this.activeProject.transcript().paragraphs.indexOf(paragraph);
if (idx >= 0) this.waveformPanel.zoomToParagraph(idx);
},
});
// Initialize waveform panel with callbacks
this.waveformPanel = new WaveformPanel(this, {
onRegionHover: (regionIdx) => {
// Only update if its changed for efficiency
if (this.hoveredSegmentIdx != regionIdx) {
this.hoveredSegmentIdx = regionIdx;
this.transcriptPanel.setHoveredSegment(this.hoveredSegmentIdx);
}
},
onRegionSelect: (regionIdx) => {
// Only update if its changed for efficiency
if (this.selectedSegmentIdx != regionIdx) {
this.selectedSegmentIdx = regionIdx;
this.transcriptPanel.setSelectedSegment(this.selectedSegmentIdx)
if (this.splitPopup) this.closeSplitPopup();
}
},
onRegionActivate: (regionIdx) => {
// Only update if its changed for efficiency
if (this.activeSegmentIdx != regionIdx) {
this.activeSegmentIdx = regionIdx;
this.transcriptPanel.setActiveSegment(this.activeSegmentIdx)
}
},
onWordActivate: (time) => {
this.transcriptPanel.setActiveWord(time);
},
});
}
/** Initializes all primitive instance variables to their default values. */
#initializeMembers() {
this.activeProject = null; // the currently loaded project
this.history = new HistoryManager({ maxSize: parseInt(localStorage.getItem('undo-queue-size') || '100', 10) });
this.isUploading = false;
this.activeCtxMenu = null;
this.ctxSpeakerListOpen = false;
this.ctxMenuSegIdx = -1;
// Handles splitting control flow, while the splitting popup itself is in its own class
this.splitPopup = null;
this.selectedSegmentIdx = -1, // index into cachedSegments; -1 = none
this.hoveredSegmentIdx = -1, // index under mouse cursor in region lane or transcript
this.activeSegmentIdx = -1, // index of the segment currently under the playhead
this.hoveredSpeakerId = null;
this.hoveredParagraphIdx = -1; // index into transcript().paragraphs whose handle is hovered
this.searchMatchSet = null; // Set of matching segment indices when search is active, null otherwise
// Resize handle variables
this.vHandleDragging = false,
this.vHandleStartX = 0,
this.vHandleStartW = 0;
this.hHandleDragging = false
this.hHandleStartY = 0
this.hHandleStartH = 0;
}
/** Binds DOM elements to instance properties. */
#getElements() {
this.root = document.getElementById('workspace');
// build the start page
this.startPage = new StartPage({ onNewProject: this._onNewProject, onOpenProject: this._onOpenProject });
this.workspaceEmpty = this.root.querySelector("#workspaceEmpty");
this.workspaceApp = this.root.querySelector("#workspaceApp");
this.workspaceLoading = this.root.querySelector("#workspaceLoading");
this.projectTitle = this.root.querySelector('#projectTitle');
this.projectNameInput = this.root.querySelector('#projectNameInput');
this.projectServerStatus = this.root.querySelector('#projectServerStatus');
this.undoBtn = this.root.querySelector("#undoBtn");
this.redoBtn = this.root.querySelector("#redoBtn");
this.revertBtn = this.root.querySelector("#revertBtn");
this.saveLocalBtn = this.root.querySelector("#saveLocalBtn");
this.uploadServerBtn = this.root.querySelector("#uploadServerBtn");
this.liveQuotesBtn = this.root.querySelector("#liveQuotesBtn");
this.presentationBtn = this.root.querySelector("#presentationBtn");
// Workspace resize handles
this.verticalHandle = this.root.querySelector('#handleV');
this.horizontalHandle = this.root.querySelector('#handleH');
this.leftCol = this.root.querySelector('#leftCol');
}
/** Sets up resize handle drag logic, keyboard shortcuts, and project title editing. */
#setupListeners() {
window.addEventListener('mousemove', (e) => {
if (!this.vHandleDragging) return;
const maxW = this.leftCol.parentElement.clientWidth - 374;
const w = Math.max(500, Math.min(maxW, this.vHandleStartW + e.clientX - this.vHandleStartX));
this.leftCol.style.flex = `0 0 ${w}px`;
if (!this.vResizeRafPending) {
this.vResizeRafPending = true;
requestAnimationFrame(() => {
this.vResizeRafPending = false;
const scrollEl = this.waveformPanel.getScrollEl();
const anchorFrac = scrollEl && scrollEl.scrollWidth > 0
? scrollEl.scrollLeft / scrollEl.scrollWidth
: null;
this.waveformPanel.resizeMinimap();
this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel, anchorFrac);
});
}
});
window.addEventListener('mouseup', () => {
if (!this.vHandleDragging) return;
this.vHandleDragging = false;
this.verticalHandle.classList.remove('dragging');
document.body.style.cursor = ''; document.body.style.userSelect = '';
});
window.addEventListener('mousemove', (e) => {
if (!this.hHandleDragging) return;
this.pendingHeight = this.hHandleStartH + e.clientY - this.hHandleStartY;
if (!this.hResizeRafPending) {
this.hResizeRafPending = true;
requestAnimationFrame(() => {
this.hResizeRafPending = false;
this.waveformPanel.setWaveformHeight(this.pendingHeight);
});
}
});
window.addEventListener('mouseup', () => {
if (!this.hHandleDragging) return;
this.hHandleDragging = false;
this.horizontalHandle.classList.remove('dragging');
document.body.style.cursor = ''; document.body.style.userSelect = '';
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// If the target is an input or selection dialog, don't handle the key press
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
// If the target is an editable dialog, don't handle the keypress
if (e.target.isContentEditable) return;
// Undo / Redo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
this.history.undo().then(cmd => this._applyHistoryResult(cmd));
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
this.history.redo().then(cmd => this._applyHistoryResult(cmd));
return;
}
// Tab / Shift+Tab — navigate segments and enter edit mode
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey) {
const segs = this.activeProject?.transcript()?.segments;
if (segs?.length) {
e.preventDefault();
const cur = this.selectedSegmentIdx;
let next;
if (cur < 0) {
next = e.shiftKey ? segs.length - 1 : 0;
} else {
next = e.shiftKey ? cur - 1 : cur + 1;
}
if (next >= 0 && next < segs.length) {
this.setSelectedSeg(next);
if (!this.isReadOnly()) {
this.transcriptPanel.makeSegmentEditable(next);
}
}
}
return;
}
// If escape is pressed, close any open dialogs and clear selection
if (e.code === 'Escape') {
this.closeCtxMenu();
this.closeSplitPopup();
this.setSelectedSeg(-1);
}
// Segment shortcuts — only when a segment is selected and no menu/popup open
const hasSel = this.selectedSegmentIdx >= 0;
// S — split (allowed even when a split popup is open; closes the existing one first)
if (hasSel && !this.activeCtxMenu && e.code === 'KeyS' && !e.shiftKey && !this.isReadOnly()) {
e.preventDefault();
const seg = this.activeProject.transcript().segments[this.selectedSegmentIdx];
if (seg && seg.text.trim().split(/\s+/).length > 1) {
const anchor = document.querySelector(`.t-seg[data-idx="${this.selectedSegmentIdx}"]`);
this.enterSplitMode(this.selectedSegmentIdx, anchor);
}
}
if (hasSel && !this.activeCtxMenu && !this.splitPopup && !this.isReadOnly()) {
// E — edit text
if (e.code === 'KeyE') {
e.preventDefault();
this.transcriptPanel.makeSegmentEditable(this.selectedSegmentIdx);
}
// C — change speaker (open ctx menu at segment position)
if (e.code === 'KeyC' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
const anchor = document.querySelector(`.t-seg[data-idx="${this.selectedSegmentIdx}"]`);
if (anchor) {
const rect = anchor.getBoundingClientRect();
this.openSegmentCtxMenu(rect.left, rect.bottom + 4, this.selectedSegmentIdx);
// Auto-expand speaker list
requestAnimationFrame(() => {
const item = this.activeCtxMenu?.root?.querySelector('.ctx-item');
if (item) item.click();
});
}
}
// Shift+A — merge with previous
if (e.code === 'KeyA' && e.shiftKey) {
e.preventDefault();
const segs = this.activeProject.transcript().segments;
const idx = this.selectedSegmentIdx;
if (idx > 0 && segs[idx - 1].speaker === segs[idx].speaker) {
this._mergeWithHistory(idx - 1, idx);
this.setSelectedSeg(idx - 1);
}
}
// Shift+D — merge with next
if (e.code === 'KeyD' && e.shiftKey) {
e.preventDefault();
const segs = this.activeProject.transcript().segments;
const idx = this.selectedSegmentIdx;
if (idx < segs.length - 1 && segs[idx].speaker === segs[idx + 1].speaker) {
this._mergeWithHistory(idx, idx + 1);
this.setSelectedSeg(idx);
}
}
}
});
this.projectTitle.addEventListener('click', () => {
if (this.isReadOnly()) return;
this.projectTitle.style.display = 'none';
this.projectNameInput.style.display = '';
this.projectNameInput.focus();
this.projectNameInput.select();
});
this.projectNameInput.addEventListener('blur', () => this.commitProjectName());
this.projectNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.projectNameInput.blur();
}
if (e.key === 'Escape') {
this.projectNameInput.value = this.activeProject.projectName || 'Untitled Project';
this.projectNameInput.blur();
}
e.stopPropagation();
});
this.undoBtn.addEventListener('click', () => {
this.history.undo().then(cmd => this._applyHistoryResult(cmd));
});
this.redoBtn.addEventListener('click', () => {
this.history.redo().then(cmd => this._applyHistoryResult(cmd));
});
this.revertBtn.addEventListener('click', () => {
if (!this.activeProject?.isDirty()) return;
new ConfirmDialog('Discard all changes since the last save?', {
onConfirm: () => {
this.activeProject.revertToLastSave();
this.history.clear();
this._updateUndoRedoButtons();
}
});
});
this.saveLocalBtn.addEventListener('click', async () => { await this.saveLocalCopy(this.activeProject); });
this.uploadServerBtn.addEventListener('click', () => {
if (!this.uploadServerBtn.classList.contains('uploading')) {
this._onUpload(this.activeProject);
}
});
this.liveQuotesBtn.addEventListener('click', () => {
new LiveQuotesDialog(this.activeProject.projectId, this.getToken);
});
this.presentationBtn.addEventListener('click', () => {
this._onPresentation(this.activeProject);
});
// Workspace resize handles
this.verticalHandle.addEventListener('mousedown', (e) => {
this.vHandleDragging = true;
this.vHandleStartX = e.clientX;
this.vHandleStartW = this.leftCol.getBoundingClientRect().width;
this.verticalHandle.classList.add('dragging');
document.body.style.cssText += ';cursor:col-resize;user-select:none';
});
this.horizontalHandle.addEventListener('mousedown', (e) => {
this.hHandleDragging = true;
this.hHandleStartY = e.clientY;
this.hHandleStartH = this.waveformPanel.waveformHeightPx;
this.horizontalHandle.classList.add('dragging');
document.body.style.cssText += ';cursor:row-resize;user-select:none';
});
}
/**
* Loads project data from the server uplink and populates the workspace
* @param {object} project - the project to be loaded
*/
async loadProject(project) {
this.activeProject = project;
// Show loading overlay while pulling data; keep workspace panels hidden
this.workspaceEmpty.style.display = "none";
this.workspaceLoading.style.display = "flex";
this.workspaceApp.style.display = "none";
try {
// If its a not a local only server, pull it from the server
if (!this.activeProject.localOnly) {
// Reset any stale callbacks before pulling — server project objects are reused
// across sessions, so old callbacks may still be registered from a prior load.
// The real callbacks are re-registered below after the pull completes.
project.registerModifyCallbacks({});
await project.pullFromServer();
}
} catch(e) {
// On failure, go back to the empty state and re-throw
this.workspaceLoading.style.display = "none";
this.workspaceEmpty.style.display = "flex";
this.activeProject = null;
throw e;
}
// Hide the loading overlay and reveal the workspace
this.workspaceLoading.style.display = "none";
this.workspaceApp.style.display = "flex";
// Set project name label
this.projectNameInput.value = this.activeProject.projectName;
this.projectTitle.textContent = this.activeProject.projectName;
this.projectTitle.title = this.isReadOnly() ? '' : 'Click to rename';
// Load the project into the three workspace panels
this.speakersPanel.loadFromProject(project);
this.waveformPanel.loadFromProject(project);
this.transcriptPanel.loadFromProject(project);
// Mark the project to all clean
this.activeProject.markClean();
// Clear undo/redo history when a new project is loaded
this.history.clear();
this._updateUndoRedoButtons();
this.updateProjectServerStatus();
this.activeProject.registerModifyCallbacks({
onSpeakersModified: () => {
if(this.activeProject.hasTranscript && this.activeProject.transcript()) {
this.transcriptPanel.renderTranscript();
}
if(this.activeProject.hasWaveform) { this.waveformPanel.drawRegions(); }
if(this.activeProject.hasSpeakers) { this.speakersPanel.renderSpeakersPanel(); }
this.updateProjectServerStatus();
},
onTranscriptModified: () => {
if(this.activeProject.hasTranscript) { this.transcriptPanel.renderTranscript(); }
if(this.activeProject.hasWaveform) { this.waveformPanel.drawRegions(); }
this.updateProjectServerStatus();
},
onWaveformModified: () => {
this.waveformPanel.loadWaveform()
if(this.activeProject.hasTranscript) { this.waveformPanel.drawRegions(); }
this.updateProjectServerStatus();
},
onSpeakersSaved: () => {
this.updateProjectServerStatus();
},
onTranscriptSaved: () => {
this.updateProjectServerStatus();
},
onWaveformSaved: () => {
this.updateProjectServerStatus();
},
});
}
/**
* Unloads the currently active project, alerting user to unsaved changes
*/
unloadActiveProject() {
this.updateProjectServerStatus();
}
/**
* Prompts the user for a save location and packages the project as a .wfs archive.
* @param {object} project - the project to save
* @returns {Promise<FileSystemFileHandle|undefined>} the file handle on success, or undefined if cancelled
*/
async saveLocalCopy(project) {
const suggestedName = `${project.projectName.replace(/[^a-z0-9_\-]/gi, '_')}.wfs`;
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName,
types: [{
description: 'Workflow Save File',
accept: { 'application/octet-stream': ['.wfs'] },
}],
});
await project.packageProject(fileHandle);
project.markClean();
this.updateProjectServerStatus();
return fileHandle;
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Save failed:', err);
}
}
}
// ── Project name editing ───────────────────────────────────────────────────
/**
* Commits the project name from the inline input back to the title span,
* updates the active project's name in state, and refreshes the sidebar.
* Called on blur and Enter keydown of the title input.
*/
commitProjectName() {
const newName = this.projectNameInput.value.trim() || 'Untitled Project';
this.projectNameInput.value = newName;
this.projectTitle.textContent = newName;
this.projectTitle.style.display = '';
this.projectNameInput.style.display = 'none';
if (!this.activeProject.projectId) {
return;
}
const oldName = this.activeProject.projectName;
this.activeProject.setName(newName);
if (oldName !== newName) {
this.history.push({
label: 'Rename project', dirtyFlags: ['projectName'],
undo: () => { this.activeProject.setName(oldName); },
redo: () => { this.activeProject.setName(newName); },
});
this._updateUndoRedoButtons();
}
}
// ── Access mode ───────────────────────────────────────────────────────────
/**
* Returns true if the active project is in read-only mode.
* @returns {boolean}
*/
isReadOnly() {
return this.activeProject?.readOnly ?? false;
}
// ── Selection ─────────────────────────────────────────────────────────────
/**
* Sets the selected segment, syncing workspace state, transcript panel, and waveform panel.
* @param {number} idx - segment index, or -1 to clear selection
*/
setSelectedSeg(idx) {
this.selectedSegmentIdx = idx;
this.transcriptPanel.setSelectedSegment(idx);
this.waveformPanel.setSelectedRegion(idx);
}
// ── Split segment popup ───────────────────────────────────────────────────
/**
* Returns true if a split popup is currently active.
* @returns {boolean}
*/
isSplitting() { return this.splitPopup !== null; }
/**
* Close the active split popup if there is one
*/
closeSplitPopup() {
if (this.splitPopup) {
this.splitPopup.destroy();
this.splitPopup = null;
this.waveformPanel.drawRegions();
}
}
/**
* Opens the split popup for the given segment, closing any existing one first.
* @param {number} segIdx - index of the segment to split
* @param {HTMLElement} anchorEl - element to anchor the popup near
*/
enterSplitMode(segIdx, anchorEl) {
// If there is already a split popup, close the current one
if (this.splitPopup) {
this.closeSplitPopup();
}
// Open a new SplitPopup
this.splitPopup = new SplitPopup(segIdx, anchorEl, this.activeProject, this, {
onSplit: (newSegmentA, newSegmentB) => {
const project = this.activeProject;
const transcript = project.transcript();
const originalSeg = { ...transcript.segments[segIdx] };
const savedAnnotations = JSON.parse(JSON.stringify(project.annotations ?? {}));
project.splitSegment(segIdx, newSegmentA, newSegmentB);
const segA = { ...transcript.segments[segIdx] };
const segB = { ...transcript.segments[segIdx + 1] };
this.history.push({
label: 'Split segment', dirtyFlags: ['transcript'],
undo: () => {
transcript.segments.splice(segIdx, 2, originalSeg);
transcript.buildTranscript();
project.annotations = savedAnnotations;
},
redo: () => {
transcript.segments.splice(segIdx, 1, { ...segA }, { ...segB });
transcript.buildTranscript();
},
});
this._updateUndoRedoButtons();
this.splitPopup = null;
},
onCancel: () => {
this.splitPopup = null;
this.waveformPanel.drawRegions();
}
});
}
// ── Segment context menu ──────────────────────────────────────────────────────────
/**
* Returns true if there is a segment before segIdx.
* @param {number} segIdx - the segment index to check
* @returns {boolean}
*/
hasPrev(segIdx) { return segIdx > 0; }
/**
* Returns true if there is a segment after segIdx.
* @param {number} segIdx - the segment index to check
* @returns {boolean}
*/
hasNext(segIdx) { return segIdx < this.activeProject.transcript().segments.length - 1; }
/**
* Returns true when the app is running in local (desktop) mode.
* @returns {boolean}
*/
isLocalMode() {
return LOCAL_MODE;
}
/**
* Opens the EmbedDialog for the given text selection target.
* @param {object} selTarget - selection target from TranscriptPanel#getNativeSelection()
*/
openEmbedDialog(selTarget) {
new EmbedDialog(selTarget, this.activeProject, { getToken: this.getToken, wavesurfer: this.wavesurferInstance() });
}
/**
* Closes the active context menu
*/
closeCtxMenu() {
if (this.activeCtxMenu) {
this.activeCtxMenu.close();
this.activeCtxMenu = null;
}
}
/**
* Opens a SegmentContextMenu for the given segment index.
* @param {number} x - left position in viewport pixels
* @param {number} y - top position in viewport pixels
* @param {number} segmentIndex - index into the active project's transcript segments
* @param {Element|{left:number,top:number,bottom:number}|null} splitAnchor - anchor for the split popup; defaults to the transcript segment element
* @param {object|null} linkInfo - when opening from a link span, provides onEdit/onRemove/onCopy callbacks; null otherwise
*/
openSegmentCtxMenu(x, y, segmentIndex, splitAnchor = null, linkInfo = null) {
// If there is already a context menu open, close it
this.closeCtxMenu();
this.ctxMenuSegIdx = segmentIndex;
const transcript = this.activeProject.transcript();
const segments = transcript.segments;
const segment = segments[segmentIndex];
const hasPrev = segmentIndex > 0 && segments[segmentIndex - 1].speaker === segment.speaker;
const hasNext = segmentIndex < segments.length - 1 && segments[segmentIndex + 1].speaker === segment.speaker;
const wordCount = segment?.text.trim().split(/\s+/).length; // number of words in the segment text
// Paragraph split/merge availability
const paraIdx = transcript.paragraphs.findIndex(p => p.segments.includes(segment));
const para = transcript.paragraphs[paraIdx];
const prevPara = paraIdx > 0 ? transcript.paragraphs[paraIdx - 1] : null;
const isFirstInPara = para && para.segments[0] === segment;
const canSplitParagraph = segmentIndex > 0 && !isFirstInPara;
const canMergeParagraphPrev = !!(prevPara && prevPara.speaker === para?.speaker);
// Create a new context menu with its callbacks
this.activeCtxMenu = new SegmentContextMenu(x, y, segmentIndex, this.activeProject, {
readOnly: this.isReadOnly(),
// these callbacks are never null
onChangeSpeaker: (speakerId) => {
const segs = this.activeProject.transcript().segments;
const oldSpeakerId = segs[segmentIndex].speaker;
this.activeProject.changeSpeaker(segmentIndex, speakerId);
this.history.push({
label: 'Change segment speaker', dirtyFlags: ['transcript'],
undo: () => {
this.activeProject.transcript().changeSpeaker(segmentIndex, oldSpeakerId);
},
redo: () => {
this.activeProject.transcript().changeSpeaker(segmentIndex, speakerId);
},
});
this._updateUndoRedoButtons();
this.closeCtxMenu();
},
onEdit: () => {
this.closeCtxMenu();
this.transcriptPanel.makeSegmentEditable(segmentIndex);
},
onDismiss: () => { this.closeCtxMenu(); },
onEditLink: linkInfo?.onEdit ?? null,
onRemoveLink: linkInfo?.onRemove ?? null,
onCopyLink: linkInfo?.onCopy ?? null,
onAddLink: this.isReadOnly() ? null : () => {
this.closeCtxMenu();
this.transcriptPanel.openAddLinkDialog(segmentIndex);
},
// if wordCount is less than one, function is nullified
onSplit: wordCount > 1 ? () => {
const anchor = splitAnchor ?? document.querySelector(`.t-seg[data-idx="${segmentIndex}"]`);
this.closeCtxMenu();
this.enterSplitMode(segmentIndex, anchor);
} : null,
// if segment has no previous, function is nullified
onMergePrev: hasPrev ? () => {
this.closeCtxMenu();
this._mergeWithHistory(segmentIndex - 1, segmentIndex);
} : null,
// if segment has no next, function is nullified
onMergeNext: hasNext ? () => {
this.closeCtxMenu();
this._mergeWithHistory(segmentIndex, segmentIndex + 1);
} : null,
onSplitParagraph: canSplitParagraph ? () => {
this.closeCtxMenu();
this._splitParagraphWithHistory(segmentIndex);
} : null,
onMergeParagraphPrev: canMergeParagraphPrev ? () => {
this.closeCtxMenu();
this._mergeParagraphWithHistory(segmentIndex);
} : null,
onZoom: () => {
this.closeCtxMenu();
this.waveformPanel.zoomToRegion(segmentIndex);
this.waveformPanel.setSelectedRegion(segmentIndex);
},
onRetranscribe: segment.wordsStale ? () => {
this.closeCtxMenu();
this.transcriptPanel.retranscribeSegments([segmentIndex]);
} : null,
});
}
/**
* Shows or hides the upload spinner on the workspace header upload button.
* @param {boolean} busy - true to show spinner, false to restore the icon
*/
setUploadBusy(busy) {
this.isUploading = busy;
this.uploadServerBtn.classList.toggle('uploading', busy);
this.uploadServerBtn.disabled = busy;
this.updateProjectServerStatus();
}
/**
* Updates the badge and optional push button in the project title bar
* to reflect whether the active project is local-only, synced, or has
* unsynced changes.
*/
updateProjectServerStatus() {
const el = this.projectServerStatus;
if (!el) {
return;
}
el.innerHTML = '';
// If there is no active project, don't display the badges
if (!this.activeProject) {
this.revertBtn.style.display = 'none';
this.saveLocalBtn.classList.remove('active');
this.uploadServerBtn.classList.remove('active');
this.liveQuotesBtn.style.display = 'none';
this.presentationBtn.style.display = 'none';
return;
}
// const connected = AppState.server.isConnected;
const localOnlyBadge = document.createElement('span');
localOnlyBadge.className = 'project-cloud-badge';
const projectDirtyBadge = document.createElement('span');
projectDirtyBadge.className = 'project-cloud-badge';
const connected = this._isServerConnected();
// set the local only / server badge
if (this.activeProject.localOnly) {
localOnlyBadge.className += ' modified';
localOnlyBadge.textContent = 'offline';
localOnlyBadge.title = 'Project not pushed to a server';
} else if (!connected) {
localOnlyBadge.className += ' server';
localOnlyBadge.textContent = 'on server ';
localOnlyBadge.title = 'Project has been pushed to a server';
}
// When connected and on server, omit the "on server" badge — it's implied
// set the save status badge
if (this.isUploading) {
projectDirtyBadge.className += ' uploading';
projectDirtyBadge.textContent = 'uploading';
projectDirtyBadge.title = 'Uploading to server…';
} else if (this.activeProject.localOnly) {
if (this.activeProject.isDirty()) {
projectDirtyBadge.className += ' modified';
projectDirtyBadge.textContent = 'unsaved';
projectDirtyBadge.title = 'Project has unsaved changes';
} else {
projectDirtyBadge.className += ' server';
projectDirtyBadge.textContent = 'saved ';
projectDirtyBadge.title = 'Project has been saved locally';
}
} else {
if (this.activeProject.isDirty()) {
projectDirtyBadge.className += ' modified';
projectDirtyBadge.textContent = 'unsaved';
projectDirtyBadge.title = 'Project has unsaved changes';
} else {
projectDirtyBadge.className += ' server';
projectDirtyBadge.textContent = 'saved ';
projectDirtyBadge.title = 'Project has been saved to the server';
}
}
// Trigger auto-save when a server project becomes dirty (skip while an upload is already running)
if (this.activeProject.isDirty() && !this.activeProject.localOnly && !this.isUploading) {
this._onProjectModified?.(this.activeProject);
}
el.appendChild(projectDirtyBadge);
if (!LOCAL_MODE && (this.activeProject.localOnly || !connected)) {
el.appendChild(localOnlyBadge);
}
// Access mode badge
const modeBadge = document.createElement('span');
modeBadge.className = 'project-cloud-badge';
const accessLevel = this.activeProject.accessLevel;
if (accessLevel === 'viewer') {
modeBadge.textContent = 'Read Only';
modeBadge.title = 'You have read-only access to this project';
modeBadge.classList.add('mode-readonly');
} else if (accessLevel === 'editor') {
modeBadge.textContent = 'Edit';
modeBadge.title = 'You have edit access to this project';
modeBadge.classList.add('mode-editor');
} else {
modeBadge.textContent = 'Owner';
modeBadge.title = 'You own this project';
modeBadge.classList.add('mode-owner');
}
el.appendChild(modeBadge);
// Highlight download button when local project has unsaved changes
const localDirty = this.activeProject.localOnly && this.activeProject.isDirty();
this.saveLocalBtn.classList.toggle('active', localDirty);
// Highlight upload button when server project has unsynced changes
const serverDirty = !this.activeProject.localOnly && this.activeProject.isDirty();
this.uploadServerBtn.classList.toggle('active', serverDirty);
// Show revert button when there are unsaved changes but not while uploading
this.revertBtn.style.display = (this.activeProject.isDirty() && !this.isUploading) ? '' : 'none';
// Show live quotes and presentation buttons for server projects only
const showServerBtns = !this.activeProject.localOnly && !LOCAL_MODE;
this.liveQuotesBtn.style.display = showServerBtns ? '' : 'none';
this.presentationBtn.style.display = showServerBtns ? '' : 'none';
}
// ── Undo / Redo ───────────────────────────────────────────────────────────
/**
* Fires re-render callbacks based on the command's dirtyFlags after undo/redo.
* @param {object|null} command - the command returned by history.undo() or history.redo()
*/
_applyHistoryResult(command) {
if (!command) return;
if (command.dirtyFlags.includes('transcript'))
this.activeProject.markTranscriptDirty();
if (command.dirtyFlags.includes('speakers'))
this.activeProject.markSpeakersDirty();
if (command.dirtyFlags.includes('annotations'))
this.transcriptPanel.renderTranscript();
if (command.dirtyFlags.includes('projectName')) {
this.projectTitle.textContent = this.activeProject.projectName;
this.projectNameInput.value = this.activeProject.projectName;
this._onRenderSidebar?.();
}
if (command.dirtyFlags.includes('projectFolder'))
this._onRenderSidebar?.();
this._updateUndoRedoButtons();
}
/** Updates the enabled/disabled state of the undo and redo buttons. */
_updateUndoRedoButtons() {
this.undoBtn.disabled = !this.history.canUndo;
this.redoBtn.disabled = !this.history.canRedo;
}
/**
* Merges two adjacent segments and pushes an undo/redo command to history.
* Use this instead of calling activeProject.mergeSegments() directly.
* @param {number} idxA - index of the earlier segment
* @param {number} idxB - index of the later segment (must equal idxA + 1)
*/
_mergeWithHistory(idxA, idxB) {
const project = this.activeProject;
const transcript = project.transcript();
const segs = transcript.segments;
const segA = { ...segs[idxA] };
const segB = { ...segs[idxB] };
const savedAnnotations = JSON.parse(JSON.stringify(project.annotations ?? {}));
project.mergeSegments(idxA, idxB);
this.history.push({
label: 'Merge segments', dirtyFlags: ['transcript'],
undo: () => {
transcript.segments.splice(idxA, 1, { ...segA }, { ...segB });
transcript.buildTranscript();
project.annotations = savedAnnotations;
},
redo: () => { project.mergeSegments(idxA, idxB); },
});
this._updateUndoRedoButtons();
}
/**
* Inserts a paragraph break before the segment at segIdx and pushes an undo/redo command.
* @param {number} segIdx - index of the segment that will start the new paragraph
*/
_splitParagraphWithHistory(segIdx) {
const transcript = this.activeProject.transcript();
const prevFlag = transcript.splitParagraphAt(segIdx);
if (prevFlag === null) return;
this.activeProject.markTranscriptDirty();
this.history.push({
label: 'Split paragraph', dirtyFlags: ['transcript'],
undo: () => { transcript.restoreParaBreak(segIdx, prevFlag); },
redo: () => { transcript.splitParagraphAt(segIdx); },
});
this._updateUndoRedoButtons();
}
/**
* Merges the paragraph containing segIdx with the previous paragraph and pushes undo/redo.
* @param {number} segIdx - index of any segment in the paragraph to merge upward
*/
_mergeParagraphWithHistory(segIdx) {
const transcript = this.activeProject.transcript();
const result = transcript.mergeParagraphBefore(segIdx);
if (!result) return;
this.activeProject.markTranscriptDirty();
const { segIdx: firstSegIdx, prevFlag } = result;
this.history.push({
label: 'Merge paragraph', dirtyFlags: ['transcript'],
undo: () => { transcript.restoreParaBreak(firstSegIdx, prevFlag); },
redo: () => { transcript.mergeParagraphBefore(firstSegIdx); },
});
this._updateUndoRedoButtons();
}
/**
* Tears down the current WaveSurfer instance and clears all audio, transcript,
* speaker, and UI state back to their initial values. Called when switching
* projects or when a project is deleted/closed.
*/
clearWorkspace() {
this.activeProject?.registerModifyCallbacks({});
this.waveformPanel.clearWaveformPanel();
this.transcriptPanel.clearTranscriptPanel();
this.speakersPanel.clearSpeakersPanel();
this.leftCol.style.flex = '';
this.selectedSegmentIdx = -1;
this.hoveredSegmentIdx = -1;
this.activeSegmentIdx = -1;
this.activeProject = null;
this.searchMatchSet = null;
this.hoveredParagraphIdx = -1;
this.history.clear();
this._updateUndoRedoButtons();
this.workspaceLoading.style.display = "none";
this.workspaceApp.style.display = "none";
this.workspaceEmpty.style.display = "flex";
this.updateProjectServerStatus();
}
/**
* Returns the WaveSurfer instance from the waveform panel.
* @returns {object} the WaveSurfer instance
*/
wavesurferInstance() {
return this.waveformPanel.wavesurferInstance;
}
}