Compare commits

...

6 Commits

46 changed files with 1623 additions and 96 deletions

View File

@@ -4,7 +4,7 @@ title: 'Subtitle hover: auto-pause playback with config toggle'
status: Done
assignee: []
created_date: '2026-02-28 22:43'
updated_date: '2026-02-28 22:43'
updated_date: '2026-03-04 12:07'
labels: []
dependencies: []
priority: medium
@@ -43,4 +43,11 @@ Scope:
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
Follow-up adjustments (2026-03-04):
- Hover pause now resumes immediately when leaving subtitle text (no Yomitan-popup hover retention).
- Added `subtitleStyle.autoPauseVideoOnYomitanPopup` (default `false`) to optionally keep playback paused while Yomitan popup is open, with auto-resume on close only when SubMiner initiated the popup pause.
- Yomitan popup control keybinds added while popup is open: `J/K` scroll, `M` mine, `P` audio play, `[` previous audio variant, `]` next audio variant (within selected source).
- Extension copy drift detection widened so popup runtime changes are reliably re-copied on launch (`popup.js`, `popup-main.js`, `display.js`, `display-audio.js`).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,38 @@
---
id: TASK-85
title: 'Remove docs Plausible analytics integration'
status: Done
assignee: []
created_date: '2026-03-03 00:00'
updated_date: '2026-03-03 00:00'
labels: []
dependencies: []
priority: medium
ordinal: 12001
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove Plausible analytics integration from docs theme and dependency graph. Keep docs build/runtime analytics-free.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Docs theme no longer imports or initializes Plausible tracker.
- [x] #2 `@plausible-analytics/tracker` removed from dependencies and lockfile.
- [x] #3 Docs analytics test reflects absence of Plausible wiring.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Deleted Plausible runtime wiring from VitePress theme, removed tracker package via `bun remove`, and updated docs test to assert no Plausible integration remains.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -6,7 +6,6 @@
"name": "subminer",
"dependencies": {
"@catppuccin/vitepress": "^0.1.2",
"@plausible-analytics/tracker": "^0.4.4",
"axios": "^1.13.5",
"commander": "^14.0.3",
"discord-rpc": "^4.0.1",
@@ -189,8 +188,6 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],

View File

