mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Add opt-in JLPT tagging flow
This commit is contained in:
@@ -71,6 +71,11 @@ export type RendererState = {
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
jlptN1Color: string;
|
||||
jlptN2Color: string;
|
||||
jlptN3Color: string;
|
||||
jlptN4Color: string;
|
||||
jlptN5Color: string;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
chordPending: boolean;
|
||||
@@ -130,6 +135,11 @@ export function createRendererState(): RendererState {
|
||||
|
||||
knownWordColor: "#a6da95",
|
||||
nPlusOneColor: "#c6a0f6",
|
||||
jlptN1Color: "#ed8796",
|
||||
jlptN2Color: "#f5a97f",
|
||||
jlptN3Color: "#f9e2af",
|
||||
jlptN4Color: "#a6e3a1",
|
||||
jlptN5Color: "#8aadf4",
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
chordPending: false,
|
||||
|
||||
@@ -250,6 +250,11 @@ body {
|
||||
color: #cad3f5;
|
||||
--subtitle-known-word-color: #a6da95;
|
||||
--subtitle-n-plus-one-color: #c6a0f6;
|
||||
--subtitle-jlpt-n1-color: #ed8796;
|
||||
--subtitle-jlpt-n2-color: #f5a97f;
|
||||
--subtitle-jlpt-n3-color: #f9e2af;
|
||||
--subtitle-jlpt-n4-color: #a6e3a1;
|
||||
--subtitle-jlpt-n5-color: #8aadf4;
|
||||
text-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.8),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.5);
|
||||
@@ -296,6 +301,51 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
71
src/renderer/subtitle-render.test.ts
Normal file
71
src/renderer/subtitle-render.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MergedToken } from "../types";
|
||||
import { PartOfSpeech } from "../types.js";
|
||||
import { computeWordClass } from "./subtitle-render.js";
|
||||
|
||||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
surface: "",
|
||||
reading: "",
|
||||
headword: "",
|
||||
startPos: 0,
|
||||
endPos: 0,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function extractClassBlock(cssText: string, level: number): string {
|
||||
const selector = `#subtitleRoot .word.word-jlpt-n${level}`;
|
||||
const start = cssText.indexOf(selector);
|
||||
if (start < 0) return "";
|
||||
|
||||
const openBrace = cssText.indexOf("{", start);
|
||||
if (openBrace < 0) return "";
|
||||
const closeBrace = cssText.indexOf("}", openBrace);
|
||||
if (closeBrace < 0) return "";
|
||||
|
||||
return cssText.slice(openBrace + 1, closeBrace);
|
||||
}
|
||||
|
||||
test("computeWordClass preserves known and n+1 classes while adding JLPT classes", () => {
|
||||
const knownJlpt = createToken({
|
||||
isKnown: true,
|
||||
jlptLevel: "N1",
|
||||
surface: "猫",
|
||||
});
|
||||
const nPlusOneJlpt = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: "N2",
|
||||
surface: "犬",
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(knownJlpt), "word word-known word-jlpt-n1");
|
||||
assert.equal(
|
||||
computeWordClass(nPlusOneJlpt),
|
||||
"word word-n-plus-one word-jlpt-n2",
|
||||
);
|
||||
});
|
||||
|
||||
test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
const cssText = fs.readFileSync(
|
||||
path.join(process.cwd(), "dist", "renderer", "style.css"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const block = extractClassBlock(cssText, level);
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
assert.match(block, /text-decoration-thickness:\s*2px;/);
|
||||
assert.match(block, /text-underline-offset:\s*2px;/);
|
||||
assert.match(block, /color:\s*inherit;/);
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,15 @@ function normalizeSubtitle(text: string, trim = true): string {
|
||||
return trim ? normalized.trim() : normalized;
|
||||
}
|
||||
|
||||
const HEX_COLOR_PATTERN =
|
||||
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function sanitizeHexColor(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && HEX_COLOR_PATTERN.test(value.trim())
|
||||
? value.trim()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
@@ -50,16 +59,20 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
function computeWordClass(token: MergedToken): string {
|
||||
export function computeWordClass(token: MergedToken): string {
|
||||
const classes = ["word"];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
return "word word-n-plus-one";
|
||||
classes.push("word-n-plus-one");
|
||||
} else if (token.isKnown) {
|
||||
classes.push("word-known");
|
||||
}
|
||||
|
||||
if (token.isKnown) {
|
||||
return "word word-known";
|
||||
if (token.jlptLevel) {
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
return "word";
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
function renderCharacterLevel(root: HTMLElement, text: string): void {
|
||||
@@ -189,6 +202,22 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
style.knownWordColor ?? ctx.state.knownWordColor ?? "#a6da95";
|
||||
const nPlusOneColor =
|
||||
style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? "#c6a0f6";
|
||||
const jlptColors = {
|
||||
N1: ctx.state.jlptN1Color ?? "#ed8796",
|
||||
N2: ctx.state.jlptN2Color ?? "#f5a97f",
|
||||
N3: ctx.state.jlptN3Color ?? "#f9e2af",
|
||||
N4: ctx.state.jlptN4Color ?? "#a6e3a1",
|
||||
N5: ctx.state.jlptN5Color ?? "#8aadf4",
|
||||
...(style.jlptColors
|
||||
? {
|
||||
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
|
||||
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
|
||||
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
|
||||
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
|
||||
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
ctx.state.knownWordColor = knownWordColor;
|
||||
ctx.state.nPlusOneColor = nPlusOneColor;
|
||||
@@ -197,6 +226,16 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
knownWordColor,
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-n-plus-one-color", nPlusOneColor);
|
||||
ctx.state.jlptN1Color = jlptColors.N1;
|
||||
ctx.state.jlptN2Color = jlptColors.N2;
|
||||
ctx.state.jlptN3Color = jlptColors.N3;
|
||||
ctx.state.jlptN4Color = jlptColors.N4;
|
||||
ctx.state.jlptN5Color = jlptColors.N5;
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n1-color", jlptColors.N1);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n2-color", jlptColors.N2);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n3-color", jlptColors.N3);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n4-color", jlptColors.N4);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n5-color", jlptColors.N5);
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
Reference in New Issue
Block a user