mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
Enhance AniList character dictionary sync and subtitle features (#15)
This commit is contained in:
@@ -27,6 +27,7 @@ const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA
|
||||
const FALLBACK_COLORS = {
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
nameMatchColor: '#f5bde6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
jlptN3Color: '#f9e2af',
|
||||
@@ -207,6 +208,7 @@ function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
|
||||
function buildColorSection(style: {
|
||||
knownWordColor?: unknown;
|
||||
nPlusOneColor?: unknown;
|
||||
nameMatchColor?: unknown;
|
||||
jlptColors?: {
|
||||
N1?: unknown;
|
||||
N2?: unknown;
|
||||
@@ -228,6 +230,11 @@ function buildColorSection(style: {
|
||||
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'Character names',
|
||||
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
|
||||
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
|
||||
},
|
||||
{
|
||||
shortcut: 'JLPT N1',
|
||||
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
|
||||
|
||||
@@ -28,7 +28,7 @@ test('renderer stylesheet no longer contains invisible-layer selectors', () => {
|
||||
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
|
||||
});
|
||||
|
||||
test('top-level docs avoid stale overlay-layers wording', () => {
|
||||
const docsReadmeSource = readWorkspaceFile('docs/README.md');
|
||||
assert.doesNotMatch(docsReadmeSource, /overlay layers/i);
|
||||
test('top-level readme avoids stale overlay-layers wording', () => {
|
||||
const readmeSource = readWorkspaceFile('README.md');
|
||||
assert.doesNotMatch(readmeSource, /overlay layers/i);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,8 @@ export type RendererState = {
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
nameMatchEnabled: boolean;
|
||||
nameMatchColor: string;
|
||||
jlptN1Color: string;
|
||||
jlptN2Color: string;
|
||||
jlptN3Color: string;
|
||||
@@ -125,6 +127,8 @@ export function createRendererState(): RendererState {
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
nameMatchEnabled: true,
|
||||
nameMatchColor: '#f5bde6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
jlptN3Color: '#f9e2af',
|
||||
@@ -140,7 +144,7 @@ export function createRendererState(): RendererState {
|
||||
frequencyDictionaryBand1Color: '#ed8796',
|
||||
frequencyDictionaryBand2Color: '#f5a97f',
|
||||
frequencyDictionaryBand3Color: '#f9e2af',
|
||||
frequencyDictionaryBand4Color: '#a6e3a1',
|
||||
frequencyDictionaryBand4Color: '#8bd5ca',
|
||||
frequencyDictionaryBand5Color: '#8aadf4',
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
|
||||
@@ -285,6 +285,7 @@ body {
|
||||
color: #cad3f5;
|
||||
--subtitle-known-word-color: #a6da95;
|
||||
--subtitle-n-plus-one-color: #c6a0f6;
|
||||
--subtitle-name-match-color: #f5bde6;
|
||||
--subtitle-jlpt-n1-color: #ed8796;
|
||||
--subtitle-jlpt-n2-color: #f5a97f;
|
||||
--subtitle-jlpt-n3-color: #f9e2af;
|
||||
@@ -296,7 +297,7 @@ body {
|
||||
--subtitle-frequency-band-1-color: #ed8796;
|
||||
--subtitle-frequency-band-2-color: #f5a97f;
|
||||
--subtitle-frequency-band-3-color: #f9e2af;
|
||||
--subtitle-frequency-band-4-color: #a6e3a1;
|
||||
--subtitle-frequency-band-4-color: #8bd5ca;
|
||||
--subtitle-frequency-band-5-color: #8aadf4;
|
||||
text-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.8),
|
||||
@@ -416,6 +417,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
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 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
@@ -502,7 +508,7 @@ body.settings-modal-open #subtitleContainer {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-4 {
|
||||
color: var(--subtitle-frequency-band-4-color, #a6e3a1);
|
||||
color: var(--subtitle-frequency-band-4-color, #8bd5ca);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-5 {
|
||||
@@ -510,11 +516,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
}
|
||||
|
||||
#subtitleRoot
|
||||
.word:not(.word-known):not(.word-n-plus-one):not(.word-frequency-single):not(
|
||||
.word-frequency-band-1
|
||||
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
|
||||
.word-frequency-band-5
|
||||
):hover {
|
||||
.word:not(.word-known):not(.word-n-plus-one):not(.word-name-match):not(
|
||||
.word-frequency-single
|
||||
):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(
|
||||
.word-frequency-band-4
|
||||
):not(.word-frequency-band-5):hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
border-radius: 3px;
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
@@ -523,6 +529,7 @@ body.settings-modal-open #subtitleContainer {
|
||||
|
||||
#subtitleRoot .word.word-known:hover,
|
||||
#subtitleRoot .word.word-n-plus-one:hover,
|
||||
#subtitleRoot .word.word-name-match:hover,
|
||||
#subtitleRoot .word.word-frequency-single:hover,
|
||||
#subtitleRoot .word.word-frequency-band-1:hover,
|
||||
#subtitleRoot .word.word-frequency-band-2:hover,
|
||||
@@ -536,6 +543,7 @@ body.settings-modal-open #subtitleContainer {
|
||||
|
||||
#subtitleRoot .word.word-known .c:hover,
|
||||
#subtitleRoot .word.word-n-plus-one .c:hover,
|
||||
#subtitleRoot .word.word-name-match .c:hover,
|
||||
#subtitleRoot .word.word-frequency-single .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-1 .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-2 .c:hover,
|
||||
@@ -550,9 +558,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not(
|
||||
.word-frequency-band-2
|
||||
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover {
|
||||
):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(
|
||||
.word-frequency-band-1
|
||||
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
|
||||
.word-frequency-band-5
|
||||
):hover {
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
@@ -583,6 +593,12 @@ body.settings-modal-open #subtitleContainer {
|
||||
-webkit-text-fill-color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-name-match::selection,
|
||||
#subtitleRoot .word.word-name-match .c::selection {
|
||||
color: var(--subtitle-name-match-color, #f5bde6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-name-match-color, #f5bde6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single::selection,
|
||||
#subtitleRoot .word.word-frequency-single .c::selection {
|
||||
color: var(--subtitle-frequency-single-color, #f5a97f) !important;
|
||||
@@ -609,8 +625,8 @@ body.settings-modal-open #subtitleContainer {
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-4::selection,
|
||||
#subtitleRoot .word.word-frequency-band-4 .c::selection {
|
||||
color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important;
|
||||
color: var(--subtitle-frequency-band-4-color, #8bd5ca) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-4-color, #8bd5ca) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-5::selection,
|
||||
@@ -622,15 +638,19 @@ body.settings-modal-open #subtitleContainer {
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not(
|
||||
.word-frequency-band-2
|
||||
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection,
|
||||
):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(
|
||||
.word-frequency-band-1
|
||||
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
|
||||
.word-frequency-band-5
|
||||
)::selection,
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not(
|
||||
.word-frequency-band-2
|
||||
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)
|
||||
):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(
|
||||
.word-frequency-band-1
|
||||
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
|
||||
.word-frequency-band-5
|
||||
)
|
||||
.c::selection {
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
|
||||
@@ -9,12 +9,115 @@ import {
|
||||
alignTokensToSourceText,
|
||||
buildSubtitleTokenHoverRanges,
|
||||
computeWordClass,
|
||||
createSubtitleRenderer,
|
||||
getFrequencyRankLabelForToken,
|
||||
getJlptLevelLabelForToken,
|
||||
normalizeSubtitle,
|
||||
sanitizeSubtitleHoverTokenColor,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
} from './subtitle-render.js';
|
||||
import { createRendererState } from './state.js';
|
||||
|
||||
class FakeTextNode {
|
||||
constructor(public textContent: string) {}
|
||||
}
|
||||
|
||||
class FakeDocumentFragment {
|
||||
childNodes: Array<FakeElement | FakeTextNode> = [];
|
||||
|
||||
appendChild(
|
||||
child: FakeElement | FakeTextNode | FakeDocumentFragment,
|
||||
): FakeElement | FakeTextNode | FakeDocumentFragment {
|
||||
if (child instanceof FakeDocumentFragment) {
|
||||
this.childNodes.push(...child.childNodes);
|
||||
child.childNodes = [];
|
||||
return child;
|
||||
}
|
||||
|
||||
this.childNodes.push(child);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStyleDeclaration {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
setProperty(name: string, value: string) {
|
||||
this.values.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
childNodes: Array<FakeElement | FakeTextNode> = [];
|
||||
dataset: Record<string, string> = {};
|
||||
style = new FakeStyleDeclaration();
|
||||
className = '';
|
||||
private ownTextContent = '';
|
||||
|
||||
constructor(public tagName: string) {}
|
||||
|
||||
appendChild(
|
||||
child: FakeElement | FakeTextNode | FakeDocumentFragment,
|
||||
): FakeElement | FakeTextNode | FakeDocumentFragment {
|
||||
if (child instanceof FakeDocumentFragment) {
|
||||
this.childNodes.push(...child.childNodes);
|
||||
child.childNodes = [];
|
||||
return child;
|
||||
}
|
||||
|
||||
this.childNodes.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
set textContent(value: string) {
|
||||
this.ownTextContent = value;
|
||||
this.childNodes = [];
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
if (this.childNodes.length === 0) {
|
||||
return this.ownTextContent;
|
||||
}
|
||||
|
||||
return this.childNodes
|
||||
.map((child) => (child instanceof FakeTextNode ? child.textContent : child.textContent))
|
||||
.join('');
|
||||
}
|
||||
|
||||
set innerHTML(value: string) {
|
||||
if (value === '') {
|
||||
this.childNodes = [];
|
||||
this.ownTextContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeDocument() {
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createDocumentFragment: () => new FakeDocumentFragment(),
|
||||
createElement: (tagName: string) => new FakeElement(tagName),
|
||||
createTextNode: (text: string) => new FakeTextNode(text),
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function collectWordNodes(root: FakeElement): FakeElement[] {
|
||||
return root.childNodes.filter(
|
||||
(child): child is FakeElement =>
|
||||
child instanceof FakeElement && child.className.includes('word'),
|
||||
);
|
||||
}
|
||||
|
||||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
@@ -35,17 +138,15 @@ function extractClassBlock(cssText: string, selector: string): string {
|
||||
const ruleRegex = /([^{}]+)\{([^}]*)\}/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
let fallbackBlock = '';
|
||||
const normalizedSelector = normalizeCssSelector(selector);
|
||||
|
||||
while ((match = ruleRegex.exec(cssText)) !== null) {
|
||||
const selectorsBlock = match[1]?.trim() ?? '';
|
||||
const selectorBlock = match[2] ?? '';
|
||||
|
||||
const selectors = selectorsBlock
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const selectors = splitCssSelectors(selectorsBlock);
|
||||
|
||||
if (selectors.includes(selector)) {
|
||||
if (selectors.some((entry) => normalizeCssSelector(entry) === normalizedSelector)) {
|
||||
if (selectors.length === 1) {
|
||||
return selectorBlock;
|
||||
}
|
||||
@@ -63,6 +164,53 @@ function extractClassBlock(cssText: string, selector: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function splitCssSelectors(selectorsBlock: string): string[] {
|
||||
const selectors: string[] = [];
|
||||
let current = '';
|
||||
let parenDepth = 0;
|
||||
|
||||
for (const char of selectorsBlock) {
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ')') {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ',' && parenDepth === 0) {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed.length > 0) {
|
||||
selectors.push(trimmed);
|
||||
}
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
const trimmed = current.trim();
|
||||
if (trimmed.length > 0) {
|
||||
selectors.push(trimmed);
|
||||
}
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function normalizeCssSelector(selector: string): string {
|
||||
return selector
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\(\s+/g, '(')
|
||||
.replace(/\s+\)/g, ')')
|
||||
.replace(/\s*,\s*/g, ', ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||||
const knownJlpt = createToken({
|
||||
isKnown: true,
|
||||
@@ -79,6 +227,45 @@ 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', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
frequencyRank: 10,
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-name-match',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass skips name-match class when disabled', () => {
|
||||
const token = createToken({
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
token.isNameMatch = true;
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
nameMatchEnabled: false,
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass keeps known and N+1 color classes exclusive over frequency classes', () => {
|
||||
const known = createToken({
|
||||
isKnown: true,
|
||||
@@ -127,6 +314,39 @@ test('computeWordClass keeps known and N+1 color classes exclusive over frequenc
|
||||
);
|
||||
});
|
||||
|
||||
test('applySubtitleStyle sets subtitle name-match color variable', () => {
|
||||
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({
|
||||
nameMatchColor: '#f5bde6',
|
||||
} as never);
|
||||
|
||||
assert.equal(
|
||||
(subtitleRoot.style as unknown as { values?: Map<string, string> }).values?.get(
|
||||
'--subtitle-name-match-color',
|
||||
),
|
||||
'#f5bde6',
|
||||
);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
|
||||
const token = createToken({
|
||||
surface: '猫',
|
||||
@@ -288,6 +508,16 @@ test('alignTokensToSourceText treats whitespace-only token surfaces as plain tex
|
||||
);
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText preserves unsupported punctuation between matched tokens', () => {
|
||||
const tokens = [createToken({ surface: 'えっ' }), createToken({ surface: 'マジ' })];
|
||||
|
||||
const segments = alignTokensToSourceText(tokens, 'えっ!?マジ');
|
||||
assert.deepEqual(
|
||||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||||
['token', 'text:!?', 'token'],
|
||||
);
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText avoids duplicate tail when later token surface does not match source', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: '君たちが潰した拠点に' }),
|
||||
@@ -327,6 +557,55 @@ test('buildSubtitleTokenHoverRanges ignores unmatched token surfaces', () => {
|
||||
assert.deepEqual(ranges, [{ start: 0, end: 10, tokenIndex: 0 }]);
|
||||
});
|
||||
|
||||
test('buildSubtitleTokenHoverRanges skips unsupported punctuation while preserving later offsets', () => {
|
||||
const tokens = [createToken({ surface: 'えっ' }), createToken({ surface: 'マジ' })];
|
||||
|
||||
const ranges = buildSubtitleTokenHoverRanges(tokens, 'えっ!?マジ');
|
||||
assert.deepEqual(ranges, [
|
||||
{ start: 0, end: 2, tokenIndex: 0 },
|
||||
{ start: 4, end: 6, tokenIndex: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('renderSubtitle preserves unsupported punctuation while keeping it non-interactive', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const renderer = createSubtitleRenderer({
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
platform: {
|
||||
isMacOSPlatform: false,
|
||||
isModalLayer: false,
|
||||
overlayLayer: 'visible',
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
state: createRendererState(),
|
||||
} as never);
|
||||
|
||||
renderer.renderSubtitle({
|
||||
text: 'えっ!?マジ',
|
||||
tokens: [createToken({ surface: 'えっ' }), createToken({ surface: 'マジ' })],
|
||||
});
|
||||
|
||||
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),
|
||||
@@ -435,9 +714,21 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
);
|
||||
assert.match(jlptTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
|
||||
|
||||
assert.match(
|
||||
const plainWordHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
/#subtitleRoot\s+\.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;/,
|
||||
'#subtitleRoot .word:not(.word-known):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover',
|
||||
);
|
||||
assert.match(
|
||||
plainWordHoverBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
);
|
||||
assert.match(
|
||||
plainWordHoverBlock,
|
||||
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
plainWordHoverBlock,
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover');
|
||||
@@ -473,13 +764,31 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
assert.match(coloredCharHoverBlock, /background:\s*transparent;/);
|
||||
assert.match(coloredCharHoverBlock, /color:\s*inherit\s*!important;/);
|
||||
|
||||
assert.match(
|
||||
const jlptOnlyHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
/\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\):hover\s*\{[\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;/,
|
||||
'#subtitleRoot .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(.word-known):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover',
|
||||
);
|
||||
assert.match(
|
||||
cssText,
|
||||
/\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\)::selection[\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;/,
|
||||
jlptOnlyHoverBlock,
|
||||
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
jlptOnlyHoverBlock,
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(.word-known):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection',
|
||||
),
|
||||
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(.word-known):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection',
|
||||
),
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
|
||||
|
||||
@@ -9,6 +9,10 @@ type FrequencyRenderSettings = {
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
|
||||
type TokenRenderSettings = FrequencyRenderSettings & {
|
||||
nameMatchEnabled: boolean;
|
||||
};
|
||||
|
||||
export type SubtitleTokenHoverRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -75,8 +79,9 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
topX: 1000,
|
||||
mode: 'single',
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
};
|
||||
const DEFAULT_NAME_MATCH_ENABLED = true;
|
||||
|
||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
@@ -218,50 +223,49 @@ export function getJlptLevelLabelForToken(token: MergedToken): string | null {
|
||||
function renderWithTokens(
|
||||
root: HTMLElement,
|
||||
tokens: MergedToken[],
|
||||
frequencyRenderSettings?: Partial<FrequencyRenderSettings>,
|
||||
tokenRenderSettings?: Partial<TokenRenderSettings>,
|
||||
sourceText?: string,
|
||||
preserveLineBreaks = false,
|
||||
): void {
|
||||
const resolvedFrequencyRenderSettings = {
|
||||
const resolvedTokenRenderSettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencyRenderSettings,
|
||||
...tokenRenderSettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencyRenderSettings?.bandedColors,
|
||||
tokenRenderSettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(
|
||||
frequencyRenderSettings?.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(tokenRenderSettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencyRenderSettings?.singleColor,
|
||||
tokenRenderSettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
nameMatchEnabled: tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED,
|
||||
};
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (preserveLineBreaks && sourceText) {
|
||||
const normalizedSource = normalizeSubtitle(sourceText, true, false);
|
||||
if (sourceText) {
|
||||
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
|
||||
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
if (preserveLineBreaks) {
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(segment.text));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = segment.token;
|
||||
const span = document.createElement('span');
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||
span.textContent = token.surface;
|
||||
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,
|
||||
);
|
||||
const frequencyRankLabel = getFrequencyRankLabelForToken(token, resolvedTokenRenderSettings);
|
||||
if (frequencyRankLabel) {
|
||||
span.dataset.frequencyRank = frequencyRankLabel;
|
||||
}
|
||||
@@ -292,15 +296,12 @@ function renderWithTokens(
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||
span.textContent = surface;
|
||||
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,
|
||||
);
|
||||
const frequencyRankLabel = getFrequencyRankLabelForToken(token, resolvedTokenRenderSettings);
|
||||
if (frequencyRankLabel) {
|
||||
span.dataset.frequencyRank = frequencyRankLabel;
|
||||
}
|
||||
@@ -397,26 +398,29 @@ export function buildSubtitleTokenHoverRanges(
|
||||
|
||||
export function computeWordClass(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
tokenRenderSettings?: Partial<TokenRenderSettings>,
|
||||
): string {
|
||||
const resolvedFrequencySettings = {
|
||||
const resolvedTokenRenderSettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
...tokenRenderSettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencySettings?.bandedColors,
|
||||
tokenRenderSettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
|
||||
topX: sanitizeFrequencyTopX(tokenRenderSettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencySettings?.singleColor,
|
||||
tokenRenderSettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
nameMatchEnabled: tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED,
|
||||
};
|
||||
|
||||
const classes = ['word'];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
classes.push('word-n-plus-one');
|
||||
} else if (resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch) {
|
||||
classes.push('word-name-match');
|
||||
} else if (token.isKnown) {
|
||||
classes.push('word-known');
|
||||
}
|
||||
@@ -425,8 +429,12 @@ export function computeWordClass(
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
if (!token.isKnown && !token.isNPlusOneTarget) {
|
||||
const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings);
|
||||
if (
|
||||
!token.isKnown &&
|
||||
!token.isNPlusOneTarget &&
|
||||
!(resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch)
|
||||
) {
|
||||
const frequencyClass = getFrequencyDictionaryClass(token, resolvedTokenRenderSettings);
|
||||
if (frequencyClass) {
|
||||
classes.push(frequencyClass);
|
||||
}
|
||||
@@ -490,7 +498,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
getTokenRenderSettings(),
|
||||
text,
|
||||
ctx.state.preserveSubtitleLineBreaks,
|
||||
);
|
||||
@@ -499,8 +507,9 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
|
||||
}
|
||||
|
||||
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> {
|
||||
function getTokenRenderSettings(): Partial<TokenRenderSettings> {
|
||||
return {
|
||||
nameMatchEnabled: ctx.state.nameMatchEnabled,
|
||||
enabled: ctx.state.frequencyDictionaryEnabled,
|
||||
topX: ctx.state.frequencyDictionaryTopX,
|
||||
mode: ctx.state.frequencyDictionaryMode,
|
||||
@@ -573,6 +582,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
|
||||
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
|
||||
const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? true;
|
||||
const nameMatchColor = style.nameMatchColor ?? ctx.state.nameMatchColor ?? '#f5bde6';
|
||||
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
|
||||
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
|
||||
style.hoverTokenBackgroundColor,
|
||||
@@ -596,8 +607,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
|
||||
ctx.state.knownWordColor = knownWordColor;
|
||||
ctx.state.nPlusOneColor = nPlusOneColor;
|
||||
ctx.state.nameMatchEnabled = nameMatchEnabled;
|
||||
ctx.state.nameMatchColor = nameMatchColor;
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-name-match-color', nameMatchColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-hover-token-background-color',
|
||||
|
||||
Reference in New Issue
Block a user