mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-08 17:16:18 -07:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user