From 1e5d7747b4705569023c39f7c73652794ba75f45 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 7 Sep 2026 14:03:54 -0700 Subject: [PATCH] feat(sidebar): add dialogue selection and copying (#238) --- changes/sidebar-selection-copy.md | 4 + docs-site/shortcuts.md | 2 + docs-site/subtitle-sidebar.md | 8 + docs/architecture/domains.md | 8 + src/core/services/ipc.test.ts | 1 + src/core/services/overlay-window-input.ts | 9 ++ src/core/services/overlay-window.test.ts | 29 ++++ src/main.ts | 28 ++++ src/preload-clipboard.test.ts | 46 ++++++ src/preload.ts | 6 +- src/renderer/handlers/keyboard.test.ts | 23 +++ src/renderer/handlers/keyboard.ts | 5 +- src/renderer/index.html | 1 + ...itle-sidebar-selection.electron-fixture.ts | 126 +++++++++++++++ .../modals/subtitle-sidebar-selection.test.ts | 130 +++++++++++++++ .../modals/subtitle-sidebar-selection.ts | 150 ++++++++++++++++++ src/renderer/modals/subtitle-sidebar.test.ts | 21 +++ src/renderer/modals/subtitle-sidebar.ts | 16 ++ src/renderer/overlay-mouse-ignore.ts | 4 +- src/renderer/renderer.ts | 3 + src/renderer/style.css | 3 + src/renderer/utils/dom.ts | 2 + src/types/runtime.ts | 1 + src/types/subtitle.ts | 1 + 24 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 changes/sidebar-selection-copy.md create mode 100644 src/preload-clipboard.test.ts create mode 100644 src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts create mode 100644 src/renderer/modals/subtitle-sidebar-selection.test.ts create mode 100644 src/renderer/modals/subtitle-sidebar-selection.ts diff --git a/changes/sidebar-selection-copy.md b/changes/sidebar-selection-copy.md new file mode 100644 index 00000000..b7ee4410 --- /dev/null +++ b/changes/sidebar-selection-copy.md @@ -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. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index f2e0d079..8a64e99e 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -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` | diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index 4478098b..da03bda5 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -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`: diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index f35eae38..74fd7b7d 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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` diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 18f98564..7196d037 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -89,6 +89,7 @@ function createControllerConfigFixture() { function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot { return { + sourceKey: 'test-subtitles', cues: [], currentSubtitle: { text: '', startTime: null, endTime: null }, config: { diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts index e39ecee4..5f783701 100644 --- a/src/core/services/overlay-window-input.ts +++ b/src/core/services/overlay-window-input.ts @@ -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(); diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 2458f017..2d38c042 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -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[] = []; diff --git a/src/main.ts b/src/main.ts index ce5625f3..f142fd01 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, diff --git a/src/preload-clipboard.test.ts b/src/preload-clipboard.test.ts new file mode 100644 index 00000000..d0bd4224 --- /dev/null +++ b/src/preload-clipboard.test.ts @@ -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]); +}); diff --git a/src/preload.ts b/src/preload.ts index e44e2f56..b650aba6 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -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 => ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused), onSubtitleAss: (callback: (assText: string) => void) => { diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 35392ed1..6638680e 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index d83708ad..bc3cd0e8 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -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'); } diff --git a/src/renderer/index.html b/src/renderer/index.html index c12ed781..ca62caef 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -731,6 +731,7 @@