components_split_popup.js
import { formatTimeMs } from "../utilities/tools.js"
/**
* Popup UI for splitting a transcript segment at a chosen word boundary and time.
* Manages its own DOM, event listeners, waveform rendering, and playback state.
*/
export class SplitPopup {
/**
* Creates and displays a SplitPopup.
* @param {number} segIdx - Index of the segment in segments
* @param {Element|null} anchorEl - Element to position the popup near
* @param {object} project - The active project instance
* @param {object} workspace - The workspace controller instance
* @param {object} [callbacks={}] - Callback functions for split actions
* @param {function} [callbacks.onSplit] - Called with (segA, segB) when the split is confirmed
* @param {function} [callbacks.onCancel] - Called when the popup is cancelled or dismissed
*/
constructor(segIdx, anchorEl, project, workspace, callbacks={}) {
this.segIdx = segIdx;
this.activeProject = project;
this.workspace = workspace;
this.segment = this.activeProject.transcript().segments[segIdx];
this.hasValidWords = !!(this.segment.words?.length && !this.segment.wordsStale);
if (this.hasValidWords) {
this.wordItems = this.segment.words;
this.words = this.wordItems.map(w => w.word.trimStart());
} else {
this.words = this.segment.text.trim().split(/\s+/);
}
this.wordBoundary = Math.max(1, Math.ceil(this.words.length / 2));
this.timeFrac = this.hasValidWords
? (this.wordItems[this.wordBoundary].start - this.segment.start) / (this.segment.end - this.segment.start)
: 0.5;
this.segPlaying = false;
this.rafPlayhead = null;
this.waveDragging = false;
this._onSplit = callbacks.onSplit ?? (() => {});
this._onCancel = callbacks.onCancel ?? (() => {});
this.popup = this.#buildPopup();
document.body.appendChild(this.popup);
this.#bindGlobalEvents();
this.#position(anchorEl);
requestAnimationFrame(() => {
if (!this.hasValidWords && this.activeProject.hasWaveform) {
this.#drawWaveStrip();
this.#updateCursor();
}
});
}
/**
* Returns the current split preview state for use by the waveform panel.
* @returns {{segIdx: number, timeFrac: number}}
*/
get previewFrac() {
if (this.hasValidWords) {
const t = this.wordItems[this.wordBoundary].start;
return { segIdx: this.segIdx, timeFrac: (t - this.segment.start) / (this.segment.end - this.segment.start) };
}
return { segIdx: this.segIdx, timeFrac: this.timeFrac };
}
// ── DOM Construction ──────────────────────────────────────────────────
/**
* Builds the full popup DOM and returns the root element.
* @returns {HTMLElement}
*/
#buildPopup() {
const popup = document.createElement('div');
popup.className = 'split-popup';
const lbl = document.createElement('div');
lbl.className = 'split-popup-label';
lbl.textContent = 'Split segment';
popup.appendChild(lbl);
this.textRow = document.createElement('div');
this.textRow.className = 'split-text-row';
popup.appendChild(this.textRow);
this.#rebuildTextRow();
const waveform = this.#buildWaveform();
if (!this.hasValidWords && this.activeProject.hasWaveform) {
popup.appendChild(waveform);
}
popup.appendChild(this.#buildActions());
return popup;
}
/**
* Builds the waveform strip, cursor, time label, and playhead elements.
* @returns {HTMLElement}
*/
#buildWaveform() {
this.waveWrap = document.createElement('div');
this.waveWrap.className = 'split-wave-wrap';
this.waveCanvas = document.createElement('canvas');
this.waveCanvas.width = 300;
this.waveCanvas.height = 52;
this.waveWrap.appendChild(this.waveCanvas);
this.cursor = document.createElement('div');
this.cursor.className = 'split-wave-cursor';
this.waveWrap.appendChild(this.cursor);
this.timeLabel = document.createElement('div');
this.timeLabel.className = 'split-time-label';
this.waveWrap.appendChild(this.timeLabel);
this.playhead = document.createElement('div');
this.playhead.style.cssText = 'position:absolute;top:0;bottom:0;width:1px;background:var(--accent-strong);pointer-events:none;display:none;';
this.waveWrap.appendChild(this.playhead);
this.waveWrap.addEventListener('mousedown', (e) => {
e.preventDefault();
this.waveDragging = true;
this.#setTimeFracFromEvent(e);
});
return this.waveWrap;
}
/**
* Builds the action buttons row (play, cancel, split).
* @returns {HTMLElement}
*/
#buildActions() {
const actions = document.createElement('div');
actions.className = 'split-actions';
this.playBtn = document.createElement('button');
this.playBtn.className = 'split-btn split-btn-cancel';
this.playBtn.style.cssText = 'margin-right:auto;min-width:2.2rem;';
this.playBtn.textContent = '▶';
this.playBtn.title = 'Play segment';
this.playBtn.addEventListener('click', () => this.#togglePlayback());
if (this.hasValidWords || !this.activeProject.hasWaveform) {
this.playBtn.style.display = 'none';
}
const cancelBtn = document.createElement('button');
cancelBtn.className = 'split-btn split-btn-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => {
this.#stopPlayback();
this.destroy();
this._onCancel();
// this.splitPreviewFrac = null;
// drawRegions();
});
const splitBtn = document.createElement('button');
splitBtn.className = 'split-btn split-btn-confirm';
splitBtn.textContent = '⋮ Split';
splitBtn.addEventListener('click', () => this.#confirmSplit());
actions.appendChild(this.playBtn);
actions.appendChild(cancelBtn);
actions.appendChild(splitBtn);
return actions;
}
// ── Text Row ──────────────────────────────────────────────────────────
/**
* Rebuilds the word spans and draggable divider in the text row.
*/
#rebuildTextRow() {
this.textRow.innerHTML = '';
this.words.forEach((word, wi) => {
if (wi === this.wordBoundary) {
this.textRow.appendChild(this.#buildDivider());
}
const sp = document.createElement('span');
sp.className = 'split-word ' + (wi < this.wordBoundary ? 'left' : 'right');
sp.textContent = word;
sp.addEventListener('click', () => {
this.wordBoundary = Math.max(1, Math.min(this.words.length - 1, wi === 0 ? 1 : wi));
this.#rebuildTextRow();
});
this.textRow.appendChild(sp);
});
}
/**
* Builds the draggable divider span used to adjust the word boundary.
* @returns {HTMLElement}
*/
#buildDivider() {
const div = document.createElement('span');
div.className = 'split-divider';
div.textContent = '┆';
let dDragging = false, dStartX = 0, dStartB = 0;
div.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
dDragging = true;
dStartX = e.clientX;
dStartB = this.wordBoundary;
});
const onMove = (e) => {
if (!dDragging) return;
const delta = Math.round((e.clientX - dStartX) / 28);
const nb = Math.max(1, Math.min(this.words.length - 1, dStartB + delta));
if (nb !== this.wordBoundary) {
this.wordBoundary = nb;
this.#rebuildTextRow();
}
};
const onUp = () => { dDragging = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return div;
}
// ── Waveform ──────────────────────────────────────────────────────────
/**
* Draws the waveform slice for the current segment onto the canvas,
* coloring peaks left/right of the split point differently.
*/
#drawWaveStrip() {
const W = this.waveCanvas.width, H = this.waveCanvas.height;
const ctx = this.waveCanvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
if (!this.activeProject.waveform().peaks?.[0] || !this.activeProject.waveform().duration) return;
const peaks = this.activeProject.waveform().peaks[0];
const startFrac = this.segment.start / this.activeProject.waveform().duration;
const endFrac = this.segment.end / this.activeProject.waveform().duration;
const peakStart = Math.floor(startFrac * peaks.length);
const peakEnd = Math.ceil(endFrac * peaks.length);
const segPeaks = peaks.slice(peakStart, peakEnd);
for (let x = 0; x < W; x++) {
const pi = Math.floor((x / W) * segPeaks.length);
const amp = Math.min(segPeaks[pi] * H * 0.9, H / 2);
ctx.fillStyle = (x / W < this.timeFrac) ? '#dde8ff' : '#4a4a60';
ctx.fillRect(x, H / 2 - amp, 1, Math.max(amp * 2, 1));
}
}
/**
* Updates the cursor and time label positions to reflect the current timeFrac,
* and triggers a region redraw.
*/
#updateCursor() {
const W = this.waveWrap.clientWidth || 300;
const px = this.timeFrac * W;
this.cursor.style.left = px + 'px';
this.timeLabel.style.left = px + 'px';
this.timeLabel.textContent = formatTimeMs(this.segment.start + this.timeFrac * (this.segment.end - this.segment.start));
// this.splitPreviewFrac = { segIdx: this.segIdx, timeFrac: this.timeFrac };
// drawRegions();
}
/**
* Sets timeFrac from a mouse event on the waveform, clamped to a safe margin.
* @param {MouseEvent} e - the mouse event used to determine cursor position on the waveform
*/
#setTimeFracFromEvent(e) {
const rect = this.waveWrap.getBoundingClientRect();
const margin = 0.05;
this.timeFrac = Math.max(margin, Math.min(1 - margin, (e.clientX - rect.left) / rect.width));
this.#drawWaveStrip();
this.#updateCursor();
}
// ── Playback ──────────────────────────────────────────────────────────
/**
* Toggles playback of the current segment.
*/
#togglePlayback() {
if (!this.workspace.wavesurferInstance() || !this.activeProject.waveform().duration) return;
if (this.segPlaying) {
this.#stopPlayback();
} else {
this.workspace.wavesurferInstance().seekTo(this.segment.start / this.activeProject.waveform().duration);
this.workspace.wavesurferInstance().play();
this.segPlaying = true;
this.playBtn.textContent = '⏸';
this.rafPlayhead = requestAnimationFrame(() => this.#updatePlayhead());
}
}
/**
* Stops playback and resets playback state.
*/
#stopPlayback() {
if (!this.segPlaying) return;
this.workspace.wavesurferInstance().pause();
this.segPlaying = false;
this.playBtn.textContent = '▶';
this.playhead.style.display = 'none';
cancelAnimationFrame(this.rafPlayhead);
}
/**
* Animation frame callback that advances the playhead during segment playback.
*/
#updatePlayhead() {
if (!this.workspace.wavesurferInstance() || !this.segPlaying) return;
const t = this.workspace.wavesurferInstance().getCurrentTime();
if (t >= this.segment.end) {
this.#stopPlayback();
return;
}
const frac = (t - this.segment.start) / (this.segment.end - this.segment.start);
this.playhead.style.left = (frac * (this.waveWrap.clientWidth || 300)) + 'px';
this.playhead.style.display = '';
this.rafPlayhead = requestAnimationFrame(() => this.#updatePlayhead());
}
// ── Split Execution ───────────────────────────────────────────────────
/**
* Executes the split, replacing the original segment with two new segments.
*/
#confirmSplit() {
this.#stopPlayback();
this.#cleanup();
// this.splitPreviewFrac = null;
// splitPopup = null;
this.popup.remove();
let splitTime, textA, textB, wordsA, wordsB;
if (this.hasValidWords) {
wordsA = this.wordItems.slice(0, this.wordBoundary);
wordsB = this.wordItems.slice(this.wordBoundary);
splitTime = this.wordItems[this.wordBoundary].start;
textA = wordsA.map(w => w.word).join('').trim() || '…';
textB = wordsB.map(w => w.word).join('').trim() || '…';
} else {
splitTime = this.segment.start + this.timeFrac * (this.segment.end - this.segment.start);
textA = this.words.slice(0, this.wordBoundary).join(' ') || '…';
textB = this.words.slice(this.wordBoundary).join(' ') || '…';
}
const stale = this.segment.wordsStale ? { wordsStale: true } : {};
const segA = {
start: this.segment.start,
end: splitTime,
speaker: this.segment.speaker,
text: textA,
...(wordsA ? { words: wordsA } : {}),
...stale
};
const segB = {
start: splitTime,
end: this.segment.end,
speaker: this.segment.speaker,
text: textB,
...(wordsB ? { words: wordsB } : {}),
...stale
};
this._onSplit(segA, segB);
// drawRegions();
}
// ── Positioning ───────────────────────────────────────────────────────
/**
* Positions the popup near the anchor element, adjusting for viewport edges.
* anchorEl may be a DOM Element or a plain {left, top, bottom} rect object.
* @param {Element|{left:number,top:number,bottom:number}|null} anchorEl - element or rect to anchor the popup near
*/
#position(anchorEl) {
const ar = anchorEl
? (typeof anchorEl.getBoundingClientRect === 'function'
? anchorEl.getBoundingClientRect()
: anchorEl)
: { left: window.innerWidth / 2 - 160, bottom: window.innerHeight / 2, top: window.innerHeight / 2 };
const popupH = this.popup.offsetHeight || 220;
let left = ar.left;
let top;
if (ar.bottom > window.innerHeight) {
// Segment end is off-screen: center the popup vertically
top = Math.max(4, (window.innerHeight - popupH) / 2);
} else {
top = ar.bottom + 6;
if (top + popupH > window.innerHeight) top = ar.top - popupH - 6;
}
if (left + 320 > window.innerWidth) left = window.innerWidth - 328;
if (left < 4) left = 4;
this.popup.style.left = left + 'px';
this.popup.style.top = top + 'px';
}
// ── Event Management ──────────────────────────────────────────────────
/**
* Binds window-level mouse and keyboard event listeners.
*/
#bindGlobalEvents() {
this._onWaveMove = (e) => {
if (!this.waveDragging) return;
this.#setTimeFracFromEvent(e);
};
this._onWaveUp = () => { this.waveDragging = false; };
window.addEventListener('mousemove', this._onWaveMove);
window.addEventListener('mouseup', this._onWaveUp);
setTimeout(() => {
this._outsideClick = (e) => {
if (this.popup && !this.popup.contains(e.target)) {
this.#stopPlayback();
this.#cleanup();
this._onCancel();
// this.splitPreviewFrac = null;
document.removeEventListener('mousedown', this._outsideClick);
}
};
document.addEventListener('mousedown', this._outsideClick);
}, 0);
}
/**
* Removes all window-level event listeners added by this instance.
*/
#cleanup() {
window.removeEventListener('mousemove', this._onWaveMove);
window.removeEventListener('mouseup', this._onWaveUp);
}
/**
* Fully removes the popup from the DOM and cleans up all listeners and state.
*/
destroy() {
this.#stopPlayback();
this.#cleanup();
if (this._outsideClick) {
document.removeEventListener('mousedown', this._outsideClick);
}
this.popup.remove();
}
}