feat(renderer): show JLPT level on token hover

This commit is contained in:
2026-02-28 02:50:26 -08:00
parent 3229485fa6
commit d24283e82d
3 changed files with 188 additions and 0 deletions

View File

@@ -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,

View File

@@ -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;/,

View File

@@ -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<FrequencyRenderSettings>,
): 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);
}