@@ -5,26 +5,8 @@ import '@catppuccin/vitepress/theme/macchiato/mauve.css';
import './mermaid-modal.css';
let mermaidLoader: Promise<any> | null = null;
let plausibleTrackerInitialized = false;
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
async function initPlausibleTracker() {
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
return;
}
const { init } = await import('@plausible-analytics/tracker');
init({
domain: 'subminer.moe',
endpoint: 'https://worker.subminer.moe/api/event',
outboundLinks: true,
fileDownloads: true,
formSubmissions: true,
captureOnLocalhost: false,
});
plausibleTrackerInitialized = true;
}
function closeMermaidModal() {
if (typeof document === 'undefined') {
return;
@@ -207,9 +189,6 @@ export default {
};
onMounted(() => {
initPlausibleTracker().catch((error) => {
console.error('Failed to initialize Plausible tracker:', error);
});
render();
});
watch(() => route.path, render);

View File

@@ -258,7 +258,8 @@ See `config.example.jsonc` for detailed configuration options.
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text; resume after leaving subtitle area (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while Yomitan popup is open; resume when popup closes (`false` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |

View File

@@ -34,6 +34,7 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w
- Word-level click targets for Yomitan lookup
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
- Optional auto-pause while Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
- Right-click to pause/resume
- Right-click + drag to reposition subtitles
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options

View File

@@ -4,12 +4,12 @@ import { readFileSync } from 'node:fs';
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe api endpoint', () => {
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe/api/event'");
expect(docsThemeContents).toContain('outboundLinks: true');
expect(docsThemeContents).toContain('fileDownloads: true');
expect(docsThemeContents).toContain('formSubmissions: true');
test('docs theme has no plausible analytics wiring', () => {
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
expect(docsThemeContents).not.toContain('initPlausibleTracker');
expect(docsThemeContents).not.toContain('worker.subminer.moe');
expect(docsThemeContents).not.toContain('domain:');
expect(docsThemeContents).not.toContain('outboundLinks: true');
expect(docsThemeContents).not.toContain('fileDownloads: true');
expect(docsThemeContents).not.toContain('formSubmissions: true');
});

View File

@@ -107,7 +107,8 @@
"subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text; resume after leaving subtitle area. Values: true | false
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open; resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.

View File

@@ -58,7 +58,18 @@ These control playback and subtitle display. They require overlay window focus.
These keybindings can be overridden or disabled via the `keybindings` config array.
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover; resume after leaving subtitle area). Optional popup behavior: set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true` to keep playback paused while Yomitan popup is open.
When a Yomitan popup is open, SubMiner also provides popup control shortcuts:
| Shortcut | Action |
| ------------- | -------------------------------------- |
| `J` | Scroll definitions down |
| `K` | Scroll definitions up |
| `M` | Mine/add selected term |
| `P` | Play selected term audio |
| `[` | Play previous available audio (selected source) |
| `]` | Play next available audio (selected source) |
## Subtitle & Feature Shortcuts

View File

@@ -209,7 +209,11 @@ Notes:
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
By default, hovering over subtitle text pauses mpv playback. Playback resumes as soon as the cursor leaves subtitle text. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
If the Yomitan popup is open, you can control it directly from the overlay: `J/K` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source.
### Drag-and-drop Queueing

View File

@@ -58,7 +58,6 @@
"license": "GPL-3.0-or-later",
"dependencies": {
"@catppuccin/vitepress": "^0.1.2",
"@plausible-analytics/tracker": "^0.4.4",
"axios": "^1.13.5",
"commander": "^14.0.3",
"discord-rpc": "^4.0.1",

View File

@@ -33,6 +33,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(
@@ -160,6 +161,44 @@ test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', (
);
});
test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnYomitanPopup": true
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnYomitanPopup": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup,
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'),
);
});
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(

View File

@@ -5,6 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
enableJlpt: false,
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: false,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',

View File

@@ -20,6 +20,7 @@ test('config option registry includes critical paths and has unique entries', ()
'logging.level',
'startupWarmups.lowPowerMode',
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'immersionTracking.enabled',
]) {

View File

@@ -28,6 +28,13 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
},
{
path: 'subtitleStyle.autoPauseVideoOnYomitanPopup',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnYomitanPopup,
description:
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
},
{
path: 'subtitleStyle.hoverTokenColor',
kind: 'string',

View File

@@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor;
@@ -171,6 +173,26 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const autoPauseVideoOnYomitanPopup = asBoolean(
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown }).autoPauseVideoOnYomitanPopup,
);
if (autoPauseVideoOnYomitanPopup !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup = autoPauseVideoOnYomitanPopup;
} else if (
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
.autoPauseVideoOnYomitanPopup !== undefined
) {
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup =
fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup;
warn(
'subtitleStyle.autoPauseVideoOnYomitanPopup',
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
.autoPauseVideoOnYomitanPopup,
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup,
'Expected boolean.',
);
}
const hoverTokenColor = asColor(
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
);

View File

@@ -47,6 +47,25 @@ test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
);
});
test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', () => {
const { context, warnings } = createResolveContext({
subtitleStyle: {
autoPauseVideoOnYomitanPopup: 'invalid' as unknown as boolean,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' &&
warning.message === 'Expected boolean.',
),
);
});
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {

View File

@@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -24,6 +25,24 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
export type OverlayWindowKind = 'visible' | 'modal';
function isLookupWindowToggleInput(input: Electron.Input): boolean {
if (input.type !== 'keyDown') return false;
if (input.alt) return false;
if (!input.control && !input.meta) return false;
if (input.shift) return false;
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
return input.code === 'KeyY' || normalizedKey === 'y';
}
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
if (input.type !== 'keyDown') return false;
if (input.alt) return false;
if (!input.control && !input.meta) return false;
if (!input.shift) return false;
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
return input.code === 'KeyY' || normalizedKey === 'y';
}
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
window: BrowserWindow | null,
@@ -118,6 +137,16 @@ export function createOverlayWindow(
window.webContents.on('before-input-event', (event, input) => {
if (kind === 'modal') return;
if (!window.isVisible()) return;
if (isKeyboardModeToggleInput(input)) {
event.preventDefault();
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested);
return;
}
if (isLookupWindowToggleInput(input)) {
event.preventDefault();
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested);
return;
}
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
event.preventDefault();
});

View File

@@ -972,6 +972,34 @@ test('tokenizeSubtitle skips frequency rank when Yomitan token is enriched as pa
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
});
test('tokenizeSubtitle keeps frequency rank when mecab tags classify token as content-bearing', async () => {
const result = await tokenizeSubtitle(
'ふふ',
makeDepsFromYomitanTokens([{ surface: 'ふふ', reading: '', headword: 'ふふ' }], {
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === 'ふふ' ? 3014 : null),
tokenizeWithMecab: async () => [
{
headword: 'ふふ',
surface: 'ふふ',
reading: 'フフ',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, 3014);
});
test('tokenizeSubtitle ignores invalid frequency ranks', async () => {
const result = await tokenizeSubtitle(
'猫',
@@ -2400,7 +2428,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
});
test('tokenizeSubtitle keeps merged token when overlap contains at least one content pos1 tag', async () => {
test('tokenizeSubtitle excludes merged function/content token from frequency highlighting but keeps N+1', async () => {
const result = await tokenizeSubtitle(
'になれば',
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
@@ -2453,7 +2481,7 @@ test('tokenizeSubtitle keeps merged token when overlap contains at least one con
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
assert.equal(result.tokens?.[0]?.frequencyRank, 13);
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
});

View File

@@ -314,6 +314,26 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag
assert.equal(result[0]?.frequencyRank, undefined);
});
test('annotateTokens keeps frequency when mecab tags classify token as content-bearing', () => {
const tokens = [
makeToken({
surface: 'ふふ',
headword: 'ふふ',
pos1: '動詞',
pos2: '自立',
frequencyRank: 3014,
startPos: 0,
endPos: 2,
}),
];
const result = annotateTokens(tokens, makeDeps(), {
minSentenceWordsForNPlusOne: 1,
});
assert.equal(result[0]?.frequencyRank, 3014);
});
test('annotateTokens allows previously default-excluded pos2 when removed from effective set', () => {
const tokens = [
makeToken({
@@ -337,7 +357,7 @@ test('annotateTokens allows previously default-excluded pos2 when removed from e
assert.equal(result[0]?.isNPlusOneTarget, true);
});
test('annotateTokens keeps composite tokens when any component pos tag is content-bearing', () => {
test('annotateTokens excludes composite function/content tokens from frequency but keeps N+1 eligible', () => {
const tokens = [
makeToken({
surface: 'になれば',
@@ -354,7 +374,7 @@ test('annotateTokens keeps composite tokens when any component pos tag is conten
minSentenceWordsForNPlusOne: 1,
});
assert.equal(result[0]?.frequencyRank, 5);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.isNPlusOneTarget, true);
});

View File

@@ -73,8 +73,9 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
if (parts.length === 0) {
return false;
}
// Composite tags like "助詞|名詞" stay eligible unless every component is excluded.
return parts.every((part) => exclusions.has(part));
// Frequency highlighting should be conservative: if any merged component is excluded,
// skip highlighting the whole token to avoid noisy merged fragments.
return parts.some((part) => exclusions.has(part));
}
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {

View File

@@ -39,6 +39,30 @@ test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallba
assert.equal(enriched[0]?.pos1, '助詞');
});
test('enrichTokensWithMecabPos1 keeps partOfSpeech unchanged and only enriches POS tags', () => {
const tokens = [makeToken({ surface: 'これは', startPos: 0, endPos: 3 })];
const mecabTokens = [
makeToken({
surface: 'これ',
startPos: 0,
endPos: 2,
pos1: '名詞',
partOfSpeech: PartOfSpeech.noun,
}),
makeToken({
surface: 'は',
startPos: 2,
endPos: 3,
pos1: '助詞',
partOfSpeech: PartOfSpeech.particle,
}),
];
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
assert.equal(enriched[0]?.pos1, '名詞|助詞');
assert.equal(enriched[0]?.partOfSpeech, PartOfSpeech.other);
});
test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are null or empty', () => {
const tokens = [makeToken({ surface: '猫', startPos: 0, endPos: 1 })];

View File

@@ -41,6 +41,8 @@ test('syncYomitanDefaultAnkiServer updates default profile server when script re
assert.equal(updated, true);
assert.match(scriptValue, /optionsGetFull/);
assert.match(scriptValue, /setAllSettings/);
assert.match(scriptValue, /profileCurrent/);
assert.match(scriptValue, /forceOverride = false/);
assert.equal(infoLogs.length, 1);
});
@@ -59,6 +61,45 @@ test('syncYomitanDefaultAnkiServer returns true when script reports no change',
assert.equal(infoLogCount, 0);
});
test('syncYomitanDefaultAnkiServer returns false when existing non-default server blocks update', async () => {
const deps = createDeps(async () => ({
updated: false,
matched: false,
reason: 'blocked-existing-server',
}));
const infoLogs: string[] = [];
const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
error: () => undefined,
info: (message) => infoLogs.push(message),
});
assert.equal(synced, false);
assert.equal(infoLogs.length, 1);
assert.match(infoLogs[0] ?? '', /blocked-existing-server/);
});
test('syncYomitanDefaultAnkiServer injects force override when enabled', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
scriptValue = script;
return { updated: false, matched: true };
});
const synced = await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
deps,
{
error: () => undefined,
info: () => undefined,
},
{ forceOverride: true },
);
assert.equal(synced, true);
assert.match(scriptValue, /forceOverride = true/);
});
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
const deps = createDeps(async () => {
throw new Error('execute failed');

View File

@@ -848,11 +848,15 @@ export async function syncYomitanDefaultAnkiServer(
serverUrl: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
options?: {
forceOverride?: boolean;
},
): Promise<boolean> {
const normalizedTargetServer = serverUrl.trim();
if (!normalizedTargetServer) {
return false;
}
const forceOverride = options?.forceOverride === true;
const isReady = await ensureYomitanParserWindow(deps, logger);
const parserWindow = deps.getYomitanParserWindow();
@@ -882,35 +886,42 @@ export async function syncYomitanDefaultAnkiServer(
});
const targetServer = ${JSON.stringify(normalizedTargetServer)};
const forceOverride = ${forceOverride ? 'true' : 'false'};
const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profiles.length === 0) {
return { updated: false, reason: "no-profiles" };
}
const defaultProfile = profiles[0];
if (!defaultProfile || typeof defaultProfile !== "object") {
const profileCurrent = Number.isInteger(optionsFull.profileCurrent)
? optionsFull.profileCurrent
: 0;
const targetProfile = profiles[profileCurrent];
if (!targetProfile || typeof targetProfile !== "object") {
return { updated: false, reason: "invalid-default-profile" };
}
defaultProfile.options = defaultProfile.options && typeof defaultProfile.options === "object"
? defaultProfile.options
targetProfile.options = targetProfile.options && typeof targetProfile.options === "object"
? targetProfile.options
: {};
defaultProfile.options.anki = defaultProfile.options.anki && typeof defaultProfile.options.anki === "object"
? defaultProfile.options.anki
targetProfile.options.anki = targetProfile.options.anki && typeof targetProfile.options.anki === "object"
? targetProfile.options.anki
: {};
const currentServerRaw = defaultProfile.options.anki.server;
const currentServerRaw = targetProfile.options.anki.server;
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
const canReplaceDefault =
currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
if (!canReplaceDefault || currentServer === targetServer) {
return { updated: false, reason: "no-change", currentServer, targetServer };
if (currentServer === targetServer) {
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer };
}
const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
if (!canReplaceCurrent) {
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
}
defaultProfile.options.anki.server = targetServer;
targetProfile.options.anki.server = targetServer;
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
return { updated: true, currentServer, targetServer };
return { updated: true, matched: true, currentServer, targetServer };
})();
`;
@@ -924,6 +935,24 @@ export async function syncYomitanDefaultAnkiServer(
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
return true;
}
const matchedWithoutUpdate =
isObject(result) &&
result.updated === false &&
(result as { matched?: unknown }).matched === true;
if (matchedWithoutUpdate) {
return true;
}
const blockedByExistingServer =
isObject(result) &&
result.updated === false &&
(result as { matched?: unknown }).matched === false &&
typeof (result as { reason?: unknown }).reason === 'string';
if (blockedByExistingServer) {
logger.info?.(
`Skipped syncing Yomitan Anki server (reason=${String((result as { reason: string }).reason)})`,
);
return false;
}
const checkedWithoutUpdate =
typeof result === 'object' &&
result !== null &&

View File

@@ -0,0 +1,53 @@
import * as fs from 'fs';
import * as path from 'path';
const YOMITAN_SYNC_SCRIPT_PATHS = [
path.join('js', 'app', 'popup.js'),
path.join('js', 'display', 'popup-main.js'),
path.join('js', 'display', 'display.js'),
path.join('js', 'display', 'display-audio.js'),
];
function readManifestVersion(manifestPath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
return typeof parsed.version === 'string' ? parsed.version : null;
} catch {
return null;
}
}
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
try {
return fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath));
} catch {
return false;
}
}
export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string): boolean {
if (!fs.existsSync(targetDir)) {
return true;
}
const sourceManifest = path.join(sourceDir, 'manifest.json');
const targetManifest = path.join(targetDir, 'manifest.json');
if (!fs.existsSync(sourceManifest) || !fs.existsSync(targetManifest)) {
return true;
}
const sourceVersion = readManifestVersion(sourceManifest);
const targetVersion = readManifestVersion(targetManifest);
if (sourceVersion === null || targetVersion === null || sourceVersion !== targetVersion) {
return true;
}
for (const relativePath of YOMITAN_SYNC_SCRIPT_PATHS) {
if (!areFilesEqual(path.join(sourceDir, relativePath), path.join(targetDir, relativePath))) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
function writeFile(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
}
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
const sourceDir = path.join(tempRoot, 'source');
const targetDir = path.join(tempRoot, 'target');
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
});
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
const sourceDir = path.join(tempRoot, 'source');
const targetDir = path.join(tempRoot, 'target');
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
});

