mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 18:12:08 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
@@ -51,6 +51,11 @@ function installKeyboardTestGlobals() {
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
let statsToggleKey = 'Backquote';
|
||||
let markWatchedKey = 'KeyW';
|
||||
let markActiveVideoWatchedResult = true;
|
||||
let markActiveVideoWatchedCalls = 0;
|
||||
let statsToggleOverlayCalls = 0;
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
|
||||
@@ -137,7 +142,16 @@ function installKeyboardTestGlobals() {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
getStatsToggleKey: async () => statsToggleKey,
|
||||
getMarkWatchedKey: async () => markWatchedKey,
|
||||
markActiveVideoWatched: async () => {
|
||||
markActiveVideoWatchedCalls += 1;
|
||||
return markActiveVideoWatchedResult;
|
||||
},
|
||||
toggleDevTools: () => {},
|
||||
toggleStatsOverlay: () => {
|
||||
statsToggleOverlayCalls += 1;
|
||||
},
|
||||
focusMainWindow: () => {
|
||||
focusMainWindowCalls += 1;
|
||||
return Promise.resolve();
|
||||
@@ -253,6 +267,17 @@ function installKeyboardTestGlobals() {
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
setStatsToggleKey: (value: string) => {
|
||||
statsToggleKey = value;
|
||||
},
|
||||
setMarkWatchedKey: (value: string) => {
|
||||
markWatchedKey = value;
|
||||
},
|
||||
setMarkActiveVideoWatchedResult: (value: boolean) => {
|
||||
markActiveVideoWatchedResult = value;
|
||||
},
|
||||
markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls,
|
||||
statsToggleOverlayCalls: () => statsToggleOverlayCalls,
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
@@ -291,6 +316,7 @@ function createKeyboardHandlerHarness() {
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: false,
|
||||
isMacOSPlatform: false,
|
||||
isModalLayer: false,
|
||||
overlayLayer: 'always-on-top',
|
||||
},
|
||||
state: createRendererState(),
|
||||
@@ -548,6 +574,22 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
testGlobals.setPopupVisible(true);
|
||||
testGlobals.setStatsToggleKey('KeyG');
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
|
||||
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -620,6 +662,42 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: visible-layer Ctrl+Shift+Y should not be toggled by renderer keydown', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.platform.isModalLayer = false;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.platform.isModalLayer = true;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: turning mode off clears selected token highlight', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
@@ -985,3 +1063,44 @@ test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', asyn
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mark-watched keybinding calls markActiveVideoWatched and sends mpv commands', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
const beforeCalls = testGlobals.markActiveVideoWatchedCalls();
|
||||
const beforeMpvCount = testGlobals.mpvCommands.length;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'w', code: 'KeyW' });
|
||||
await wait(10);
|
||||
|
||||
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeCalls + 1);
|
||||
const newMpvCommands = testGlobals.mpvCommands.slice(beforeMpvCount);
|
||||
assert.deepEqual(newMpvCommands, [
|
||||
['show-text', 'Marked as watched', '1500'],
|
||||
['playlist-next', 'force'],
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mark-watched keybinding does not send mpv commands when no active session', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
testGlobals.setMarkActiveVideoWatchedResult(false);
|
||||
const beforeMpvCount = testGlobals.mpvCommands.length;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'w', code: 'KeyW' });
|
||||
await wait(10);
|
||||
|
||||
assert.equal(testGlobals.markActiveVideoWatchedCalls() > 0, true);
|
||||
const newMpvCommands = testGlobals.mpvCommands.slice(beforeMpvCount);
|
||||
assert.deepEqual(newMpvCommands, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -181,6 +181,36 @@ export function createKeyboardHandlers(
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.code === ctx.state.statsToggleKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!e.shiftKey &&
|
||||
!e.repeat
|
||||
);
|
||||
}
|
||||
|
||||
function isMarkWatchedKey(e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.code === ctx.state.markWatchedKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!e.shiftKey &&
|
||||
!e.repeat
|
||||
);
|
||||
}
|
||||
|
||||
async function handleMarkWatched(): Promise<void> {
|
||||
const marked = await window.electronAPI.markActiveVideoWatched();
|
||||
if (marked) {
|
||||
window.electronAPI.sendMpvCommand(['show-text', 'Marked as watched', '1500']);
|
||||
window.electronAPI.sendMpvCommand(['playlist-next', 'force']);
|
||||
}
|
||||
}
|
||||
|
||||
function getSubtitleWordNodes(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||
@@ -693,7 +723,14 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
updateKeybindings(await window.electronAPI.getKeybindings());
|
||||
const [keybindings, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||
window.electronAPI.getKeybindings(),
|
||||
window.electronAPI.getStatsToggleKey(),
|
||||
window.electronAPI.getMarkWatchedKey(),
|
||||
]);
|
||||
updateKeybindings(keybindings);
|
||||
ctx.state.statsToggleKey = statsToggleKey;
|
||||
ctx.state.markWatchedKey = markWatchedKey;
|
||||
syncKeyboardTokenSelection();
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
@@ -743,7 +780,7 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (isKeyboardDrivenModeToggle(e)) {
|
||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
||||
e.preventDefault();
|
||||
handleKeyboardModeToggleRequested();
|
||||
return;
|
||||
@@ -789,6 +826,18 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStatsOverlayToggle(e)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.toggleStatsOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkWatchedKey(e)) {
|
||||
e.preventDefault();
|
||||
void handleMarkWatched();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
|
||||
@@ -40,7 +40,7 @@ import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { createRendererState } from './state.js';
|
||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||
import {
|
||||
createRendererRecoveryController,
|
||||
registerRendererGlobalErrorHandlers,
|
||||
@@ -451,6 +451,11 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
|
||||
|
||||
registerModalOpenHandlers();
|
||||
registerKeyboardCommandHandlers();
|
||||
registerYomitanLookupListener(window, () => {
|
||||
runGuarded('yomitan:lookup', () => {
|
||||
window.electronAPI.recordYomitanLookup();
|
||||
});
|
||||
});
|
||||
|
||||
async function init(): Promise<void> {
|
||||
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
||||
|
||||
@@ -91,6 +91,8 @@ export type RendererState = {
|
||||
frequencyDictionaryBand5Color: string;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
statsToggleKey: string;
|
||||
markWatchedKey: string;
|
||||
chordPending: boolean;
|
||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||
keyboardDrivenModeEnabled: boolean;
|
||||
@@ -170,6 +172,8 @@ export function createRendererState(): RendererState {
|
||||
frequencyDictionaryBand5Color: '#8aadf4',
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
statsToggleKey: 'Backquote',
|
||||
markWatchedKey: 'KeyW',
|
||||
chordPending: false,
|
||||
chordTimeout: null,
|
||||
keyboardDrivenModeEnabled: false,
|
||||
|
||||
@@ -90,6 +90,15 @@ class FakeElement {
|
||||
this.ownTextContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
replaceChildren(): void {
|
||||
this.childNodes = [];
|
||||
this.ownTextContent = '';
|
||||
}
|
||||
|
||||
cloneNode(_deep: boolean): FakeElement {
|
||||
return new FakeElement(this.tagName);
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeDocument() {
|
||||
@@ -227,9 +236,11 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
|
||||
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
|
||||
});
|
||||
|
||||
test('computeWordClass applies name-match class ahead of known and frequency classes', () => {
|
||||
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: 'N2',
|
||||
frequencyRank: 10,
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
@@ -502,19 +513,32 @@ test('getFrequencyRankLabelForToken returns rank only for frequency-colored toke
|
||||
const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 });
|
||||
const nPlusOneToken = createToken({ surface: '目標', isNPlusOneTarget: true, frequencyRank: 20 });
|
||||
const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 });
|
||||
const nameToken = createToken({ surface: 'アクア', frequencyRank: 20 }) as MergedToken & {
|
||||
isNameMatch?: boolean;
|
||||
};
|
||||
nameToken.isNameMatch = true;
|
||||
|
||||
assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(nPlusOneToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null);
|
||||
assert.equal(
|
||||
getFrequencyRankLabelForToken(nameToken, { ...settings, nameMatchEnabled: true }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('getJlptLevelLabelForToken returns level when token has jlpt metadata', () => {
|
||||
const jlptToken = createToken({ surface: '語彙', jlptLevel: 'N2' });
|
||||
const noJlptToken = createToken({ surface: '語彙' });
|
||||
const nameToken = createToken({ surface: 'アクア', jlptLevel: 'N5' }) as MergedToken & {
|
||||
isNameMatch?: boolean;
|
||||
};
|
||||
nameToken.isNameMatch = true;
|
||||
|
||||
assert.equal(getJlptLevelLabelForToken(jlptToken), 'N2');
|
||||
assert.equal(getJlptLevelLabelForToken(noJlptToken), null);
|
||||
assert.equal(getJlptLevelLabelForToken(nameToken, { nameMatchEnabled: true }), null);
|
||||
});
|
||||
|
||||
test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => {
|
||||
@@ -658,6 +682,61 @@ test('renderSubtitle preserves unsupported punctuation while keeping it non-inte
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle keeps excluded interjection tokens hoverable while rendering them without annotation styling', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const secondaryRoot = new FakeElement('div');
|
||||
const renderer = createSubtitleRenderer({
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
secondarySubtitleRoot: secondaryRoot,
|
||||
},
|
||||
config: {
|
||||
subtitleStyle: {},
|
||||
frequencyDictionary: {
|
||||
colorTopX: 1000,
|
||||
colorMode: 'single',
|
||||
colorSingle: '#f5a97f',
|
||||
colorBanded: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondarySubtitles: { mode: 'hidden' },
|
||||
},
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
runtime: {
|
||||
secondaryMode: 'hidden' as const,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
state: createRendererState(),
|
||||
} as never);
|
||||
|
||||
renderer.renderSubtitle({
|
||||
text: 'ぐはっ 猫',
|
||||
tokens: [
|
||||
createToken({ surface: 'ぐはっ', headword: 'ぐはっ', reading: 'ぐはっ' }),
|
||||
createToken({ surface: '猫', headword: '猫', reading: 'ねこ' }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(subtitleRoot.textContent, 'ぐはっ 猫');
|
||||
assert.deepEqual(
|
||||
collectWordNodes(subtitleRoot).map((node) => [node.textContent, node.dataset.tokenIndex]),
|
||||
[
|
||||
['ぐはっ', '0'],
|
||||
['猫', '1'],
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
|
||||
assert.equal(
|
||||
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),
|
||||
|
||||
@@ -19,6 +19,14 @@ export type SubtitleTokenHoverRange = {
|
||||
tokenIndex: number;
|
||||
};
|
||||
|
||||
let _spanTemplate: HTMLSpanElement | null = null;
|
||||
function getSpanTemplate(): HTMLSpanElement {
|
||||
if (!_spanTemplate) {
|
||||
_spanTemplate = document.createElement('span');
|
||||
}
|
||||
return _spanTemplate;
|
||||
}
|
||||
|
||||
export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean {
|
||||
return tokenCount > 0;
|
||||
}
|
||||
@@ -83,6 +91,16 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
};
|
||||
const DEFAULT_NAME_MATCH_ENABLED = true;
|
||||
|
||||
function hasPrioritizedNameMatch(
|
||||
token: MergedToken,
|
||||
tokenRenderSettings?: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
|
||||
): boolean {
|
||||
return (
|
||||
(tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED) &&
|
||||
token.isNameMatch === true
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
@@ -219,8 +237,12 @@ function getNormalizedFrequencyRank(token: MergedToken): number | null {
|
||||
|
||||
export function getFrequencyRankLabelForToken(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
frequencySettings?: Partial<TokenRenderSettings>,
|
||||
): string | null {
|
||||
if (hasPrioritizedNameMatch(token, frequencySettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedFrequencySettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
@@ -243,7 +265,14 @@ export function getFrequencyRankLabelForToken(
|
||||
return rank === null ? null : String(rank);
|
||||
}
|
||||
|
||||
export function getJlptLevelLabelForToken(token: MergedToken): string | null {
|
||||
export function getJlptLevelLabelForToken(
|
||||
token: MergedToken,
|
||||
tokenRenderSettings?: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
|
||||
): string | null {
|
||||
if (hasPrioritizedNameMatch(token, tokenRenderSettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token.jlptLevel ?? null;
|
||||
}
|
||||
|
||||
@@ -286,7 +315,7 @@ function renderWithTokens(
|
||||
}
|
||||
|
||||
const token = segment.token;
|
||||
const span = document.createElement('span');
|
||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||
span.textContent = token.surface;
|
||||
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||
@@ -296,7 +325,7 @@ function renderWithTokens(
|
||||
if (frequencyRankLabel) {
|
||||
span.dataset.frequencyRank = frequencyRankLabel;
|
||||
}
|
||||
const jlptLevelLabel = getJlptLevelLabelForToken(token);
|
||||
const jlptLevelLabel = getJlptLevelLabelForToken(token, resolvedTokenRenderSettings);
|
||||
if (jlptLevelLabel) {
|
||||
span.dataset.jlptLevel = jlptLevelLabel;
|
||||
}
|
||||
@@ -322,7 +351,7 @@ function renderWithTokens(
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||
span.textContent = surface;
|
||||
span.dataset.tokenIndex = String(index);
|
||||
@@ -332,7 +361,7 @@ function renderWithTokens(
|
||||
if (frequencyRankLabel) {
|
||||
span.dataset.frequencyRank = frequencyRankLabel;
|
||||
}
|
||||
const jlptLevelLabel = getJlptLevelLabelForToken(token);
|
||||
const jlptLevelLabel = getJlptLevelLabelForToken(token, resolvedTokenRenderSettings);
|
||||
if (jlptLevelLabel) {
|
||||
span.dataset.jlptLevel = jlptLevelLabel;
|
||||
}
|
||||
@@ -444,22 +473,22 @@ export function computeWordClass(
|
||||
|
||||
const classes = ['word'];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
classes.push('word-n-plus-one');
|
||||
} else if (resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch) {
|
||||
if (hasPrioritizedNameMatch(token, resolvedTokenRenderSettings)) {
|
||||
classes.push('word-name-match');
|
||||
} else if (token.isNPlusOneTarget) {
|
||||
classes.push('word-n-plus-one');
|
||||
} else if (token.isKnown) {
|
||||
classes.push('word-known');
|
||||
}
|
||||
|
||||
if (token.jlptLevel) {
|
||||
if (!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings) && token.jlptLevel) {
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
if (
|
||||
!token.isKnown &&
|
||||
!token.isNPlusOneTarget &&
|
||||
!(resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch)
|
||||
!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings)
|
||||
) {
|
||||
const frequencyClass = getFrequencyDictionaryClass(token, resolvedTokenRenderSettings);
|
||||
if (frequencyClass) {
|
||||
@@ -478,7 +507,7 @@ function renderCharacterLevel(root: HTMLElement, text: string): void {
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
continue;
|
||||
}
|
||||
const span = document.createElement('span');
|
||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||
span.className = 'c';
|
||||
span.textContent = char;
|
||||
fragment.appendChild(span);
|
||||
@@ -503,7 +532,7 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
|
||||
|
||||
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
function renderSubtitle(data: SubtitleData | string): void {
|
||||
ctx.dom.subtitleRoot.innerHTML = '';
|
||||
ctx.dom.subtitleRoot.replaceChildren();
|
||||
|
||||
let text: string;
|
||||
let tokens: MergedToken[] | null;
|
||||
@@ -552,7 +581,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
}
|
||||
|
||||
function renderSecondarySub(text: string): void {
|
||||
ctx.dom.secondarySubRoot.innerHTML = '';
|
||||
ctx.dom.secondarySubRoot.replaceChildren();
|
||||
if (!text) return;
|
||||
|
||||
const normalized = text
|
||||
|
||||
18
src/renderer/yomitan-popup.test.ts
Normal file
18
src/renderer/yomitan-popup.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { YOMITAN_LOOKUP_EVENT, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||
|
||||
test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => {
|
||||
const target = new EventTarget();
|
||||
const calls: string[] = [];
|
||||
|
||||
const dispose = registerYomitanLookupListener(target, () => {
|
||||
calls.push('lookup');
|
||||
});
|
||||
|
||||
target.dispatchEvent(new CustomEvent(YOMITAN_LOOKUP_EVENT));
|
||||
dispose();
|
||||
target.dispatchEvent(new CustomEvent(YOMITAN_LOOKUP_EVENT));
|
||||
|
||||
assert.deepEqual(calls, ['lookup']);
|
||||
});
|
||||
@@ -4,6 +4,20 @@ export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||
export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
|
||||
|
||||
export function registerYomitanLookupListener(
|
||||
target: EventTarget = window,
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
const wrapped = (): void => {
|
||||
listener();
|
||||
};
|
||||
target.addEventListener(YOMITAN_LOOKUP_EVENT, wrapped);
|
||||
return () => {
|
||||
target.removeEventListener(YOMITAN_LOOKUP_EVENT, wrapped);
|
||||
};
|
||||
}
|
||||
|
||||
export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
Reference in New Issue
Block a user