mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
Restore multi-copy digit capture and add AniList selection (#56)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 + [');
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user