View File

@@ -2,6 +2,7 @@ import { BrowserWindow, Extension, session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
const logger = createLogger('main:yomitan-extension-loader');
@@ -22,27 +23,7 @@ function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const sourceManifest = path.join(sourceDir, 'manifest.json');
const targetManifest = path.join(targetDir, 'manifest.json');
let shouldCopy = !fs.existsSync(targetDir);
if (!shouldCopy && fs.existsSync(sourceManifest) && fs.existsSync(targetManifest)) {
try {
const sourceVersion = (
JSON.parse(fs.readFileSync(sourceManifest, 'utf-8')) as {
version: string;
}
).version;
const targetVersion = (
JSON.parse(fs.readFileSync(targetManifest, 'utf-8')) as {
version: string;
}
).version;
shouldCopy = sourceVersion !== targetVersion;
} catch {
shouldCopy = true;
}
}
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
if (shouldCopy) {
fs.mkdirSync(extensionsRoot, { recursive: true });

View File

@@ -2663,6 +2663,9 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
logger.info(message, ...args);
},
},
{
forceOverride: getResolvedConfig().ankiConnect.proxy?.enabled === true,
},
);
if (synced) {

View File

@@ -118,6 +118,12 @@ function createQueuedIpcListenerWithPayload<T>(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.keyboardModeToggleRequested,
);
const onLookupWindowToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.lookupWindowToggleRequested,
);
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload,
@@ -282,6 +288,8 @@ const electronAPI: ElectronAPI = {
},
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenJimaku: onOpenJimakuEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {

View File

@@ -6,6 +6,7 @@ import {
YOMITAN_POPUP_IFRAME_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
isYomitanPopupVisible,
} from './yomitan-popup.js';
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
import { resolvePlatformInfo } from './utils/platform.js';
@@ -283,6 +284,43 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
});
test('isYomitanPopupVisible requires visible iframe geometry', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
let selector = '';
const visibleFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
const hiddenFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
getComputedStyle: (element: Element) => {
if (element === hiddenFrame) {
return { visibility: 'hidden', display: 'block', opacity: '1' } as CSSStyleDeclaration;
}
return { visibility: 'visible', display: 'block', opacity: '1' } as CSSStyleDeclaration;
},
},
});
try {
const root = {
querySelectorAll: (value: string) => {
selector = value;
return [hiddenFrame, visibleFrame];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
const activeItem = {

View File

@@ -1,6 +1,11 @@
import type { Keybinding } from '../../types';
import type { RendererContext } from '../context';
import { hasYomitanPopupIframe, isYomitanPopupIframe } from '../yomitan-popup.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_COMMAND_EVENT,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
export function createKeyboardHandlers(
ctx: RendererContext,
@@ -20,6 +25,7 @@ export function createKeyboardHandlers(
) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
const CHORD_MAP = new Map<
string,
@@ -55,6 +61,293 @@ export function createKeyboardHandlers(
return parts.join('+');
}
function dispatchYomitanPopupKeydown(
key: string,
code: string,
modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'>,
repeat: boolean,
) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'forwardKeyDown',
key,
code,
modifiers,
repeat,
},
}),
);
}
function dispatchYomitanPopupVisibility(visible: boolean) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'setVisible',
visible,
},
}),
);
}
function dispatchYomitanPopupMineSelected() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'mineSelected',
},
}),
);
}
function dispatchYomitanFrontendScanSelectedText() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'scanSelectedText',
},
}),
);
}
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey;
}
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
}
function isLookupWindowToggle(e: KeyboardEvent): boolean {
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
}
function getSubtitleWordNodes(): HTMLElement[] {
return Array.from(ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'));
}
function syncKeyboardTokenSelection(): void {
const wordNodes = getSubtitleWordNodes();
for (const wordNode of wordNodes) {
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
}
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
ctx.state.keyboardSelectedWordIndex = null;
return;
}
const selectedIndex = Math.min(
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
wordNodes.length - 1,
);
ctx.state.keyboardSelectedWordIndex = selectedIndex;
const selectedWordNode = wordNodes[selectedIndex];
if (selectedWordNode) {
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
}
}
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
ctx.state.keyboardDrivenModeEnabled = enabled;
if (!enabled) {
ctx.state.keyboardSelectedWordIndex = null;
}
syncKeyboardTokenSelection();
}
function toggleKeyboardDrivenMode(): void {
setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled);
}
function moveKeyboardSelection(delta: -1 | 1): boolean {
const wordNodes = getSubtitleWordNodes();
if (wordNodes.length === 0) {
ctx.state.keyboardSelectedWordIndex = null;
syncKeyboardTokenSelection();
return true;
}
const currentIndex = ctx.state.keyboardSelectedWordIndex ?? 0;
const nextIndex = Math.min(Math.max(currentIndex + delta, 0), wordNodes.length - 1);
ctx.state.keyboardSelectedWordIndex = nextIndex;
syncKeyboardTokenSelection();
return true;
}
type ScanModifierState = {
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
};
function emitSyntheticScanEvents(
target: Element,
clientX: number,
clientY: number,
modifiers: ScanModifierState = {},
): void {
if (typeof PointerEvent !== 'undefined') {
const pointerEventInit = {
bubbles: true,
cancelable: true,
composed: true,
clientX,
clientY,
pointerType: 'mouse',
isPrimary: true,
button: 0,
buttons: 0,
shiftKey: modifiers.shiftKey ?? false,
ctrlKey: modifiers.ctrlKey ?? false,
altKey: modifiers.altKey ?? false,
metaKey: modifiers.metaKey ?? false,
} satisfies PointerEventInit;
target.dispatchEvent(new PointerEvent('pointerover', pointerEventInit));
target.dispatchEvent(new PointerEvent('pointermove', pointerEventInit));
target.dispatchEvent(new PointerEvent('pointerdown', { ...pointerEventInit, buttons: 1 }));
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
}
const mouseEventInit = {
bubbles: true,
cancelable: true,
composed: true,
clientX,
clientY,
button: 0,
buttons: 0,
shiftKey: modifiers.shiftKey ?? false,
ctrlKey: modifiers.ctrlKey ?? false,
altKey: modifiers.altKey ?? false,
metaKey: modifiers.metaKey ?? false,
} satisfies MouseEventInit;
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
}
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
emitSyntheticScanEvents(target, clientX, clientY, {});
}
function selectWordNodeText(wordNode: HTMLElement): void {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(wordNode);
selection.removeAllRanges();
selection.addRange(range);
ctx.dom.subtitleRoot.classList.add('has-selection');
}
function triggerLookupForSelectedWord(): boolean {
const wordNodes = getSubtitleWordNodes();
if (wordNodes.length === 0) {
return false;
}
const selectedIndex = Math.min(
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
wordNodes.length - 1,
);
ctx.state.keyboardSelectedWordIndex = selectedIndex;
const selectedWordNode = wordNodes[selectedIndex];
if (!selectedWordNode) return false;
syncKeyboardTokenSelection();
selectWordNodeText(selectedWordNode);
const rect = selectedWordNode.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
dispatchYomitanFrontendScanSelectedText();
// Fallback only if the explicit scan path did not open popup quickly.
setTimeout(() => {
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
return;
}
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
// elementFromPoint may resolve to the underlying video surface instead.
emitLookupScanFallback(selectedWordNode, clientX, clientY);
}, 60);
return true;
}
function handleKeyboardModeToggleRequested(): void {
toggleKeyboardDrivenMode();
}
function handleLookupWindowToggleRequested(): void {
if (ctx.state.yomitanPopupVisible) {
dispatchYomitanPopupVisibility(false);
if (ctx.state.keyboardDrivenModeEnabled) {
queueMicrotask(() => {
restoreOverlayKeyboardFocus();
});
}
return;
}
triggerLookupForSelectedWord();
}
function restoreOverlayKeyboardFocus(): void {
void window.electronAPI.focusMainWindow();
window.focus();
ctx.dom.overlay.focus({ preventScroll: true });
}
function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean {
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return false;
}
const key = e.code;
if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'KeyH' || key === 'KeyK') {
return moveKeyboardSelection(-1);
}
if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'KeyL' || key === 'KeyJ') {
return moveKeyboardSelection(1);
}
return false;
}
function handleYomitanPopupKeybind(e: KeyboardEvent): boolean {
if (e.repeat) return false;
const modifierOnlyCodes = new Set([
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
]);
if (modifierOnlyCodes.has(e.code)) return false;
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') {
dispatchYomitanPopupMineSelected();
return true;
}
const modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'> = [];
if (e.altKey) modifiers.push('alt');
if (e.ctrlKey) modifiers.push('ctrl');
if (e.shiftKey) modifiers.push('shift');
if (e.metaKey) modifiers.push('meta');
dispatchYomitanPopupKeydown(e.key, e.code, modifiers, e.repeat);
return true;
}
function resolveSessionHelpChordBinding(): {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -106,9 +399,42 @@ export function createKeyboardHandlers(
async function setupMpvInputForwarding(): Promise<void> {
updateKeybindings(await window.electronAPI.getKeybindings());
syncKeyboardTokenSelection();
const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection();
});
subtitleMutationObserver.observe(ctx.dom.subtitleRoot, {
childList: true,
subtree: true,
});
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
if (!ctx.state.keyboardDrivenModeEnabled) {
return;
}
restoreOverlayKeyboardFocus();
});
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (hasYomitanPopupIframe(document)) return;
if (isKeyboardDrivenModeToggle(e)) {
e.preventDefault();
handleKeyboardModeToggleRequested();
return;
}
if (isLookupWindowToggle(e)) {
e.preventDefault();
handleLookupWindowToggleRequested();
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
}
return;
}
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
@@ -131,6 +457,11 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
e.preventDefault();
return;
}
if (ctx.state.chordPending) {
const modifierKeys = [
'ShiftLeft',
@@ -211,5 +542,8 @@ export function createKeyboardHandlers(
return {
setupMpvInputForwarding,
updateKeybindings,
syncKeyboardTokenSelection,
handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested,
};
}

View File

@@ -2,6 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createMouseHandlers } from './mouse.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
} from '../yomitan-popup.js';
function createClassList() {
const classes = new Set<string>();
@@ -28,6 +32,12 @@ function createDeferred<T>() {
return { promise, resolve };
}
function waitForNextTick(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function createMouseTestContext() {
const overlayClassList = createClassList();
const subtitleRootClassList = createClassList();
@@ -78,6 +88,7 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
@@ -106,6 +117,7 @@ test('auto-pause on subtitle hover skips when playback is already paused', async
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => true,
sendMpvCommand: (command) => {
mpvCommands.push(command);
@@ -131,6 +143,7 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async ()
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
@@ -157,6 +170,7 @@ test('pending hover pause check is ignored when mouse leaves before pause state
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => deferred.promise,
sendMpvCommand: (command) => {
mpvCommands.push(command);
@@ -170,3 +184,273 @@ test('pending hover pause check is ignored when mouse leaves before pause state
assert.deepEqual(mpvCommands, []);
});
test('hover pause resumes immediately on subtitle leave even when yomitan popup is visible', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: () => {},
},
focus: () => {},
innerHeight: 1000,
getSelection: () => null,
setTimeout,
clearTimeout,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
querySelector: () => null,
querySelectorAll: () => [],
body: {},
elementFromPoint: () => null,
addEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
handlers.setupYomitanObserver();
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
await handlers.handleMouseEnter();
await handlers.handleMouseLeave();
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('auto-pause still works when yomitan popup is already visible', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: () => {},
},
focus: () => {},
innerHeight: 1000,
getSelection: () => null,
setTimeout,
clearTimeout,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
querySelector: () => null,
querySelectorAll: () => [],
body: {},
elementFromPoint: () => null,
addEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: (command) => {
mpvCommands.push(command);
},
});
handlers.setupYomitanObserver();
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
await handlers.handleMouseEnter();
await handlers.handleMouseLeave();
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('popup open pauses and popup close resumes when yomitan popup auto-pause is enabled', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: () => {},
},
focus: () => {},
innerHeight: 1000,
getSelection: () => null,
setTimeout,
clearTimeout,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
querySelector: () => null,
querySelectorAll: () => [],
body: {},
elementFromPoint: () => null,
addEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => true,
getYomitanPopupAutoPauseEnabled: () => true,
getPlaybackPaused: async () => false,
sendMpvCommand: (command: (string | number)[]) => {
mpvCommands.push(command);
},
});
handlers.setupYomitanObserver();
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
await waitForNextTick();
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
listener();
}
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});

