import { formatTimeMs } from "../utilities/tools.js"
/**
* A context menu for transcript segment operations.
*
* Reads segment and speaker data directly from `AppState` using the provided
* segment index, then renders a positioned dropdown containing segment metadata
* and action items for changing speaker, splitting, editing, and merging.
* Actions are decoupled from application logic via callbacks supplied by the caller.
*
* @example
* const menu = new SegmentContextMenu(event.clientX, event.clientY, segIdx, {
* onChangeSpeaker: (id) => { changeSpeaker(segIdx, id); menu.close(); },
* onSplit: () => { enterSplitMode(segIdx, anchor); },
* onEdit: () => { makeSegmentEditable(segIdx); },
* onMergePrev: () => { ... },
* onMergeNext: () => { ... },
* });
*
* // Later:
* menu.close();
*
* @param {number} x - Preferred left position in viewport pixels.
* @param {number} y - Preferred top position in viewport pixels.
* @param {number} segIdx - Index into `AppState.transcript.loadedSegments`.
* @param {object} callbacks
*
* @param {function(string): void} callbacks.onChangeSpeaker
* Called with the selected speaker `id` when a speaker row is clicked.
* @param {function(): void} callbacks.onSplit
* Called when the Split Segment item is clicked. Omit or pass `null` to
* suppress the item (e.g. when the segment has only one word).
* @param {function(): void} callbacks.onEdit
* Called when the Edit Text item is clicked.
* @param {function(): void|null} callbacks.onMergePrev
* Called when Merge With Previous is clicked. Pass `null` to suppress the item.
* @param {function(): void|null} callbacks.onMergeNext
* Called when Merge With Next is clicked. Pass `null` to suppress the item.
* @param {function(): void|null} callbacks.onAddLink
* Called when Add Link is clicked. Pass `null` to suppress the item.
* @param {function(): void|null} callbacks.onSplitParagraph
* Called when Split Paragraph Here is clicked. Pass `null` to suppress the item.
* @param {function(): void|null} callbacks.onMergeParagraphPrev
* Called when Merge Paragraph with Previous is clicked. Pass `null` to suppress the item.
* @param {function(): void|null} callbacks.onZoom
* Called when Zoom to Sentence is clicked. Pass `null` to suppress the item.
*/
export class SegmentContextMenu {
/**
* @param {number} x - Preferred left position in viewport pixels.
* @param {number} y - Preferred top position in viewport pixels.
* @param {number} segIdx - Index into the active project's transcript segments.
* @param {object} project - The active project instance.
* @param {object} callbacks - Callback functions for menu actions.
* @param {function(string): void} callbacks.onChangeSpeaker - Called with the selected speaker id when a speaker row is clicked.
* @param {function(): void|null} callbacks.onSplit - Called when Split Segment is clicked; pass null to suppress the item.
* @param {function(): void} callbacks.onEdit - Called when Edit Text is clicked.
* @param {function(): void|null} callbacks.onMergePrev - Called when Merge With Previous is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onMergeNext - Called when Merge With Next is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onAddLink - Called when Add Link is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onRemoveLink - Called when Remove Link is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onCopyLink - Called when Copy Link is clicked; pass null to suppress.
* @param {function(): void} callbacks.onDismiss - Called when the menu is dismissed via outside click.
* @param {function(): void|null} callbacks.onSplitParagraph - Called when Split Paragraph Here is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onMergeParagraphPrev - Called when Merge Paragraph with Previous is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onZoom - Called when Zoom to Sentence is clicked; pass null to suppress.
* @param {function(): void|null} callbacks.onRetranscribe - Called when Retranscribe Sentence is clicked; pass null to suppress (show only when word timestamps are stale).
* @param {boolean} [callbacks.readOnly=false] - When true, speaker-change and edit actions are suppressed.
*/
constructor(x, y, segIdx, project, { onChangeSpeaker, onSplit, onEdit, onMergePrev, onMergeNext, onAddLink, onDismiss, onEditLink, onRemoveLink, onCopyLink, onSplitParagraph, onMergeParagraphPrev, onZoom, onRetranscribe, readOnly = false }) {
this.activeProject = project;
this.segIdx = segIdx;
this.seg = this.activeProject.transcript().segments[segIdx];
this.readOnly = readOnly;
this.onChangeSpeaker = onChangeSpeaker ?? (() => {});
this.onSplit = onSplit;
this.onEdit = onEdit ?? (() => {});
this.onMergePrev = onMergePrev;
this.onMergeNext = onMergeNext;
this.onAddLink = onAddLink ?? null;
this.onDismiss = onDismiss ?? (() => {});
this.onEditLink = onEditLink ?? null;
this.onRemoveLink = onRemoveLink ?? null;
this.onCopyLink = onCopyLink ?? null;
this.onSplitParagraph = onSplitParagraph ?? null;
this.onMergeParagraphPrev = onMergeParagraphPrev ?? null;
this.onZoom = onZoom ?? null;
this.onRetranscribe = onRetranscribe ?? null;
this.root = document.createElement('div');
this.root.className = 'ctx-menu';
if (this.onEditLink || this.onRemoveLink || this.onCopyLink) this.#buildLinkBlock();
this.#buildInfoBlock();
this.#buildControlsBlock();
this._onDismiss = onDismiss;// ?? (() => {});
document.body.appendChild(this.root);
this.#position(x, y);
this.#bindOutsideClick();
this.#setupListeners();
}
/** Registers a keydown listener that maps digit keys to speaker selection when the speaker list is open. */
#setupListeners() {
this._keydownHandler = (e) => {
if (!this.speakerListOpen || this.segIdx < 0) return;
const num = parseInt(e.key);
if (num >= 1 && num <= 9) {
const speaker = Object.values(this.activeProject.speakers())[num - 1];
if (speaker && this.seg?.speaker !== speaker.id) {
this.onChangeSpeaker(speaker.id);
this._onDismiss();
}
}
};
document.addEventListener('keydown', this._keydownHandler);
}
/** Removes the context menu from the DOM and resets internal state. */
close() {
this.root.remove();
document.removeEventListener('keydown', this._keydownHandler);
this.speakerListOpen = false;
this.segIdx = -1;
}
/** Builds the link-action section shown at the top when a link was right-clicked. */
#buildLinkBlock() {
const controls = document.createElement('div');
controls.className = 'ctx-controls';
if (this.onEditLink) {
const editItem = document.createElement('div');
editItem.className = 'ctx-item';
editItem.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Edit link</span>';
editItem.addEventListener('click', () => this.onEditLink());
controls.appendChild(editItem);
}
if (this.onCopyLink) {
const copyItem = document.createElement('div');
copyItem.className = 'ctx-item';
copyItem.innerHTML = '<span class="ctx-icon">⧉</span><span class="ctx-item-inner">Copy link</span>';
copyItem.addEventListener('click', () => { this.onCopyLink(); this._onDismiss(); });
controls.appendChild(copyItem);
}
if (this.onRemoveLink) {
const removeItem = document.createElement('div');
removeItem.className = 'ctx-item ctx-item--danger';
removeItem.innerHTML = '<span class="ctx-icon"><span class="icon icon-close" style="width:12px;height:12px;"></span></span><span class="ctx-item-inner">Remove link</span>';
removeItem.addEventListener('click', () => this.onRemoveLink());
controls.appendChild(removeItem);
}
this.root.appendChild(controls);
this.root.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
}
/** Builds the segment info block showing start, end, duration, and word count. */
#buildInfoBlock() {
const { seg } = this;
if (!seg) {
return;
}
const duration = seg.end - seg.start;
const words = seg.text.trim().split(/\s+/).filter(Boolean);
const wps = duration > 0 ? (words.length / duration).toFixed(1) : '—';
const info = document.createElement('div');
info.className = 'ctx-info';
const infoElements = [
['START', formatTimeMs(seg.start)],
['END', formatTimeMs(seg.end)],
['DURATION', duration.toFixed(2) + 's'],
['WORDS', words.length + ' (' + wps + '/s)'],
];
infoElements.forEach(([k, v]) => {
const key = document.createElement('span');
key.className = 'ctx-info-key'; key.textContent = k;
const val = document.createElement('span');
val.className = 'ctx-info-val'; val.textContent = v;
info.appendChild(key);
info.appendChild(val);
});
this.root.appendChild(info);
}
/** Builds the controls block containing all action items. */
#buildControlsBlock() {
const controls = document.createElement('div');
controls.className = 'ctx-controls';
this.controlsBlock = controls;
if (!this.readOnly) {
this.#buildChangeSpeaker();
// If onSplit has been hooked up, build its item
if (this.onSplit) {
this.#buildSplitItem();
}
this.#buildEditItem();
if (this.onAddLink) {
this.#buildAddLinkItem();
}
// TODO: (issue-12) Merge allows you to merge with a segment in a different speaker. Should this be allowed?
if (this.onMergePrev || this.onMergeNext) {
this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
if (this.onMergePrev) {
this.#buildMergeItem('Merge with previous', '⇧A', this.onMergePrev);
}
if (this.onMergeNext) {
this.#buildMergeItem('Merge with next', '⇧D', this.onMergeNext);
}
}
if (this.onSplitParagraph || this.onMergeParagraphPrev) {
this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
if (this.onSplitParagraph) {
this.#buildParagraphItem('Split paragraph here', '¶', this.onSplitParagraph);
}
if (this.onMergeParagraphPrev) {
this.#buildParagraphItem('Merge paragraph with previous', '⇑¶', this.onMergeParagraphPrev);
}
}
}
if (this.onZoom) {
if (!this.readOnly) {
this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
}
this.#buildZoomItem();
}
if (this.onRetranscribe) {
this.controlsBlock.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⟳</span><span class="ctx-item-inner">Retranscribe sentence</span>';
item.addEventListener('click', () => this.onRetranscribe());
this.controlsBlock.appendChild(item);
}
this.root.appendChild(this.controlsBlock);
}
/** Builds and appends the speaker selection list, replacing the controls block. */
#buildSpeakerSelector() {
const speakerSelector = document.createElement('div');
speakerSelector.className = 'ctx-speaker-list';
Object.values(this.activeProject.speakers()).forEach((speaker, i) => {
const speakerItem = document.createElement('div');
speakerItem.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;
const numHint = document.createElement('span');
numHint.className = 'ctx-kbd';
numHint.textContent = i + 1;
speakerItem.appendChild(swatch);
speakerItem.appendChild(label);
speakerItem.appendChild(numHint);
if (this.seg?.speaker === speaker.id) {
speakerItem.style.opacity = '0.4';
speakerItem.style.cursor = 'default';
} else {
speakerItem.addEventListener('click', (e) => {
e.stopPropagation();
this.onChangeSpeaker(speaker.id);
});
}
speakerSelector.appendChild(speakerItem);
});
this.root.appendChild(speakerSelector);
}
/** Builds the "Change speaker" menu item. Clicking it expands the speaker list. */
#buildChangeSpeaker() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon"><span class="icon icon-diamond" style="width:14px;height:14px;"></span></span><span class="ctx-item-inner">Change speaker</span><span class="ctx-kbd">C</span>';
this.speakerListOpen = false;
item.addEventListener('click', () => {
if (this.speakerListOpen) {
return;
}
this.speakerListOpen = true;
item.style.color = 'var(--accent)';
// hide the controls
this.controlsBlock.style.display = 'none';
// build the speaker selection list
this.#buildSpeakerSelector();
const mr = this.root.getBoundingClientRect();
if (mr.bottom > window.innerHeight) {
this.root.style.top = (window.innerHeight - mr.height - 8) + 'px';
}
});
this.controlsBlock.appendChild(item);
}
/** Builds the "Split segment" menu item. */
#buildSplitItem() {
const onSplit = this.onSplit;
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⋮</span><span class="ctx-item-inner">Split segment</span><span class="ctx-kbd">S</span>';
item.addEventListener('click', onSplit);
this.controlsBlock.appendChild(item);
}
/** Builds the "Edit text" menu item. */
#buildEditItem() {
const onEdit = this.onEdit;
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Edit text</span><span class="ctx-kbd">E</span>';
item.addEventListener('click', onEdit);
this.controlsBlock.appendChild(item);
}
/** Builds the "Add link" menu item. */
#buildAddLinkItem() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon"><span class="icon icon-plus" style="width:14px;height:14px;"></span></span><span class="ctx-item-inner">Add link</span><span class="ctx-kbd">⇧K</span>';
item.addEventListener('click', () => this.onAddLink());
this.controlsBlock.appendChild(item);
}
/**
* Builds a paragraph action menu item (split or merge paragraph).
* @param {string} label - display label
* @param {string} icon - icon character
* @param {function} onAction - callback invoked on click
*/
#buildParagraphItem(label, icon, onAction) {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = `<span class="ctx-icon">${icon}</span><span class="ctx-item-inner">${label}</span>`;
item.addEventListener('click', onAction);
this.controlsBlock.appendChild(item);
}
/**
* Builds a merge menu item.
* @param {string} label - display label (e.g. "Merge with previous")
* @param {string} kbd - keyboard shortcut hint text
* @param {function} onMerge - callback invoked on click
*/
#buildMergeItem(label, kbd, onMerge) {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = `<span class="ctx-icon">⇔</span><span class="ctx-item-inner">${label}</span><span class="ctx-kbd">${kbd}</span>`;
item.addEventListener('click', onMerge);
this.controlsBlock.appendChild(item);
}
/** Builds the "Zoom to sentence" menu item. */
#buildZoomItem() {
const item = document.createElement('div');
item.className = 'ctx-item';
item.innerHTML = '<span class="ctx-icon">⌕</span><span class="ctx-item-inner">Zoom to sentence</span>';
item.addEventListener('click', () => this.onZoom());
this.controlsBlock.appendChild(item);
}
/**
* Positions the menu at (x, y), then clamps it to stay within the viewport.
* @param {number} x - preferred left position in viewport pixels
* @param {number} y - preferred top position in viewport pixels
*/
#position(x, y) {
this.root.style.left = x + 'px';
this.root.style.top = y + 'px';
requestAnimationFrame(() => {
const mr = this.root.getBoundingClientRect();
if (x + mr.width > window.innerWidth) {
this.root.style.left = (window.innerWidth - mr.width - 8) + 'px';
}
if (y + mr.height > window.innerHeight) {
this.root.style.top = (window.innerHeight - mr.height - 8) + 'px';
}
});
}
/** Registers a mousedown listener that dismisses the menu on outside clicks. */
#bindOutsideClick() {
setTimeout(() => {
const outside = (e) => {
if (!this.root.contains(e.target)) {
this._onDismiss();
document.removeEventListener('mousedown', outside);
}
};
document.addEventListener('mousedown', outside);
}, 0);
}
}