feat(sidebar): add dialogue selection and copying

- Support multi-row selection with clean clipboard text
- Preserve selections across playback updates and clear them on source changes
This commit is contained in:
2026-09-06 22:36:04 -07:00
parent c14c690875
commit 97b8ecb8a3
24 changed files with 621 additions and 3 deletions
+23
View File
@@ -1863,6 +1863,29 @@ test('keyboard mode: popup hidden after mode off clears stale selected token hig
}
});
test('Yomitan popup dismissal and subtitle updates preserve selection outside the overlay subtitle', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
let cleared = false;
try {
Object.defineProperty(window, 'getSelection', {
configurable: true,
value: () => ({
anchorNode: {},
removeAllRanges: () => {
cleared = true;
},
}),
});
Object.assign(ctx.dom.subtitleRoot, { contains: () => false });
await handlers.setupMpvInputForwarding();
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
handlers.syncKeyboardTokenSelection();
assert.equal(cleared, false);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+4 -1
View File
@@ -436,7 +436,10 @@ export function createKeyboardHandlers(
}
function clearNativeSubtitleSelection(): void {
window.getSelection()?.removeAllRanges();
const selection = window.getSelection();
if (!selection?.anchorNode || ctx.dom.subtitleRoot.contains(selection.anchorNode)) {
selection?.removeAllRanges();
}
ctx.dom.subtitleRoot.classList.remove('has-selection');
}
+1
View File
@@ -731,6 +731,7 @@
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
<div class="modal-header">
<div class="modal-title">Subtitle Sidebar</div>
<button id="subtitleSidebarCopy" class="modal-close" type="button" hidden>Copy</button>
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body subtitle-sidebar-body">
@@ -0,0 +1,126 @@
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle';
import { createRendererState } from '../state';
import { resolveRendererDom } from '../utils/dom';
import { resolvePlatformInfo } from '../utils/platform';
import { createSubtitleSidebarModal } from './subtitle-sidebar';
import {
getSubtitleSidebarSelection,
wireSubtitleSidebarSelection,
} from './subtitle-sidebar-selection';
export async function setup() {
const commands: unknown[] = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'episode-1:track-1',
cues: [
{ text: '最初の台詞', startTime: 0, endTime: 1 },
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
{ text: '同じ台詞', startTime: 2, endTime: 3 },
...Array.from({ length: 30 }, (_, i) => ({
text: `後の台詞${i}`,
startTime: i + 3,
endTime: i + 4,
})),
],
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
currentTimeSec: 0,
config: {
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
enabled: true,
layout: 'overlay',
pauseVideoOnHover: false,
autoScroll: true,
css: {},
},
};
Object.defineProperty(window, 'electronAPI', {
value: {
getSubtitleSidebarSnapshot: async () => snapshot,
copySubtitleSidebarSelection: async (text) => {
if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function')
throw new Error('Missing test clipboard bridge');
window.copyTestSelection(text);
},
getOverlayLayer: () => 'visible',
sendMpvCommand: (command) => {
commands.push(command);
},
setIgnoreMouseEvents: () => {},
} satisfies Pick<
ElectronAPI,
| 'getSubtitleSidebarSnapshot'
| 'copySubtitleSidebarSelection'
| 'getOverlayLayer'
| 'sendMpvCommand'
| 'setIgnoreMouseEvents'
>,
});
const ctx = {
dom: resolveRendererDom(),
state: createRendererState(),
platform: resolvePlatformInfo(),
};
const modal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
wireSubtitleSidebarSelection(ctx);
await modal.openSubtitleSidebarModal();
const list = ctx.dom.subtitleSidebarList;
list.style.height = '180px';
list.style.overflowY = 'auto';
const selection = window.getSelection();
if (!selection) throw new Error('Native selection unavailable');
const textNode = (index: number) => {
const node = list.children[index]?.querySelector('.subtitle-sidebar-text')?.firstChild;
if (!node) throw new Error(`Missing cue ${index}`);
return node;
};
const select = (backward = false) => {
const start = textNode(0);
const end = textNode(2);
selection.setBaseAndExtent(backward ? end : start, 2, backward ? start : end, 2);
document.dispatchEvent(new Event('selectionchange'));
return getSubtitleSidebarSelection(list);
};
// This is the same competing action as the renderer's current-subtitle shortcut.
let fallbackCopies = 0;
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1;
});
return {
select,
selected: () => getSubtitleSidebarSelection(list),
buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden,
fallbackCopies: () => fallbackCopies,
dragPoints: () =>
[0, 2].map((index) => {
const range = document.createRange();
range.setStart(textNode(index), 2);
range.collapse(true);
const rect = range.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y + rect.height / 2) };
}),
clickCopy: () => ctx.dom.subtitleSidebarCopy.click(),
clickCue: () => {
const before = commands.length;
list.children[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
return commands.length - before;
},
updatePlayback: async () => {
list.scrollTop = 0;
snapshot.currentTimeSec = 25;
snapshot.currentSubtitle = { text: '後の台詞22', startTime: 25, endTime: 26 };
await modal.refreshSubtitleSidebarSnapshot();
return list.scrollTop;
},
changeSource: async () => {
snapshot.sourceKey = 'episode-2:track-1';
await modal.refreshSubtitleSidebarSnapshot();
return getSubtitleSidebarSelection(list);
},
clear: () => selection.removeAllRanges(),
close: () => modal.closeSubtitleSidebarModal(),
};
}
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import test from 'node:test';
import { build } from 'esbuild';
// This check opens Electron and uses the clipboard. Keep it out of normal code-only lanes.
test(
'sidebar selection copies clean chronological text without seeking or losing context',
{
skip:
process.env.SUBMINER_ELECTRON_TESTS !== '1' ||
(process.platform === 'linux' && !process.env.DISPLAY),
timeout: 30_000,
},
async () => {
const dir = await mkdtemp(join(tmpdir(), 'subminer-sidebar-selection-'));
await build({
entryPoints: [resolve('src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts')],
bundle: true,
platform: 'browser',
format: 'iife',
globalName: 'sidebarTest',
outfile: join(dir, 'fixture.js'),
});
const html = (await readFile('src/renderer/index.html', 'utf8'))
.replace(
'<script type="module" src="renderer.js"></script>',
'<script src="fixture.js"></script>',
)
.replace(
'href="style.css"',
`href="${new URL(`file://${resolve('src/renderer/style.css')}`).href}"`,
);
await writeFile(join(dir, 'index.html'), html);
await writeFile(
join(dir, 'clipboard.cjs'),
'const { clipboard, contextBridge } = require("electron"); contextBridge.exposeInMainWorld("copyTestSelection", text => clipboard.writeText(text));',
);
await build({
entryPoints: [resolve('src/core/services/overlay-window-input.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: join(dir, 'input.cjs'),
});
await writeFile(
join(dir, 'run.cjs'),
`
const { app, BrowserWindow, clipboard } = require('electron');
const assert = require('node:assert/strict');
const { handleOverlayWindowBeforeInputEvent } = require('./input.cjs');
app.setPath('userData', ${JSON.stringify(join(dir, 'user-data'))});
app.whenReady().then(async () => {
const window = new BrowserWindow({ width: 900, height: 700, show: false, webPreferences: { preload: ${JSON.stringify(join(dir, 'clipboard.cjs'))}, sandbox: false } });
window.webContents.on('console-message', (_event, details) => {
if (details.level === 'error') console.error(details.message);
});
let intercepted = 0;
window.webContents.on('before-input-event', (event, input) => handleOverlayWindowBeforeInputEvent({
kind: 'visible', windowVisible: true, input,
preventDefault: () => event.preventDefault(),
sendKeyboardModeToggleRequested() {}, sendLookupWindowToggleRequested() {}, forwardTabToMpv() {},
tryHandleOverlayShortcutLocalFallback() { intercepted++; return true; },
}));
await window.loadFile(${JSON.stringify(join(dir, 'index.html'))});
const run = (code) => window.webContents.executeJavaScript(code, true);
await run('sidebarTest.setup().then(checks => { window.checks = checks; })');
const expected = { text: 'の台詞\\n\\n同じ台詞\\n二行目\\n\\n同じ', cueCount: 3 };
assert.deepEqual(await run('checks.select()'), expected);
assert.deepEqual(await run('checks.select(true)'), expected);
assert.equal(await run('checks.buttonVisible()'), true);
assert.equal(await run('checks.clickCue()'), 0);
assert.equal(await run('checks.updatePlayback()'), 0);
assert.deepEqual(await run('checks.selected()'), expected);
const previousClipboard = clipboard.readText();
try {
clipboard.writeText('sentinel');
window.show(); app.focus({ steal: true }); window.focus(); window.webContents.focus();
await new Promise(resolve => setTimeout(resolve, 100));
await run('checks.clear()');
const [start, end] = await run('checks.dragPoints()');
window.webContents.sendInputEvent({ type: 'mouseDown', ...start, button: 'left', clickCount: 1 });
window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' });
window.webContents.sendInputEvent({ type: 'mouseUp', ...end, button: 'left', clickCount: 1 });
await new Promise(resolve => setTimeout(resolve, 100));
assert.deepEqual(await run('checks.selected()'), expected);
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(intercepted, 0);
assert.equal(await run('checks.fallbackCopies()'), 0);
assert.equal(clipboard.readText() === expected.text, true, 'Keyboard copies the selected excerpt');
clipboard.writeText('sentinel');
await run('checks.clickCopy()');
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(clipboard.readText() === expected.text, true, 'Button copies the selected excerpt');
} finally { clipboard.writeText(previousClipboard); }
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape", bubbles:true}))');
assert.equal(await run('checks.selected()'), null);
assert.equal(await run('checks.buttonVisible()'), false);
assert.equal(await run('checks.clickCue()'), 1);
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"c", ctrlKey:true, bubbles:true}))');
assert.equal(await run('checks.fallbackCopies()'), 1);
await run('checks.select()');
assert.equal(await run('checks.changeSource()'), null);
await run('checks.select(); checks.close()');
assert.equal(await run('checks.selected()'), null);
window.destroy();
console.log('SIDEBAR_SELECTION_OK');
app.quit();
}).catch(error => { console.error(error); app.exit(1); });
`,
);
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const { stdout } = await promisify(execFile)(
resolve('node_modules/.bin/electron'),
[join(dir, 'run.cjs')],
{ env, timeout: 25_000 },
);
assert.match(stdout, /SIDEBAR_SELECTION_OK/);
},
);
@@ -0,0 +1,150 @@
import type { RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
function isEditingText(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement)
);
}
function getSelectionRange(list: HTMLElement): Range | null {
const selection = list.ownerDocument?.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return list.contains(range.startContainer) && list.contains(range.endContainer) ? range : null;
}
export function hasSubtitleSidebarSelection(list: HTMLElement): boolean {
return getSelectionRange(list) !== null;
}
// Read only dialogue nodes, preserving partial first/last lines and DOM cue order.
export function getSubtitleSidebarSelection(list: HTMLElement): {
text: string;
cueCount: number;
} | null {
const range = getSelectionRange(list);
if (!range) return null;
const parts: string[] = [];
for (const text of list.querySelectorAll<HTMLElement>('.subtitle-sidebar-text')) {
if (!range.intersectsNode(text)) continue;
const part = list.ownerDocument.createRange();
part.selectNodeContents(text);
if (range.compareBoundaryPoints(Range.START_TO_START, part) > 0) {
part.setStart(range.startContainer, range.startOffset);
}
if (range.compareBoundaryPoints(Range.END_TO_END, part) < 0) {
part.setEnd(range.endContainer, range.endOffset);
}
const selectedText = part.toString();
if (selectedText.trim()) parts.push(selectedText);
}
return parts.length > 0 ? { text: parts.join('\n\n'), cueCount: parts.length } : null;
}
export function clearSubtitleSidebarSelection(list: HTMLElement): void {
const selection = list.ownerDocument?.defaultView?.getSelection();
if (selection?.anchorNode && list.contains(selection.anchorNode)) {
selection.removeAllRanges();
}
}
export function wireSubtitleSidebarSelection(ctx: RendererContext): () => void {
const list = ctx.dom.subtitleSidebarList;
const button = ctx.dom.subtitleSidebarCopy;
const doc = list.ownerDocument;
const abort = new AbortController();
const { signal } = abort;
const updateButton = () => {
const selected = getSubtitleSidebarSelection(list);
button.hidden = !selected;
button.textContent = selected
? `Copy ${selected.cueCount} ${selected.cueCount === 1 ? 'line' : 'lines'}`
: 'Copy';
};
const copied = () => {
ctx.dom.subtitleSidebarStatus.textContent = 'Selection copied.';
};
const copySelection = async () => {
const selected = getSubtitleSidebarSelection(list);
if (!selected) return;
try {
await window.electronAPI.copySubtitleSidebarSelection(selected.text);
copied();
} catch {
ctx.dom.subtitleSidebarStatus.textContent = 'Could not copy selection. Try again.';
}
};
doc.addEventListener('selectionchange', updateButton, { signal });
doc.addEventListener(
'copy',
(event) => {
if (!ctx.state.subtitleSidebarModalOpen || isEditingText(event.target)) return;
const selected = getSubtitleSidebarSelection(list);
if (!selected || !event.clipboardData) return;
event.preventDefault();
event.clipboardData.setData('text/plain', selected.text);
copied();
},
{ signal },
);
// Capture before modal Escape handling and the current-subtitle copy shortcut.
doc.addEventListener(
'keydown',
(event) => {
if (
!ctx.state.subtitleSidebarModalOpen ||
isEditingText(event.target) ||
!getSubtitleSidebarSelection(list)
)
return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
clearSubtitleSidebarSelection(list);
updateButton();
} else if (
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopImmediatePropagation();
void copySelection();
}
},
{ capture: true, signal },
);
button.addEventListener('mousedown', (event) => event.preventDefault(), { signal });
button.addEventListener('click', copySelection, { signal });
list.addEventListener(
'pointerdown',
(event) => {
if (event.button === 0) {
list.dataset.selecting = 'true';
list.scrollTo({ top: list.scrollTop, behavior: 'instant' });
syncOverlayMouseIgnoreState(ctx);
}
},
{ signal },
);
const stopDragging = () => {
delete list.dataset.selecting;
syncOverlayMouseIgnoreState(ctx);
};
doc.addEventListener('pointerup', stopDragging, { signal });
doc.addEventListener('pointercancel', stopDragging, { signal });
doc.defaultView?.addEventListener('blur', stopDragging, { signal });
updateButton();
return () => {
abort.abort();
stopDragging();
};
}
@@ -239,6 +239,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const modalNotifications: string[] = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 3.4, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
@@ -381,6 +382,7 @@ test('subtitle sidebar rows support keyboard activation', async () => {
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
@@ -487,6 +489,7 @@ test('subtitle sidebar renders hour-long cue timestamps as HH:MM:SS', async () =
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 3665, endTime: 3670, text: 'long cue' }],
currentSubtitle: {
text: 'long cue',
@@ -580,6 +583,7 @@ test('subtitle sidebar does not open when the feature is disabled', async () =>
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [],
currentSubtitle: {
text: '',
@@ -676,6 +680,7 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
const previousDocument = globals.document;
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -786,6 +791,7 @@ test('subtitle sidebar auto-open restores previously open sidebar after renderer
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -884,6 +890,7 @@ test('subtitle sidebar refresh closes and clears state when config becomes disab
const previousDocument = globals.document;
const bodyClassList = createClassList();
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1008,6 +1015,7 @@ test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timi
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 3, endTime: 4, text: 'other' },
@@ -1125,6 +1133,7 @@ test('subtitle sidebar does not regress to previous cue on text-only transition
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
@@ -1233,6 +1242,7 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
const previousDocument = globals.document;
let snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: Array.from({ length: 12 }, (_, index) => ({
startTime: index * 2,
endTime: index * 2 + 1.5,
@@ -1403,6 +1413,7 @@ test('subtitle sidebar polling schedules serialized timeouts instead of interval
});
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1515,6 +1526,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1633,6 +1645,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1752,6 +1765,7 @@ test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains op
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -1881,6 +1895,7 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin',
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2040,6 +2055,7 @@ test('subtitle sidebar embedded layout measures reserved width after embedded cl
const contentClassList = createClassList();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2161,6 +2177,7 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2290,6 +2307,7 @@ test('subtitle sidebar overlay layout restores macOS and Windows passthrough out
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2417,6 +2435,7 @@ test('subtitle sidebar overlay layout only stays interactive while focus remains
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2532,6 +2551,7 @@ test('closing embedded subtitle sidebar recomputes passthrough from remaining su
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
@@ -2640,6 +2660,7 @@ test('subtitle sidebar resets embedded mpv margin on startup while closed', asyn
const mpvCommands: Array<Array<string | number>> = [];
const snapshot: SubtitleSidebarSnapshot = {
sourceKey: 'test-subtitles',
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
+16
View File
@@ -7,6 +7,10 @@ import type {
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
clearSubtitleSidebarSelection,
hasSubtitleSidebarSelection,
} from './subtitle-sidebar-selection.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
@@ -209,6 +213,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -469,6 +474,8 @@ export function createSubtitleSidebarModal(
): void {
if (
!ctx.state.subtitleSidebarAutoScroll ||
ctx.dom.subtitleSidebarList.dataset?.selecting === 'true' ||
hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList) ||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
@@ -565,8 +572,14 @@ export function createSubtitleSidebarModal(
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1;
subtitleSourceKey = snapshot.sourceKey;
}
applyConfig(snapshot);
if (!snapshot.config.enabled) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.subtitleSidebarCues = [];
@@ -586,6 +599,7 @@ export function createSubtitleSidebarModal(
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
if (cuesChanged) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
ctx.state.subtitleSidebarCues = snapshot.cues;
if (ctx.state.subtitleSidebarModalOpen) {
renderCueList();
@@ -670,6 +684,7 @@ export function createSubtitleSidebarModal(
if (!ctx.state.subtitleSidebarModalOpen) {
return;
}
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.subtitleSidebarModalOpen = false;
@@ -710,6 +725,7 @@ export function createSubtitleSidebarModal(
closeSubtitleSidebarModal();
});
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
if (hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList)) return;
const target = event.target;
if (!(target instanceof Element)) {
return;
+3 -1
View File
@@ -27,7 +27,9 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldKeepWindowInteractive =
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
ctx.dom.subtitleSidebarList?.dataset?.selecting === 'true' ||
isYomitanPopupInteractionActive(ctx.state) ||
isBlockingOverlayModalOpen(ctx.state);
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
+3
View File
@@ -40,6 +40,7 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createChangelogModal } from './modals/changelog.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
@@ -239,6 +240,8 @@ const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
measurementReporter.emitNow();
},
});
const disposeSubtitleSidebarSelection = wireSubtitleSidebarSelection(ctx);
window.addEventListener('beforeunload', disposeSubtitleSidebarSelection, { once: true });
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
+3
View File
@@ -3690,6 +3690,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
}
.subtitle-sidebar-timestamp {
user-select: none;
font-size: 0.72em;
font-weight: 600;
font-variant-numeric: tabular-nums;
@@ -3712,6 +3713,8 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
}
.subtitle-sidebar-text {
user-select: text;
cursor: text;
white-space: pre-wrap;
line-height: 1.5;
font-size: 1em;
+2
View File
@@ -157,6 +157,7 @@ export type RendererDom = {
subtitleSidebarModal: HTMLDivElement;
subtitleSidebarContent: HTMLDivElement;
subtitleSidebarClose: HTMLButtonElement;
subtitleSidebarCopy: HTMLButtonElement;
subtitleSidebarStatus: HTMLDivElement;
subtitleSidebarList: HTMLUListElement;
@@ -405,6 +406,7 @@ export function resolveRendererDom(): RendererDom {
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
subtitleSidebarCopy: getRequiredElement<HTMLButtonElement>('subtitleSidebarCopy'),
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),