View File

@@ -2,7 +2,7 @@ import type { ModalStateReader, RendererContext } from '../context';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
hasYomitanPopupIframe,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
@@ -14,16 +14,72 @@ export function createMouseHandlers(
getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
getSubtitleHoverAutoPauseEnabled: () => boolean;
getYomitanPopupAutoPauseEnabled: () => boolean;
getPlaybackPaused: () => Promise<boolean | null>;
sendMpvCommand: (command: (string | number)[]) => void;
},
) {
let yomitanPopupVisible = false;
let hoverPauseRequestId = 0;
let popupPauseRequestId = 0;
let pausedBySubtitleHover = false;
let pausedByYomitanPopup = false;
function maybeResumeHoverPause(): void {
if (!pausedBySubtitleHover) return;
if (pausedByYomitanPopup) return;
if (ctx.state.isOverSubtitle) return;
pausedBySubtitleHover = false;
options.sendMpvCommand(['set_property', 'pause', 'no']);
}
function maybeResumeYomitanPopupPause(): void {
if (!pausedByYomitanPopup) return;
pausedByYomitanPopup = false;
if (ctx.state.isOverSubtitle && options.getSubtitleHoverAutoPauseEnabled()) {
pausedBySubtitleHover = true;
return;
}
options.sendMpvCommand(['set_property', 'pause', 'no']);
}
async function maybePauseForYomitanPopup(): Promise<void> {
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
return;
}
const requestId = ++popupPauseRequestId;
if (pausedByYomitanPopup) return;
if (pausedBySubtitleHover) {
pausedBySubtitleHover = false;
pausedByYomitanPopup = true;
return;
}
let paused: boolean | null = null;
try {
paused = await options.getPlaybackPaused();
} catch {
return;
}
if (
requestId !== popupPauseRequestId ||
!yomitanPopupVisible ||
!options.getYomitanPopupAutoPauseEnabled()
) {
return;
}
if (paused !== false) return;
options.sendMpvCommand(['set_property', 'pause', 'yes']);
pausedByYomitanPopup = true;
}
function enablePopupInteraction(): void {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
@@ -34,12 +90,17 @@ export function createMouseHandlers(
}
function disablePopupInteractionIfIdle(): void {
if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) {
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
yomitanPopupVisible = true;
ctx.state.yomitanPopupVisible = true;
return;
}
yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false;
popupPauseRequestId += 1;
maybeResumeYomitanPopupPause();
maybeResumeHoverPause();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
@@ -55,6 +116,10 @@ export function createMouseHandlers(
window.electronAPI.setIgnoreMouseEvents(false);
}
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
return;
}
if (!options.getSubtitleHoverAutoPauseEnabled()) {
return;
}
@@ -79,10 +144,7 @@ export function createMouseHandlers(
async function handleMouseLeave(): Promise<void> {
ctx.state.isOverSubtitle = false;
hoverPauseRequestId += 1;
if (pausedBySubtitleHover) {
pausedBySubtitleHover = false;
options.sendMpvCommand(['set_property', 'pause', 'no']);
}
maybeResumeHoverPause();
if (yomitanPopupVisible) return;
disablePopupInteractionIfIdle();
}
@@ -143,10 +205,13 @@ export function createMouseHandlers(
}
function setupYomitanObserver(): void {
yomitanPopupVisible = hasYomitanPopupIframe(document);
yomitanPopupVisible = isYomitanPopupVisible(document);
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
void maybePauseForYomitanPopup();
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
enablePopupInteraction();
void maybePauseForYomitanPopup();
});
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
@@ -160,6 +225,7 @@ export function createMouseHandlers(
const element = node as Element;
if (isYomitanPopupIframe(element)) {
enablePopupInteraction();
void maybePauseForYomitanPopup();
}
});

