mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-07 20:43:00 -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:
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
|
||||
@@ -25,6 +25,8 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
||||
|
||||
These work when the overlay window has focus.
|
||||
|
||||
When text is selected in the [subtitle sidebar](./subtitle-sidebar.md#selecting-and-copying-dialogue), `Ctrl/Cmd+C` copies that selection without timestamps, taking priority over the current-subtitle action. `Escape` clears the sidebar selection.
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | ----------------------------------------------- | --------------------------------------- |
|
||||
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
|
||||
|
||||
@@ -16,6 +16,14 @@ For typeset ASS karaoke and animated signs, SubMiner collapses generated animati
|
||||
|
||||
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
|
||||
|
||||
## Selecting and copying dialogue
|
||||
|
||||
Drag across subtitle text to select an excerpt, including across multiple rows. Scroll to extend a selection through a longer conversation. `Ctrl/Cmd+C` or the **Copy** button copies the highlighted text in subtitle order, without timestamps. Partial first and last lines are preserved, with a blank line between subtitle cues.
|
||||
|
||||
Dragging to select does not seek playback. Playback-following auto-scroll stops while you drag or have a selection, so the excerpt stays in view. Press `Escape` to clear the selection. An ordinary click with no selection still seeks to that cue.
|
||||
|
||||
Selection survives playback updates and Yomitan popup dismissal. Changing media or subtitle sources, refreshing the cue list, or closing the sidebar clears it. Copying an excerpt does not require creating an Anki card.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
Two layout modes are available via `subtitleSidebar.layout`:
|
||||
|
||||
@@ -37,6 +37,14 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
## Shared Contract Entry Points
|
||||
|
||||
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
||||
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
||||
including changes whose cue text and timings are identical. Native selection and clean clipboard
|
||||
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
||||
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
||||
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
||||
on Chromium document focus or require activating the overlay window.
|
||||
|
||||
- Config + app-state contracts: `src/types/config.ts`
|
||||
- Subtitle/token/media annotation contracts: `src/types/subtitle.ts`
|
||||
- Runtime/window/controller/Electron bridge contracts: `src/types/runtime.ts`
|
||||
|
||||
@@ -89,6 +89,7 @@ function createControllerConfigFixture() {
|
||||
|
||||
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||
return {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [],
|
||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||
config: {
|
||||
|
||||
@@ -37,6 +37,15 @@ export function handleOverlayWindowBeforeInputEvent(options: {
|
||||
if (options.kind === 'modal') return false;
|
||||
if (!options.windowVisible) return false;
|
||||
|
||||
// The renderer decides whether Copy targets selected sidebar text or the live cue.
|
||||
if (
|
||||
(options.input.control || options.input.meta) &&
|
||||
!options.input.alt &&
|
||||
!options.input.shift &&
|
||||
(options.input.code === 'KeyC' || options.input.key.toLowerCase() === 'c')
|
||||
)
|
||||
return false;
|
||||
|
||||
if (isKeyboardModeToggleInput(options.input)) {
|
||||
options.preventDefault();
|
||||
options.sendKeyboardModeToggleRequested();
|
||||
|
||||
@@ -85,6 +85,35 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('native Copy reaches the renderer before the current-subtitle fallback', () => {
|
||||
for (const modifier of [{ control: true }, { meta: true }]) {
|
||||
const handled = handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
input: {
|
||||
type: 'keyDown',
|
||||
key: 'c',
|
||||
code: 'KeyC',
|
||||
isAutoRepeat: false,
|
||||
isComposing: false,
|
||||
shift: false,
|
||||
control: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
location: 0,
|
||||
modifiers: [],
|
||||
...modifier,
|
||||
},
|
||||
preventDefault: () => assert.fail('Copy must reach Chromium'),
|
||||
sendKeyboardModeToggleRequested: () => assert.fail('Unexpected mode toggle'),
|
||||
sendLookupWindowToggleRequested: () => assert.fail('Unexpected lookup toggle'),
|
||||
tryHandleOverlayShortcutLocalFallback: () => assert.fail('Renderer owns Copy'),
|
||||
forwardTabToMpv: () => assert.fail('Unexpected mpv input'),
|
||||
});
|
||||
assert.equal(handled, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
+28
@@ -5724,6 +5724,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5743,6 +5747,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||
if (!videoPath) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5757,6 +5765,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
})
|
||||
) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5773,6 +5785,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5783,6 +5799,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
try {
|
||||
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5796,6 +5816,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5806,6 +5830,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
test('sidebar clipboard bridge writes exact text without renderer focus and rejects non-text input', async () => {
|
||||
const result = await build({
|
||||
entryPoints: ['src/preload.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['electron'],
|
||||
write: false,
|
||||
});
|
||||
const output = result.outputFiles[0];
|
||||
assert.ok(output);
|
||||
const writes: string[] = [];
|
||||
let exposed: unknown;
|
||||
runInNewContext(output.text, {
|
||||
process: { argv: [] },
|
||||
require: (name: string) => {
|
||||
assert.equal(name, 'electron');
|
||||
return {
|
||||
ipcRenderer: { on: () => {} },
|
||||
clipboard: { writeText: (text: string) => writes.push(text) },
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name: string, api: unknown) => {
|
||||
exposed = api;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.ok(
|
||||
typeof exposed === 'object' && exposed !== null && 'copySubtitleSidebarSelection' in exposed,
|
||||
);
|
||||
const copy = exposed.copySubtitleSidebarSelection;
|
||||
if (typeof copy !== 'function') throw new Error('Missing clipboard bridge');
|
||||
const text = '最初の台詞\n二行目\n\n同じ台詞';
|
||||
await copy(text);
|
||||
assert.deepEqual(writes, [text]);
|
||||
for (const value of [null, undefined, 42, { text }, ['台詞']]) {
|
||||
await assert.rejects(async () => copy(value), /Subtitle selection must be text/);
|
||||
}
|
||||
assert.deepEqual(writes, [text]);
|
||||
});
|
||||
+5
-1
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||
import type {
|
||||
SubtitleData,
|
||||
@@ -301,6 +301,10 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
|
||||
getSubtitleSidebarSnapshot: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
||||
copySubtitleSidebarSelection: async (text: unknown) => {
|
||||
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
|
||||
clipboard.writeText(text);
|
||||
},
|
||||
getPlaybackPaused: (): Promise<boolean | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -448,6 +448,7 @@ export interface ElectronAPI {
|
||||
getCurrentSubtitleRaw: () => Promise<string>;
|
||||
getCurrentSubtitleAss: () => Promise<string>;
|
||||
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
||||
copySubtitleSidebarSelection: (text: string) => Promise<void>;
|
||||
getSubtitleSidebarOpen: () => Promise<boolean>;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||
|
||||
@@ -227,6 +227,7 @@ export interface SubtitleData {
|
||||
}
|
||||
|
||||
export interface SubtitleSidebarSnapshot {
|
||||
sourceKey: string | null;
|
||||
cues: SubtitleCue[];
|
||||
currentTimeSec?: number | null;
|
||||
currentSubtitle: {
|
||||
|
||||
Reference in New Issue
Block a user