Restore multi-copy digit capture and add AniList selection (#56)

This commit is contained in:
2026-04-25 21:44:55 -07:00
committed by GitHub
parent 7ac51cd5e9
commit d8934647a9
140 changed files with 4097 additions and 326 deletions
+49
View File
@@ -330,6 +330,7 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList();
const subtitleContainerClassList = createClassList();
let controllerSelectKeydownCount = 0;
let openControllerSelectCount = 0;
let openControllerDebugCount = 0;
@@ -349,6 +350,7 @@ function createKeyboardHandlerHarness() {
querySelectorAll: () => wordNodes,
},
subtitleContainer: {
classList: subtitleContainerClassList,
contains: () => false,
},
overlay: testGlobals.overlay,
@@ -365,6 +367,7 @@ function createKeyboardHandlerHarness() {
const handlers = createKeyboardHandlers(ctx as never, {
handleRuntimeOptionsKeydown: () => false,
handleCharacterDictionaryKeydown: () => false,
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
@@ -404,6 +407,26 @@ function createKeyboardHandlerHarness() {
};
}
test('primary subtitle visibility key hides and restores the subtitle bar without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), true);
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
assert.equal(
testGlobals.mpvCommands.some((command) => command.includes('sub-visibility')),
false,
);
} finally {
testGlobals.restore();
}
});
test('session help chord resolver follows remapped session bindings', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -1119,6 +1142,32 @@ test('session binding: Ctrl+Shift+O dispatches runtime options locally', async (
}
});
test('session binding: copy subtitle multiple captures follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.copySubtitleMultiple',
originalKey: 'Ctrl+M',
key: { code: 'KeyM', modifiers: ['ctrl'] },
actionType: 'session-action',
actionId: 'copySubtitleMultiple',
},
] as never);
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM', ctrlKey: true });
testGlobals.dispatchKeydown({ key: '3', code: 'Digit3' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'copySubtitleMultiple', payload: { count: 3 } },
]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+26
View File
@@ -12,6 +12,7 @@ export function createKeyboardHandlers(
ctx: RendererContext,
options: {
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
@@ -360,6 +361,20 @@ export function createKeyboardHandlers(
);
}
function isPrimarySubtitleVisibilityToggle(e: KeyboardEvent): boolean {
return e.code === 'KeyV' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !e.repeat;
}
function togglePrimarySubtitleBarVisibility(): void {
const visible = !ctx.state.primarySubtitleBarVisible;
ctx.state.primarySubtitleBarVisible = visible;
if (visible) {
ctx.dom.subtitleContainer.classList.remove('primary-sub-hidden');
} else {
ctx.dom.subtitleContainer.classList.add('primary-sub-hidden');
}
}
async function handleMarkWatched(): Promise<void> {
const marked = await window.electronAPI.markActiveVideoWatched();
if (marked) {
@@ -1004,6 +1019,10 @@ export function createKeyboardHandlers(
options.handleRuntimeOptionsKeydown(e);
return;
}
if (ctx.state.characterDictionaryModalOpen) {
options.handleCharacterDictionaryKeydown(e);
return;
}
if (ctx.state.subsyncModalOpen) {
options.handleSubsyncKeydown(e);
return;
@@ -1060,6 +1079,12 @@ export function createKeyboardHandlers(
return;
}
if (isPrimarySubtitleVisibilityToggle(e)) {
e.preventDefault();
togglePrimarySubtitleBarVisibility();
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
@@ -1147,6 +1172,7 @@ export function createKeyboardHandlers(
updateSessionBindings,
syncKeyboardTokenSelection,
handleSubtitleContentUpdated,
togglePrimarySubtitleBarVisibility,
handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested,
closeLookupWindow,
+14
View File
@@ -197,6 +197,20 @@
</div>
</div>
</div>
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
<div class="modal-content character-dictionary-content">
<div class="modal-header">
<div class="modal-title">Character Dictionary Anime</div>
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CharacterDictionarySelectionSnapshot, ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createCharacterDictionaryModal } from './character-dictionary.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => entries.forEach((entry) => tokens.add(entry)),
remove: (...entries: string[]) => entries.forEach((entry) => tokens.delete(entry)),
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return;
}
if (force) tokens.add(entry);
else tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
return {
className: '',
textContent: '',
type: '',
children: [] as unknown[],
classList: createClassList(),
append(...children: unknown[]) {
this.children.push(...children);
},
addEventListener: () => {},
};
}
function createNodeStub(hidden = false) {
const listeners = new Map<string, Array<() => void>>();
return {
textContent: '',
children: [] as unknown[],
classList: createClassList(hidden ? ['hidden'] : []),
setAttribute: () => {},
addEventListener: (event: string, listener: () => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
dispatchEvent: (event: string) => {
for (const listener of listeners.get(event) ?? []) listener();
},
replaceChildren(...children: unknown[]) {
this.children = [...children];
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
test('character dictionary modal loads candidates and applies selected override', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const snapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
};
const calls: number[] = [];
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const closeButton = createNodeStub();
const candidates = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => snapshot,
setCharacterDictionarySelection: async (mediaId: number) => {
calls.push(mediaId);
return {
ok: true,
seriesKey: snapshot.seriesKey,
selected: snapshot.candidates[0]!,
staleMediaIds: [10607],
};
},
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: closeButton,
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionaryCandidates: candidates,
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.wireDomEvents();
await modal.openCharacterDictionaryModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.equal(overlay.classList.contains('interactive'), true);
assert.equal(modalNode.classList.contains('hidden'), false);
assert.equal(candidates.children.length, 1);
modal.handleCharacterDictionaryKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.deepEqual(calls, [21355]);
assert.match(status.textContent, /Override saved/);
closeButton.dispatchEvent('click');
assert.equal(state.characterDictionaryModalOpen, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal shows refresh errors without rejecting open', async () => {
const previousWindow = globalThis.window;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => {
throw new Error('candidate lookup failed');
},
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: 'test',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
>,
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.equal(status.textContent, 'candidate lookup failed');
assert.equal(status.classList.contains('error'), true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
+231
View File
@@ -0,0 +1,231 @@
import type {
CharacterDictionaryCandidate,
CharacterDictionarySelectionSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.min(Math.max(index, 0), length - 1);
}
function formatCandidate(candidate: CharacterDictionaryCandidate | null): string {
if (!candidate) return 'None';
const episodes = candidate.episodes === null ? '?' : String(candidate.episodes);
return `${candidate.id} - ${candidate.title} (${episodes} episodes)`;
}
function buildSummary(snapshot: CharacterDictionarySelectionSnapshot): string {
const guess = snapshot.guessTitle ?? 'No active title';
return `Series key: ${snapshot.seriesKey} · Guess: ${guess}`;
}
export function createCharacterDictionaryModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setStatus(message: string, isError = false): void {
ctx.state.characterDictionaryStatus = message;
ctx.dom.characterDictionaryStatus.textContent = message;
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
}
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
const previousId =
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
?.id;
ctx.state.characterDictionarySelection = snapshot;
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
ctx.state.characterDictionarySelectedIndex = clampIndex(
nextIndex >= 0 ? nextIndex : 0,
snapshot.candidates.length,
);
render();
}
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
const item = document.createElement('li');
item.className = 'character-dictionary-candidate';
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
const main = document.createElement('div');
main.className = 'runtime-options-label';
main.textContent = candidate.title;
const meta = document.createElement('div');
meta.className = 'runtime-options-allowed';
const episodeLabel = candidate.episodes === null ? '?' : String(candidate.episodes);
meta.textContent = `AniList ${candidate.id} · ${episodeLabel} episodes`;
const button = document.createElement('button');
button.className = 'character-dictionary-use';
button.type = 'button';
button.textContent = 'Use';
button.addEventListener('click', (event) => {
event.stopPropagation();
ctx.state.characterDictionarySelectedIndex = index;
void applySelectedCandidate();
});
const body = document.createElement('div');
body.className = 'character-dictionary-candidate-body';
body.append(main, meta);
item.append(body, button);
item.addEventListener('click', () => {
ctx.state.characterDictionarySelectedIndex = index;
render();
});
item.addEventListener('dblclick', () => {
ctx.state.characterDictionarySelectedIndex = index;
void applySelectedCandidate();
});
return item;
}
function render(): void {
const snapshot = ctx.state.characterDictionarySelection;
ctx.dom.characterDictionaryCandidates.replaceChildren();
if (!snapshot) {
ctx.dom.characterDictionarySummary.textContent = '';
ctx.dom.characterDictionaryCurrent.textContent = '';
return;
}
ctx.dom.characterDictionarySummary.textContent = buildSummary(snapshot);
ctx.dom.characterDictionaryCurrent.textContent = `Current: ${formatCandidate(
snapshot.current,
)} · Override: ${formatCandidate(snapshot.override)}`;
if (snapshot.candidates.length === 0) {
const empty = document.createElement('li');
empty.className = 'character-dictionary-empty';
empty.textContent = 'No AniList candidates found.';
ctx.dom.characterDictionaryCandidates.append(empty);
return;
}
ctx.dom.characterDictionaryCandidates.replaceChildren(
...snapshot.candidates.map((candidate, index) => renderCandidate(candidate, index)),
);
}
async function refreshSelection(): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
setSelection(snapshot);
setStatus(
snapshot.override
? `Override active: ${formatCandidate(snapshot.override)}`
: 'Select the correct AniList entry.',
);
}
async function applySelectedCandidate(): Promise<void> {
const snapshot = ctx.state.characterDictionarySelection;
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
if (!candidate) return;
setStatus(`Saving override for ${candidate.title}...`);
try {
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
if (!result.ok) {
setStatus('Failed to save override', true);
return;
}
await refreshSelection();
const staleLabel =
result.staleMediaIds.length > 0
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
: '';
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
function showShell(): void {
ctx.state.characterDictionaryModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.characterDictionaryModal.classList.remove('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
setStatus('Loading AniList candidates...');
}
async function openCharacterDictionaryModal(): Promise<void> {
if (!ctx.state.characterDictionaryModalOpen) {
showShell();
} else {
setStatus('Refreshing AniList candidates...');
}
try {
await refreshSelection();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
function closeCharacterDictionaryModal(): void {
if (!ctx.state.characterDictionaryModalOpen) return;
ctx.state.characterDictionaryModalOpen = false;
ctx.state.characterDictionarySelection = null;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.characterDictionaryModal.classList.add('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
ctx.dom.characterDictionaryCandidates.replaceChildren();
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
setStatus('');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function moveSelection(delta: -1 | 1): void {
const length = ctx.state.characterDictionarySelection?.candidates.length ?? 0;
if (length <= 0) return;
ctx.state.characterDictionarySelectedIndex = clampIndex(
ctx.state.characterDictionarySelectedIndex + delta,
length,
);
render();
}
function handleCharacterDictionaryKeydown(e: KeyboardEvent): boolean {
if (e.key === 'Escape') {
e.preventDefault();
closeCharacterDictionaryModal();
return true;
}
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault();
moveSelection(1);
return true;
}
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
e.preventDefault();
moveSelection(-1);
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
void applySelectedCandidate();
return true;
}
return false;
}
function wireDomEvents(): void {
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
}
return {
openCharacterDictionaryModal,
closeCharacterDictionaryModal,
handleCharacterDictionaryKeydown,
wireDomEvents,
};
}
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import {
describeSessionHelpCommand,
formatSessionHelpKeybinding,
} from './session-help.js';
test('session help describes sub-seek commands as subtitle-line navigation', () => {
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
});
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => {
assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]),
'Shift subtitle delay to next cue',
);
assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
'Shift subtitle delay to previous cue',
);
});
test('session help formats bracket keybindings as physical keys', () => {
assert.equal(formatSessionHelpKeybinding('Shift+BracketRight'), 'Shift + ]');
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
});
+17 -1
View File
@@ -44,6 +44,8 @@ const KEY_NAME_MAP: Record<string, string> = {
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
BracketLeft: '[',
BracketRight: ']',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
@@ -94,6 +96,7 @@ const OVERLAY_SHORTCUTS: Array<{
{ 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' },
@@ -131,7 +134,9 @@ function describeCommand(command: (string | number)[]): string {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
return `Shift subtitle by ${command[1]} ms`;
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';
@@ -139,6 +144,12 @@ function describeCommand(command: (string | number)[]): string {
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'}`;
@@ -147,6 +158,11 @@ function describeCommand(command: (string | number)[]): string {
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';
+25
View File
@@ -38,6 +38,7 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
@@ -71,6 +72,7 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen ||
@@ -87,6 +89,7 @@ function isAnyModalOpen(): boolean {
ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen ||
@@ -114,6 +117,10 @@ const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -165,6 +172,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
@@ -221,6 +229,7 @@ function getActiveModal(): string | null {
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
if (ctx.state.characterDictionaryModalOpen) return 'character-dictionary';
if (ctx.state.subsyncModalOpen) return 'subsync';
if (ctx.state.sessionHelpModalOpen) return 'session-help';
return null;
@@ -248,6 +257,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.runtimeOptionsModalOpen) {
runtimeOptionsModal.closeRuntimeOptionsModal();
}
if (ctx.state.characterDictionaryModalOpen) {
characterDictionaryModal.closeCharacterDictionaryModal();
}
if (ctx.state.subsyncModalOpen) {
subsyncModal.closeSubsyncModal();
}
@@ -435,6 +447,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('runtime-options');
});
});
window.electronAPI.onOpenCharacterDictionary(() => {
runGuardedAsync('character-dictionary:open', async () => {
await characterDictionaryModal.openCharacterDictionaryModal();
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
});
});
window.electronAPI.onOpenSessionHelp(() => {
runGuarded('session-help:open', () => {
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
@@ -514,6 +532,12 @@ function registerKeyboardCommandHandlers(): void {
await subtitleSidebarModal.toggleSubtitleSidebarModal();
});
});
window.electronAPI.onPrimarySubtitleBarToggle(() => {
runGuarded('primary-subtitle-bar:toggle', () => {
keyboardHandlers.togglePrimarySubtitleBarVisibility();
});
});
}
function runGuarded(action: string, fn: () => void): void {
@@ -633,6 +657,7 @@ async function init(): Promise<void> {
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
subtitleSidebarModal.disposeDomEvents();
});
+13
View File
@@ -11,6 +11,7 @@ import type {
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
CharacterDictionarySelectionSnapshot,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleCue,
@@ -64,6 +65,11 @@ export type RendererState = {
runtimeOptionSelectedIndex: number;
runtimeOptionDraftValues: Map<RuntimeOptionId, RuntimeOptionValue>;
characterDictionaryModalOpen: boolean;
characterDictionarySelection: CharacterDictionarySelectionSnapshot | null;
characterDictionarySelectedIndex: number;
characterDictionaryStatus: string;
subsyncModalOpen: boolean;
subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubmitting: boolean;
@@ -128,6 +134,7 @@ export type RendererState = {
keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
primarySubtitleBarVisible: boolean;
};
export function createRendererState(): RendererState {
@@ -169,6 +176,11 @@ export function createRendererState(): RendererState {
runtimeOptionSelectedIndex: 0,
runtimeOptionDraftValues: new Map(),
characterDictionaryModalOpen: false,
characterDictionarySelection: null,
characterDictionarySelectedIndex: 0,
characterDictionaryStatus: '',
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubmitting: false,
@@ -233,5 +245,6 @@ export function createRendererState(): RendererState {
keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
primarySubtitleBarVisible: true,
};
}
+146 -43
View File
@@ -678,6 +678,11 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
display: none;
}
#subtitleContainer.primary-sub-hidden {
display: none;
pointer-events: none;
}
body.settings-modal-open #subtitleContainer {
display: none !important;
pointer-events: none !important;
@@ -778,88 +783,121 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-known {
color: var(--subtitle-known-word-color, #a6da95);
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
}
#subtitleRoot .word.word-n-plus-one {
color: var(--subtitle-n-plus-one-color, #c6a0f6);
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
}
#subtitleRoot .word.word-name-match {
color: var(--subtitle-name-match-color, #f5bde6);
text-shadow: 0 0 6px rgba(245, 189, 230, 0.35);
}
#subtitleRoot .word.word-jlpt-n1 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n1-color, #ed8796);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n1:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n1-color, #ed8796);
}
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n1-color, #ed8796);
}
#subtitleRoot .word.word-jlpt-n2 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n2-color, #f5a97f);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n2:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n2-color, #f5a97f);
}
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n2-color, #f5a97f);
}
#subtitleRoot .word.word-jlpt-n3 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n3-color, #f9e2af);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n3:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n3-color, #f9e2af);
}
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n3-color, #f9e2af);
}
#subtitleRoot .word.word-jlpt-n4 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n4:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
}
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
}
#subtitleRoot .word.word-jlpt-n5 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n5-color, #8aadf4);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n5:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n5-color, #8aadf4);
}
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n5-color, #8aadf4);
}
#subtitleRoot .word.word-frequency-single,
#subtitleRoot .word.word-frequency-band-1,
#subtitleRoot .word.word-frequency-band-2,
#subtitleRoot .word.word-frequency-band-3,
#subtitleRoot .word.word-frequency-band-4,
#subtitleRoot .word.word-frequency-band-5 {
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
}
#subtitleRoot .word.word-frequency-single {
color: var(--subtitle-frequency-single-color, #f5a97f);
}
@@ -907,7 +945,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-frequency-band-5:hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
border-radius: 3px;
font-weight: 800;
filter: brightness(1.18) saturate(1.08);
}
#subtitleRoot .word.word-known .c:hover,
@@ -1463,6 +1501,71 @@ iframe[id^='yomitan-popup'],
color: var(--ctp-red);
}
.character-dictionary-content {
width: min(680px, 92%);
}
.character-dictionary-current {
font-size: 12px;
color: var(--ctp-subtext1);
}
.character-dictionary-candidates {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid rgba(110, 115, 141, 0.2);
border-radius: 8px;
max-height: 340px;
overflow-y: auto;
}
.character-dictionary-candidate,
.character-dictionary-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(110, 115, 141, 0.1);
}
.character-dictionary-candidate {
cursor: pointer;
}
.character-dictionary-candidate:last-child,
.character-dictionary-empty:last-child {
border-bottom: none;
}
.character-dictionary-candidate.active {
background: rgba(138, 173, 244, 0.15);
}
.character-dictionary-candidate-body {
min-width: 0;
}
.character-dictionary-use {
flex: 0 0 auto;
border: 1px solid rgba(138, 173, 244, 0.38);
border-radius: 6px;
background: rgba(54, 58, 79, 0.8);
color: var(--ctp-text);
padding: 6px 10px;
cursor: pointer;
}
.character-dictionary-use:hover {
background: rgba(91, 96, 120, 0.9);
}
.character-dictionary-empty {
color: var(--ctp-overlay1);
font-size: 13px;
}
.controller-select-field {
display: flex;
flex-direction: column;
+159 -12
View File
@@ -220,6 +220,22 @@ function normalizeCssSelector(selector: string): string {
.trim();
}
function buildJlptColorSelector(level: number): string {
const higherPriorityClasses = [
'.word-known',
'.word-n-plus-one',
'.word-name-match',
'.word-frequency-single',
'.word-frequency-band-1',
'.word-frequency-band-2',
'.word-frequency-band-3',
'.word-frequency-band-4',
'.word-frequency-band-5',
].join(', ');
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,
@@ -410,6 +426,96 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
}
});
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
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({
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
fontColor: '#cad3f5',
fontWeight: 700,
lineHeight: 1.35,
letterSpacing: '-0.01em',
textRendering: 'geometricPrecision',
textShadow: '3px 0 0 #000, -3px 0 0 #000, 0 3px 0 #000, 0 -3px 0 #000, 2px 2px 0 #000',
frequencyDictionary: {
enabled: true,
topX: 10000,
mode: 'single',
singleColor: '#f5a97f',
},
enableJlpt: true,
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
N3: '#f9e2af',
N4: '#a6e3a1',
N5: '#8aadf4',
},
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
} as never);
renderer.renderSubtitle({
text: 'お礼をされるようなことしてない',
tokens: [
createToken({ surface: 'お礼', isKnown: true }),
createToken({ surface: 'を' }),
createToken({ surface: 'される', jlptLevel: 'N4' }),
createToken({ surface: 'ような', frequencyRank: 15 }),
],
});
const rootStyle = subtitleRoot.style as unknown as Record<string, string>;
assert.equal(rootStyle.fontFamily, 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP');
assert.equal(rootStyle.fontSize, '35px');
assert.equal(rootStyle.color, '#cad3f5');
assert.equal(rootStyle.fontWeight, '700');
assert.equal(rootStyle.lineHeight, '1.35');
assert.equal(rootStyle.letterSpacing, '-0.01em');
assert.equal(rootStyle.textRendering, 'geometricPrecision');
assert.match(rootStyle.textShadow ?? '', /3px 0 0 #000/);
const wordNodes = collectWordNodes(subtitleRoot);
assert.deepEqual(
wordNodes.map((node) => [node.textContent, node.className]),
[
['お礼', 'word word-known'],
['を', 'word'],
['される', 'word word-jlpt-n4'],
['ような', 'word word-frequency-single'],
],
);
for (const wordNode of wordNodes) {
const tokenStyle = wordNode.style as unknown as Record<string, string>;
assert.equal(tokenStyle.fontFamily, undefined);
assert.equal(tokenStyle.fontSize, undefined);
assert.equal(tokenStyle.fontWeight, undefined);
assert.equal(tokenStyle.lineHeight, undefined);
assert.equal(tokenStyle.letterSpacing, undefined);
assert.equal(tokenStyle.textRendering, undefined);
assert.equal(tokenStyle.textShadow, undefined);
}
} finally {
restoreDocument();
}
});
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
const token = createToken({
surface: '猫',
@@ -552,6 +658,36 @@ test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
});
test('applySubtitleStyle keeps transparent hover token background', () => {
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({
hoverTokenBackgroundColor: 'transparent',
} as never);
const rootStyleValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
.values;
assert.equal(rootStyleValues?.get('--subtitle-hover-token-background-color'), 'transparent');
} finally {
restoreDocument();
}
});
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
const tokens = [
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
@@ -749,7 +885,7 @@ test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist',
assert.equal(shouldRenderTokenizedSubtitle(0), false);
});
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
test('subtitle annotation CSS changes token color without overriding typography', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
@@ -763,17 +899,27 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const cssText = fs.readFileSync(cssPath, 'utf-8');
for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
const block = extractClassBlock(cssText, buildJlptColorSelector(level));
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
assert.match(
block,
new RegExp(`--subtitle-jlpt-underline-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
);
assert.match(block, /border-bottom:\s*2px solid var\(--subtitle-jlpt-underline-color\);/);
assert.match(block, /padding-bottom:\s*1px;/);
assert.match(block, /box-decoration-break:\s*clone;/);
assert.match(block, /-webkit-box-decoration-break:\s*clone;/);
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
assert.match(block, new RegExp(`color:\\s*var\\(--subtitle-jlpt-n${level}-color,`));
assert.doesNotMatch(block, /border-bottom\s*:/);
assert.doesNotMatch(block, /padding-bottom\s*:/);
assert.doesNotMatch(block, /box-decoration-break\s*:/);
assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
assert.doesNotMatch(block, /text-shadow\s*:/);
}
for (const selector of [
'#subtitleRoot .word.word-known',
'#subtitleRoot .word.word-n-plus-one',
'#subtitleRoot .word.word-name-match',
]) {
const block = extractClassBlock(cssText, selector);
assert.match(block, /color:\s*var\(/);
assert.doesNotMatch(block, /text-shadow\s*:/);
}
for (let band = 1; band <= 5; band += 1) {
@@ -873,7 +1019,8 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
);
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/);
assert.match(coloredWordHoverBlock, /filter:\s*brightness\(1\.18\) saturate\(1\.08\);/);
assert.doesNotMatch(coloredWordHoverBlock, /font-weight\s*:/);
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
assert.doesNotMatch(
coloredWordHoverBlock,
+16
View File
@@ -57,6 +57,13 @@ export type RendererDom = {
runtimeOptionsList: HTMLUListElement;
runtimeOptionsStatus: HTMLDivElement;
characterDictionaryModal: HTMLDivElement;
characterDictionaryClose: HTMLButtonElement;
characterDictionarySummary: HTMLDivElement;
characterDictionaryCurrent: HTMLDivElement;
characterDictionaryCandidates: HTMLUListElement;
characterDictionaryStatus: HTMLDivElement;
subsyncModal: HTMLDivElement;
subsyncCloseButton: HTMLButtonElement;
subsyncEngineAlass: HTMLInputElement;
@@ -177,6 +184,15 @@ export function resolveRendererDom(): RendererDom {
runtimeOptionsList: getRequiredElement<HTMLUListElement>('runtimeOptionsList'),
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>('runtimeOptionsStatus'),
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
'characterDictionaryCandidates',
),
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),