mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(renderer): show JLPT level on token hover
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;/,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user