mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor(core): normalize service naming across app runtime
This commit is contained in:
@@ -24,13 +24,28 @@ export function createKeyboardHandlers(
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
const CHORD_MAP = new Map<string, { type: "mpv" | "electron"; command?: string[]; action?: () => void }>([
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
{ type: "mpv" | "electron"; command?: string[]; action?: () => void }
|
||||
>([
|
||||
["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }],
|
||||
["Shift+KeyS", { type: "mpv", command: ["script-message", "subminer-stop"] }],
|
||||
[
|
||||
"Shift+KeyS",
|
||||
{ type: "mpv", command: ["script-message", "subminer-stop"] },
|
||||
],
|
||||
["KeyT", { type: "mpv", command: ["script-message", "subminer-toggle"] }],
|
||||
["KeyI", { type: "mpv", command: ["script-message", "subminer-toggle-invisible"] }],
|
||||
["Shift+KeyI", { type: "mpv", command: ["script-message", "subminer-show-invisible"] }],
|
||||
["KeyU", { type: "mpv", command: ["script-message", "subminer-hide-invisible"] }],
|
||||
[
|
||||
"KeyI",
|
||||
{ type: "mpv", command: ["script-message", "subminer-toggle-invisible"] },
|
||||
],
|
||||
[
|
||||
"Shift+KeyI",
|
||||
{ type: "mpv", command: ["script-message", "subminer-show-invisible"] },
|
||||
],
|
||||
[
|
||||
"KeyU",
|
||||
{ type: "mpv", command: ["script-message", "subminer-hide-invisible"] },
|
||||
],
|
||||
["KeyO", { type: "mpv", command: ["script-message", "subminer-options"] }],
|
||||
["KeyR", { type: "mpv", command: ["script-message", "subminer-restart"] }],
|
||||
["KeyC", { type: "mpv", command: ["script-message", "subminer-status"] }],
|
||||
@@ -48,7 +63,8 @@ export function createKeyboardHandlers(
|
||||
if (target.tagName === "IFRAME" && target.id?.startsWith("yomitan-popup")) {
|
||||
return true;
|
||||
}
|
||||
if (target.closest && target.closest('iframe[id^="yomitan-popup"]')) return true;
|
||||
if (target.closest && target.closest('iframe[id^="yomitan-popup"]'))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -193,7 +209,9 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
const yomitanPopup = document.querySelector(
|
||||
'iframe[id^="yomitan-popup"]',
|
||||
);
|
||||
if (yomitanPopup) return;
|
||||
if (handleInvisiblePositionEditKeydown(e)) return;
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ export function createMouseHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: ModalStateReader;
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void;
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (
|
||||
metrics: any,
|
||||
source: string,
|
||||
) => void;
|
||||
applyYPercent: (yPercent: number) => void;
|
||||
getCurrentYPercent: () => number;
|
||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||
@@ -26,7 +29,11 @@ export function createMouseHandlers(
|
||||
function handleMouseLeave(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (!yomitanPopup && !options.modalStateReader.isAnyModalOpen() && !ctx.state.invisiblePositionEditMode) {
|
||||
if (
|
||||
!yomitanPopup &&
|
||||
!options.modalStateReader.isAnyModalOpen() &&
|
||||
!ctx.state.invisiblePositionEditMode
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
@@ -70,7 +77,10 @@ export function createMouseHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function getCaretTextPointRange(clientX: number, clientY: number): Range | null {
|
||||
function getCaretTextPointRange(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): Range | null {
|
||||
const documentWithCaretApi = document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
caretPositionFromPoint?: (
|
||||
@@ -84,7 +94,10 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
if (typeof documentWithCaretApi.caretPositionFromPoint === "function") {
|
||||
const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY);
|
||||
const caretPosition = documentWithCaretApi.caretPositionFromPoint(
|
||||
clientX,
|
||||
clientY,
|
||||
);
|
||||
if (!caretPosition) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(caretPosition.offsetNode, caretPosition.offset);
|
||||
@@ -103,7 +116,9 @@ export function createMouseHandlers(
|
||||
|
||||
const clampedOffset = Math.max(0, Math.min(offset, text.length));
|
||||
const probeIndex =
|
||||
clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset;
|
||||
clampedOffset >= text.length
|
||||
? Math.max(0, text.length - 1)
|
||||
: clampedOffset;
|
||||
|
||||
if (wordSegmenter) {
|
||||
for (const part of wordSegmenter.segment(text)) {
|
||||
@@ -117,7 +132,9 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
const isBoundary = (char: string): boolean =>
|
||||
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char);
|
||||
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(
|
||||
char,
|
||||
);
|
||||
|
||||
const probeChar = text[probeIndex];
|
||||
if (!probeChar || isBoundary(probeChar)) return null;
|
||||
@@ -148,7 +165,10 @@ export function createMouseHandlers(
|
||||
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return;
|
||||
|
||||
const textNode = caretRange.startContainer as Text;
|
||||
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset);
|
||||
const wordBounds = getWordBoundsAtOffset(
|
||||
textNode.data,
|
||||
caretRange.startOffset,
|
||||
);
|
||||
if (!wordBounds) return;
|
||||
|
||||
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
|
||||
@@ -242,10 +262,15 @@ export function createMouseHandlers(
|
||||
element.id &&
|
||||
element.id.startsWith("yomitan-popup")
|
||||
) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnyModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
window.electronAPI.setIgnoreMouseEvents(true, {
|
||||
forward: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@ export function createJimakuModal(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number, episode: number | null): Promise<void> {
|
||||
async function loadFiles(
|
||||
entryId: number,
|
||||
episode: number | null,
|
||||
): Promise<void> {
|
||||
setJimakuStatus("Loading files...");
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedFileIndex = 0;
|
||||
@@ -224,11 +227,12 @@ export function createJimakuModal(
|
||||
const file = ctx.state.jimakuFiles[index];
|
||||
setJimakuStatus("Downloading subtitle...");
|
||||
|
||||
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({
|
||||
entryId: ctx.state.currentEntryId,
|
||||
url: file.url,
|
||||
name: file.name,
|
||||
});
|
||||
const result: JimakuDownloadResult =
|
||||
await window.electronAPI.jimakuDownloadFile({
|
||||
entryId: ctx.state.currentEntryId,
|
||||
url: file.url,
|
||||
name: file.name,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||
@@ -265,8 +269,12 @@ export function createJimakuModal(
|
||||
.getJimakuMediaInfo()
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
ctx.dom.jimakuTitleInput.value = info.title || "";
|
||||
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : "";
|
||||
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : "";
|
||||
ctx.dom.jimakuSeasonInput.value = info.season
|
||||
? String(info.season)
|
||||
: "";
|
||||
ctx.dom.jimakuEpisodeInput.value = info.episode
|
||||
? String(info.episode)
|
||||
: "";
|
||||
ctx.state.currentEpisodeFilter = info.episode ?? null;
|
||||
|
||||
if (info.confidence === "high" && info.title && info.episode) {
|
||||
@@ -291,7 +299,10 @@ export function createJimakuModal(
|
||||
ctx.dom.jimakuModal.setAttribute("aria-hidden", "true");
|
||||
window.electronAPI.notifyOverlayModalClosed("jimaku");
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnyModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
|
||||
@@ -334,10 +345,16 @@ export function createJimakuModal(
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
ctx.state.selectedFileIndex = Math.max(0, ctx.state.selectedFileIndex - 1);
|
||||
ctx.state.selectedFileIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedFileIndex - 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
ctx.state.selectedEntryIndex = Math.max(0, ctx.state.selectedEntryIndex - 1);
|
||||
ctx.state.selectedEntryIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedEntryIndex - 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -20,8 +20,14 @@ export function createKikuModal(
|
||||
}
|
||||
|
||||
function updateKikuCardSelection(): void {
|
||||
ctx.dom.kikuCard1.classList.toggle("active", ctx.state.kikuSelectedCard === 1);
|
||||
ctx.dom.kikuCard2.classList.toggle("active", ctx.state.kikuSelectedCard === 2);
|
||||
ctx.dom.kikuCard1.classList.toggle(
|
||||
"active",
|
||||
ctx.state.kikuSelectedCard === 1,
|
||||
);
|
||||
ctx.dom.kikuCard2.classList.toggle(
|
||||
"active",
|
||||
ctx.state.kikuSelectedCard === 2,
|
||||
);
|
||||
}
|
||||
|
||||
function setKikuModalStep(step: "select" | "preview"): void {
|
||||
@@ -50,7 +56,9 @@ export function createKikuModal(
|
||||
ctx.state.kikuPreviewMode === "compact"
|
||||
? ctx.state.kikuPreviewCompactData
|
||||
: ctx.state.kikuPreviewFullData;
|
||||
ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : "{}";
|
||||
ctx.dom.kikuPreviewJson.textContent = payload
|
||||
? JSON.stringify(payload, null, 2)
|
||||
: "{}";
|
||||
updateKikuPreviewToggle();
|
||||
}
|
||||
|
||||
@@ -78,7 +86,8 @@ export function createKikuModal(
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
|
||||
ctx.dom.kikuCard1Expression.textContent = data.original.expression;
|
||||
ctx.dom.kikuCard1Sentence.textContent = data.original.sentencePreview || "(no sentence)";
|
||||
ctx.dom.kikuCard1Sentence.textContent =
|
||||
data.original.sentencePreview || "(no sentence)";
|
||||
ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original);
|
||||
|
||||
ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression;
|
||||
@@ -123,7 +132,10 @@ export function createKikuModal(
|
||||
ctx.state.kikuOriginalData = null;
|
||||
ctx.state.kikuDuplicateData = null;
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnyModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export function createSubsyncModal(
|
||||
option.textContent = track.label;
|
||||
ctx.dom.subsyncSourceSelect.appendChild(option);
|
||||
}
|
||||
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
|
||||
ctx.dom.subsyncSourceSelect.disabled =
|
||||
ctx.state.subsyncSourceTracks.length === 0;
|
||||
}
|
||||
|
||||
function closeSubsyncModal(): void {
|
||||
@@ -39,7 +40,10 @@ export function createSubsyncModal(
|
||||
ctx.dom.subsyncModal.setAttribute("aria-hidden", "true");
|
||||
window.electronAPI.notifyOverlayModalClosed("subsync");
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnyModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ function toMeasuredRect(rect: DOMRect): OverlayContentRect | null {
|
||||
};
|
||||
}
|
||||
|
||||
function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect {
|
||||
function unionRects(
|
||||
a: OverlayContentRect,
|
||||
b: OverlayContentRect,
|
||||
): OverlayContentRect {
|
||||
const left = Math.min(a.x, b.x);
|
||||
const top = Math.min(a.y, b.y);
|
||||
const right = Math.max(a.x + a.width, b.x + b.width);
|
||||
@@ -48,7 +51,9 @@ function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
|
||||
|
||||
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
|
||||
if (subtitleHasContent) {
|
||||
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
|
||||
const subtitleRect = toMeasuredRect(
|
||||
ctx.dom.subtitleRoot.getBoundingClientRect(),
|
||||
);
|
||||
if (subtitleRect) {
|
||||
combinedRect = subtitleRect;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function createPositioningController(
|
||||
{
|
||||
applyInvisibleSubtitleOffsetPosition:
|
||||
invisibleOffset.applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
|
||||
updateInvisiblePositionEditHud:
|
||||
invisibleOffset.updateInvisiblePositionEditHud,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = "0.92";
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = "1.2";
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = "1.3";
|
||||
|
||||
export function applyContainerBaseLayout(ctx: RendererContext, params: {
|
||||
horizontalAvailable: number;
|
||||
leftInset: number;
|
||||
marginX: number;
|
||||
hAlign: 0 | 1 | 2;
|
||||
}): void {
|
||||
export function applyContainerBaseLayout(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
horizontalAvailable: number;
|
||||
leftInset: number;
|
||||
marginX: number;
|
||||
hAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = "absolute";
|
||||
@@ -42,19 +45,26 @@ export function applyContainerBaseLayout(ctx: RendererContext, params: {
|
||||
ctx.dom.subtitleRoot.style.pointerEvents = "auto";
|
||||
}
|
||||
|
||||
export function applyVerticalPosition(ctx: RendererContext, params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
renderAreaHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
marginY: number;
|
||||
effectiveFontSize: number;
|
||||
vAlign: 0 | 1 | 2;
|
||||
}): void {
|
||||
export function applyVerticalPosition(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
renderAreaHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
marginY: number;
|
||||
effectiveFontSize: number;
|
||||
vAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const multiline = lineCount > 1;
|
||||
const baselineCompensationFactor = lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7;
|
||||
const baselineCompensationPx = Math.max(0, params.effectiveFontSize * baselineCompensationFactor);
|
||||
const baselineCompensationFactor =
|
||||
lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7;
|
||||
const baselineCompensationPx = Math.max(
|
||||
0,
|
||||
params.effectiveFontSize * baselineCompensationFactor,
|
||||
);
|
||||
|
||||
if (params.vAlign === 2) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(
|
||||
@@ -72,7 +82,8 @@ export function applyVerticalPosition(ctx: RendererContext, params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
|
||||
const subPosMargin =
|
||||
((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
|
||||
const effectiveMargin = Math.max(params.marginY, subPosMargin);
|
||||
const bottomPx = Math.max(
|
||||
0,
|
||||
@@ -96,7 +107,10 @@ function resolveFontFamily(rawFont: string): string {
|
||||
: `"${rawFont}", sans-serif`;
|
||||
}
|
||||
|
||||
function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string {
|
||||
function resolveLineHeight(
|
||||
lineCount: number,
|
||||
isMacOSPlatform: boolean,
|
||||
): string {
|
||||
if (!isMacOSPlatform) return "normal";
|
||||
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
|
||||
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
|
||||
@@ -115,8 +129,13 @@ function resolveLetterSpacing(
|
||||
return isMacOSPlatform ? "-0.02em" : "0px";
|
||||
}
|
||||
|
||||
function applyComputedLineHeightCompensation(ctx: RendererContext, effectiveFontSize: number): void {
|
||||
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
|
||||
function applyComputedLineHeightCompensation(
|
||||
ctx: RendererContext,
|
||||
effectiveFontSize: number,
|
||||
): void {
|
||||
const computedLineHeight = parseFloat(
|
||||
getComputedStyle(ctx.dom.subtitleRoot).lineHeight,
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(computedLineHeight) ||
|
||||
computedLineHeight <= effectiveFontSize
|
||||
@@ -151,11 +170,14 @@ function applyMacOSAdjustments(ctx: RendererContext): void {
|
||||
)}px`;
|
||||
}
|
||||
|
||||
export function applyTypography(ctx: RendererContext, params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
pxPerScaledPixel: number;
|
||||
effectiveFontSize: number;
|
||||
}): void {
|
||||
export function applyTypography(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
pxPerScaledPixel: number;
|
||||
effectiveFontSize: number;
|
||||
},
|
||||
): void {
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
|
||||
|
||||
@@ -164,7 +186,9 @@ export function applyTypography(ctx: RendererContext, params: {
|
||||
resolveLineHeight(lineCount, isMacOSPlatform),
|
||||
isMacOSPlatform ? "important" : "",
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
|
||||
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(
|
||||
params.metrics.subFont,
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"letter-spacing",
|
||||
resolveLetterSpacing(
|
||||
@@ -175,8 +199,12 @@ export function applyTypography(ctx: RendererContext, params: {
|
||||
isMacOSPlatform ? "important" : "",
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? "auto" : "none";
|
||||
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? "700" : "400";
|
||||
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? "italic" : "normal";
|
||||
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold
|
||||
? "700"
|
||||
: "400";
|
||||
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic
|
||||
? "italic"
|
||||
: "normal";
|
||||
ctx.dom.subtitleRoot.style.transform = "";
|
||||
ctx.dom.subtitleRoot.style.transformOrigin = "";
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ export function applyPlatformFontCompensation(
|
||||
function calculateGeometry(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
osdToCssScale: number,
|
||||
): Omit<SubtitleLayoutGeometry, "marginY" | "marginX" | "pxPerScaledPixel" | "effectiveFontSize"> {
|
||||
): Omit<
|
||||
SubtitleLayoutGeometry,
|
||||
"marginY" | "marginX" | "pxPerScaledPixel" | "effectiveFontSize"
|
||||
> {
|
||||
const dims = metrics.osdDimensions;
|
||||
const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight;
|
||||
const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth;
|
||||
@@ -88,7 +91,10 @@ function calculateGeometry(
|
||||
const rightInset = anchorToVideoArea ? videoRightInset : 0;
|
||||
const topInset = anchorToVideoArea ? videoTopInset : 0;
|
||||
const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
|
||||
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
|
||||
const horizontalAvailable = Math.max(
|
||||
0,
|
||||
renderAreaWidth - leftInset - rightInset,
|
||||
);
|
||||
|
||||
return {
|
||||
renderAreaHeight,
|
||||
@@ -113,11 +119,16 @@ export function calculateSubtitleMetrics(
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
const geometry = calculateGeometry(metrics, osdToCssScale);
|
||||
const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
|
||||
const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight;
|
||||
const videoHeight =
|
||||
geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
|
||||
const scaleRefHeight = metrics.subScaleByWindow
|
||||
? geometry.renderAreaHeight
|
||||
: videoHeight;
|
||||
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
|
||||
const computedFontSize =
|
||||
metrics.subFontSize * metrics.subScale * (ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
|
||||
metrics.subFontSize *
|
||||
metrics.subScale *
|
||||
(ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
|
||||
const effectiveFontSize = applyPlatformFontCompensation(
|
||||
computedFontSize,
|
||||
ctx.platform.isMacOSPlatform,
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "./invisible-layout-metrics.js";
|
||||
|
||||
export type MpvSubtitleLayoutController = {
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: MpvSubtitleRenderMetrics, source: string) => void;
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
source: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function createMpvSubtitleLayoutController(
|
||||
@@ -29,10 +32,15 @@ export function createMpvSubtitleLayoutController(
|
||||
ctx.state.mpvSubtitleRenderMetrics = metrics;
|
||||
|
||||
const geometry = calculateSubtitleMetrics(ctx, metrics);
|
||||
const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2);
|
||||
const alignment = calculateSubtitlePosition(
|
||||
metrics,
|
||||
geometry.pxPerScaledPixel,
|
||||
2,
|
||||
);
|
||||
|
||||
applySubtitleFontSize(geometry.effectiveFontSize);
|
||||
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel;
|
||||
const effectiveBorderSize =
|
||||
metrics.subBorderSize * geometry.pxPerScaledPixel;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--sub-border-size",
|
||||
@@ -81,7 +89,10 @@ export function createMpvSubtitleLayoutController(
|
||||
options.applyInvisibleSubtitleOffsetPosition();
|
||||
options.updateInvisiblePositionEditHud();
|
||||
|
||||
console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source);
|
||||
console.log(
|
||||
"[invisible-overlay] Applied mpv subtitle render metrics from",
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { SubtitlePosition } from "../../types";
|
||||
import type { ModalStateReader, RendererContext } from "../context";
|
||||
|
||||
export type InvisibleOffsetController = {
|
||||
applyInvisibleStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
|
||||
applyInvisibleStoredSubtitlePosition: (
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
) => void;
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
setInvisiblePositionEditMode: (enabled: boolean) => void;
|
||||
@@ -15,9 +18,7 @@ function formatEditHudText(offsetX: number, offsetY: number): string {
|
||||
return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`;
|
||||
}
|
||||
|
||||
function createEditPositionText(
|
||||
ctx: RendererContext,
|
||||
): string {
|
||||
function createEditPositionText(ctx: RendererContext): string {
|
||||
return formatEditHudText(
|
||||
ctx.state.invisibleSubtitleOffsetXPx,
|
||||
ctx.state.invisibleSubtitleOffsetYPx,
|
||||
@@ -32,7 +33,8 @@ function applyOffsetByBasePosition(ctx: RendererContext): void {
|
||||
if (ctx.state.invisibleLayoutBaseBottomPx !== null) {
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
|
||||
0,
|
||||
ctx.state.invisibleLayoutBaseBottomPx + ctx.state.invisibleSubtitleOffsetYPx,
|
||||
ctx.state.invisibleLayoutBaseBottomPx +
|
||||
ctx.state.invisibleSubtitleOffsetYPx,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.top = "";
|
||||
return;
|
||||
@@ -59,14 +61,19 @@ export function createInvisibleOffsetController(
|
||||
document.body.classList.toggle("invisible-position-edit", enabled);
|
||||
|
||||
if (enabled) {
|
||||
ctx.state.invisiblePositionEditStartX = ctx.state.invisibleSubtitleOffsetXPx;
|
||||
ctx.state.invisiblePositionEditStartY = ctx.state.invisibleSubtitleOffsetYPx;
|
||||
ctx.state.invisiblePositionEditStartX =
|
||||
ctx.state.invisibleSubtitleOffsetXPx;
|
||||
ctx.state.invisiblePositionEditStartY =
|
||||
ctx.state.invisibleSubtitleOffsetYPx;
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
} else {
|
||||
if (!ctx.state.isOverSubtitle && !modalStateReader.isAnySettingsModalOpen()) {
|
||||
if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!modalStateReader.isAnySettingsModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
@@ -79,14 +86,18 @@ export function createInvisibleOffsetController(
|
||||
|
||||
function updateInvisiblePositionEditHud(): void {
|
||||
if (!ctx.state.invisiblePositionEditHud) return;
|
||||
ctx.state.invisiblePositionEditHud.textContent = createEditPositionText(ctx);
|
||||
ctx.state.invisiblePositionEditHud.textContent =
|
||||
createEditPositionText(ctx);
|
||||
}
|
||||
|
||||
function applyInvisibleSubtitleOffsetPosition(): void {
|
||||
applyOffsetByBasePosition(ctx);
|
||||
}
|
||||
|
||||
function applyInvisibleStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void {
|
||||
function applyInvisibleStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
if (
|
||||
position &&
|
||||
typeof position.yPercent === "number" &&
|
||||
@@ -100,11 +111,13 @@ export function createInvisibleOffsetController(
|
||||
|
||||
if (position) {
|
||||
const nextX =
|
||||
typeof position.invisibleOffsetXPx === "number" && Number.isFinite(position.invisibleOffsetXPx)
|
||||
typeof position.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(position.invisibleOffsetXPx)
|
||||
? position.invisibleOffsetXPx
|
||||
: 0;
|
||||
const nextY =
|
||||
typeof position.invisibleOffsetYPx === "number" && Number.isFinite(position.invisibleOffsetYPx)
|
||||
typeof position.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(position.invisibleOffsetYPx)
|
||||
? position.invisibleOffsetYPx
|
||||
: 0;
|
||||
ctx.state.invisibleSubtitleOffsetXPx = nextX;
|
||||
@@ -135,8 +148,10 @@ export function createInvisibleOffsetController(
|
||||
}
|
||||
|
||||
function cancelInvisiblePositionEdit(): void {
|
||||
ctx.state.invisibleSubtitleOffsetXPx = ctx.state.invisiblePositionEditStartX;
|
||||
ctx.state.invisibleSubtitleOffsetYPx = ctx.state.invisiblePositionEditStartY;
|
||||
ctx.state.invisibleSubtitleOffsetXPx =
|
||||
ctx.state.invisiblePositionEditStartX;
|
||||
ctx.state.invisibleSubtitleOffsetYPx =
|
||||
ctx.state.invisiblePositionEditStartY;
|
||||
applyOffsetByBasePosition(ctx);
|
||||
setInvisiblePositionEditMode(false);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,31 @@ const PREFERRED_Y_PERCENT_MIN = 2;
|
||||
const PREFERRED_Y_PERCENT_MAX = 80;
|
||||
|
||||
export type SubtitlePositionController = {
|
||||
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
|
||||
applyStoredSubtitlePosition: (
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
) => void;
|
||||
getCurrentYPercent: () => number;
|
||||
applyYPercent: (yPercent: number) => void;
|
||||
persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void;
|
||||
};
|
||||
|
||||
function clampYPercent(yPercent: number): number {
|
||||
return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent));
|
||||
return Math.max(
|
||||
PREFERRED_Y_PERCENT_MIN,
|
||||
Math.min(PREFERRED_Y_PERCENT_MAX, yPercent),
|
||||
);
|
||||
}
|
||||
|
||||
function getPersistedYPercent(
|
||||
ctx: RendererContext,
|
||||
position: SubtitlePosition | null,
|
||||
): number {
|
||||
if (!position || typeof position.yPercent !== "number" || !Number.isFinite(position.yPercent)) {
|
||||
if (
|
||||
!position ||
|
||||
typeof position.yPercent !== "number" ||
|
||||
!Number.isFinite(position.yPercent)
|
||||
) {
|
||||
return ctx.state.persistedSubtitlePosition.yPercent;
|
||||
}
|
||||
|
||||
@@ -66,12 +76,12 @@ function getNextPersistedPosition(
|
||||
typeof patch.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(patch.invisibleOffsetXPx)
|
||||
? patch.invisibleOffsetXPx
|
||||
: ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0,
|
||||
: (ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0),
|
||||
invisibleOffsetYPx:
|
||||
typeof patch.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(patch.invisibleOffsetYPx)
|
||||
? patch.invisibleOffsetYPx
|
||||
: ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0,
|
||||
: (ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,8 +93,11 @@ export function createInMemorySubtitlePositionController(
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
|
||||
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
|
||||
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
|
||||
const marginBottom =
|
||||
parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
|
||||
ctx.state.currentYPercent = clampYPercent(
|
||||
(marginBottom / window.innerHeight) * 100,
|
||||
);
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
|
||||
@@ -101,13 +114,18 @@ export function createInMemorySubtitlePositionController(
|
||||
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
||||
}
|
||||
|
||||
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
|
||||
function persistSubtitlePositionPatch(
|
||||
patch: Partial<SubtitlePosition>,
|
||||
): void {
|
||||
const nextPosition = getNextPersistedPosition(ctx, patch);
|
||||
ctx.state.persistedSubtitlePosition = nextPosition;
|
||||
window.electronAPI.saveSubtitlePosition(nextPosition);
|
||||
}
|
||||
|
||||
function applyStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void {
|
||||
function applyStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
updatePersistedSubtitlePosition(ctx, position);
|
||||
if (position && position.yPercent !== undefined) {
|
||||
applyYPercent(position.yPercent);
|
||||
|
||||
@@ -132,7 +132,10 @@ async function init(): Promise<void> {
|
||||
|
||||
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
positioning.applyInvisibleStoredSubtitlePosition(position, "media-change");
|
||||
positioning.applyInvisibleStoredSubtitlePosition(
|
||||
position,
|
||||
"media-change",
|
||||
);
|
||||
} else {
|
||||
positioning.applyStoredSubtitlePosition(position, "media-change");
|
||||
}
|
||||
@@ -140,10 +143,15 @@ async function init(): Promise<void> {
|
||||
});
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => {
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "event");
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
window.electronAPI.onMpvSubtitleRenderMetrics(
|
||||
(metrics: MpvSubtitleRenderMetrics) => {
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
metrics,
|
||||
"event",
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
},
|
||||
);
|
||||
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
|
||||
document.body.classList.toggle("debug-invisible-visualization", enabled);
|
||||
});
|
||||
@@ -162,8 +170,12 @@ async function init(): Promise<void> {
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
|
||||
subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode());
|
||||
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
|
||||
subtitleRenderer.updateSecondarySubMode(
|
||||
await window.electronAPI.getSecondarySubMode(),
|
||||
);
|
||||
subtitleRenderer.renderSecondarySub(
|
||||
await window.electronAPI.getCurrentSecondarySub(),
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
const hoverTarget = ctx.platform.isInvisibleLayer
|
||||
@@ -171,8 +183,14 @@ async function init(): Promise<void> {
|
||||
: ctx.dom.subtitleContainer;
|
||||
hoverTarget.addEventListener("mouseenter", mouseHandlers.handleMouseEnter);
|
||||
hoverTarget.addEventListener("mouseleave", mouseHandlers.handleMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener("mouseenter", mouseHandlers.handleMouseEnter);
|
||||
ctx.dom.secondarySubContainer.addEventListener("mouseleave", mouseHandlers.handleMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
"mouseenter",
|
||||
mouseHandlers.handleMouseEnter,
|
||||
);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
"mouseleave",
|
||||
mouseHandlers.handleMouseLeave,
|
||||
);
|
||||
|
||||
mouseHandlers.setupInvisibleHoverSelection();
|
||||
positioning.setupInvisiblePositionEditHud();
|
||||
@@ -189,9 +207,11 @@ async function init(): Promise<void> {
|
||||
subsyncModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
|
||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||
});
|
||||
window.electronAPI.onRuntimeOptionsChanged(
|
||||
(options: RuntimeOptionState[]) => {
|
||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||
},
|
||||
);
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runtimeOptionsModal.openRuntimeOptionsModal().catch(() => {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus(
|
||||
@@ -209,7 +229,10 @@ async function init(): Promise<void> {
|
||||
subsyncModal.openSubsyncModal(payload);
|
||||
});
|
||||
window.electronAPI.onKikuFieldGroupingRequest(
|
||||
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
|
||||
(data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}) => {
|
||||
kikuModal.openKikuFieldGroupingModal(data);
|
||||
},
|
||||
);
|
||||
@@ -220,7 +243,9 @@ async function init(): Promise<void> {
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
|
||||
subtitleRenderer.applySubtitleStyle(
|
||||
await window.electronAPI.getSubtitleStyle(),
|
||||
);
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
positioning.applyInvisibleStoredSubtitlePosition(
|
||||
|
||||
@@ -95,7 +95,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
bandedColors: [
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
] as const,
|
||||
}),
|
||||
"word word-known",
|
||||
);
|
||||
@@ -105,7 +111,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
bandedColors: [
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
] as const,
|
||||
}),
|
||||
"word word-n-plus-one",
|
||||
);
|
||||
@@ -115,7 +127,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
bandedColors: [
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
] as const,
|
||||
}),
|
||||
"word word-frequency-single",
|
||||
);
|
||||
@@ -127,16 +145,19 @@ test("computeWordClass adds frequency class for single mode when rank is within
|
||||
frequencyRank: 50,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: [
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, "word word-frequency-single");
|
||||
});
|
||||
@@ -147,16 +168,19 @@ test("computeWordClass adds frequency class when rank equals topX", () => {
|
||||
frequencyRank: 100,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: [
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, "word word-frequency-single");
|
||||
});
|
||||
@@ -167,17 +191,19 @@ test("computeWordClass adds frequency class for banded mode", () => {
|
||||
frequencyRank: 250,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "banded",
|
||||
singleColor: "#000000",
|
||||
bandedColors:
|
||||
["#111111", "#222222", "#333333", "#444444", "#555555"] as const,
|
||||
},
|
||||
);
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "banded",
|
||||
singleColor: "#000000",
|
||||
bandedColors: [
|
||||
"#111111",
|
||||
"#222222",
|
||||
"#333333",
|
||||
"#444444",
|
||||
"#555555",
|
||||
] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, "word word-frequency-band-2");
|
||||
});
|
||||
@@ -193,13 +219,7 @@ test("computeWordClass uses configured band count for banded mode", () => {
|
||||
topX: 4,
|
||||
mode: "banded",
|
||||
singleColor: "#000000",
|
||||
bandedColors: [
|
||||
"#111111",
|
||||
"#222222",
|
||||
"#333333",
|
||||
"#444444",
|
||||
"#555555",
|
||||
],
|
||||
bandedColors: ["#111111", "#222222", "#333333", "#444444", "#555555"],
|
||||
} as any);
|
||||
|
||||
assert.equal(actual, "word word-frequency-band-3");
|
||||
@@ -211,16 +231,19 @@ test("computeWordClass skips frequency class when rank is out of topX", () => {
|
||||
frequencyRank: 1200,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: [
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
"#000000",
|
||||
] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, "word");
|
||||
});
|
||||
@@ -229,9 +252,7 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
const distCssPath = path.join(process.cwd(), "dist", "renderer", "style.css");
|
||||
const srcCssPath = path.join(process.cwd(), "src", "renderer", "style.css");
|
||||
|
||||
const cssPath = fs.existsSync(distCssPath)
|
||||
? distCssPath
|
||||
: srcCssPath;
|
||||
const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
|
||||
if (!fs.existsSync(cssPath)) {
|
||||
assert.fail(
|
||||
"JLPT CSS file missing. Run `pnpm run build` first, or ensure src/renderer/style.css exists.",
|
||||
@@ -259,7 +280,10 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
? "#subtitleRoot .word.word-frequency-single"
|
||||
: `#subtitleRoot .word.word-frequency-band-${band}`,
|
||||
);
|
||||
assert.ok(block.length > 0, `frequency class word-frequency-${band === 1 ? "single" : `band-${band}`} should exist`);
|
||||
assert.ok(
|
||||
block.length > 0,
|
||||
`frequency class word-frequency-${band === 1 ? "single" : `band-${band}`} should exist`,
|
||||
);
|
||||
assert.match(block, /color:\s*var\(/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,12 +72,18 @@ function getFrequencyDictionaryClass(
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof token.frequencyRank !== "number" || !Number.isFinite(token.frequencyRank)) {
|
||||
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);
|
||||
const topX = sanitizeFrequencyTopX(
|
||||
settings.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
);
|
||||
if (rank > topX) {
|
||||
return "";
|
||||
}
|
||||
@@ -121,16 +127,16 @@ function renderWithTokens(
|
||||
|
||||
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;
|
||||
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) {
|
||||
@@ -214,7 +220,10 @@ function renderCharacterLevel(root: HTMLElement, text: string): void {
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
function renderPlainTextPreserveLineBreaks(root: HTMLElement, text: string): void {
|
||||
function renderPlainTextPreserveLineBreaks(
|
||||
root: HTMLElement,
|
||||
text: string,
|
||||
): void {
|
||||
const lines = text.split("\n");
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
@@ -255,7 +264,10 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
1,
|
||||
normalizedInvisible.split("\n").length,
|
||||
);
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
renderPlainTextPreserveLineBreaks(
|
||||
ctx.dom.subtitleRoot,
|
||||
normalizedInvisible,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,10 +343,13 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
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.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.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;
|
||||
@@ -352,12 +367,12 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
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),
|
||||
}
|
||||
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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -367,20 +382,39 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
"--subtitle-known-word-color",
|
||||
knownWordColor,
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-n-plus-one-color", nPlusOneColor);
|
||||
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);
|
||||
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;
|
||||
frequencyDictionarySettings.enabled ??
|
||||
ctx.state.frequencyDictionaryEnabled;
|
||||
const frequencyTopX = sanitizeFrequencyTopX(
|
||||
frequencyDictionarySettings.topX,
|
||||
ctx.state.frequencyDictionaryTopX,
|
||||
@@ -458,7 +492,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||
}
|
||||
if (secondaryStyle.backgroundColor) {
|
||||
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
|
||||
ctx.dom.secondarySubContainer.style.background =
|
||||
secondaryStyle.backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,8 +77,9 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleRoot: getRequiredElement<HTMLElement>("subtitleRoot"),
|
||||
subtitleContainer: getRequiredElement<HTMLElement>("subtitleContainer"),
|
||||
overlay: getRequiredElement<HTMLElement>("overlay"),
|
||||
secondarySubContainer:
|
||||
getRequiredElement<HTMLElement>("secondarySubContainer"),
|
||||
secondarySubContainer: getRequiredElement<HTMLElement>(
|
||||
"secondarySubContainer",
|
||||
),
|
||||
secondarySubRoot: getRequiredElement<HTMLElement>("secondarySubRoot"),
|
||||
|
||||
jimakuModal: getRequiredElement<HTMLDivElement>("jimakuModal"),
|
||||
@@ -88,60 +89,89 @@ export function resolveRendererDom(): RendererDom {
|
||||
jimakuSearchButton: getRequiredElement<HTMLButtonElement>("jimakuSearch"),
|
||||
jimakuCloseButton: getRequiredElement<HTMLButtonElement>("jimakuClose"),
|
||||
jimakuStatus: getRequiredElement<HTMLDivElement>("jimakuStatus"),
|
||||
jimakuEntriesSection: getRequiredElement<HTMLDivElement>("jimakuEntriesSection"),
|
||||
jimakuEntriesSection: getRequiredElement<HTMLDivElement>(
|
||||
"jimakuEntriesSection",
|
||||
),
|
||||
jimakuEntriesList: getRequiredElement<HTMLUListElement>("jimakuEntries"),
|
||||
jimakuFilesSection: getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
|
||||
jimakuFilesSection:
|
||||
getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
|
||||
jimakuFilesList: getRequiredElement<HTMLUListElement>("jimakuFiles"),
|
||||
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>("jimakuBroaden"),
|
||||
|
||||
kikuModal: getRequiredElement<HTMLDivElement>("kikuFieldGroupingModal"),
|
||||
kikuCard1: getRequiredElement<HTMLDivElement>("kikuCard1"),
|
||||
kikuCard2: getRequiredElement<HTMLDivElement>("kikuCard2"),
|
||||
kikuCard1Expression: getRequiredElement<HTMLDivElement>("kikuCard1Expression"),
|
||||
kikuCard2Expression: getRequiredElement<HTMLDivElement>("kikuCard2Expression"),
|
||||
kikuCard1Expression: getRequiredElement<HTMLDivElement>(
|
||||
"kikuCard1Expression",
|
||||
),
|
||||
kikuCard2Expression: getRequiredElement<HTMLDivElement>(
|
||||
"kikuCard2Expression",
|
||||
),
|
||||
kikuCard1Sentence: getRequiredElement<HTMLDivElement>("kikuCard1Sentence"),
|
||||
kikuCard2Sentence: getRequiredElement<HTMLDivElement>("kikuCard2Sentence"),
|
||||
kikuCard1Meta: getRequiredElement<HTMLDivElement>("kikuCard1Meta"),
|
||||
kikuCard2Meta: getRequiredElement<HTMLDivElement>("kikuCard2Meta"),
|
||||
kikuConfirmButton: getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
|
||||
kikuConfirmButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
|
||||
kikuCancelButton: getRequiredElement<HTMLButtonElement>("kikuCancelButton"),
|
||||
kikuDeleteDuplicateCheckbox:
|
||||
getRequiredElement<HTMLInputElement>("kikuDeleteDuplicate"),
|
||||
kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>(
|
||||
"kikuDeleteDuplicate",
|
||||
),
|
||||
kikuSelectionStep: getRequiredElement<HTMLDivElement>("kikuSelectionStep"),
|
||||
kikuPreviewStep: getRequiredElement<HTMLDivElement>("kikuPreviewStep"),
|
||||
kikuPreviewJson: getRequiredElement<HTMLPreElement>("kikuPreviewJson"),
|
||||
kikuPreviewCompactButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuPreviewCompact"),
|
||||
kikuPreviewFullButton: getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
|
||||
kikuPreviewFullButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
|
||||
kikuPreviewError: getRequiredElement<HTMLDivElement>("kikuPreviewError"),
|
||||
kikuBackButton: getRequiredElement<HTMLButtonElement>("kikuBackButton"),
|
||||
kikuFinalConfirmButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuFinalConfirmButton"),
|
||||
kikuFinalCancelButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuFinalCancelButton"),
|
||||
kikuFinalConfirmButton: getRequiredElement<HTMLButtonElement>(
|
||||
"kikuFinalConfirmButton",
|
||||
),
|
||||
kikuFinalCancelButton: getRequiredElement<HTMLButtonElement>(
|
||||
"kikuFinalCancelButton",
|
||||
),
|
||||
kikuHint: getRequiredElement<HTMLDivElement>("kikuHint"),
|
||||
|
||||
runtimeOptionsModal: getRequiredElement<HTMLDivElement>("runtimeOptionsModal"),
|
||||
runtimeOptionsClose: getRequiredElement<HTMLButtonElement>("runtimeOptionsClose"),
|
||||
runtimeOptionsList: getRequiredElement<HTMLUListElement>("runtimeOptionsList"),
|
||||
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>("runtimeOptionsStatus"),
|
||||
runtimeOptionsModal: getRequiredElement<HTMLDivElement>(
|
||||
"runtimeOptionsModal",
|
||||
),
|
||||
runtimeOptionsClose: getRequiredElement<HTMLButtonElement>(
|
||||
"runtimeOptionsClose",
|
||||
),
|
||||
runtimeOptionsList:
|
||||
getRequiredElement<HTMLUListElement>("runtimeOptionsList"),
|
||||
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>(
|
||||
"runtimeOptionsStatus",
|
||||
),
|
||||
|
||||
subsyncModal: getRequiredElement<HTMLDivElement>("subsyncModal"),
|
||||
subsyncCloseButton: getRequiredElement<HTMLButtonElement>("subsyncClose"),
|
||||
subsyncEngineAlass: getRequiredElement<HTMLInputElement>("subsyncEngineAlass"),
|
||||
subsyncEngineFfsubsync:
|
||||
getRequiredElement<HTMLInputElement>("subsyncEngineFfsubsync"),
|
||||
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"),
|
||||
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"),
|
||||
subsyncEngineAlass:
|
||||
getRequiredElement<HTMLInputElement>("subsyncEngineAlass"),
|
||||
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>(
|
||||
"subsyncEngineFfsubsync",
|
||||
),
|
||||
subsyncSourceLabel:
|
||||
getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"),
|
||||
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>(
|
||||
"subsyncSourceSelect",
|
||||
),
|
||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
|
||||
subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"),
|
||||
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>("sessionHelpModal"),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>("sessionHelpClose"),
|
||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>("sessionHelpShortcut"),
|
||||
sessionHelpWarning: getRequiredElement<HTMLDivElement>("sessionHelpWarning"),
|
||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>(
|
||||
"sessionHelpShortcut",
|
||||
),
|
||||
sessionHelpWarning:
|
||||
getRequiredElement<HTMLDivElement>("sessionHelpWarning"),
|
||||
sessionHelpStatus: getRequiredElement<HTMLDivElement>("sessionHelpStatus"),
|
||||
sessionHelpFilter: getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
|
||||
sessionHelpContent: getRequiredElement<HTMLDivElement>("sessionHelpContent"),
|
||||
sessionHelpFilter:
|
||||
getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
|
||||
sessionHelpContent:
|
||||
getRequiredElement<HTMLDivElement>("sessionHelpContent"),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user