From d24283e82d0e205058a254043af6ca4b5cef9df1 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 28 Feb 2026 02:50:26 -0800 Subject: [PATCH] feat(renderer): show JLPT level on token hover --- src/renderer/style.css | 75 ++++++++++++++++++++++++++++ src/renderer/subtitle-render.test.ts | 56 +++++++++++++++++++++ src/renderer/subtitle-render.ts | 57 +++++++++++++++++++++ 3 files changed, 188 insertions(+) diff --git a/src/renderer/style.css b/src/renderer/style.css index 8d29a15..c932eb0 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -340,6 +340,61 @@ body.settings-modal-open #subtitleContainer { -webkit-text-fill-color: currentColor !important; } +#subtitleRoot .word[data-frequency-rank]::before { + content: attr(data-frequency-rank); + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%) translateY(2px); + padding: 1px 6px; + border-radius: 6px; + background: rgba(15, 17, 26, 0.9); + border: 1px solid rgba(255, 255, 255, 0.22); + color: #f5f5f5; + font-size: 0.48em; + line-height: 1.2; + font-weight: 700; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease, + transform 120ms ease; + z-index: 1; +} + +#subtitleRoot .word[data-frequency-rank]:hover::before { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +#subtitleRoot .word[data-jlpt-level]::after { + content: attr(data-jlpt-level); + position: absolute; + left: 50%; + bottom: -0.42em; + transform: translateX(-50%) translateY(2px); + font-size: 0.42em; + line-height: 1; + font-weight: 800; + letter-spacing: 0.03em; + white-space: nowrap; + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.85), + 0 0 3px rgba(0, 0, 0, 0.65); + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease, + transform 120ms ease; + z-index: 1; +} + +#subtitleRoot .word[data-jlpt-level]:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + #subtitleRoot .word.word-known { color: var(--subtitle-known-word-color, #a6da95); text-shadow: 0 0 6px rgba(166, 218, 149, 0.35); @@ -359,6 +414,10 @@ body.settings-modal-open #subtitleContainer { text-decoration-style: solid; } +#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after { + color: var(--subtitle-jlpt-n1-color, #ed8796); +} + #subtitleRoot .word.word-jlpt-n2 { color: inherit; text-decoration-line: underline; @@ -368,6 +427,10 @@ body.settings-modal-open #subtitleContainer { text-decoration-style: solid; } +#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after { + color: var(--subtitle-jlpt-n2-color, #f5a97f); +} + #subtitleRoot .word.word-jlpt-n3 { color: inherit; text-decoration-line: underline; @@ -377,6 +440,10 @@ body.settings-modal-open #subtitleContainer { text-decoration-style: solid; } +#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after { + color: var(--subtitle-jlpt-n3-color, #f9e2af); +} + #subtitleRoot .word.word-jlpt-n4 { color: inherit; text-decoration-line: underline; @@ -386,6 +453,10 @@ body.settings-modal-open #subtitleContainer { text-decoration-style: solid; } +#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after { + color: var(--subtitle-jlpt-n4-color, #a6e3a1); +} + #subtitleRoot .word.word-jlpt-n5 { color: inherit; text-decoration-line: underline; @@ -395,6 +466,10 @@ body.settings-modal-open #subtitleContainer { text-decoration-style: solid; } +#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, diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 959a357..c5e2359 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -9,6 +9,8 @@ import { alignTokensToSourceText, buildSubtitleTokenHoverRanges, computeWordClass, + getFrequencyRankLabelForToken, + getJlptLevelLabelForToken, normalizeSubtitle, sanitizeSubtitleHoverTokenColor, shouldRenderTokenizedSubtitle, @@ -210,6 +212,37 @@ test('computeWordClass skips frequency class when rank is out of topX', () => { assert.equal(actual, 'word'); }); +test('getFrequencyRankLabelForToken returns rank only for frequency-colored tokens', () => { + const settings = { + enabled: true, + topX: 100, + mode: 'single' as const, + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as [ + string, + string, + string, + string, + string, + ], + }; + const frequencyToken = createToken({ surface: '頻度', frequencyRank: 20 }); + const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 }); + const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 }); + + assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20'); + assert.equal(getFrequencyRankLabelForToken(knownToken, settings), null); + assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null); +}); + +test('getJlptLevelLabelForToken returns level when token has jlpt metadata', () => { + const jlptToken = createToken({ surface: '語彙', jlptLevel: 'N2' }); + const noJlptToken = createToken({ surface: '語彙' }); + + assert.equal(getJlptLevelLabelForToken(jlptToken), 'N2'); + assert.equal(getJlptLevelLabelForToken(noJlptToken), null); +}); + test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => { assert.equal(sanitizeSubtitleHoverTokenColor('#000000'), '#f4dbd6'); assert.equal(sanitizeSubtitleHoverTokenColor('000000'), '#f4dbd6'); @@ -361,6 +394,29 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word'); assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/); + const frequencyTooltipBaseBlock = extractClassBlock(cssText, '#subtitleRoot .word[data-frequency-rank]::before'); + assert.match(frequencyTooltipBaseBlock, /content:\s*attr\(data-frequency-rank\);/); + assert.match(frequencyTooltipBaseBlock, /opacity:\s*0;/); + assert.match(frequencyTooltipBaseBlock, /pointer-events:\s*none;/); + + const frequencyTooltipHoverBlock = extractClassBlock( + cssText, + '#subtitleRoot .word[data-frequency-rank]:hover::before', + ); + assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/); + + const jlptTooltipBaseBlock = extractClassBlock(cssText, '#subtitleRoot .word[data-jlpt-level]::after'); + assert.match(jlptTooltipBaseBlock, /content:\s*attr\(data-jlpt-level\);/); + assert.match(jlptTooltipBaseBlock, /bottom:\s*-\s*0\.42em;/); + assert.match(jlptTooltipBaseBlock, /opacity:\s*0;/); + assert.match(jlptTooltipBaseBlock, /pointer-events:\s*none;/); + + const jlptTooltipHoverBlock = extractClassBlock( + cssText, + '#subtitleRoot .word[data-jlpt-level]:hover::after', + ); + assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/); + assert.match( cssText, /#subtitleRoot \.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index ea71915..2025b21 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -173,6 +173,47 @@ function getFrequencyDictionaryClass( return 'word-frequency-single'; } +function getNormalizedFrequencyRank(token: MergedToken): number | null { + if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) { + return null; + } + return Math.max(1, Math.floor(token.frequencyRank)); +} + +export function getFrequencyRankLabelForToken( + token: MergedToken, + frequencySettings?: Partial, +): string | null { + if (token.isKnown || token.isNPlusOneTarget) { + return null; + } + + const resolvedFrequencySettings = { + ...DEFAULT_FREQUENCY_RENDER_SETTINGS, + ...frequencySettings, + bandedColors: sanitizeFrequencyBandedColors( + frequencySettings?.bandedColors, + DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors, + ), + topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX), + singleColor: sanitizeHexColor( + frequencySettings?.singleColor, + DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, + ), + }; + + if (!getFrequencyDictionaryClass(token, resolvedFrequencySettings)) { + return null; + } + + const rank = getNormalizedFrequencyRank(token); + return rank === null ? null : String(rank); +} + +export function getJlptLevelLabelForToken(token: MergedToken): string | null { + return token.jlptLevel ?? null; +} + function renderWithTokens( root: HTMLElement, tokens: MergedToken[], @@ -216,6 +257,14 @@ function renderWithTokens( span.dataset.tokenIndex = String(segment.tokenIndex); if (token.reading) span.dataset.reading = token.reading; if (token.headword) span.dataset.headword = token.headword; + const frequencyRankLabel = getFrequencyRankLabelForToken(token, resolvedFrequencyRenderSettings); + if (frequencyRankLabel) { + span.dataset.frequencyRank = frequencyRankLabel; + } + const jlptLevelLabel = getJlptLevelLabelForToken(token); + if (jlptLevelLabel) { + span.dataset.jlptLevel = jlptLevelLabel; + } fragment.appendChild(span); } @@ -244,6 +293,14 @@ function renderWithTokens( span.dataset.tokenIndex = String(index); if (token.reading) span.dataset.reading = token.reading; if (token.headword) span.dataset.headword = token.headword; + const frequencyRankLabel = getFrequencyRankLabelForToken(token, resolvedFrequencyRenderSettings); + if (frequencyRankLabel) { + span.dataset.frequencyRank = frequencyRankLabel; + } + const jlptLevelLabel = getJlptLevelLabelForToken(token); + if (jlptLevelLabel) { + span.dataset.jlptLevel = jlptLevelLabel; + } fragment.appendChild(span); }