mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createKeyboardHandlers } from './keyboard.js';
|
||||
@@ -108,6 +110,7 @@ function installKeyboardTestGlobals() {
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
|
||||
let sessionBindings: CompiledSessionBinding[] = [];
|
||||
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let statsToggleKey = 'Backquote';
|
||||
let markWatchedKey = 'KeyW';
|
||||
@@ -216,7 +219,7 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
electronAPI: {
|
||||
getKeybindings: async () => [],
|
||||
getSessionBindings: async () => sessionBindings,
|
||||
getSessionBindings: () => getSessionBindingsImpl(),
|
||||
getConfiguredShortcuts: async () => configuredShortcuts,
|
||||
sendMpvCommand: (command: Array<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
@@ -366,6 +369,9 @@ function installKeyboardTestGlobals() {
|
||||
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||
sessionBindings = value;
|
||||
},
|
||||
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
|
||||
getSessionBindingsImpl = value;
|
||||
},
|
||||
setMarkActiveVideoWatchedResult: (value: boolean) => {
|
||||
markActiveVideoWatchedResult = value;
|
||||
},
|
||||
@@ -462,6 +468,19 @@ function createKeyboardHandlerHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('renderer installs keyboard forwarding before startup subtitle IPC awaits', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const keyboardSetupIndex = source.indexOf('await keyboardHandlers.setupMpvInputForwarding();');
|
||||
const subtitleRequestIndex = source.indexOf('await window.electronAPI.getCurrentSubtitle();');
|
||||
|
||||
assert.notEqual(keyboardSetupIndex, -1);
|
||||
assert.notEqual(subtitleRequestIndex, -1);
|
||||
assert.equal(keyboardSetupIndex < subtitleRequestIndex, true);
|
||||
});
|
||||
|
||||
test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -498,6 +517,76 @@ test('primary subtitle visibility key cycles modes with primary OSD without mpv
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv input forwarding installs local key handling when session binding IPC stalls', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
testGlobals.setGetSessionBindings(() => new Promise<CompiledSessionBinding[]>(() => {}));
|
||||
const setupResult = await Promise.race([
|
||||
handlers.setupMpvInputForwarding().then(() => 'resolved'),
|
||||
wait(75).then(() => 'pending'),
|
||||
]);
|
||||
|
||||
assert.equal(setupResult, 'resolved');
|
||||
testGlobals.dispatchKeydown({ key: '`', code: 'Backquote' });
|
||||
|
||||
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv input forwarding waits for session bindings before resolving setup', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
testGlobals.setGetSessionBindings(async () => {
|
||||
await wait(20);
|
||||
return [
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'KeyH',
|
||||
key: { code: 'KeyH', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
] as CompiledSessionBinding[];
|
||||
});
|
||||
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
testGlobals.setGetSessionBindings(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error('transient');
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await handlers.setupMpvInputForwarding();
|
||||
await wait(25);
|
||||
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session help chord resolver follows remapped session bindings', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -1295,6 +1384,30 @@ test('session binding: Ctrl+Shift+O dispatches runtime options locally', async (
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: remapped mark watched dispatches locally with modifiers', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'stats.markWatchedKey',
|
||||
originalKey: 'Ctrl+Shift+KeyW',
|
||||
key: { code: 'KeyW', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'markWatched',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'W', code: 'KeyW', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'markWatched', payload: undefined }]);
|
||||
assert.equal(testGlobals.markActiveVideoWatchedCalls(), 0);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: copy subtitle multiple captures follow-up digit locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function createKeyboardHandlers(
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50;
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
@@ -44,6 +45,7 @@ export function createKeyboardHandlers(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
} | null = null;
|
||||
let mpvInputForwardingListenersInstalled = false;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -940,7 +942,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
async function loadMpvInputForwardingConfig(): Promise<void> {
|
||||
const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getSessionBindings(),
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
@@ -950,6 +952,55 @@ export function createKeyboardHandlers(
|
||||
updateSessionBindings(sessionBindings);
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
|
||||
async function loadMpvInputForwardingConfigWithRetry(): Promise<void> {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await loadMpvInputForwardingConfig();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < 2) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 10 * (attempt + 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
installMpvInputForwardingListeners();
|
||||
syncKeyboardTokenSelection();
|
||||
|
||||
let configLoadError: unknown = null;
|
||||
const configLoad = loadMpvInputForwardingConfigWithRetry().then(
|
||||
() => {},
|
||||
(error) => {
|
||||
configLoadError = error;
|
||||
console.error('Failed to load overlay keyboard configuration.', error);
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
configLoad,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (configLoadError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function installMpvInputForwardingListeners(): void {
|
||||
if (mpvInputForwardingListenersInstalled) {
|
||||
return;
|
||||
}
|
||||
mpvInputForwardingListenersInstalled = true;
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
syncKeyboardTokenSelection();
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Auto Subtitle Sync</div>
|
||||
<div class="modal-title">Subtitle Sync</div>
|
||||
<button id="subsyncClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SessionHelpSection } from './session-help-sections';
|
||||
|
||||
export type SessionHelpSubtitleStyle = {
|
||||
knownWordColor?: unknown;
|
||||
nPlusOneColor?: unknown;
|
||||
nameMatchColor?: unknown;
|
||||
jlptColors?: {
|
||||
N1?: unknown;
|
||||
N2?: unknown;
|
||||
N3?: unknown;
|
||||
N4?: unknown;
|
||||
N5?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
const FALLBACK_COLORS = {
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
nameMatchColor: '#f5bde6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
jlptN3Color: '#f9e2af',
|
||||
jlptN4Color: '#a6e3a1',
|
||||
jlptN5Color: '#8aadf4',
|
||||
};
|
||||
|
||||
function normalizeColor(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const next = value.trim();
|
||||
return HEX_COLOR_RE.test(next) ? next : fallback;
|
||||
}
|
||||
|
||||
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
|
||||
return {
|
||||
title: 'Color legend',
|
||||
rows: [
|
||||
{
|
||||
shortcut: 'Known words',
|
||||
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'N+1 words',
|
||||
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'Character names',
|
||||
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
|
||||
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N1',
|
||||
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N2',
|
||||
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N3',
|
||||
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N4',
|
||||
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N5',
|
||||
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
|
||||
|
||||
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'session-help-item';
|
||||
button.tabIndex = -1;
|
||||
button.dataset.sessionHelpIndex = String(globalIndex);
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'session-help-item-left';
|
||||
const shortcut = document.createElement('span');
|
||||
shortcut.className = 'session-help-key';
|
||||
shortcut.textContent = row.shortcut;
|
||||
left.appendChild(shortcut);
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'session-help-item-right';
|
||||
const action = document.createElement('span');
|
||||
action.className = 'session-help-action';
|
||||
action.textContent = row.action;
|
||||
right.appendChild(action);
|
||||
|
||||
if (row.color) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'session-help-color-dot';
|
||||
dot.style.backgroundColor = row.color;
|
||||
right.insertBefore(dot, action);
|
||||
}
|
||||
|
||||
button.appendChild(left);
|
||||
button.appendChild(right);
|
||||
return button;
|
||||
}
|
||||
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
'Playback and navigation': '▶',
|
||||
'Visual feedback': '◉',
|
||||
'Subtitle sync': '⟲',
|
||||
'Mining and capture': '✦',
|
||||
'Stats and progress': '◉',
|
||||
'Overlay controls': '◈',
|
||||
'Modals and tools': '▣',
|
||||
'Runtime settings': '⚙',
|
||||
'System actions': '◆',
|
||||
'Other shortcuts': '…',
|
||||
'Fixed overlay controls': '◇',
|
||||
'Y chords': '⌘',
|
||||
'Global shortcuts': '◆',
|
||||
'Color legend': '◈',
|
||||
};
|
||||
|
||||
export function createSessionHelpSectionNode(
|
||||
section: SessionHelpSection,
|
||||
sectionIndex: number,
|
||||
globalIndexMap: number[],
|
||||
): HTMLElement {
|
||||
const sectionNode = document.createElement('section');
|
||||
sectionNode.className = 'session-help-section';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'session-help-section-title';
|
||||
const icon = SECTION_ICON[section.title] ?? '•';
|
||||
title.textContent = `${icon} ${section.title}`;
|
||||
sectionNode.appendChild(title);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'session-help-item-list';
|
||||
|
||||
section.rows.forEach((row, rowIndex) => {
|
||||
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
||||
const button = createShortcutRow(row, globalIndex);
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
sectionNode.appendChild(list);
|
||||
return sectionNode;
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
SessionActionId,
|
||||
SessionKeyModifier,
|
||||
SessionKeySpec,
|
||||
} from '../../types';
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
||||
import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors';
|
||||
|
||||
export type SessionHelpItem = {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type SessionHelpSection = {
|
||||
title: string;
|
||||
rows: SessionHelpItem[];
|
||||
};
|
||||
|
||||
export type SessionHelpTabId = 'essentials' | 'playback' | 'mining' | 'tools' | 'reference';
|
||||
|
||||
export type SessionHelpTab = {
|
||||
id: SessionHelpTabId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const SESSION_HELP_TABS: SessionHelpTab[] = [
|
||||
{ id: 'essentials', label: 'Essentials' },
|
||||
{ id: 'playback', label: 'Playback' },
|
||||
{ id: 'mining', label: 'Mining' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'reference', label: 'Reference' },
|
||||
];
|
||||
|
||||
const KEY_NAME_MAP: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Escape: 'Esc',
|
||||
Tab: 'Tab',
|
||||
Enter: 'Enter',
|
||||
Slash: '/',
|
||||
Backslash: '\\',
|
||||
Backquote: '`',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
CommandOrControl: 'Cmd/Ctrl',
|
||||
Ctrl: 'Ctrl',
|
||||
Control: 'Ctrl',
|
||||
Command: 'Cmd',
|
||||
Cmd: 'Cmd',
|
||||
Shift: 'Shift',
|
||||
Alt: 'Alt',
|
||||
Super: 'Meta',
|
||||
Meta: 'Meta',
|
||||
Backspace: 'Backspace',
|
||||
};
|
||||
|
||||
function normalizeKeyToken(token: string): string {
|
||||
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
|
||||
if (token.startsWith('Key')) return token.slice(3);
|
||||
if (token.startsWith('Digit')) return token.slice(5);
|
||||
if (token.startsWith('Numpad')) return token.slice(6);
|
||||
return token;
|
||||
}
|
||||
|
||||
function formatKeybinding(rawBinding: string): string {
|
||||
const parts = rawBinding
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const key = parts.pop();
|
||||
if (!key) return rawBinding;
|
||||
const normalized = [...parts.map(normalizeKeyToken), normalizeKeyToken(key)];
|
||||
return normalized.join(' + ');
|
||||
}
|
||||
|
||||
function describeCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Unknown action';
|
||||
|
||||
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
|
||||
if (first === 'seek' && typeof command[1] === 'number') {
|
||||
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
|
||||
}
|
||||
if (first === 'sub-seek' && typeof command[1] === 'number') {
|
||||
if (command[1] > 0) return 'Jump to next subtitle';
|
||||
if (command[1] < 0) return 'Jump to previous subtitle';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to next cue';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${
|
||||
rawDirection === 'prev' ? 'previous' : 'next'
|
||||
}`;
|
||||
}
|
||||
|
||||
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
|
||||
}
|
||||
|
||||
export {
|
||||
describeCommand as describeSessionHelpCommand,
|
||||
formatKeybinding as formatSessionHelpKeybinding,
|
||||
};
|
||||
|
||||
function sectionForCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Other shortcuts';
|
||||
|
||||
if (
|
||||
first === 'cycle' ||
|
||||
first === 'seek' ||
|
||||
first === 'sub-seek' ||
|
||||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
|
||||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
|
||||
) {
|
||||
return 'Playback and navigation';
|
||||
}
|
||||
|
||||
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
|
||||
return 'Visual feedback';
|
||||
}
|
||||
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
|
||||
return 'Subtitle sync';
|
||||
}
|
||||
|
||||
if (
|
||||
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
||||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
|
||||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
return 'Runtime settings';
|
||||
}
|
||||
|
||||
if (first === 'quit') return 'System actions';
|
||||
return 'Other shortcuts';
|
||||
}
|
||||
|
||||
const MODIFIER_LABELS: Record<SessionKeyModifier, string> = {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
meta: 'Meta',
|
||||
};
|
||||
|
||||
function formatSessionKeySpec(key: SessionKeySpec): string {
|
||||
return [
|
||||
...key.modifiers.map((modifier) => MODIFIER_LABELS[modifier]),
|
||||
normalizeKeyToken(key.code),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' + ');
|
||||
}
|
||||
|
||||
function describeSessionAction(
|
||||
actionId: SessionActionId,
|
||||
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
|
||||
): string {
|
||||
switch (actionId) {
|
||||
case 'toggleStatsOverlay':
|
||||
return 'Toggle stats overlay';
|
||||
case 'toggleVisibleOverlay':
|
||||
return 'Show/hide visible overlay';
|
||||
case 'copySubtitle':
|
||||
return 'Copy subtitle';
|
||||
case 'copySubtitleMultiple':
|
||||
return 'Copy subtitle (multi)';
|
||||
case 'updateLastCardFromClipboard':
|
||||
return 'Update last card from clipboard';
|
||||
case 'triggerFieldGrouping':
|
||||
return 'Trigger field grouping';
|
||||
case 'triggerSubsync':
|
||||
return 'Open subtitle sync controls';
|
||||
case 'mineSentence':
|
||||
return 'Mine sentence';
|
||||
case 'mineSentenceMultiple':
|
||||
return 'Mine sentence (multi)';
|
||||
case 'toggleSecondarySub':
|
||||
return 'Toggle secondary subtitle mode';
|
||||
case 'toggleSubtitleSidebar':
|
||||
return 'Toggle subtitle sidebar';
|
||||
case 'markAudioCard':
|
||||
return 'Mark audio card';
|
||||
case 'markWatched':
|
||||
return 'Mark video watched';
|
||||
case 'openRuntimeOptions':
|
||||
return 'Open runtime options';
|
||||
case 'openSessionHelp':
|
||||
return 'Open session help';
|
||||
case 'openCharacterDictionary':
|
||||
return 'Open character dictionary anime selector';
|
||||
case 'openControllerSelect':
|
||||
return 'Open controller select';
|
||||
case 'openControllerDebug':
|
||||
return 'Open controller debug';
|
||||
case 'openJimaku':
|
||||
return 'Open jimaku';
|
||||
case 'openYoutubePicker':
|
||||
return 'Open YouTube subtitle picker';
|
||||
case 'openPlaylistBrowser':
|
||||
return 'Open playlist browser';
|
||||
case 'replayCurrentSubtitle':
|
||||
return 'Replay current subtitle';
|
||||
case 'playNextSubtitle':
|
||||
return 'Play next subtitle';
|
||||
case 'shiftSubDelayPrevLine':
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
case 'shiftSubDelayNextLine':
|
||||
return 'Shift subtitle delay to next cue';
|
||||
case 'cycleRuntimeOption':
|
||||
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
|
||||
payload?.direction === -1 ? 'previous' : 'next'
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
if (binding.actionType === 'mpv-command') return sectionForCommand(binding.command);
|
||||
|
||||
switch (binding.actionId) {
|
||||
case 'copySubtitle':
|
||||
case 'copySubtitleMultiple':
|
||||
case 'updateLastCardFromClipboard':
|
||||
case 'triggerFieldGrouping':
|
||||
case 'mineSentence':
|
||||
case 'mineSentenceMultiple':
|
||||
case 'markAudioCard':
|
||||
return 'Mining and capture';
|
||||
case 'toggleStatsOverlay':
|
||||
case 'markWatched':
|
||||
return 'Stats and progress';
|
||||
case 'toggleVisibleOverlay':
|
||||
case 'toggleSecondarySub':
|
||||
case 'toggleSubtitleSidebar':
|
||||
return 'Overlay controls';
|
||||
case 'triggerSubsync':
|
||||
return 'Subtitle sync';
|
||||
case 'openRuntimeOptions':
|
||||
case 'openJimaku':
|
||||
case 'openCharacterDictionary':
|
||||
case 'openControllerSelect':
|
||||
case 'openControllerDebug':
|
||||
case 'openYoutubePicker':
|
||||
case 'openPlaylistBrowser':
|
||||
case 'openSessionHelp':
|
||||
return 'Modals and tools';
|
||||
case 'replayCurrentSubtitle':
|
||||
case 'playNextSubtitle':
|
||||
case 'shiftSubDelayPrevLine':
|
||||
case 'shiftSubDelayNextLine':
|
||||
return 'Playback and navigation';
|
||||
case 'cycleRuntimeOption':
|
||||
return 'Runtime settings';
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionBindingSections(
|
||||
sessionBindings: CompiledSessionBinding[],
|
||||
): SessionHelpSection[] {
|
||||
const grouped = new Map<string, SessionHelpItem[]>();
|
||||
|
||||
for (const binding of sessionBindings) {
|
||||
const section = sectionForSessionBinding(binding);
|
||||
const row: SessionHelpItem = {
|
||||
shortcut: formatSessionKeySpec(binding.key),
|
||||
action:
|
||||
binding.actionType === 'mpv-command'
|
||||
? describeCommand(binding.command)
|
||||
: describeSessionAction(binding.actionId, binding.payload),
|
||||
};
|
||||
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
||||
}
|
||||
|
||||
const sectionOrder = [
|
||||
'Playback and navigation',
|
||||
'Mining and capture',
|
||||
'Stats and progress',
|
||||
'Overlay controls',
|
||||
'Subtitle sync',
|
||||
'Runtime settings',
|
||||
'Modals and tools',
|
||||
'Visual feedback',
|
||||
'System actions',
|
||||
'Other shortcuts',
|
||||
];
|
||||
return Array.from(grouped.entries())
|
||||
.sort((a, b) => {
|
||||
const aIdx = sectionOrder.indexOf(a[0]);
|
||||
const bIdx = sectionOrder.indexOf(b[0]);
|
||||
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
})
|
||||
.map(([title, rows]) => ({ title, rows }));
|
||||
}
|
||||
|
||||
function buildConfiguredOverlaySections(input: {
|
||||
markWatchedKey?: string | null;
|
||||
subtitleSidebarToggleKey?: string | null;
|
||||
}): SessionHelpSection[] {
|
||||
const statsRows: SessionHelpItem[] = [];
|
||||
if (input.markWatchedKey) {
|
||||
statsRows.push({
|
||||
shortcut: formatKeybinding(input.markWatchedKey),
|
||||
action: 'Mark video watched',
|
||||
});
|
||||
}
|
||||
|
||||
const overlayRows: SessionHelpItem[] = [];
|
||||
if (input.subtitleSidebarToggleKey) {
|
||||
overlayRows.push({
|
||||
shortcut: formatKeybinding(input.subtitleSidebarToggleKey),
|
||||
action: 'Toggle subtitle sidebar',
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
...(statsRows.length > 0 ? [{ title: 'Stats and progress', rows: statsRows }] : []),
|
||||
...(overlayRows.length > 0 ? [{ title: 'Overlay controls', rows: overlayRows }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
return [
|
||||
{
|
||||
title: 'Fixed overlay controls',
|
||||
rows: [
|
||||
{ shortcut: 'V', action: 'Toggle primary subtitle bar visibility' },
|
||||
{ shortcut: 'Ctrl/Cmd + A', action: 'Append clipboard video path to playlist' },
|
||||
{ shortcut: 'Right-click', action: 'Toggle playback outside subtitle area' },
|
||||
{ shortcut: 'Right-click + drag', action: 'Reposition subtitles on subtitle area' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Y chords',
|
||||
rows: [
|
||||
{ shortcut: 'Y then Y', action: 'Open SubMiner menu' },
|
||||
{ shortcut: 'Y then S', action: 'Start overlay' },
|
||||
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
|
||||
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
|
||||
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
|
||||
{ shortcut: 'Y then R', action: 'Restart overlay' },
|
||||
{ shortcut: 'Y then C', action: 'Check overlay status' },
|
||||
{ shortcut: 'Y then H/K', action: 'Open session help' },
|
||||
{ shortcut: 'Y then D', action: 'Toggle DevTools' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Global shortcuts',
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mergeSectionsByTitle(sections: SessionHelpSection[]): SessionHelpSection[] {
|
||||
const merged: SessionHelpSection[] = [];
|
||||
const byTitle = new Map<string, SessionHelpSection>();
|
||||
|
||||
for (const section of sections) {
|
||||
const existing = byTitle.get(section.title);
|
||||
if (existing) {
|
||||
existing.rows.push(...section.rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = { title: section.title, rows: [...section.rows] };
|
||||
byTitle.set(section.title, next);
|
||||
merged.push(next);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function buildSessionHelpSections(input: {
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
markWatchedKey?: string | null;
|
||||
subtitleSidebarToggleKey?: string | null;
|
||||
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
|
||||
}): SessionHelpSection[] {
|
||||
const sessionBindings = input.sessionBindings.filter((binding) => {
|
||||
if (binding.actionType !== 'session-action') return true;
|
||||
if (input.markWatchedKey && binding.actionId === 'markWatched') return false;
|
||||
if (input.subtitleSidebarToggleKey && binding.actionId === 'toggleSubtitleSidebar') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return mergeSectionsByTitle([
|
||||
...buildSessionBindingSections(sessionBindings),
|
||||
...buildConfiguredOverlaySections({
|
||||
markWatchedKey: input.markWatchedKey,
|
||||
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
|
||||
}),
|
||||
...buildFixedOverlaySections(),
|
||||
buildColorSection(input.subtitleStyle ?? {}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getSessionHelpSectionTabId(section: SessionHelpSection): SessionHelpTabId {
|
||||
switch (section.title) {
|
||||
case 'Stats and progress':
|
||||
case 'Overlay controls':
|
||||
case 'Fixed overlay controls':
|
||||
case 'Global shortcuts':
|
||||
return 'essentials';
|
||||
case 'Playback and navigation':
|
||||
case 'Subtitle sync':
|
||||
case 'Visual feedback':
|
||||
case 'System actions':
|
||||
return 'playback';
|
||||
case 'Mining and capture':
|
||||
return 'mining';
|
||||
case 'Modals and tools':
|
||||
case 'Runtime settings':
|
||||
return 'tools';
|
||||
case 'Y chords':
|
||||
case 'Color legend':
|
||||
case 'Other shortcuts':
|
||||
default:
|
||||
return 'reference';
|
||||
}
|
||||
}
|
||||
|
||||
export function filterSessionHelpSections(
|
||||
sections: SessionHelpSection[],
|
||||
query: string,
|
||||
): SessionHelpSection[] {
|
||||
const normalize = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/commandorcontrol/gu, 'ctrl')
|
||||
.replace(/cmd\/ctrl/gu, 'ctrl')
|
||||
.replace(/[\s+\-_/]/gu, '');
|
||||
const normalized = normalize(query);
|
||||
if (!normalized) return sections;
|
||||
|
||||
return sections
|
||||
.map((section) => {
|
||||
if (normalize(section.title).includes(normalized)) {
|
||||
return section;
|
||||
}
|
||||
|
||||
const rows = section.rows.filter(
|
||||
(row) =>
|
||||
normalize(row.shortcut).includes(normalized) ||
|
||||
normalize(row.action).includes(normalized),
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return { ...section, rows };
|
||||
})
|
||||
.filter((section): section is SessionHelpSection => section !== null)
|
||||
.filter((section) => section.rows.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
filterSessionHelpSections,
|
||||
getSessionHelpSectionTabId,
|
||||
SESSION_HELP_TABS,
|
||||
type SessionHelpSection,
|
||||
type SessionHelpTabId,
|
||||
} from './session-help-sections';
|
||||
|
||||
function countRows(sections: SessionHelpSection[]): number {
|
||||
return sections.reduce((count, section) => count + section.rows.length, 0);
|
||||
}
|
||||
|
||||
function sectionMatchesTab(section: SessionHelpSection, tabId: SessionHelpTabId): boolean {
|
||||
return getSessionHelpSectionTabId(section) === tabId;
|
||||
}
|
||||
|
||||
export function buildVisibleSessionHelpSections(
|
||||
sections: SessionHelpSection[],
|
||||
tabId: SessionHelpTabId,
|
||||
query: string,
|
||||
): SessionHelpSection[] {
|
||||
if (query.trim()) return filterSessionHelpSections(sections, query);
|
||||
return sections.filter((section) => sectionMatchesTab(section, tabId));
|
||||
}
|
||||
|
||||
export function createSessionHelpTabBar(
|
||||
sections: SessionHelpSection[],
|
||||
activeTabId: SessionHelpTabId,
|
||||
onSelect: (tabId: SessionHelpTabId) => void,
|
||||
): HTMLElement {
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'session-help-tabs';
|
||||
|
||||
for (const tab of SESSION_HELP_TABS) {
|
||||
const tabSections = sections.filter((section) => sectionMatchesTab(section, tab.id));
|
||||
if (tabSections.length === 0) continue;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'session-help-tab';
|
||||
button.dataset.sessionHelpTab = tab.id;
|
||||
button.setAttribute('aria-pressed', String(tab.id === activeTabId));
|
||||
if (tab.id === activeTabId) button.classList.add('active');
|
||||
button.textContent = `${tab.label} ${countRows(tabSections)}`;
|
||||
button.addEventListener('click', () => onSelect(tab.id));
|
||||
tabBar.appendChild(button);
|
||||
}
|
||||
|
||||
return tabBar;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
||||
import { createRendererState } from '../state.js';
|
||||
import {
|
||||
buildSessionHelpSections,
|
||||
createSessionHelpModal,
|
||||
describeSessionHelpCommand,
|
||||
formatSessionHelpKeybinding,
|
||||
@@ -30,6 +33,81 @@ test('session help formats bracket keybindings as physical keys', () => {
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
|
||||
});
|
||||
|
||||
test('session help normalizes configured modifier aliases', () => {
|
||||
assert.equal(formatSessionHelpKeybinding('CommandOrControl+KeyS'), 'Cmd/Ctrl + S');
|
||||
});
|
||||
|
||||
test('session help imports browser-safe special command constants', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help-sections.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/config\/definitions\/shared['"]/);
|
||||
assert.doesNotMatch(source, /from ['"]\.\.\/\.\.\/config\/definitions['"]/);
|
||||
});
|
||||
|
||||
test('session help builds rows from canonical session bindings and fixed overlay affordances', () => {
|
||||
const sections = buildSessionHelpSections({
|
||||
sessionBindings: [
|
||||
{
|
||||
sourcePath: 'stats.toggleKey',
|
||||
originalKey: 'Backquote',
|
||||
key: { code: 'Backquote', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleStatsOverlay',
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.openSessionHelp',
|
||||
originalKey: 'CommandOrControl+Slash',
|
||||
key: { code: 'Slash', modifiers: ['ctrl'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSessionHelp',
|
||||
},
|
||||
{
|
||||
sourcePath: 'shortcuts.toggleSubtitleSidebar',
|
||||
originalKey: 'Backslash',
|
||||
key: { code: 'Backslash', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleSubtitleSidebar',
|
||||
},
|
||||
{
|
||||
sourcePath: 'stats.markWatchedKey',
|
||||
originalKey: 'KeyW',
|
||||
key: { code: 'KeyW', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'markWatched',
|
||||
},
|
||||
{
|
||||
sourcePath: 'keybindings[0].key',
|
||||
originalKey: 'Space',
|
||||
key: { code: 'Space', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['cycle', 'pause'],
|
||||
},
|
||||
],
|
||||
markWatchedKey: 'KeyW',
|
||||
subtitleSidebarToggleKey: 'KeyB',
|
||||
subtitleStyle: {},
|
||||
});
|
||||
|
||||
const rows = sections.flatMap((section) => section.rows);
|
||||
assert.ok(rows.some((row) => row.shortcut === '`' && row.action === 'Toggle stats overlay'));
|
||||
assert.ok(rows.some((row) => row.shortcut === 'W' && row.action === 'Mark video watched'));
|
||||
assert.equal(rows.filter((row) => row.action === 'Mark video watched').length, 1);
|
||||
assert.equal(sections.filter((section) => section.title === 'Stats and progress').length, 1);
|
||||
assert.ok(rows.some((row) => row.shortcut === 'B' && row.action === 'Toggle subtitle sidebar'));
|
||||
assert.equal(rows.filter((row) => row.action === 'Toggle subtitle sidebar').length, 1);
|
||||
assert.ok(rows.some((row) => row.shortcut === 'Ctrl + /' && row.action === 'Open session help'));
|
||||
assert.ok(rows.some((row) => row.shortcut === 'Space' && row.action === 'Toggle playback'));
|
||||
assert.ok(
|
||||
rows.some(
|
||||
(row) => row.shortcut === 'V' && row.action === 'Toggle primary subtitle bar visibility',
|
||||
),
|
||||
);
|
||||
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
|
||||
});
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
@@ -96,11 +174,12 @@ test('modal-layer session help does not focus hidden main overlay and still clos
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
notifications.push(modal);
|
||||
},
|
||||
getKeybindings: async () => {
|
||||
throw new Error('mpv unavailable');
|
||||
},
|
||||
getSessionBindings: async () => [],
|
||||
getSubtitleStyle: async () => ({}),
|
||||
getConfiguredShortcuts: async () => ({}),
|
||||
getMarkWatchedKey: async () => 'KeyW',
|
||||
getSubtitleSidebarSnapshot: async () => ({
|
||||
config: { toggleKey: 'Backslash' },
|
||||
}),
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Keybinding } from '../../types';
|
||||
import type { ShortcutsConfig } from '../../types';
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
buildSessionHelpSections,
|
||||
type SessionHelpSection,
|
||||
type SessionHelpTabId,
|
||||
} from './session-help-sections';
|
||||
import { createSessionHelpSectionNode } from './session-help-render';
|
||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||
|
||||
export {
|
||||
buildSessionHelpSections,
|
||||
describeSessionHelpCommand,
|
||||
formatSessionHelpKeybinding,
|
||||
} from './session-help-sections';
|
||||
|
||||
type SessionHelpBindingInfo = {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
@@ -9,314 +19,6 @@ type SessionHelpBindingInfo = {
|
||||
fallbackUnavailable: boolean;
|
||||
};
|
||||
|
||||
type SessionHelpItem = {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
type SessionHelpSection = {
|
||||
title: string;
|
||||
rows: SessionHelpItem[];
|
||||
};
|
||||
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, 'multiCopyTimeoutMs'>;
|
||||
|
||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
|
||||
const FALLBACK_COLORS = {
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
nameMatchColor: '#f5bde6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
jlptN3Color: '#f9e2af',
|
||||
jlptN4Color: '#a6e3a1',
|
||||
jlptN5Color: '#8aadf4',
|
||||
};
|
||||
|
||||
const KEY_NAME_MAP: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Escape: 'Esc',
|
||||
Tab: 'Tab',
|
||||
Enter: 'Enter',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
CommandOrControl: 'Cmd/Ctrl',
|
||||
Ctrl: 'Ctrl',
|
||||
Control: 'Ctrl',
|
||||
Command: 'Cmd',
|
||||
Cmd: 'Cmd',
|
||||
Shift: 'Shift',
|
||||
Alt: 'Alt',
|
||||
Super: 'Meta',
|
||||
Meta: 'Meta',
|
||||
Backspace: 'Backspace',
|
||||
};
|
||||
|
||||
function normalizeColor(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const next = value.trim();
|
||||
return HEX_COLOR_RE.test(next) ? next : fallback;
|
||||
}
|
||||
|
||||
function normalizeKeyToken(token: string): string {
|
||||
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
|
||||
if (token.startsWith('Key')) return token.slice(3);
|
||||
if (token.startsWith('Digit')) return token.slice(5);
|
||||
if (token.startsWith('Numpad')) return token.slice(6);
|
||||
return token;
|
||||
}
|
||||
|
||||
function formatKeybinding(rawBinding: string): string {
|
||||
const parts = rawBinding.split('+');
|
||||
const key = parts.pop();
|
||||
if (!key) return rawBinding;
|
||||
const normalized = [...parts, normalizeKeyToken(key)];
|
||||
return normalized.join(' + ');
|
||||
}
|
||||
|
||||
const OVERLAY_SHORTCUTS: Array<{
|
||||
key: keyof RuntimeShortcutConfig;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: 'copySubtitle', label: 'Copy subtitle' },
|
||||
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
|
||||
{
|
||||
key: 'updateLastCardFromClipboard',
|
||||
label: 'Update last card from clipboard',
|
||||
},
|
||||
{ key: 'triggerFieldGrouping', label: 'Trigger field grouping' },
|
||||
{ key: 'triggerSubsync', label: 'Open subtitle sync controls' },
|
||||
{ key: 'mineSentence', label: 'Mine sentence' },
|
||||
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
|
||||
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
|
||||
{ key: 'markAudioCard', label: 'Mark audio card' },
|
||||
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
|
||||
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
|
||||
{ key: 'openJimaku', label: 'Open jimaku' },
|
||||
{ key: 'openSessionHelp', label: 'Open session help' },
|
||||
{ key: 'openControllerSelect', label: 'Open controller select' },
|
||||
{ key: 'openControllerDebug', label: 'Open controller debug' },
|
||||
{ key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' },
|
||||
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
|
||||
];
|
||||
|
||||
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {
|
||||
const rows: SessionHelpItem[] = [];
|
||||
|
||||
for (const shortcut of OVERLAY_SHORTCUTS) {
|
||||
const keybind = shortcuts[shortcut.key];
|
||||
|
||||
rows.push({
|
||||
shortcut:
|
||||
typeof keybind === 'string' && keybind.trim().length > 0
|
||||
? formatKeybinding(keybind)
|
||||
: 'Unbound',
|
||||
action: shortcut.label,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
return [{ title: 'Overlay shortcuts', rows }];
|
||||
}
|
||||
|
||||
function describeCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Unknown action';
|
||||
|
||||
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
|
||||
if (first === 'seek' && typeof command[1] === 'number') {
|
||||
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
|
||||
}
|
||||
if (first === 'sub-seek' && typeof command[1] === 'number') {
|
||||
if (command[1] > 0) return 'Jump to next subtitle';
|
||||
if (command[1] < 0) return 'Jump to previous subtitle';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to next cue';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
|
||||
}
|
||||
|
||||
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
|
||||
}
|
||||
|
||||
export {
|
||||
describeCommand as describeSessionHelpCommand,
|
||||
formatKeybinding as formatSessionHelpKeybinding,
|
||||
};
|
||||
|
||||
function sectionForCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Other shortcuts';
|
||||
|
||||
if (
|
||||
first === 'cycle' ||
|
||||
first === 'seek' ||
|
||||
first === 'sub-seek' ||
|
||||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
|
||||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
|
||||
) {
|
||||
return 'Playback and navigation';
|
||||
}
|
||||
|
||||
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
|
||||
return 'Visual feedback';
|
||||
}
|
||||
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
|
||||
return 'Subtitle sync';
|
||||
}
|
||||
|
||||
if (
|
||||
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
||||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
|
||||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
return 'Runtime settings';
|
||||
}
|
||||
|
||||
if (first === 'quit') return 'System actions';
|
||||
return 'Other shortcuts';
|
||||
}
|
||||
|
||||
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
|
||||
const grouped = new Map<string, SessionHelpItem[]>();
|
||||
|
||||
for (const binding of keybindings) {
|
||||
const section = sectionForCommand(binding.command ?? []);
|
||||
const row: SessionHelpItem = {
|
||||
shortcut: formatKeybinding(binding.key),
|
||||
action: describeCommand(binding.command ?? []),
|
||||
};
|
||||
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
||||
}
|
||||
|
||||
const sectionOrder = [
|
||||
'Playback and navigation',
|
||||
'Visual feedback',
|
||||
'Subtitle sync',
|
||||
'Runtime settings',
|
||||
'System actions',
|
||||
'Other shortcuts',
|
||||
];
|
||||
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
|
||||
const aIdx = sectionOrder.indexOf(a[0]);
|
||||
const bIdx = sectionOrder.indexOf(b[0]);
|
||||
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
|
||||
return sectionEntries.map(([title, rows]) => ({ title, rows }));
|
||||
}
|
||||
|
||||
function buildColorSection(style: {
|
||||
knownWordColor?: unknown;
|
||||
nPlusOneColor?: unknown;
|
||||
nameMatchColor?: unknown;
|
||||
jlptColors?: {
|
||||
N1?: unknown;
|
||||
N2?: unknown;
|
||||
N3?: unknown;
|
||||
N4?: unknown;
|
||||
N5?: unknown;
|
||||
};
|
||||
}): SessionHelpSection {
|
||||
return {
|
||||
title: 'Color legend',
|
||||
rows: [
|
||||
{
|
||||
shortcut: 'Known words',
|
||||
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'N+1 words',
|
||||
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'Character names',
|
||||
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
|
||||
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N1',
|
||||
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N2',
|
||||
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N3',
|
||||
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N4',
|
||||
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N5',
|
||||
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] {
|
||||
const normalize = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/commandorcontrol/gu, 'ctrl')
|
||||
.replace(/cmd\/ctrl/gu, 'ctrl')
|
||||
.replace(/[\s+\-_/]/gu, '');
|
||||
const normalized = normalize(query);
|
||||
if (!normalized) return sections;
|
||||
|
||||
return sections
|
||||
.map((section) => {
|
||||
if (normalize(section.title).includes(normalized)) {
|
||||
return section;
|
||||
}
|
||||
|
||||
const rows = section.rows.filter(
|
||||
(row) =>
|
||||
normalize(row.shortcut).includes(normalized) ||
|
||||
normalize(row.action).includes(normalized),
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return { ...section, rows };
|
||||
})
|
||||
.filter((section): section is SessionHelpSection => section !== null)
|
||||
.filter((section) => section.rows.length > 0);
|
||||
}
|
||||
|
||||
function formatBindingHint(info: SessionHelpBindingInfo): string {
|
||||
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
|
||||
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
|
||||
@@ -324,79 +26,6 @@ function formatBindingHint(info: SessionHelpBindingInfo): string {
|
||||
return 'Y-H';
|
||||
}
|
||||
|
||||
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'session-help-item';
|
||||
button.tabIndex = -1;
|
||||
button.dataset.sessionHelpIndex = String(globalIndex);
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'session-help-item-left';
|
||||
const shortcut = document.createElement('span');
|
||||
shortcut.className = 'session-help-key';
|
||||
shortcut.textContent = row.shortcut;
|
||||
left.appendChild(shortcut);
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'session-help-item-right';
|
||||
const action = document.createElement('span');
|
||||
action.className = 'session-help-action';
|
||||
action.textContent = row.action;
|
||||
right.appendChild(action);
|
||||
|
||||
if (row.color) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'session-help-color-dot';
|
||||
dot.style.backgroundColor = row.color;
|
||||
right.insertBefore(dot, action);
|
||||
}
|
||||
|
||||
button.appendChild(left);
|
||||
button.appendChild(right);
|
||||
return button;
|
||||
}
|
||||
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
'MPV shortcuts': '⚙',
|
||||
'Playback and navigation': '▶',
|
||||
'Visual feedback': '◉',
|
||||
'Subtitle sync': '⟲',
|
||||
'Runtime settings': '⚙',
|
||||
'System actions': '◆',
|
||||
'Other shortcuts': '…',
|
||||
'Overlay shortcuts (configurable)': '✦',
|
||||
'Overlay shortcuts': '✦',
|
||||
'Color legend': '◈',
|
||||
};
|
||||
|
||||
function createSectionNode(
|
||||
section: SessionHelpSection,
|
||||
sectionIndex: number,
|
||||
globalIndexMap: number[],
|
||||
): HTMLElement {
|
||||
const sectionNode = document.createElement('section');
|
||||
sectionNode.className = 'session-help-section';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'session-help-section-title';
|
||||
const icon = SECTION_ICON[section.title] ?? '•';
|
||||
title.textContent = `${icon} ${section.title}`;
|
||||
sectionNode.appendChild(title);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'session-help-item-list';
|
||||
|
||||
section.rows.forEach((row, rowIndex) => {
|
||||
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
||||
const button = createShortcutRow(row, globalIndex);
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
sectionNode.appendChild(list);
|
||||
return sectionNode;
|
||||
}
|
||||
|
||||
export function createSessionHelpModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -412,6 +41,7 @@ export function createSessionHelpModal(
|
||||
};
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let activeTabId: SessionHelpTabId = 'essentials';
|
||||
let focusGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
@@ -497,7 +127,7 @@ export function createSessionHelpModal(
|
||||
}
|
||||
|
||||
function applyFilterAndRender(): void {
|
||||
const sections = filterSections(helpSections, helpFilterValue);
|
||||
const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue);
|
||||
const indexOffsets: number[] = [];
|
||||
let running = 0;
|
||||
for (const section of sections) {
|
||||
@@ -506,8 +136,16 @@ export function createSessionHelpModal(
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpContent.innerHTML = '';
|
||||
if (!helpFilterValue.trim()) {
|
||||
ctx.dom.sessionHelpContent.appendChild(
|
||||
createSessionHelpTabBar(helpSections, activeTabId, (tabId) => {
|
||||
activeTabId = tabId;
|
||||
applyFilterAndRender();
|
||||
}),
|
||||
);
|
||||
}
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
const sectionNode = createSectionNode(section, sectionIndex, indexOffsets);
|
||||
const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets);
|
||||
ctx.dom.sessionHelpContent.appendChild(sectionNode);
|
||||
});
|
||||
|
||||
@@ -515,7 +153,7 @@ export function createSessionHelpModal(
|
||||
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
|
||||
ctx.dom.sessionHelpContent.textContent = helpFilterValue
|
||||
? 'No matching shortcuts found.'
|
||||
: 'No active session shortcuts found.';
|
||||
: 'No active shortcuts in this tab.';
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
@@ -572,6 +210,7 @@ export function createSessionHelpModal(
|
||||
function showRenderError(message: string): void {
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
activeTabId = 'essentials';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
|
||||
ctx.dom.sessionHelpContent.textContent = message;
|
||||
@@ -580,28 +219,23 @@ export function createSessionHelpModal(
|
||||
|
||||
async function render(): Promise<boolean> {
|
||||
try {
|
||||
const [keybindings, styleConfig, shortcuts] = await Promise.all([
|
||||
window.electronAPI.getKeybindings(),
|
||||
window.electronAPI.getSubtitleStyle(),
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
]);
|
||||
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
|
||||
await Promise.all([
|
||||
window.electronAPI.getSessionBindings(),
|
||||
window.electronAPI.getSubtitleStyle(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
window.electronAPI
|
||||
.getSubtitleSidebarSnapshot()
|
||||
.then((snapshot) => snapshot.config.toggleKey)
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
|
||||
const bindingSections = buildBindingSections(keybindings);
|
||||
if (bindingSections.length > 0) {
|
||||
const playback = bindingSections.find(
|
||||
(section) => section.title === 'Playback and navigation',
|
||||
);
|
||||
if (playback) {
|
||||
playback.title = 'MPV shortcuts';
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutSections = buildOverlayShortcutSections(shortcuts);
|
||||
if (shortcutSections.length > 0) {
|
||||
shortcutSections[0]!.title = 'Overlay shortcuts (configurable)';
|
||||
}
|
||||
const colorSection = buildColorSection(styleConfig ?? {});
|
||||
helpSections = [...bindingSections, ...shortcutSections, colorSection];
|
||||
helpSections = buildSessionHelpSections({
|
||||
sessionBindings,
|
||||
markWatchedKey,
|
||||
subtitleSidebarToggleKey,
|
||||
subtitleStyle: styleConfig ?? {},
|
||||
});
|
||||
applyFilterAndRender();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,11 @@ import test from 'node:test';
|
||||
|
||||
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createSubtitleSidebarModal, findActiveSubtitleCueIndex } from './subtitle-sidebar.js';
|
||||
import {
|
||||
applySidebarCssDeclarations,
|
||||
createSubtitleSidebarModal,
|
||||
findActiveSubtitleCueIndex,
|
||||
} from './subtitle-sidebar.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
@@ -108,6 +112,42 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
|
||||
});
|
||||
|
||||
test('applySidebarCssDeclarations clears declarations removed by config reload', () => {
|
||||
const removed: string[] = [];
|
||||
const style = {
|
||||
color: '',
|
||||
backgroundColor: '',
|
||||
setProperty(property: string, value: string) {
|
||||
(this as unknown as Record<string, string>)[property] = value;
|
||||
},
|
||||
removeProperty(property: string) {
|
||||
removed.push(property);
|
||||
delete (this as unknown as Record<string, string>)[property];
|
||||
},
|
||||
};
|
||||
const target = { style } as unknown as HTMLElement;
|
||||
|
||||
applySidebarCssDeclarations(target, {
|
||||
color: '#cad3f5',
|
||||
'background-color': '#181926',
|
||||
});
|
||||
applySidebarCssDeclarations(target, {
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
assert.equal(style.color, '#ffffff');
|
||||
assert.equal(style.backgroundColor, '');
|
||||
assert.deepEqual(removed, ['background-color']);
|
||||
|
||||
applySidebarCssDeclarations(target, {
|
||||
color: '',
|
||||
'background-color': '',
|
||||
});
|
||||
|
||||
assert.equal(style.color, '');
|
||||
assert.deepEqual(removed, ['background-color', 'background-color']);
|
||||
});
|
||||
|
||||
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
@@ -141,6 +181,11 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
activeLineColor: '#f5bde6',
|
||||
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
css: {
|
||||
'font-size': '22px',
|
||||
color: '#ffffff',
|
||||
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -175,6 +220,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
const overlayClassList = createClassList();
|
||||
const modalClassList = createClassList(['hidden']);
|
||||
const cueList = createListStub();
|
||||
const contentStyleValues = new Map<string, string>();
|
||||
const contentStyle = {
|
||||
setProperty: (name: string, value: string) => {
|
||||
contentStyleValues.set(name, value);
|
||||
},
|
||||
} as CSSStyleDeclaration & { color?: string };
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
@@ -187,6 +238,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 420 }),
|
||||
style: contentStyle,
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
@@ -207,6 +259,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
assert.equal(cueList.children.length, 2);
|
||||
assert.equal(cueList.scrollTop, 0);
|
||||
assert.deepEqual(cueList.scrollToCalls, []);
|
||||
assert.equal(contentStyleValues.get('font-size'), '22px');
|
||||
assert.equal(contentStyle.color, '#ffffff');
|
||||
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
|
||||
|
||||
modal.seekToCue(snapshot.cues[0]!);
|
||||
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
|
||||
|
||||
@@ -8,6 +8,7 @@ const CLICK_SEEK_OFFSET_SEC = 0.08;
|
||||
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
||||
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
||||
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
|
||||
|
||||
function nowForUiTiming(): number {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
@@ -55,6 +56,46 @@ function formatCueTimestamp(seconds: number): string {
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function applySidebarCssDeclarations(
|
||||
target: HTMLElement,
|
||||
declarations: Record<string, string>,
|
||||
): void {
|
||||
const targetStyle = (target as HTMLElement & { style?: CSSStyleDeclaration }).style;
|
||||
if (!targetStyle) return;
|
||||
const styleTarget = targetStyle as unknown as Record<string, string>;
|
||||
const previousKeys = appliedSidebarCssKeys.get(target) ?? new Set<string>();
|
||||
const nextKeys = new Set<string>();
|
||||
|
||||
for (const property of previousKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(declarations, property)) continue;
|
||||
if (property.includes('-')) {
|
||||
targetStyle.removeProperty(property);
|
||||
} else {
|
||||
styleTarget[property] = '';
|
||||
}
|
||||
}
|
||||
|
||||
for (const [property, rawValue] of Object.entries(declarations)) {
|
||||
const value = rawValue.trim();
|
||||
if (value.length === 0) {
|
||||
if (property.includes('-')) {
|
||||
targetStyle.removeProperty(property);
|
||||
} else {
|
||||
styleTarget[property] = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (property.includes('-')) {
|
||||
targetStyle.setProperty(property, value);
|
||||
} else {
|
||||
styleTarget[property] = value;
|
||||
}
|
||||
nextKeys.add(property);
|
||||
}
|
||||
|
||||
appliedSidebarCssKeys.set(target, nextKeys);
|
||||
}
|
||||
|
||||
export function findActiveSubtitleCueIndex(
|
||||
cues: SubtitleCue[],
|
||||
current: { text: string; startTime?: number | null } | null,
|
||||
@@ -266,6 +307,7 @@ export function createSubtitleSidebarModal(
|
||||
'--subtitle-sidebar-hover-background-color',
|
||||
snapshot.config.hoverLineBackgroundColor,
|
||||
);
|
||||
applySidebarCssDeclarations(ctx.dom.subtitleSidebarContent, snapshot.config.css ?? {});
|
||||
}
|
||||
|
||||
function seekToCue(cue: SubtitleCue): void {
|
||||
|
||||
@@ -40,6 +40,20 @@ test('renderer stylesheet only hides visible focus chrome on top-level overlay f
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar stylesheet keeps quoted font fallbacks and generic family', () => {
|
||||
const cssSource = readWorkspaceFile('src/renderer/style.css');
|
||||
const sidebarContentBlock = cssSource.match(
|
||||
/\.subtitle-sidebar-content\s*\{(?<body>[\s\S]*?)\s*\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(sidebarContentBlock);
|
||||
assert.match(sidebarContentBlock, /'Hiragino Sans'/);
|
||||
assert.match(sidebarContentBlock, /'M PLUS 1'/);
|
||||
assert.match(sidebarContentBlock, /'Source Han Sans JP'/);
|
||||
assert.match(sidebarContentBlock, /'Noto Sans CJK JP'/);
|
||||
assert.match(sidebarContentBlock, /sans-serif/);
|
||||
});
|
||||
|
||||
test('top-level readme avoids stale overlay-layers wording', () => {
|
||||
const readmeSource = readWorkspaceFile('README.md');
|
||||
assert.doesNotMatch(readmeSource, /overlay layers/i);
|
||||
|
||||
@@ -613,6 +613,8 @@ async function init(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
let initialSubtitle: SubtitleData | string = '';
|
||||
try {
|
||||
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
||||
@@ -698,8 +700,6 @@ async function init(): Promise<void> {
|
||||
});
|
||||
});
|
||||
mouseHandlers.setupDragging();
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
try {
|
||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
CharacterDictionarySelectionSnapshot,
|
||||
PrimarySubMode,
|
||||
SubtitlePosition,
|
||||
SubtitleSidebarConfig,
|
||||
SubtitleSidebarSnapshotConfig,
|
||||
SubtitleCue,
|
||||
SubsyncSourceTrack,
|
||||
YoutubePickerOpenPayload,
|
||||
@@ -98,7 +98,7 @@ export type RendererState = {
|
||||
subtitleSidebarToggleKey: string;
|
||||
subtitleSidebarPauseVideoOnHover: boolean;
|
||||
subtitleSidebarAutoScroll: boolean;
|
||||
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
|
||||
subtitleSidebarConfig: SubtitleSidebarSnapshotConfig | null;
|
||||
subtitleSidebarManualScrollUntilMs: number;
|
||||
subtitleSidebarPausedByHover: boolean;
|
||||
|
||||
@@ -215,7 +215,7 @@ export function createRendererState(): RendererState {
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
nameMatchEnabled: true,
|
||||
nameMatchEnabled: false,
|
||||
nameMatchColor: '#f5bde6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
|
||||
+56
-9
@@ -660,7 +660,7 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||
--subtitle-jlpt-n4-color: #a6e3a1;
|
||||
--subtitle-jlpt-n5-color: #8aadf4;
|
||||
--subtitle-hover-token-color: #f4dbd6;
|
||||
--subtitle-hover-token-background-color: rgba(54, 58, 79, 0.84);
|
||||
--subtitle-hover-token-background-color: transparent;
|
||||
--subtitle-frequency-single-color: #f5a97f;
|
||||
--subtitle-frequency-band-1-color: #ed8796;
|
||||
--subtitle-frequency-band-2-color: #f5a97f;
|
||||
@@ -719,7 +719,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .c:hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
background: var(--subtitle-hover-token-background-color, transparent);
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
border-radius: 2px;
|
||||
@@ -884,7 +884,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(
|
||||
.word-frequency-band-4
|
||||
):not(.word-frequency-band-5):hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
background: var(--subtitle-hover-token-background-color, transparent);
|
||||
border-radius: 3px;
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
@@ -899,7 +899,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
#subtitleRoot .word.word-frequency-band-3:hover,
|
||||
#subtitleRoot .word.word-frequency-band-4:hover,
|
||||
#subtitleRoot .word.word-frequency-band-5:hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
background: var(--subtitle-hover-token-background-color, transparent);
|
||||
border-radius: 3px;
|
||||
filter: brightness(1.18) saturate(1.08);
|
||||
}
|
||||
@@ -933,13 +933,13 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
#subtitleRoot::selection,
|
||||
#subtitleRoot .word::selection,
|
||||
#subtitleRoot .c::selection {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
background: var(--subtitle-hover-token-background-color, transparent);
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot *::selection {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)) !important;
|
||||
background: var(--subtitle-hover-token-background-color, transparent) !important;
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
@@ -1912,9 +1912,10 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
|
||||
margin-left: auto;
|
||||
font-family: var(
|
||||
--subtitle-sidebar-font-family,
|
||||
'M PLUS 1',
|
||||
'Noto Sans CJK JP',
|
||||
'Hiragino Sans',
|
||||
'M PLUS 1',
|
||||
'Source Han Sans JP',
|
||||
'Noto Sans CJK JP',
|
||||
sans-serif
|
||||
);
|
||||
font-size: var(--subtitle-sidebar-font-size, 16px);
|
||||
@@ -2062,7 +2063,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
}
|
||||
|
||||
.subtitle-sidebar-timestamp {
|
||||
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -2129,6 +2130,48 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.session-help-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 4px 0 6px;
|
||||
background: linear-gradient(180deg, rgba(30, 32, 48, 0.98), rgba(30, 32, 48, 0.82));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.session-help-tab {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(110, 115, 141, 0.22);
|
||||
background: rgba(49, 50, 68, 0.76);
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-help-tab:hover,
|
||||
.session-help-tab:focus-visible {
|
||||
border-color: rgba(138, 173, 244, 0.48);
|
||||
color: var(--ctp-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.session-help-tab.active {
|
||||
border-color: rgba(238, 212, 159, 0.62);
|
||||
background: rgba(238, 212, 159, 0.16);
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.session-help-filter {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
@@ -2276,6 +2319,10 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
max-height: calc(84vh - 190px);
|
||||
}
|
||||
|
||||
.session-help-tabs {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.session-help-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MergedToken } from '../types';
|
||||
import { PartOfSpeech } from '../types.js';
|
||||
|
||||
export function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
surface: '',
|
||||
reading: '',
|
||||
headword: '',
|
||||
startPos: 0,
|
||||
endPos: 0,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { MergedToken } from '../types';
|
||||
import { computeWordClass } from './subtitle-render.js';
|
||||
import { createToken } from './subtitle-render-test-helpers.js';
|
||||
|
||||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||||
const knownJlpt = createToken({
|
||||
isKnown: true,
|
||||
jlptLevel: 'N1',
|
||||
surface: '猫',
|
||||
});
|
||||
const nPlusOneJlpt = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: 'N2',
|
||||
surface: '犬',
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1');
|
||||
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
|
||||
});
|
||||
|
||||
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes when enabled', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: 'N2',
|
||||
frequencyRank: 10,
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
nameMatchEnabled: true,
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-name-match',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass skips name-match class by default', () => {
|
||||
const token = createToken({
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(computeWordClass(token), 'word');
|
||||
});
|
||||
|
||||
test('computeWordClass skips name-match class when disabled', () => {
|
||||
const token = createToken({
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
nameMatchEnabled: false,
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass keeps known and N+1 color classes exclusive over frequency classes', () => {
|
||||
const known = createToken({
|
||||
isKnown: true,
|
||||
frequencyRank: 10,
|
||||
surface: '既知',
|
||||
});
|
||||
const nPlusOne = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
frequencyRank: 10,
|
||||
surface: '目標',
|
||||
});
|
||||
const frequency = createToken({
|
||||
frequencyRank: 10,
|
||||
surface: '頻度',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(known, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-known',
|
||||
);
|
||||
assert.equal(
|
||||
computeWordClass(nPlusOne, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-n-plus-one',
|
||||
);
|
||||
assert.equal(
|
||||
computeWordClass(frequency, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-frequency-single',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
|
||||
const token = createToken({
|
||||
surface: '猫',
|
||||
frequencyRank: 50,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-single');
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class when rank equals topX', () => {
|
||||
const token = createToken({
|
||||
surface: '水',
|
||||
frequencyRank: 100,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-single');
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for banded mode', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 250,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: 'banded',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-band-2');
|
||||
});
|
||||
|
||||
test('computeWordClass uses configured band count for banded mode', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 2,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 4,
|
||||
mode: 'banded',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'],
|
||||
} as any);
|
||||
|
||||
assert.equal(actual, 'word word-frequency-band-3');
|
||||
});
|
||||
|
||||
test('computeWordClass skips frequency class when rank is out of topX', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 1200,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word');
|
||||
});
|
||||
@@ -4,11 +4,9 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MergedToken } from '../types';
|
||||
import { PartOfSpeech } from '../types.js';
|
||||
import {
|
||||
alignTokensToSourceText,
|
||||
buildSubtitleTokenHoverRanges,
|
||||
computeWordClass,
|
||||
createSubtitleRenderer,
|
||||
getFrequencyRankLabelForToken,
|
||||
getJlptLevelLabelForToken,
|
||||
@@ -16,6 +14,7 @@ import {
|
||||
sanitizeSubtitleHoverTokenColor,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
} from './subtitle-render.js';
|
||||
import { createToken } from './subtitle-render-test-helpers.js';
|
||||
import { createRendererState } from './state.js';
|
||||
|
||||
class FakeTextNode {
|
||||
@@ -45,6 +44,12 @@ class FakeStyleDeclaration {
|
||||
setProperty(name: string, value: string) {
|
||||
this.values.set(name, value);
|
||||
}
|
||||
|
||||
removeProperty(name: string) {
|
||||
const previous = this.values.get(name) ?? '';
|
||||
this.values.delete(name);
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
@@ -128,21 +133,6 @@ function collectWordNodes(root: FakeElement): FakeElement[] {
|
||||
);
|
||||
}
|
||||
|
||||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
surface: '',
|
||||
reading: '',
|
||||
headword: '',
|
||||
startPos: 0,
|
||||
endPos: 0,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function extractClassBlock(cssText: string, selector: string): string {
|
||||
const ruleRegex = /([^{}]+)\{([^}]*)\}/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
@@ -236,111 +226,6 @@ function buildJlptColorSelector(level: number): string {
|
||||
return `#subtitleRoot .word.word-jlpt-n${level}:not(:is(${higherPriorityClasses}))`;
|
||||
}
|
||||
|
||||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||||
const knownJlpt = createToken({
|
||||
isKnown: true,
|
||||
jlptLevel: 'N1',
|
||||
surface: '猫',
|
||||
});
|
||||
const nPlusOneJlpt = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: 'N2',
|
||||
surface: '犬',
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1');
|
||||
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
|
||||
});
|
||||
|
||||
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: 'N2',
|
||||
frequencyRank: 10,
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-name-match',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass skips name-match class when disabled', () => {
|
||||
const token = createToken({
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
nameMatchEnabled: false,
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass keeps known and N+1 color classes exclusive over frequency classes', () => {
|
||||
const known = createToken({
|
||||
isKnown: true,
|
||||
frequencyRank: 10,
|
||||
surface: '既知',
|
||||
});
|
||||
const nPlusOne = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
frequencyRank: 10,
|
||||
surface: '目標',
|
||||
});
|
||||
const frequency = createToken({
|
||||
frequencyRank: 10,
|
||||
surface: '頻度',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(known, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-known',
|
||||
);
|
||||
assert.equal(
|
||||
computeWordClass(nPlusOne, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-n-plus-one',
|
||||
);
|
||||
assert.equal(
|
||||
computeWordClass(frequency, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-frequency-single',
|
||||
);
|
||||
});
|
||||
|
||||
test('applySubtitleStyle sets subtitle name-match color variable', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
@@ -426,6 +311,106 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
|
||||
}
|
||||
});
|
||||
|
||||
test('applySubtitleStyle applies primary and secondary css declaration objects', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const subtitleContainer = new FakeElement('div');
|
||||
const secondarySubRoot = new FakeElement('div');
|
||||
const secondarySubContainer = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer,
|
||||
secondarySubRoot,
|
||||
secondarySubContainer,
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.applySubtitleStyle({
|
||||
fontSize: 35,
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-outline': '1px',
|
||||
},
|
||||
secondary: {
|
||||
fontSize: 24,
|
||||
css: {
|
||||
'font-size': '28px',
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const primaryValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
|
||||
assert.equal(primaryValues?.get('font-size'), '42px');
|
||||
assert.equal(primaryValues?.get('text-wrap'), 'balance');
|
||||
assert.equal(primaryValues?.get('--subtitle-outline'), '1px');
|
||||
assert.equal(secondaryValues?.get('font-size'), '28px');
|
||||
assert.equal(secondaryValues?.get('text-transform'), 'uppercase');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('applySubtitleStyle removes css declarations missing from later updates', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const subtitleContainer = new FakeElement('div');
|
||||
const secondarySubRoot = new FakeElement('div');
|
||||
const secondarySubContainer = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer,
|
||||
secondarySubRoot,
|
||||
secondarySubContainer,
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.applySubtitleStyle({
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
},
|
||||
secondary: {
|
||||
css: {
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
renderer.applySubtitleStyle({
|
||||
css: {
|
||||
'font-size': '44px',
|
||||
},
|
||||
secondary: {
|
||||
css: {},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const primaryValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
|
||||
assert.equal(primaryValues?.get('font-size'), '44px');
|
||||
assert.equal(primaryValues?.has('text-wrap'), false);
|
||||
assert.equal(secondaryValues?.has('text-transform'), false);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
@@ -516,91 +501,6 @@ test('annotated subtitle tokens inherit configured base subtitle typography', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
|
||||
const token = createToken({
|
||||
surface: '猫',
|
||||
frequencyRank: 50,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-single');
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class when rank equals topX', () => {
|
||||
const token = createToken({
|
||||
surface: '水',
|
||||
frequencyRank: 100,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-single');
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for banded mode', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 250,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: 'banded',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-band-2');
|
||||
});
|
||||
|
||||
test('computeWordClass uses configured band count for banded mode', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 2,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 4,
|
||||
mode: 'banded',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'],
|
||||
} as any);
|
||||
|
||||
assert.equal(actual, 'word word-frequency-band-3');
|
||||
});
|
||||
|
||||
test('computeWordClass skips frequency class when rank is out of topX', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 1200,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word');
|
||||
});
|
||||
|
||||
test('getFrequencyRankLabelForToken returns rank only for frequency-colored tokens', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
@@ -960,10 +860,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
|
||||
const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot');
|
||||
assert.match(subtitleRootBlock, /--subtitle-hover-token-color:\s*#f4dbd6;/);
|
||||
assert.match(
|
||||
subtitleRootBlock,
|
||||
/--subtitle-hover-token-background-color:\s*rgba\(54,\s*58,\s*79,\s*0\.84\);/,
|
||||
);
|
||||
assert.match(subtitleRootBlock, /--subtitle-hover-token-background-color:\s*transparent;/);
|
||||
assert.match(subtitleRootBlock, /-webkit-text-fill-color:\s*currentColor;/);
|
||||
|
||||
const charBlock = extractClassBlock(cssText, '#subtitleRoot .c');
|
||||
@@ -1017,7 +914,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
);
|
||||
assert.match(
|
||||
plainWordHoverBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\);/,
|
||||
);
|
||||
assert.match(
|
||||
plainWordHoverBlock,
|
||||
@@ -1031,7 +928,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover');
|
||||
assert.match(
|
||||
coloredWordHoverBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\);/,
|
||||
);
|
||||
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
|
||||
assert.match(coloredWordHoverBlock, /filter:\s*brightness\(1\.18\) saturate\(1\.08\);/);
|
||||
@@ -1140,7 +1037,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
|
||||
assert.match(
|
||||
selectionBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\);/,
|
||||
);
|
||||
assert.match(
|
||||
selectionBlock,
|
||||
@@ -1154,7 +1051,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
const descendantSelectionBlock = extractClassBlock(cssText, '#subtitleRoot *::selection');
|
||||
assert.match(
|
||||
descendantSelectionBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\)\s*!important;/,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
descendantSelectionBlock,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
PrimarySubMode,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitleStyleConfig,
|
||||
SubtitleRendererStyleConfig,
|
||||
} from '../types';
|
||||
import type { RendererContext } from './context';
|
||||
|
||||
@@ -80,12 +80,10 @@ export function sanitizeSubtitleHoverTokenColor(value: unknown): string {
|
||||
|
||||
function sanitizeSubtitleHoverTokenBackgroundColor(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return 'rgba(54, 58, 79, 0.84)';
|
||||
return 'transparent';
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && SAFE_CSS_COLOR_PATTERN.test(trimmed)
|
||||
? trimmed
|
||||
: 'rgba(54, 58, 79, 0.84)';
|
||||
return trimmed.length > 0 && SAFE_CSS_COLOR_PATTERN.test(trimmed) ? trimmed : 'transparent';
|
||||
}
|
||||
|
||||
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
@@ -95,7 +93,7 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
};
|
||||
const DEFAULT_NAME_MATCH_ENABLED = true;
|
||||
const DEFAULT_NAME_MATCH_ENABLED = false;
|
||||
|
||||
function hasPrioritizedNameMatch(
|
||||
token: MergedToken,
|
||||
@@ -158,6 +156,75 @@ function applyInlineStyleDeclarations(
|
||||
}
|
||||
}
|
||||
|
||||
const appliedCssKeys = new WeakMap<HTMLElement, Set<string>>();
|
||||
|
||||
function inlineStyleDeclarationKeys(
|
||||
declarations: Record<string, unknown>,
|
||||
excludedKeys: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const [key, value] of Object.entries(declarations)) {
|
||||
if (excludedKeys.has(key)) continue;
|
||||
if (value === null || value === undefined || typeof value === 'object') continue;
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function clearInlineStyleDeclaration(target: HTMLElement, key: string): void {
|
||||
if (key.includes('-')) {
|
||||
target.style.removeProperty(key);
|
||||
if (key === '--webkit-text-stroke') {
|
||||
target.style.removeProperty('-webkit-text-stroke');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
(target.style as unknown as Record<string, string>)[key] = '';
|
||||
}
|
||||
|
||||
function replaceInlineStyleDeclarations(
|
||||
target: HTMLElement,
|
||||
declarations: Record<string, unknown>,
|
||||
excludedKeys: ReadonlySet<string> = new Set<string>(),
|
||||
): void {
|
||||
const nextKeys = inlineStyleDeclarationKeys(declarations, excludedKeys);
|
||||
const previousKeys = appliedCssKeys.get(target) ?? new Set<string>();
|
||||
for (const key of previousKeys) {
|
||||
if (!nextKeys.has(key)) {
|
||||
clearInlineStyleDeclaration(target, key);
|
||||
}
|
||||
}
|
||||
applyInlineStyleDeclarations(target, declarations, excludedKeys);
|
||||
appliedCssKeys.set(target, nextKeys);
|
||||
}
|
||||
|
||||
function normalizeCssDeclarationObject(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
if (typeof rawValue !== 'string') continue;
|
||||
const cssValue = rawValue.trim();
|
||||
if (cssValue.length > 0) declarations[key] = cssValue;
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function applySubtitleCssDeclarations(
|
||||
root: HTMLElement,
|
||||
container: HTMLElement,
|
||||
declarations: Record<string, string>,
|
||||
): void {
|
||||
replaceInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS);
|
||||
replaceInlineStyleDeclarations(
|
||||
container,
|
||||
pickInlineStyleDeclarations(declarations, CONTAINER_STYLE_KEYS),
|
||||
);
|
||||
}
|
||||
|
||||
function pickInlineStyleDeclarations(
|
||||
declarations: Record<string, unknown>,
|
||||
includedKeys: ReadonlySet<string>,
|
||||
@@ -172,7 +239,9 @@ function pickInlineStyleDeclarations(
|
||||
|
||||
const CONTAINER_STYLE_KEYS = new Set<string>([
|
||||
'background',
|
||||
'background-color',
|
||||
'backgroundColor',
|
||||
'backdrop-filter',
|
||||
'backdropFilter',
|
||||
'WebkitBackdropFilter',
|
||||
'webkitBackdropFilter',
|
||||
@@ -180,7 +249,7 @@ const CONTAINER_STYLE_KEYS = new Set<string>([
|
||||
]);
|
||||
|
||||
function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>): string {
|
||||
for (const key of ['backgroundColor', 'background']) {
|
||||
for (const key of ['backgroundColor', 'background-color', 'background']) {
|
||||
const value = declarations[key];
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
@@ -193,6 +262,7 @@ function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>):
|
||||
function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string {
|
||||
for (const key of [
|
||||
'backdropFilter',
|
||||
'backdrop-filter',
|
||||
'WebkitBackdropFilter',
|
||||
'webkitBackdropFilter',
|
||||
'-webkit-backdrop-filter',
|
||||
@@ -635,7 +705,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
document.documentElement.style.setProperty('--subtitle-font-size', `${clampedSize}px`);
|
||||
}
|
||||
|
||||
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
|
||||
function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void {
|
||||
if (!style) return;
|
||||
|
||||
const styleDeclarations = style as Record<string, unknown>;
|
||||
@@ -654,7 +724,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
|
||||
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
|
||||
const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? true;
|
||||
const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? false;
|
||||
const nameMatchColor = style.nameMatchColor ?? ctx.state.nameMatchColor ?? '#f5bde6';
|
||||
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
|
||||
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
|
||||
@@ -762,20 +832,26 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
'--subtitle-frequency-band-5-color',
|
||||
frequencyBandedColors[4],
|
||||
);
|
||||
applySubtitleCssDeclarations(
|
||||
ctx.dom.subtitleRoot,
|
||||
ctx.dom.subtitleContainer,
|
||||
normalizeCssDeclarationObject(style.css),
|
||||
);
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
|
||||
const secondaryCssDeclarations = normalizeCssDeclarationObject(secondaryStyle.css);
|
||||
applyInlineStyleDeclarations(
|
||||
ctx.dom.secondarySubRoot,
|
||||
secondaryStyleDeclarations,
|
||||
CONTAINER_STYLE_KEYS,
|
||||
);
|
||||
const secondaryContainerStyleDeclarations = pickInlineStyleDeclarations(
|
||||
secondaryStyleDeclarations,
|
||||
CONTAINER_STYLE_KEYS,
|
||||
);
|
||||
const secondaryContainerStyleDeclarations = {
|
||||
...pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
|
||||
...pickInlineStyleDeclarations(secondaryCssDeclarations, CONTAINER_STYLE_KEYS),
|
||||
};
|
||||
ctx.dom.secondarySubContainer.style.setProperty(
|
||||
'--secondary-sub-background-color',
|
||||
resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations),
|
||||
@@ -800,6 +876,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
if (secondaryStyle.fontStyle) {
|
||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||
}
|
||||
applySubtitleCssDeclarations(
|
||||
ctx.dom.secondarySubRoot,
|
||||
ctx.dom.secondarySubContainer,
|
||||
secondaryCssDeclarations,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user