mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import type {
|
|
MergedToken,
|
|
SecondarySubMode,
|
|
SubtitleData,
|
|
SubtitleStyleConfig,
|
|
} from "../types";
|
|
import type { RendererContext } from "./context";
|
|
|
|
type FrequencyRenderSettings = {
|
|
enabled: boolean;
|
|
topX: number;
|
|
mode: "single" | "banded";
|
|
singleColor: string;
|
|
bandedColors: [string, string, string, string, string];
|
|
};
|
|
|
|
function normalizeSubtitle(text: string, trim = true): string {
|
|
if (!text) return "";
|
|
|
|
let normalized = text.replace(/\\N/g, "\n").replace(/\\n/g, "\n");
|
|
normalized = normalized.replace(/\{[^}]*\}/g, "");
|
|
|
|
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;
|
|
}
|
|
|
|
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
|
enabled: false,
|
|
topX: 1000,
|
|
mode: "single",
|
|
singleColor: "#f5a97f",
|
|
bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
|
};
|
|
|
|
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
return fallback;
|
|
}
|
|
return Math.max(1, Math.floor(value));
|
|
}
|
|
|
|
function sanitizeFrequencyBandedColors(
|
|
value: unknown,
|
|
fallback: FrequencyRenderSettings["bandedColors"],
|
|
): FrequencyRenderSettings["bandedColors"] {
|
|
if (!Array.isArray(value) || value.length !== 5) {
|
|
return fallback;
|
|
}
|
|
|
|
return [
|
|
sanitizeHexColor(value[0], fallback[0]),
|
|
sanitizeHexColor(value[1], fallback[1]),
|
|
sanitizeHexColor(value[2], fallback[2]),
|
|
sanitizeHexColor(value[3], fallback[3]),
|
|
sanitizeHexColor(value[4], fallback[4]),
|
|
];
|
|
}
|
|
|
|
function getFrequencyDictionaryClass(
|
|
token: MergedToken,
|
|
settings: FrequencyRenderSettings,
|
|
): string {
|
|
if (!settings.enabled) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof token.frequencyRank !== "number" || !Number.isFinite(token.frequencyRank)) {
|
|
return "";
|
|
}
|
|
|
|
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
|
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX);
|
|
if (rank > topX) {
|
|
return "";
|
|
}
|
|
|
|
if (settings.mode === "banded") {
|
|
const bandCount = settings.bandedColors.length;
|
|
const normalizedBand = Math.ceil((rank / topX) * bandCount);
|
|
const band = Math.min(bandCount, Math.max(1, normalizedBand));
|
|
return `word-frequency-band-${band}`;
|
|
}
|
|
|
|
return "word-frequency-single";
|
|
}
|
|
|
|
function renderWithTokens(
|
|
root: HTMLElement,
|
|
tokens: MergedToken[],
|
|
frequencyRenderSettings?: Partial<FrequencyRenderSettings>,
|
|
): void {
|
|
const resolvedFrequencyRenderSettings = {
|
|
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
|
...frequencyRenderSettings,
|
|
bandedColors: sanitizeFrequencyBandedColors(
|
|
frequencyRenderSettings?.bandedColors,
|
|
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
|
),
|
|
topX: sanitizeFrequencyTopX(
|
|
frequencyRenderSettings?.topX,
|
|
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
|
),
|
|
singleColor: sanitizeHexColor(
|
|
frequencyRenderSettings?.singleColor,
|
|
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
|
),
|
|
};
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (const token of tokens) {
|
|
const surface = token.surface;
|
|
|
|
if (surface.includes("\n")) {
|
|
const parts = surface.split("\n");
|
|
for (let i = 0; i < parts.length; i += 1) {
|
|
if (parts[i]) {
|
|
const span = document.createElement("span");
|
|
span.className = computeWordClass(
|
|
token,
|
|
resolvedFrequencyRenderSettings,
|
|
);
|
|
span.textContent = parts[i];
|
|
if (token.reading) span.dataset.reading = token.reading;
|
|
if (token.headword) span.dataset.headword = token.headword;
|
|
fragment.appendChild(span);
|
|
}
|
|
if (i < parts.length - 1) {
|
|
fragment.appendChild(document.createElement("br"));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const span = document.createElement("span");
|
|
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
|
span.textContent = surface;
|
|
if (token.reading) span.dataset.reading = token.reading;
|
|
if (token.headword) span.dataset.headword = token.headword;
|
|
fragment.appendChild(span);
|
|
}
|
|
|
|
root.appendChild(fragment);
|
|
}
|
|
|
|
export function computeWordClass(
|
|
token: MergedToken,
|
|
frequencySettings?: Partial<FrequencyRenderSettings>,
|
|
): string {
|
|
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,
|
|
),
|
|
};
|
|
|
|
const classes = ["word"];
|
|
|
|
if (token.isNPlusOneTarget) {
|
|
classes.push("word-n-plus-one");
|
|
} else if (token.isKnown) {
|
|
classes.push("word-known");
|
|
}
|
|
|
|
if (token.jlptLevel) {
|
|
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
|
}
|
|
|
|
if (!token.isKnown && !token.isNPlusOneTarget) {
|
|
const frequencyClass = getFrequencyDictionaryClass(
|
|
token,
|
|
resolvedFrequencySettings,
|
|
);
|
|
if (frequencyClass) {
|
|
classes.push(frequencyClass);
|
|
}
|
|
}
|
|
|
|
return classes.join(" ");
|
|
}
|
|
|
|
function renderCharacterLevel(root: HTMLElement, text: string): void {
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (const char of text) {
|
|
if (char === "\n") {
|
|
fragment.appendChild(document.createElement("br"));
|
|
continue;
|
|
}
|
|
const span = document.createElement("span");
|
|
span.className = "c";
|
|
span.textContent = char;
|
|
fragment.appendChild(span);
|
|
}
|
|
|
|
root.appendChild(fragment);
|
|
}
|
|
|
|
function renderPlainTextPreserveLineBreaks(root: HTMLElement, text: string): void {
|
|
const lines = text.split("\n");
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
fragment.appendChild(document.createTextNode(lines[i]));
|
|
if (i < lines.length - 1) {
|
|
fragment.appendChild(document.createElement("br"));
|
|
}
|
|
}
|
|
|
|
root.appendChild(fragment);
|
|
}
|
|
|
|
export function createSubtitleRenderer(ctx: RendererContext) {
|
|
function renderSubtitle(data: SubtitleData | string): void {
|
|
ctx.dom.subtitleRoot.innerHTML = "";
|
|
ctx.state.lastHoverSelectionKey = "";
|
|
ctx.state.lastHoverSelectionNode = null;
|
|
|
|
let text: string;
|
|
let tokens: MergedToken[] | null;
|
|
|
|
if (typeof data === "string") {
|
|
text = data;
|
|
tokens = null;
|
|
} else if (data && typeof data === "object") {
|
|
text = data.text;
|
|
tokens = data.tokens;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
if (!text) return;
|
|
|
|
if (ctx.platform.isInvisibleLayer) {
|
|
const normalizedInvisible = normalizeSubtitle(text, false);
|
|
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
|
|
1,
|
|
normalizedInvisible.split("\n").length,
|
|
);
|
|
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
|
return;
|
|
}
|
|
|
|
const normalized = normalizeSubtitle(text);
|
|
if (tokens && tokens.length > 0) {
|
|
renderWithTokens(
|
|
ctx.dom.subtitleRoot,
|
|
tokens,
|
|
getFrequencyRenderSettings(),
|
|
);
|
|
return;
|
|
}
|
|
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
|
|
}
|
|
|
|
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> {
|
|
return {
|
|
enabled: ctx.state.frequencyDictionaryEnabled,
|
|
topX: ctx.state.frequencyDictionaryTopX,
|
|
mode: ctx.state.frequencyDictionaryMode,
|
|
singleColor: ctx.state.frequencyDictionarySingleColor,
|
|
bandedColors: [
|
|
ctx.state.frequencyDictionaryBand1Color,
|
|
ctx.state.frequencyDictionaryBand2Color,
|
|
ctx.state.frequencyDictionaryBand3Color,
|
|
ctx.state.frequencyDictionaryBand4Color,
|
|
ctx.state.frequencyDictionaryBand5Color,
|
|
] as [string, string, string, string, string],
|
|
};
|
|
}
|
|
|
|
function renderSecondarySub(text: string): void {
|
|
ctx.dom.secondarySubRoot.innerHTML = "";
|
|
if (!text) return;
|
|
|
|
const normalized = text
|
|
.replace(/\\N/g, "\n")
|
|
.replace(/\\n/g, "\n")
|
|
.replace(/\{[^}]*\}/g, "")
|
|
.trim();
|
|
|
|
if (!normalized) return;
|
|
|
|
const lines = normalized.split("\n");
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (lines[i]) {
|
|
ctx.dom.secondarySubRoot.appendChild(document.createTextNode(lines[i]));
|
|
}
|
|
if (i < lines.length - 1) {
|
|
ctx.dom.secondarySubRoot.appendChild(document.createElement("br"));
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateSecondarySubMode(mode: SecondarySubMode): void {
|
|
ctx.dom.secondarySubContainer.classList.remove(
|
|
"secondary-sub-hidden",
|
|
"secondary-sub-visible",
|
|
"secondary-sub-hover",
|
|
);
|
|
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`);
|
|
}
|
|
|
|
function applySubtitleFontSize(fontSize: number): void {
|
|
const clampedSize = Math.max(10, fontSize);
|
|
ctx.dom.subtitleRoot.style.fontSize = `${clampedSize}px`;
|
|
document.documentElement.style.setProperty(
|
|
"--subtitle-font-size",
|
|
`${clampedSize}px`,
|
|
);
|
|
}
|
|
|
|
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
|
|
if (!style) return;
|
|
|
|
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
|
|
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
|
|
if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor;
|
|
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
|
|
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
|
if (style.backgroundColor) {
|
|
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
|
|
}
|
|
|
|
const knownWordColor =
|
|
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;
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-known-word-color",
|
|
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 frequencyDictionarySettings = style.frequencyDictionary ?? {};
|
|
const frequencyEnabled =
|
|
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled;
|
|
const frequencyTopX = sanitizeFrequencyTopX(
|
|
frequencyDictionarySettings.topX,
|
|
ctx.state.frequencyDictionaryTopX,
|
|
);
|
|
const frequencyMode = frequencyDictionarySettings.mode
|
|
? frequencyDictionarySettings.mode
|
|
: ctx.state.frequencyDictionaryMode;
|
|
const frequencySingleColor = sanitizeHexColor(
|
|
frequencyDictionarySettings.singleColor,
|
|
ctx.state.frequencyDictionarySingleColor,
|
|
);
|
|
const frequencyBandedColors = sanitizeFrequencyBandedColors(
|
|
frequencyDictionarySettings.bandedColors,
|
|
[
|
|
ctx.state.frequencyDictionaryBand1Color,
|
|
ctx.state.frequencyDictionaryBand2Color,
|
|
ctx.state.frequencyDictionaryBand3Color,
|
|
ctx.state.frequencyDictionaryBand4Color,
|
|
ctx.state.frequencyDictionaryBand5Color,
|
|
] as [string, string, string, string, string],
|
|
);
|
|
|
|
ctx.state.frequencyDictionaryEnabled = frequencyEnabled;
|
|
ctx.state.frequencyDictionaryTopX = frequencyTopX;
|
|
ctx.state.frequencyDictionaryMode = frequencyMode;
|
|
ctx.state.frequencyDictionarySingleColor = frequencySingleColor;
|
|
[
|
|
ctx.state.frequencyDictionaryBand1Color,
|
|
ctx.state.frequencyDictionaryBand2Color,
|
|
ctx.state.frequencyDictionaryBand3Color,
|
|
ctx.state.frequencyDictionaryBand4Color,
|
|
ctx.state.frequencyDictionaryBand5Color,
|
|
] = frequencyBandedColors;
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-frequency-single-color",
|
|
frequencySingleColor,
|
|
);
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-frequency-band-1-color",
|
|
frequencyBandedColors[0],
|
|
);
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-frequency-band-2-color",
|
|
frequencyBandedColors[1],
|
|
);
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-frequency-band-3-color",
|
|
frequencyBandedColors[2],
|
|
);
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-frequency-band-4-color",
|
|
frequencyBandedColors[3],
|
|
);
|
|
ctx.dom.subtitleRoot.style.setProperty(
|
|
"--subtitle-frequency-band-5-color",
|
|
frequencyBandedColors[4],
|
|
);
|
|
|
|
const secondaryStyle = style.secondary;
|
|
if (!secondaryStyle) return;
|
|
|
|
if (secondaryStyle.fontFamily) {
|
|
ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily;
|
|
}
|
|
if (secondaryStyle.fontSize) {
|
|
ctx.dom.secondarySubRoot.style.fontSize = `${secondaryStyle.fontSize}px`;
|
|
}
|
|
if (secondaryStyle.fontColor) {
|
|
ctx.dom.secondarySubRoot.style.color = secondaryStyle.fontColor;
|
|
}
|
|
if (secondaryStyle.fontWeight) {
|
|
ctx.dom.secondarySubRoot.style.fontWeight = secondaryStyle.fontWeight;
|
|
}
|
|
if (secondaryStyle.fontStyle) {
|
|
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
|
}
|
|
if (secondaryStyle.backgroundColor) {
|
|
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
|
|
}
|
|
}
|
|
|
|
return {
|
|
applySubtitleFontSize,
|
|
applySubtitleStyle,
|
|
renderSecondarySub,
|
|
renderSubtitle,
|
|
updateSecondarySubMode,
|
|
};
|
|
}
|