View File

@@ -121,6 +121,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup,
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => {
window.electronAPI.sendMpvCommand(command);
@@ -139,6 +140,16 @@ function truncateForErrorLog(text: string): string {
return `${normalized.slice(0, 177)}...`;
}
function getSubtitleTextForPreview(data: SubtitleData | string): string {
if (typeof data === 'string') {
return data;
}
if (data && typeof data.text === 'string') {
return data.text;
}
return '';
}
function getActiveModal(): string | null {
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku';
@@ -244,6 +255,20 @@ function registerModalOpenHandlers(): void {
);
}
function registerKeyboardCommandHandlers(): void {
window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested();
});
});
window.electronAPI.onLookupWindowToggleRequested(() => {
runGuarded('lookup-window-toggle:requested', () => {
keyboardHandlers.handleLookupWindowToggleRequested();
});
});
}
function runGuarded(action: string, fn: () => void): void {
try {
fn();
@@ -261,6 +286,7 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
}
registerModalOpenHandlers();
registerKeyboardCommandHandlers();
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
@@ -270,11 +296,7 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
if (typeof data === 'string') {
lastSubtitlePreview = truncateForErrorLog(data);
} else if (data && typeof data.text === 'string') {
lastSubtitlePreview = truncateForErrorLog(data.text);
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
});
@@ -287,8 +309,13 @@ async function init(): Promise<void> {
});
});
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
} catch {
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();

View File

@@ -65,6 +65,7 @@ export type RendererState = {
jlptN5Color: string;
preserveSubtitleLineBreaks: boolean;
autoPauseVideoOnSubtitleHover: boolean;
autoPauseVideoOnYomitanPopup: boolean;
frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number;
frequencyDictionaryMode: 'single' | 'banded';
@@ -78,6 +79,9 @@ export type RendererState = {
keybindingsMap: Map<string, (string | number)[]>;
chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null;
keyboardDrivenModeEnabled: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
};
export function createRendererState(): RendererState {
@@ -128,6 +132,7 @@ export function createRendererState(): RendererState {
jlptN5Color: '#8aadf4',
preserveSubtitleLineBreaks: false,
autoPauseVideoOnSubtitleHover: false,
autoPauseVideoOnYomitanPopup: false,
frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: 'single',
@@ -141,5 +146,8 @@ export function createRendererState(): RendererState {
keybindingsMap: new Map(),
chordPending: false,
chordTimeout: null,
keyboardDrivenModeEnabled: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
};
}

View File

@@ -340,6 +340,15 @@ body.settings-modal-open #subtitleContainer {
-webkit-text-fill-color: currentColor !important;
}
#subtitleRoot .word.keyboard-selected {
outline: 2px solid rgba(135, 201, 255, 0.92);
outline-offset: 2px;
border-radius: 4px;
box-shadow:
0 0 0 2px rgba(12, 18, 28, 0.68),
0 0 18px rgba(120, 188, 255, 0.45);
}
#subtitleRoot .word[data-frequency-rank]::before {
content: attr(data-frequency-rank);
position: absolute;
@@ -363,7 +372,8 @@ body.settings-modal-open #subtitleContainer {
z-index: 1;
}
#subtitleRoot .word[data-frequency-rank]:hover::before {
#subtitleRoot .word[data-frequency-rank]:hover::before,
#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@@ -390,7 +400,8 @@ body.settings-modal-open #subtitleContainer {
z-index: 1;
}
#subtitleRoot .word[data-jlpt-level]:hover::after {
#subtitleRoot .word[data-jlpt-level]:hover::after,
#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

View File

@@ -409,6 +409,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
'#subtitleRoot .word[data-frequency-rank]:hover::before',
);
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
const frequencyTooltipKeyboardSelectedBlock = extractClassBlock(
cssText,
'#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before',
);
assert.match(frequencyTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
const jlptTooltipBaseBlock = extractClassBlock(
cssText,
@@ -424,6 +429,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
'#subtitleRoot .word[data-jlpt-level]:hover::after',
);
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
const jlptTooltipKeyboardSelectedBlock = extractClassBlock(
cssText,
'#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after',
);
assert.match(jlptTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
assert.match(
cssText,

View File

@@ -610,6 +610,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.jlptN5Color = jlptColors.N5;
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);

View File

@@ -1,6 +1,9 @@
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
export function isYomitanPopupIframe(element: Element | null): boolean {
if (!element) return false;
@@ -14,3 +17,19 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
}
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
for (const iframe of popupIframes) {
const rect = iframe.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const styles = window.getComputedStyle(iframe);
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
continue;
}
return true;
}
return false;
}

View File

@@ -64,6 +64,8 @@ export const IPC_CHANNELS = {
runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload',
},
} as const;

View File

@@ -290,6 +290,7 @@ export interface SubtitleStyleConfig {
enableJlpt?: boolean;
preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean;
autoPauseVideoOnYomitanPopup?: boolean;
hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;
fontFamily?: string;
@@ -842,6 +843,8 @@ export interface ElectronAPI {
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;

View File

@@ -28,6 +28,40 @@ import {TextSourceGenerator} from '../dom/text-source-generator.js';
import {TextSourceRange} from '../dom/text-source-range.js';
import {TextScanner} from '../language/text-scanner.js';
const SUBMINER_FRONTEND_COMMAND_EVENT = 'subminer-yomitan-popup-command';
const subminerFrontendInstances = new Set();
let subminerFrontendCommandBridgeRegistered = false;
function getActiveFrontendForSubminerCommand() {
/** @type {?Frontend} */
let fallback = null;
for (const frontend of subminerFrontendInstances) {
if (frontend._textScanner?.isEnabled?.()) {
return frontend;
}
if (fallback === null) {
fallback = frontend;
}
}
return fallback;
}
function registerSubminerFrontendCommandBridge() {
if (subminerFrontendCommandBridgeRegistered) { return; }
subminerFrontendCommandBridgeRegistered = true;
window.addEventListener(SUBMINER_FRONTEND_COMMAND_EVENT, (event) => {
const frontend = getActiveFrontendForSubminerCommand();
if (frontend === null) { return; }
const detail = event.detail;
if (typeof detail !== 'object' || detail === null) { return; }
if (detail.type === 'scanSelectedText') {
frontend._onApiScanSelectedText();
}
});
}
/**
* This is the main class responsible for scanning and handling webpage content.
*/
@@ -158,6 +192,9 @@ export class Frontend {
* Prepares the instance for use.
*/
async prepare() {
registerSubminerFrontendCommandBridge();
subminerFrontendInstances.add(this);
await this.updateOptions();
try {
const {zoomFactor} = await this._application.api.getZoom();

View File

@@ -28,6 +28,85 @@ import {loadStyle} from '../dom/style-util.js';
import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js';
import {ThemeController} from './theme-controller.js';
const SUBMINER_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
const subminerPopupInstances = new Set();
let subminerPopupCommandBridgeRegistered = false;
function getActivePopupForSubminerCommand() {
/** @type {?Popup} */
let fallback = null;
for (const popup of subminerPopupInstances) {
if (!popup.isVisibleSync()) { continue; }
fallback = popup;
if (popup.isPointerOverSelfOrChildren()) {
return popup;
}
}
return fallback;
}
function registerSubminerPopupCommandBridge() {
if (subminerPopupCommandBridgeRegistered) { return; }
subminerPopupCommandBridgeRegistered = true;
window.addEventListener(SUBMINER_POPUP_COMMAND_EVENT, (event) => {
const popup = getActivePopupForSubminerCommand();
if (popup === null) { return; }
const detail = event.detail;
if (typeof detail !== 'object' || detail === null) { return; }
if (detail.type === 'simulateHotkey') {
const key = detail.key;
const rawModifiers = detail.modifiers;
if (typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; }
const modifiers = rawModifiers.filter((modifier) => (
modifier === 'alt' ||
modifier === 'ctrl' ||
modifier === 'shift' ||
modifier === 'meta'
));
void popup._invokeSafe('displaySimulateHotkey', {key, modifiers});
return;
}
if (detail.type === 'forwardKeyDown') {
const code = detail.code;
const key = detail.key;
const rawModifiers = detail.modifiers;
if (typeof code !== 'string' || typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; }
const modifiers = rawModifiers.filter((modifier) => (
modifier === 'alt' ||
modifier === 'ctrl' ||
modifier === 'shift' ||
modifier === 'meta'
));
void popup._invokeSafe('displayForwardKeyDown', {
key,
code,
modifiers,
repeat: detail.repeat === true,
});
return;
}
if (detail.type === 'mineSelected') {
void popup._invokeSafe('displayMineSelected', void 0);
return;
}
if (detail.type === 'cycleAudioSource') {
const direction = detail.direction === -1 ? -1 : 1;
void popup._invokeSafe('displayAudioCycleSource', {direction});
return;
}
if (detail.type === 'setVisible') {
if (detail.visible === false) {
popup.hide(false);
}
}
});
}
/**
* This class is the container which hosts the display of search results.
* @augments EventDispatcher<import('popup').Events>
@@ -209,6 +288,8 @@ export class Popup extends EventDispatcher {
* Prepares the popup for use.
*/
prepare() {
registerSubminerPopupCommandBridge();
subminerPopupInstances.add(this);
this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
@@ -471,6 +552,7 @@ export class Popup extends EventDispatcher {
*/
_onFrameMouseOver() {
this._isPointerOverPopup = true;
window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-enter'));
this.stopHideDelayed();
this.trigger('mouseOver', {});
@@ -486,6 +568,7 @@ export class Popup extends EventDispatcher {
*/
_onFrameMouseOut() {
this._isPointerOverPopup = false;
window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-leave'));
this.trigger('mouseOut', {});
@@ -836,6 +919,7 @@ export class Popup extends EventDispatcher {
* @returns {void}
*/
_onExtensionUnloaded() {
subminerPopupInstances.delete(this);
this._invokeWindow('displayExtensionUnloaded', void 0);
}

View File

@@ -69,6 +69,10 @@ export class DisplayAudio {
]);
/** @type {?boolean} */
this._enableDefaultAudioSources = null;
/** @type {?number} */
this._audioCycleSourceIndex = null;
/** @type {Map<number, number>} */
this._audioCycleAudioInfoIndexMap = new Map();
/** @type {(event: MouseEvent) => void} */
this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this);
/** @type {(event: MouseEvent) => void} */
@@ -96,6 +100,7 @@ export class DisplayAudio {
]);
this._display.registerDirectMessageHandlers([
['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)],
['displayAudioCycleSource', this._onMessageCycleAudioSource.bind(this)],
]);
/* eslint-enable @stylistic/no-multi-spaces */
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
@@ -186,6 +191,8 @@ export class DisplayAudio {
/** @type {Map<string, import('display-audio').AudioSource[]>} */
const nameMap = new Map();
this._audioSources.length = 0;
this._audioCycleSourceIndex = null;
this._audioCycleAudioInfoIndexMap.clear();
for (const {type, url, voice} of sources) {
this._addAudioSourceInfo(type, url, voice, true, nameMap);
requiredAudioSources.delete(type);
@@ -204,6 +211,8 @@ export class DisplayAudio {
_onContentClear() {
this._entriesToken = {};
this._cache.clear();
this._audioCycleSourceIndex = null;
this._audioCycleAudioInfoIndexMap.clear();
this.clearAutoPlayTimer();
this._eventListeners.removeAllEventListeners();
}
@@ -273,6 +282,73 @@ export class DisplayAudio {
this.clearAutoPlayTimer();
}
/**
* @param {{direction?: number}} details
* @returns {Promise<boolean>}
*/
async _onMessageCycleAudioSource({direction}) {
/** @type {import('display-audio').AudioSource[]} */
const configuredSources = this._audioSources.filter((source) => source.isInOptions);
const sources = configuredSources.length > 0 ? configuredSources : this._audioSources;
if (sources.length === 0) { return false; }
const dictionaryEntryIndex = this._display.selectedIndex;
const headwordIndex = 0;
const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);
if (headword === null) { return false; }
const {term, reading} = headword;
const primaryCardAudio = this._getPrimaryCardAudio(term, reading);
let source = null;
if (primaryCardAudio !== null) {
source = sources.find((item) => item.index === primaryCardAudio.index) ?? null;
}
if (source === null) {
const fallbackIndex = (
this._audioCycleSourceIndex !== null &&
this._audioCycleSourceIndex >= 0 &&
this._audioCycleSourceIndex < sources.length
) ? this._audioCycleSourceIndex : 0;
source = sources[fallbackIndex] ?? null;
this._audioCycleSourceIndex = fallbackIndex;
}
if (source === null) { return false; }
const infoList = await this._getSourceAudioInfoList(source, term, reading);
const infoListLength = infoList.length;
if (infoListLength === 0) { return false; }
const step = direction === -1 ? -1 : 1;
let currentSubIndex = this._audioCycleAudioInfoIndexMap.get(source.index);
if (
typeof currentSubIndex !== 'number' &&
primaryCardAudio !== null &&
primaryCardAudio.index === source.index &&
primaryCardAudio.subIndex !== null
) {
currentSubIndex = primaryCardAudio.subIndex;
}
if (typeof currentSubIndex !== 'number') {
currentSubIndex = step > 0 ? -1 : infoListLength;
}
for (let i = 0; i < infoListLength; ++i) {
currentSubIndex = (currentSubIndex + step + infoListLength) % infoListLength;
const {valid} = await this._playAudio(
dictionaryEntryIndex,
headwordIndex,
[source],
currentSubIndex,
);
if (valid) {
this._audioCycleAudioInfoIndexMap.set(source.index, currentSubIndex);
return true;
}
}
return false;
}
/**
* @param {import('settings').AudioSourceType} type
* @param {string} url
@@ -691,6 +767,39 @@ export class DisplayAudio {
return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
}
/**
* @param {import('display-audio').AudioSource} source
* @param {string} term
* @param {string} reading
* @returns {Promise<import('display-audio').AudioInfoList>}
*/
async _getSourceAudioInfoList(source, term, reading) {
const cacheItem = this._getCacheItem(term, reading, true);
if (typeof cacheItem === 'undefined') { return []; }
const {sourceMap} = cacheItem;
let cacheUpdated = false;
let sourceInfo = sourceMap.get(source.index);
if (typeof sourceInfo === 'undefined') {
const infoListPromise = this._getTermAudioInfoList(source, term, reading);
sourceInfo = {infoListPromise, infoList: null};
sourceMap.set(source.index, sourceInfo);
cacheUpdated = true;
}
let {infoList} = sourceInfo;
if (infoList === null) {
infoList = await sourceInfo.infoListPromise;
sourceInfo.infoList = infoList;
cacheUpdated = true;
}
if (cacheUpdated) {
this._updateOpenMenu();
}
return infoList;
}
/**
* @param {number} dictionaryEntryIndex
* @param {number} headwordIndex

View File

@@ -224,6 +224,9 @@ export class Display extends EventDispatcher {
['displaySetContentScale', this._onMessageSetContentScale.bind(this)],
['displayConfigure', this._onMessageConfigure.bind(this)],
['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)],
['displaySimulateHotkey', this._onMessageSimulateHotkey.bind(this)],
['displayForwardKeyDown', this._onMessageForwardKeyDown.bind(this)],
['displayMineSelected', this._onMessageMineSelected.bind(this)],
]);
this.registerWindowMessageHandlers([
['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)],
@@ -785,6 +788,57 @@ export class Display extends EventDispatcher {
this.trigger('frameVisibilityChange', {value});
}
/**
* @param {{key: string, modifiers: unknown[]}} details
* @returns {boolean}
*/
_onMessageSimulateHotkey({key, modifiers}) {
if (typeof key !== 'string' || !Array.isArray(modifiers)) { return false; }
const normalizedModifiers = modifiers.filter((modifier) => (
modifier === 'alt' ||
modifier === 'ctrl' ||
modifier === 'shift' ||
modifier === 'meta'
));
return this._hotkeyHandler.simulate(key, normalizedModifiers);
}
/**
* @param {{key: string, code: string, modifiers: unknown[], repeat?: boolean}} details
* @returns {boolean}
*/
_onMessageForwardKeyDown({key, code, modifiers, repeat = false}) {
if (typeof key !== 'string' || typeof code !== 'string' || !Array.isArray(modifiers)) { return false; }
const normalizedModifiers = modifiers.filter((modifier) => (
modifier === 'alt' ||
modifier === 'ctrl' ||
modifier === 'shift' ||
modifier === 'meta'
));
const eventInit = {
key,
code,
repeat,
bubbles: true,
cancelable: true,
composed: true,
altKey: normalizedModifiers.includes('alt'),
ctrlKey: normalizedModifiers.includes('ctrl'),
shiftKey: normalizedModifiers.includes('shift'),
metaKey: normalizedModifiers.includes('meta'),
};
document.dispatchEvent(new KeyboardEvent('keydown', eventInit));
return true;
}
/**
* @returns {boolean}
*/
_onMessageMineSelected() {
document.dispatchEvent(new CustomEvent('subminer-display-mine-selected'));
return true;
}
/** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */
_onMessageExtensionUnloaded() {
this._application.webExtension.triggerUnloaded();

View File

@@ -47,6 +47,50 @@ await Application.main(true, async (application) => {
const displayResizer = new DisplayResizer(display);
displayResizer.prepare();
document.addEventListener('keydown', (event) => {
if (event.defaultPrevented || event.repeat) { return; }
if (event.ctrlKey || event.metaKey || event.altKey) { return; }
const target = /** @type {?Element} */ (event.target instanceof Element ? event.target : null);
if (target !== null) {
if (target.closest('input, textarea, select, [contenteditable="true"]')) {
return;
}
}
const code = event.code;
if (code === 'KeyJ' || code === 'KeyK') {
const scanningOptions = display.getOptions()?.scanning;
const scale = Number.isFinite(scanningOptions?.reducedMotionScrollingScale)
? scanningOptions.reducedMotionScrollingScale
: 1;
display._scrollByPopupHeight(code === 'KeyJ' ? 1 : -1, scale);
event.preventDefault();
return;
}
if (code === 'KeyM') {
displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0');
event.preventDefault();
return;
}
if (code === 'KeyP') {
void displayAudio.playAudio(display.selectedIndex, 0);
event.preventDefault();
return;
}
if (code === 'BracketLeft' || code === 'BracketRight') {
displayAudio._onMessageCycleAudioSource({direction: code === 'BracketLeft' ? 1 : -1});
event.preventDefault();
}
});
document.addEventListener('subminer-display-mine-selected', () => {
displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0');
});
display.initializeState();
document.documentElement.dataset.loaded = 'true';