This commit is contained in:
2026-02-17 22:50:57 -08:00
parent ffeef9c136
commit f20d019c11
315 changed files with 9876 additions and 12537 deletions

View File

@@ -1,6 +1,6 @@
import type { RendererState } from "./state";
import type { RendererDom } from "./utils/dom";
import type { PlatformInfo } from "./utils/platform";
import type { RendererState } from './state';
import type { RendererDom } from './utils/dom';
import type { PlatformInfo } from './utils/platform';
export type RendererContext = {
dom: RendererDom;

View File

@@ -1,5 +1,5 @@
import type { Keybinding } from "../../types";
import type { RendererContext } from "../context";
import type { Keybinding } from '../../types';
import type { RendererContext } from '../context';
export function createKeyboardHandlers(
ctx: RendererContext,
@@ -10,7 +10,7 @@ export function createKeyboardHandlers(
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: "KeyH" | "KeyK";
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
fallbackUnavailable: boolean;
}) => void;
@@ -26,56 +26,40 @@ export function createKeyboardHandlers(
const CHORD_MAP = new Map<
string,
{ type: "mpv" | "electron"; command?: string[]; action?: () => void }
{ type: 'mpv' | 'electron'; command?: string[]; action?: () => void }
>([
["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }],
[
"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"] },
],
["KeyO", { type: "mpv", command: ["script-message", "subminer-options"] }],
["KeyR", { type: "mpv", command: ["script-message", "subminer-restart"] }],
["KeyC", { type: "mpv", command: ["script-message", "subminer-status"] }],
["KeyY", { type: "mpv", command: ["script-message", "subminer-menu"] }],
[
"KeyD",
{ type: "electron", action: () => window.electronAPI.toggleDevTools() },
],
['KeyS', { type: 'mpv', command: ['script-message', 'subminer-start'] }],
['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'] }],
['KeyO', { type: 'mpv', command: ['script-message', 'subminer-options'] }],
['KeyR', { type: 'mpv', command: ['script-message', 'subminer-restart'] }],
['KeyC', { type: 'mpv', command: ['script-message', 'subminer-status'] }],
['KeyY', { type: 'mpv', command: ['script-message', 'subminer-menu'] }],
['KeyD', { type: 'electron', action: () => window.electronAPI.toggleDevTools() }],
]);
function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
if (target.closest(".modal")) return true;
if (target.closest('.modal')) return true;
if (ctx.dom.subtitleContainer.contains(target)) return true;
if (target.tagName === "IFRAME" && target.id?.startsWith("yomitan-popup")) {
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;
}
function keyEventToString(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
if (e.metaKey) parts.push("Meta");
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Meta');
parts.push(e.code);
return parts.join("+");
return parts.join('+');
}
function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean {
@@ -88,12 +72,12 @@ export function createKeyboardHandlers(
}
function resolveSessionHelpChordBinding(): {
bindingKey: "KeyH" | "KeyK";
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
fallbackUnavailable: boolean;
} {
const firstChoice = "KeyH";
if (!ctx.state.keybindingsMap.has("KeyH")) {
const firstChoice = 'KeyH';
if (!ctx.state.keybindingsMap.has('KeyH')) {
return {
bindingKey: firstChoice,
fallbackUsed: false,
@@ -101,27 +85,27 @@ export function createKeyboardHandlers(
};
}
if (ctx.state.keybindingsMap.has("KeyK")) {
if (ctx.state.keybindingsMap.has('KeyK')) {
return {
bindingKey: "KeyK",
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: true,
};
}
return {
bindingKey: "KeyK",
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: false,
};
}
function applySessionHelpChordBinding(): void {
CHORD_MAP.delete("KeyH");
CHORD_MAP.delete("KeyK");
CHORD_MAP.delete('KeyH');
CHORD_MAP.delete('KeyK');
const info = resolveSessionHelpChordBinding();
CHORD_MAP.set(info.bindingKey, {
type: "electron",
type: 'electron',
action: () => {
options.openSessionHelpModal(info);
},
@@ -147,40 +131,40 @@ export function createKeyboardHandlers(
? ctx.platform.invisiblePositionStepFastPx
: ctx.platform.invisiblePositionStepPx;
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
options.cancelInvisiblePositionEdit();
return true;
}
if (e.key === "Enter" || ((e.ctrlKey || e.metaKey) && e.code === "KeyS")) {
if (e.key === 'Enter' || ((e.ctrlKey || e.metaKey) && e.code === 'KeyS')) {
e.preventDefault();
options.saveInvisiblePositionEdit();
return true;
}
if (
e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "ArrowLeft" ||
e.key === "ArrowRight" ||
e.key === "h" ||
e.key === "j" ||
e.key === "k" ||
e.key === "l" ||
e.key === "H" ||
e.key === "J" ||
e.key === "K" ||
e.key === "L"
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'h' ||
e.key === 'j' ||
e.key === 'k' ||
e.key === 'l' ||
e.key === 'H' ||
e.key === 'J' ||
e.key === 'K' ||
e.key === 'L'
) {
e.preventDefault();
if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
ctx.state.invisibleSubtitleOffsetYPx += step;
} else if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
} else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
ctx.state.invisibleSubtitleOffsetYPx -= step;
} else if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") {
} else if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') {
ctx.state.invisibleSubtitleOffsetXPx -= step;
} else if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") {
} else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
ctx.state.invisibleSubtitleOffsetXPx += step;
}
options.applyInvisibleSubtitleOffsetPosition();
@@ -208,10 +192,8 @@ export function createKeyboardHandlers(
}
}
document.addEventListener("keydown", (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector(
'iframe[id^="yomitan-popup"]',
);
document.addEventListener('keydown', (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
if (yomitanPopup) return;
if (handleInvisiblePositionEditKeydown(e)) return;
@@ -238,14 +220,14 @@ export function createKeyboardHandlers(
if (ctx.state.chordPending) {
const modifierKeys = [
"ShiftLeft",
"ShiftRight",
"ControlLeft",
"ControlRight",
"AltLeft",
"AltRight",
"MetaLeft",
"MetaRight",
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
];
if (modifierKeys.includes(e.code)) {
return;
@@ -256,23 +238,16 @@ export function createKeyboardHandlers(
const action = CHORD_MAP.get(secondKey);
resetChord();
if (action) {
if (action.type === "mpv" && action.command) {
if (action.type === 'mpv' && action.command) {
window.electronAPI.sendMpvCommand(action.command);
} else if (action.type === "electron" && action.action) {
} else if (action.type === 'electron' && action.action) {
action.action();
}
}
return;
}
if (
e.code === "KeyY" &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
!e.metaKey &&
!e.repeat
) {
if (e.code === 'KeyY' && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !e.repeat) {
e.preventDefault();
applySessionHelpChordBinding();
ctx.state.chordPending = true;
@@ -291,14 +266,14 @@ export function createKeyboardHandlers(
}
});
document.addEventListener("mousedown", (e: MouseEvent) => {
document.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 2 && !isInteractiveTarget(e.target)) {
e.preventDefault();
window.electronAPI.sendMpvCommand(["cycle", "pause"]);
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
}
});
document.addEventListener("contextmenu", (e: Event) => {
document.addEventListener('contextmenu', (e: Event) => {
if (!isInteractiveTarget(e.target)) {
e.preventDefault();
}

View File

@@ -1,26 +1,23 @@
import type { ModalStateReader, RendererContext } from "../context";
import type { ModalStateReader, RendererContext } from '../context';
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;
},
) {
const wordSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "word" })
typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'word' })
: null;
function handleMouseEnter(): void {
ctx.state.isOverSubtitle = true;
ctx.dom.overlay.classList.add("interactive");
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
@@ -34,7 +31,7 @@ export function createMouseHandlers(
!options.modalStateReader.isAnyModalOpen() &&
!ctx.state.invisiblePositionEditMode
) {
ctx.dom.overlay.classList.remove("interactive");
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
@@ -42,17 +39,17 @@ export function createMouseHandlers(
}
function setupDragging(): void {
ctx.dom.subtitleContainer.addEventListener("mousedown", (e: MouseEvent) => {
ctx.dom.subtitleContainer.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 2) {
e.preventDefault();
ctx.state.isDragging = true;
ctx.state.dragStartY = e.clientY;
ctx.state.startYPercent = options.getCurrentYPercent();
ctx.dom.subtitleContainer.style.cursor = "grabbing";
ctx.dom.subtitleContainer.style.cursor = 'grabbing';
}
});
document.addEventListener("mousemove", (e: MouseEvent) => {
document.addEventListener('mousemove', (e: MouseEvent) => {
if (!ctx.state.isDragging) return;
const deltaY = ctx.state.dragStartY - e.clientY;
@@ -62,25 +59,22 @@ export function createMouseHandlers(
options.applyYPercent(newYPercent);
});
document.addEventListener("mouseup", (e: MouseEvent) => {
document.addEventListener('mouseup', (e: MouseEvent) => {
if (ctx.state.isDragging && e.button === 2) {
ctx.state.isDragging = false;
ctx.dom.subtitleContainer.style.cursor = "";
ctx.dom.subtitleContainer.style.cursor = '';
const yPercent = options.getCurrentYPercent();
options.persistSubtitlePositionPatch({ yPercent });
}
});
ctx.dom.subtitleContainer.addEventListener("contextmenu", (e: Event) => {
ctx.dom.subtitleContainer.addEventListener('contextmenu', (e: Event) => {
e.preventDefault();
});
}
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?: (
@@ -89,15 +83,12 @@ export function createMouseHandlers(
) => { offsetNode: Node; offset: number } | null;
};
if (typeof documentWithCaretApi.caretRangeFromPoint === "function") {
if (typeof documentWithCaretApi.caretRangeFromPoint === 'function') {
return documentWithCaretApi.caretRangeFromPoint(clientX, clientY);
}
if (typeof documentWithCaretApi.caretPositionFromPoint === "function") {
const caretPosition = documentWithCaretApi.caretPositionFromPoint(
clientX,
clientY,
);
if (typeof documentWithCaretApi.caretPositionFromPoint === 'function') {
const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY);
if (!caretPosition) return null;
const range = document.createRange();
range.setStart(caretPosition.offsetNode, caretPosition.offset);
@@ -115,10 +106,7 @@ export function createMouseHandlers(
if (!text || text.length === 0) return null;
const clampedOffset = Math.max(0, Math.min(offset, text.length));
const probeIndex =
clampedOffset >= text.length
? Math.max(0, text.length - 1)
: clampedOffset;
const probeIndex = clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset;
if (wordSegmenter) {
for (const part of wordSegmenter.segment(text)) {
@@ -132,9 +120,7 @@ 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;
@@ -165,10 +151,7 @@ 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(
@@ -198,23 +181,23 @@ export function createMouseHandlers(
function setupInvisibleHoverSelection(): void {
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
ctx.dom.subtitleRoot.addEventListener("mousemove", (event: MouseEvent) => {
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
updateHoverWordSelection(event);
});
ctx.dom.subtitleRoot.addEventListener("mouseleave", () => {
ctx.state.lastHoverSelectionKey = "";
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
ctx.state.lastHoverSelectionKey = '';
ctx.state.lastHoverSelectionNode = null;
});
}
function setupResizeHandler(): void {
window.addEventListener("resize", () => {
window.addEventListener('resize', () => {
if (ctx.platform.isInvisibleLayer) {
if (!ctx.state.mpvSubtitleRenderMetrics) return;
options.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics,
"resize",
'resize',
);
return;
}
@@ -223,15 +206,14 @@ export function createMouseHandlers(
}
function setupSelectionObserver(): void {
document.addEventListener("selectionchange", () => {
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
const hasSelection =
selection && selection.rangeCount > 0 && !selection.isCollapsed;
const hasSelection = selection && selection.rangeCount > 0 && !selection.isCollapsed;
if (hasSelection) {
ctx.dom.subtitleRoot.classList.add("has-selection");
ctx.dom.subtitleRoot.classList.add('has-selection');
} else {
ctx.dom.subtitleRoot.classList.remove("has-selection");
ctx.dom.subtitleRoot.classList.remove('has-selection');
}
});
}
@@ -243,11 +225,11 @@ export function createMouseHandlers(
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
if (
element.tagName === "IFRAME" &&
element.tagName === 'IFRAME' &&
element.id &&
element.id.startsWith("yomitan-popup")
element.id.startsWith('yomitan-popup')
) {
ctx.dom.overlay.classList.add("interactive");
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
@@ -258,15 +240,12 @@ export function createMouseHandlers(
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
if (
element.tagName === "IFRAME" &&
element.tagName === 'IFRAME' &&
element.id &&
element.id.startsWith("yomitan-popup")
element.id.startsWith('yomitan-popup')
) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, {
forward: true,

View File

@@ -40,9 +40,7 @@
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Jimaku Subtitles</div>
<button id="jimakuClose" class="modal-close" type="button">
Close
</button>
<button id="jimakuClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="jimaku-form">
@@ -52,25 +50,13 @@
</label>
<label class="jimaku-field">
<span>Season</span>
<input
id="jimakuSeason"
type="number"
min="1"
placeholder="1"
/>
<input id="jimakuSeason" type="number" min="1" placeholder="1" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input
id="jimakuEpisode"
type="number"
min="1"
placeholder="1"
/>
<input id="jimakuEpisode" type="number" min="1" placeholder="1" />
</label>
<button id="jimakuSearch" class="jimaku-button" type="button">
Search
</button>
<button id="jimakuSearch" class="jimaku-button" type="button">Search</button>
</div>
<div id="jimakuStatus" class="jimaku-status"></div>
<div id="jimakuEntriesSection" class="jimaku-section hidden">
@@ -80,11 +66,7 @@
<div id="jimakuFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Files</div>
<ul id="jimakuFiles" class="jimaku-list"></ul>
<button
id="jimakuBroaden"
class="jimaku-link hidden"
type="button"
>
<button id="jimakuBroaden" class="jimaku-link hidden" type="button">
Broaden search (all files)
</button>
</div>
@@ -99,26 +81,20 @@
<div class="modal-body">
<div id="kikuSelectionStep">
<div class="kiku-info-text">
A card with the same expression already exists. Select which
card to keep. The other card's content will be merged using Kiku
field grouping. You can choose whether to delete the duplicate.
A card with the same expression already exists. Select which card to keep. The other
card's content will be merged using Kiku field grouping. You can choose whether to
delete the duplicate.
</div>
<div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0">
<div class="kiku-card-label">1 &mdash; Original Card</div>
<div
class="kiku-card-expression"
id="kikuCard1Expression"
></div>
<div class="kiku-card-expression" id="kikuCard1Expression"></div>
<div class="kiku-card-sentence" id="kikuCard1Sentence"></div>
<div class="kiku-card-meta" id="kikuCard1Meta"></div>
</div>
<div id="kikuCard2" class="kiku-card" tabindex="0">
<div class="kiku-card-label">2 &mdash; New Card</div>
<div
class="kiku-card-expression"
id="kikuCard2Expression"
></div>
<div class="kiku-card-expression" id="kikuCard2Expression"></div>
<div class="kiku-card-sentence" id="kikuCard2Sentence"></div>
<div class="kiku-card-meta" id="kikuCard2Meta"></div>
</div>
@@ -128,18 +104,10 @@
<input id="kikuDeleteDuplicate" type="checkbox" checked />
Delete duplicate card after merge
</label>
<button
id="kikuConfirmButton"
class="kiku-confirm-button"
type="button"
>
<button id="kikuConfirmButton" class="kiku-confirm-button" type="button">
Continue
</button>
<button
id="kikuCancelButton"
class="kiku-cancel-button"
type="button"
>
<button id="kikuCancelButton" class="kiku-cancel-button" type="button">
Cancel
</button>
</div>
@@ -153,39 +121,21 @@
<button id="kikuPreviewFull" type="button">Full</button>
</div>
</div>
<div
id="kikuPreviewError"
class="kiku-preview-error hidden"
></div>
<div id="kikuPreviewError" class="kiku-preview-error hidden"></div>
<pre id="kikuPreviewJson" class="kiku-preview-json"></pre>
<div class="kiku-footer">
<button
id="kikuBackButton"
class="kiku-cancel-button"
type="button"
>
Back
</button>
<button
id="kikuFinalConfirmButton"
class="kiku-confirm-button"
type="button"
>
<button id="kikuBackButton" class="kiku-cancel-button" type="button">Back</button>
<button id="kikuFinalConfirmButton" class="kiku-confirm-button" type="button">
Confirm Merge
</button>
<button
id="kikuFinalCancelButton"
class="kiku-cancel-button"
type="button"
>
<button id="kikuFinalCancelButton" class="kiku-cancel-button" type="button">
Cancel
</button>
</div>
</div>
<div id="kikuHint" class="kiku-hint">
Press 1 or 2 to select &middot; Enter to confirm &middot; Esc to
cancel
Press 1 or 2 to select &middot; Enter to confirm &middot; Esc to cancel
</div>
</div>
</div>
@@ -194,18 +144,11 @@
<div class="modal-content runtime-modal-content">
<div class="modal-header">
<div class="modal-title">Runtime Options</div>
<button
id="runtimeOptionsClose"
class="modal-close"
type="button"
>
Close
</button>
<button id="runtimeOptionsClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="runtimeOptionsHint" class="runtime-options-hint">
Arrow keys: select/change · Enter or double-click: apply · Esc:
close
Arrow keys: select/change · Enter or double-click: apply · Esc: close
</div>
<ul id="runtimeOptionsList" class="runtime-options-list"></ul>
<div id="runtimeOptionsStatus" class="runtime-options-status"></div>
@@ -216,29 +159,18 @@
<div class="modal-content subsync-modal-content">
<div class="modal-header">
<div class="modal-title">Auto Subtitle Sync</div>
<button id="subsyncClose" class="modal-close" type="button">
Close
</button>
<button id="subsyncClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="subsync-form">
<div class="subsync-field">
<span>Engine</span>
<label class="subsync-radio">
<input
id="subsyncEngineAlass"
type="radio"
name="subsyncEngine"
checked
/>
<input id="subsyncEngineAlass" type="radio" name="subsyncEngine" checked />
alass
</label>
<label class="subsync-radio">
<input
id="subsyncEngineFfsubsync"
type="radio"
name="subsyncEngine"
/>
<input id="subsyncEngineFfsubsync" type="radio" name="subsyncEngine" />
ffsubsync
</label>
</div>
@@ -249,13 +181,7 @@
</div>
<div id="subsyncStatus" class="runtime-options-status"></div>
<div class="subsync-footer">
<button
id="subsyncRun"
class="kiku-confirm-button"
type="button"
>
Run Sync
</button>
<button id="subsyncRun" class="kiku-confirm-button" type="button">Run Sync</button>
</div>
</div>
</div>
@@ -264,9 +190,7 @@
<div class="modal-content session-help-content">
<div class="modal-header">
<div class="modal-title">Session Help</div>
<button id="sessionHelpClose" class="modal-close" type="button">
Close
</button>
<button id="sessionHelpClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="sessionHelpShortcut" class="session-help-shortcut"></div>

View File

@@ -4,21 +4,21 @@ import type {
JimakuEntry,
JimakuFileEntry,
JimakuMediaInfo,
} from "../../types";
import type { ModalStateReader, RendererContext } from "../context";
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
export function createJimakuModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setJimakuStatus(message: string, isError = false): void {
ctx.dom.jimakuStatus.textContent = message;
ctx.dom.jimakuStatus.style.color = isError
? "rgba(255, 120, 120, 0.95)"
: "rgba(255, 255, 255, 0.8)";
? 'rgba(255, 120, 120, 0.95)'
: 'rgba(255, 255, 255, 0.8)';
}
function resetJimakuLists(): void {
@@ -28,11 +28,11 @@ export function createJimakuModal(
ctx.state.selectedFileIndex = 0;
ctx.state.currentEntryId = null;
ctx.dom.jimakuEntriesList.innerHTML = "";
ctx.dom.jimakuFilesList.innerHTML = "";
ctx.dom.jimakuEntriesSection.classList.add("hidden");
ctx.dom.jimakuFilesSection.classList.add("hidden");
ctx.dom.jimakuBroadenButton.classList.add("hidden");
ctx.dom.jimakuEntriesList.innerHTML = '';
ctx.dom.jimakuFilesList.innerHTML = '';
ctx.dom.jimakuEntriesSection.classList.add('hidden');
ctx.dom.jimakuFilesSection.classList.add('hidden');
ctx.dom.jimakuBroadenButton.classList.add('hidden');
}
function formatEntryLabel(entry: JimakuEntry): string {
@@ -43,29 +43,29 @@ export function createJimakuModal(
}
function renderEntries(): void {
ctx.dom.jimakuEntriesList.innerHTML = "";
ctx.dom.jimakuEntriesList.innerHTML = '';
if (ctx.state.jimakuEntries.length === 0) {
ctx.dom.jimakuEntriesSection.classList.add("hidden");
ctx.dom.jimakuEntriesSection.classList.add('hidden');
return;
}
ctx.dom.jimakuEntriesSection.classList.remove("hidden");
ctx.dom.jimakuEntriesSection.classList.remove('hidden');
ctx.state.jimakuEntries.forEach((entry, index) => {
const li = document.createElement("li");
const li = document.createElement('li');
li.textContent = formatEntryLabel(entry);
if (entry.japanese_name) {
const sub = document.createElement("div");
sub.className = "jimaku-subtext";
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = entry.japanese_name;
li.appendChild(sub);
}
if (index === ctx.state.selectedEntryIndex) {
li.classList.add("active");
li.classList.add('active');
}
li.addEventListener("click", () => {
li.addEventListener('click', () => {
selectEntry(index);
});
@@ -74,8 +74,8 @@ export function createJimakuModal(
}
function formatBytes(size: number): string {
if (!Number.isFinite(size)) return "";
const units = ["B", "KB", "MB", "GB"];
if (!Number.isFinite(size)) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
@@ -86,27 +86,27 @@ export function createJimakuModal(
}
function renderFiles(): void {
ctx.dom.jimakuFilesList.innerHTML = "";
ctx.dom.jimakuFilesList.innerHTML = '';
if (ctx.state.jimakuFiles.length === 0) {
ctx.dom.jimakuFilesSection.classList.add("hidden");
ctx.dom.jimakuFilesSection.classList.add('hidden');
return;
}
ctx.dom.jimakuFilesSection.classList.remove("hidden");
ctx.dom.jimakuFilesSection.classList.remove('hidden');
ctx.state.jimakuFiles.forEach((file, index) => {
const li = document.createElement("li");
const li = document.createElement('li');
li.textContent = file.name;
const sub = document.createElement("div");
sub.className = "jimaku-subtext";
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = `${formatBytes(file.size)}${file.last_modified}`;
li.appendChild(sub);
if (index === ctx.state.selectedFileIndex) {
li.classList.add("active");
li.classList.add('active');
}
li.addEventListener("click", () => {
li.addEventListener('click', () => {
void selectFile(index);
});
@@ -125,20 +125,21 @@ export function createJimakuModal(
async function performJimakuSearch(): Promise<void> {
const { query, episode } = getSearchQuery();
if (!query) {
setJimakuStatus("Enter a title before searching.", true);
setJimakuStatus('Enter a title before searching.', true);
return;
}
resetJimakuLists();
setJimakuStatus("Searching Jimaku...");
setJimakuStatus('Searching Jimaku...');
ctx.state.currentEpisodeFilter = episode;
const response: JimakuApiResponse<JimakuEntry[]> =
await window.electronAPI.jimakuSearchEntries({ query });
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
{ query },
);
if (!response.ok) {
const retry = response.error.retryAfter
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
: "";
: '';
setJimakuStatus(`${response.error.error}${retry}`, true);
return;
}
@@ -147,37 +148,35 @@ export function createJimakuModal(
ctx.state.selectedEntryIndex = 0;
if (ctx.state.jimakuEntries.length === 0) {
setJimakuStatus("No entries found.");
setJimakuStatus('No entries found.');
return;
}
setJimakuStatus("Select an entry.");
setJimakuStatus('Select an entry.');
renderEntries();
if (ctx.state.jimakuEntries.length === 1) {
void selectEntry(0);
}
}
async function loadFiles(
entryId: number,
episode: number | null,
): Promise<void> {
setJimakuStatus("Loading files...");
async function loadFiles(entryId: number, episode: number | null): Promise<void> {
setJimakuStatus('Loading files...');
ctx.state.jimakuFiles = [];
ctx.state.selectedFileIndex = 0;
ctx.dom.jimakuFilesList.innerHTML = "";
ctx.dom.jimakuFilesSection.classList.add("hidden");
ctx.dom.jimakuFilesList.innerHTML = '';
ctx.dom.jimakuFilesSection.classList.add('hidden');
const response: JimakuApiResponse<JimakuFileEntry[]> =
await window.electronAPI.jimakuListFiles({
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
{
entryId,
episode,
});
},
);
if (!response.ok) {
const retry = response.error.retryAfter
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
: "";
: '';
setJimakuStatus(`${response.error.error}${retry}`, true);
return;
}
@@ -185,16 +184,16 @@ export function createJimakuModal(
ctx.state.jimakuFiles = response.data;
if (ctx.state.jimakuFiles.length === 0) {
if (episode !== null) {
setJimakuStatus("No files found for this episode.");
ctx.dom.jimakuBroadenButton.classList.remove("hidden");
setJimakuStatus('No files found for this episode.');
ctx.dom.jimakuBroadenButton.classList.remove('hidden');
} else {
setJimakuStatus("No files found.");
setJimakuStatus('No files found.');
}
return;
}
ctx.dom.jimakuBroadenButton.classList.add("hidden");
setJimakuStatus("Select a subtitle file.");
ctx.dom.jimakuBroadenButton.classList.add('hidden');
setJimakuStatus('Select a subtitle file.');
renderFiles();
if (ctx.state.jimakuFiles.length === 1) {
await selectFile(0);
@@ -220,19 +219,18 @@ export function createJimakuModal(
renderFiles();
if (ctx.state.currentEntryId === null) {
setJimakuStatus("Select an entry first.", true);
setJimakuStatus('Select an entry first.', true);
return;
}
const file = ctx.state.jimakuFiles[index];
setJimakuStatus("Downloading subtitle...");
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}`);
@@ -241,7 +239,7 @@ export function createJimakuModal(
const retry = result.error.retryAfter
? ` Retry after ${result.error.retryAfter.toFixed(1)}s.`
: "";
: '';
setJimakuStatus(`${result.error.error}${retry}`, true);
}
@@ -249,7 +247,7 @@ export function createJimakuModal(
const active = document.activeElement;
if (!active) return false;
const tag = active.tagName.toLowerCase();
return tag === "input" || tag === "textarea";
return tag === 'input' || tag === 'textarea';
}
function openJimakuModal(): void {
@@ -258,35 +256,31 @@ export function createJimakuModal(
ctx.state.jimakuModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.jimakuModal.classList.remove("hidden");
ctx.dom.jimakuModal.setAttribute("aria-hidden", "false");
ctx.dom.overlay.classList.add('interactive');
ctx.dom.jimakuModal.classList.remove('hidden');
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'false');
setJimakuStatus("Loading media info...");
setJimakuStatus('Loading media info...');
resetJimakuLists();
window.electronAPI
.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.jimakuTitleInput.value = info.title || '';
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) {
if (info.confidence === 'high' && info.title && info.episode) {
void performJimakuSearch();
} else if (info.title) {
setJimakuStatus("Check title/season/episode and press Search.");
setJimakuStatus('Check title/season/episode and press Search.');
} else {
setJimakuStatus("Enter title/season/episode and press Search.");
setJimakuStatus('Enter title/season/episode and press Search.');
}
})
.catch(() => {
setJimakuStatus("Failed to load media info.", true);
setJimakuStatus('Failed to load media info.', true);
});
}
@@ -295,36 +289,33 @@ export function createJimakuModal(
ctx.state.jimakuModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.jimakuModal.classList.add("hidden");
ctx.dom.jimakuModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("jimaku");
ctx.dom.jimakuModal.classList.add('hidden');
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('jimaku');
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
resetJimakuLists();
}
function handleJimakuKeydown(e: KeyboardEvent): boolean {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
closeJimakuModal();
return true;
}
if (isTextInputFocused()) {
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
void performJimakuSearch();
}
return true;
}
if (e.key === "ArrowDown") {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (ctx.state.jimakuFiles.length > 0) {
ctx.state.selectedFileIndex = Math.min(
@@ -342,25 +333,19 @@ export function createJimakuModal(
return true;
}
if (e.key === "ArrowUp") {
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;
}
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
if (ctx.state.jimakuFiles.length > 0) {
void selectFile(ctx.state.selectedFileIndex);
@@ -376,15 +361,15 @@ export function createJimakuModal(
}
function wireDomEvents(): void {
ctx.dom.jimakuSearchButton.addEventListener("click", () => {
ctx.dom.jimakuSearchButton.addEventListener('click', () => {
void performJimakuSearch();
});
ctx.dom.jimakuCloseButton.addEventListener("click", () => {
ctx.dom.jimakuCloseButton.addEventListener('click', () => {
closeJimakuModal();
});
ctx.dom.jimakuBroadenButton.addEventListener("click", () => {
ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
if (ctx.state.currentEntryId !== null) {
ctx.dom.jimakuBroadenButton.classList.add("hidden");
ctx.dom.jimakuBroadenButton.classList.add('hidden');
void loadFiles(ctx.state.currentEntryId, null);
}
});

View File

@@ -2,75 +2,64 @@ import type {
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
} from "../../types";
import type { ModalStateReader, RendererContext } from "../context";
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
export function createKikuModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function formatMediaMeta(card: KikuDuplicateCardInfo): string {
const parts: string[] = [];
parts.push(card.hasAudio ? "Audio: Yes" : "Audio: No");
parts.push(card.hasImage ? "Image: Yes" : "Image: No");
return parts.join(" | ");
parts.push(card.hasAudio ? 'Audio: Yes' : 'Audio: No');
parts.push(card.hasImage ? 'Image: Yes' : 'Image: No');
return parts.join(' | ');
}
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 {
function setKikuModalStep(step: 'select' | 'preview'): void {
ctx.state.kikuModalStep = step;
const isSelect = step === "select";
ctx.dom.kikuSelectionStep.classList.toggle("hidden", !isSelect);
ctx.dom.kikuPreviewStep.classList.toggle("hidden", isSelect);
const isSelect = step === 'select';
ctx.dom.kikuSelectionStep.classList.toggle('hidden', !isSelect);
ctx.dom.kikuPreviewStep.classList.toggle('hidden', isSelect);
ctx.dom.kikuHint.textContent = isSelect
? "Press 1 or 2 to select · Enter to continue · Esc to cancel"
: "Enter to confirm merge · Backspace to go back · Esc to cancel";
? 'Press 1 or 2 to select · Enter to continue · Esc to cancel'
: 'Enter to confirm merge · Backspace to go back · Esc to cancel';
}
function updateKikuPreviewToggle(): void {
ctx.dom.kikuPreviewCompactButton.classList.toggle(
"active",
ctx.state.kikuPreviewMode === "compact",
);
ctx.dom.kikuPreviewFullButton.classList.toggle(
"active",
ctx.state.kikuPreviewMode === "full",
'active',
ctx.state.kikuPreviewMode === 'compact',
);
ctx.dom.kikuPreviewFullButton.classList.toggle('active', ctx.state.kikuPreviewMode === 'full');
}
function renderKikuPreview(): void {
const payload =
ctx.state.kikuPreviewMode === "compact"
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();
}
function setKikuPreviewError(message: string | null): void {
if (!message) {
ctx.dom.kikuPreviewError.textContent = "";
ctx.dom.kikuPreviewError.classList.add("hidden");
ctx.dom.kikuPreviewError.textContent = '';
ctx.dom.kikuPreviewError.classList.add('hidden');
return;
}
ctx.dom.kikuPreviewError.textContent = message;
ctx.dom.kikuPreviewError.classList.remove("hidden");
ctx.dom.kikuPreviewError.classList.remove('hidden');
}
function openKikuFieldGroupingModal(data: {
@@ -86,30 +75,28 @@ 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;
ctx.dom.kikuCard2Sentence.textContent =
data.duplicate.sentencePreview || "(current subtitle)";
ctx.dom.kikuCard2Sentence.textContent = data.duplicate.sentencePreview || '(current subtitle)';
ctx.dom.kikuCard2Meta.textContent = formatMediaMeta(data.duplicate);
ctx.dom.kikuDeleteDuplicateCheckbox.checked = true;
ctx.state.kikuPendingChoice = null;
ctx.state.kikuPreviewCompactData = null;
ctx.state.kikuPreviewFullData = null;
ctx.state.kikuPreviewMode = "compact";
ctx.state.kikuPreviewMode = 'compact';
renderKikuPreview();
setKikuPreviewError(null);
setKikuModalStep("select");
setKikuModalStep('select');
updateKikuCardSelection();
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.kikuModal.classList.remove("hidden");
ctx.dom.kikuModal.setAttribute("aria-hidden", "false");
ctx.dom.overlay.classList.add('interactive');
ctx.dom.kikuModal.classList.remove('hidden');
ctx.dom.kikuModal.setAttribute('aria-hidden', 'false');
}
function closeKikuFieldGroupingModal(): void {
@@ -118,25 +105,22 @@ export function createKikuModal(
ctx.state.kikuModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.kikuModal.classList.add("hidden");
ctx.dom.kikuModal.setAttribute("aria-hidden", "true");
ctx.dom.kikuModal.classList.add('hidden');
ctx.dom.kikuModal.setAttribute('aria-hidden', 'true');
setKikuPreviewError(null);
ctx.dom.kikuPreviewJson.textContent = "";
ctx.dom.kikuPreviewJson.textContent = '';
ctx.state.kikuPendingChoice = null;
ctx.state.kikuPreviewCompactData = null;
ctx.state.kikuPreviewFullData = null;
ctx.state.kikuPreviewMode = "compact";
setKikuModalStep("select");
ctx.state.kikuPreviewMode = 'compact';
setKikuModalStep('select');
ctx.state.kikuOriginalData = null;
ctx.state.kikuDuplicateData = null;
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
@@ -144,13 +128,9 @@ export function createKikuModal(
if (!ctx.state.kikuOriginalData || !ctx.state.kikuDuplicateData) return;
const keepData =
ctx.state.kikuSelectedCard === 1
? ctx.state.kikuOriginalData
: ctx.state.kikuDuplicateData;
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuOriginalData : ctx.state.kikuDuplicateData;
const deleteData =
ctx.state.kikuSelectedCard === 1
? ctx.state.kikuDuplicateData
: ctx.state.kikuOriginalData;
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuDuplicateData : ctx.state.kikuOriginalData;
const choice: KikuFieldGroupingChoice = {
keepNoteId: keepData.noteId,
@@ -164,23 +144,22 @@ export function createKikuModal(
ctx.dom.kikuConfirmButton.disabled = true;
try {
const preview: KikuMergePreviewResponse =
await window.electronAPI.kikuBuildMergePreview({
keepNoteId: choice.keepNoteId,
deleteNoteId: choice.deleteNoteId,
deleteDuplicate: choice.deleteDuplicate,
});
const preview: KikuMergePreviewResponse = await window.electronAPI.kikuBuildMergePreview({
keepNoteId: choice.keepNoteId,
deleteNoteId: choice.deleteNoteId,
deleteDuplicate: choice.deleteDuplicate,
});
if (!preview.ok) {
setKikuPreviewError(preview.error || "Failed to build merge preview");
setKikuPreviewError(preview.error || 'Failed to build merge preview');
return;
}
ctx.state.kikuPreviewCompactData = preview.compact || {};
ctx.state.kikuPreviewFullData = preview.full || {};
ctx.state.kikuPreviewMode = "compact";
ctx.state.kikuPreviewMode = 'compact';
renderKikuPreview();
setKikuModalStep("preview");
setKikuModalStep('preview');
} finally {
ctx.dom.kikuConfirmButton.disabled = false;
}
@@ -194,7 +173,7 @@ export function createKikuModal(
function goBackFromKikuPreview(): void {
setKikuPreviewError(null);
setKikuModalStep("select");
setKikuModalStep('select');
}
function cancelKikuFieldGrouping(): void {
@@ -210,18 +189,18 @@ export function createKikuModal(
}
function handleKikuKeydown(e: KeyboardEvent): boolean {
if (ctx.state.kikuModalStep === "preview") {
if (e.key === "Escape") {
if (ctx.state.kikuModalStep === 'preview') {
if (e.key === 'Escape') {
e.preventDefault();
cancelKikuFieldGrouping();
return true;
}
if (e.key === "Backspace") {
if (e.key === 'Backspace') {
e.preventDefault();
goBackFromKikuPreview();
return true;
}
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
confirmKikuMerge();
return true;
@@ -229,34 +208,34 @@ export function createKikuModal(
return true;
}
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
cancelKikuFieldGrouping();
return true;
}
if (e.key === "1") {
if (e.key === '1') {
e.preventDefault();
ctx.state.kikuSelectedCard = 1;
updateKikuCardSelection();
return true;
}
if (e.key === "2") {
if (e.key === '2') {
e.preventDefault();
ctx.state.kikuSelectedCard = 2;
updateKikuCardSelection();
return true;
}
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
ctx.state.kikuSelectedCard = ctx.state.kikuSelectedCard === 1 ? 2 : 1;
updateKikuCardSelection();
return true;
}
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
void confirmKikuSelection();
return true;
@@ -266,46 +245,46 @@ export function createKikuModal(
}
function wireDomEvents(): void {
ctx.dom.kikuCard1.addEventListener("click", () => {
ctx.dom.kikuCard1.addEventListener('click', () => {
ctx.state.kikuSelectedCard = 1;
updateKikuCardSelection();
});
ctx.dom.kikuCard1.addEventListener("dblclick", () => {
ctx.dom.kikuCard1.addEventListener('dblclick', () => {
ctx.state.kikuSelectedCard = 1;
void confirmKikuSelection();
});
ctx.dom.kikuCard2.addEventListener("click", () => {
ctx.dom.kikuCard2.addEventListener('click', () => {
ctx.state.kikuSelectedCard = 2;
updateKikuCardSelection();
});
ctx.dom.kikuCard2.addEventListener("dblclick", () => {
ctx.dom.kikuCard2.addEventListener('dblclick', () => {
ctx.state.kikuSelectedCard = 2;
void confirmKikuSelection();
});
ctx.dom.kikuConfirmButton.addEventListener("click", () => {
ctx.dom.kikuConfirmButton.addEventListener('click', () => {
void confirmKikuSelection();
});
ctx.dom.kikuCancelButton.addEventListener("click", () => {
ctx.dom.kikuCancelButton.addEventListener('click', () => {
cancelKikuFieldGrouping();
});
ctx.dom.kikuBackButton.addEventListener("click", () => {
ctx.dom.kikuBackButton.addEventListener('click', () => {
goBackFromKikuPreview();
});
ctx.dom.kikuFinalConfirmButton.addEventListener("click", () => {
ctx.dom.kikuFinalConfirmButton.addEventListener('click', () => {
confirmKikuMerge();
});
ctx.dom.kikuFinalCancelButton.addEventListener("click", () => {
ctx.dom.kikuFinalCancelButton.addEventListener('click', () => {
cancelKikuFieldGrouping();
});
ctx.dom.kikuPreviewCompactButton.addEventListener("click", () => {
ctx.state.kikuPreviewMode = "compact";
ctx.dom.kikuPreviewCompactButton.addEventListener('click', () => {
ctx.state.kikuPreviewMode = 'compact';
renderKikuPreview();
});
ctx.dom.kikuPreviewFullButton.addEventListener("click", () => {
ctx.state.kikuPreviewMode = "full";
ctx.dom.kikuPreviewFullButton.addEventListener('click', () => {
ctx.state.kikuPreviewMode = 'full';
renderKikuPreview();
});
}

View File

@@ -1,90 +1,79 @@
import type {
RuntimeOptionApplyResult,
RuntimeOptionState,
RuntimeOptionValue,
} from "../../types";
import type { ModalStateReader, RendererContext } from "../context";
import type { RuntimeOptionApplyResult, RuntimeOptionState, RuntimeOptionValue } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
export function createRuntimeOptionsModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
if (typeof value === "boolean") {
return value ? "On" : "Off";
if (typeof value === 'boolean') {
return value ? 'On' : 'Off';
}
return value;
}
function setRuntimeOptionsStatus(message: string, isError = false): void {
ctx.dom.runtimeOptionsStatus.textContent = message;
ctx.dom.runtimeOptionsStatus.classList.toggle("error", isError);
ctx.dom.runtimeOptionsStatus.classList.toggle('error', isError);
}
function getRuntimeOptionDisplayValue(
option: RuntimeOptionState,
): RuntimeOptionValue {
function getRuntimeOptionDisplayValue(option: RuntimeOptionState): RuntimeOptionValue {
return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value;
}
function getSelectedRuntimeOption(): RuntimeOptionState | null {
if (ctx.state.runtimeOptions.length === 0) return null;
if (ctx.state.runtimeOptionSelectedIndex < 0) return null;
if (
ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length
) {
if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) {
return null;
}
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex];
}
function renderRuntimeOptionsList(): void {
ctx.dom.runtimeOptionsList.innerHTML = "";
ctx.dom.runtimeOptionsList.innerHTML = '';
ctx.state.runtimeOptions.forEach((option, index) => {
const li = document.createElement("li");
li.className = "runtime-options-item";
li.classList.toggle(
"active",
index === ctx.state.runtimeOptionSelectedIndex,
);
const li = document.createElement('li');
li.className = 'runtime-options-item';
li.classList.toggle('active', index === ctx.state.runtimeOptionSelectedIndex);
const label = document.createElement("div");
label.className = "runtime-options-label";
const label = document.createElement('div');
label.className = 'runtime-options-label';
label.textContent = option.label;
const value = document.createElement("div");
value.className = "runtime-options-value";
const value = document.createElement('div');
value.className = 'runtime-options-value';
value.textContent = `Value: ${formatRuntimeOptionValue(getRuntimeOptionDisplayValue(option))}`;
value.title = "Click to cycle value, right-click to cycle backward";
value.title = 'Click to cycle value, right-click to cycle backward';
const allowed = document.createElement("div");
allowed.className = "runtime-options-allowed";
const allowed = document.createElement('div');
allowed.className = 'runtime-options-allowed';
allowed.textContent = `Allowed: ${option.allowedValues
.map((entry) => formatRuntimeOptionValue(entry))
.join(" | ")}`;
.join(' | ')}`;
li.appendChild(label);
li.appendChild(value);
li.appendChild(allowed);
li.addEventListener("click", () => {
li.addEventListener('click', () => {
ctx.state.runtimeOptionSelectedIndex = index;
renderRuntimeOptionsList();
});
li.addEventListener("dblclick", () => {
li.addEventListener('dblclick', () => {
ctx.state.runtimeOptionSelectedIndex = index;
void applySelectedRuntimeOption();
});
value.addEventListener("click", (event) => {
value.addEventListener('click', (event) => {
event.stopPropagation();
ctx.state.runtimeOptionSelectedIndex = index;
cycleRuntimeDraftValue(1);
});
value.addEventListener("contextmenu", (event) => {
value.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
ctx.state.runtimeOptionSelectedIndex = index;
@@ -107,9 +96,7 @@ export function createRuntimeOptionsModal(
ctx.state.runtimeOptionDraftValues.set(option.id, option.value);
}
const nextIndex = ctx.state.runtimeOptions.findIndex(
(option) => option.id === previousId,
);
const nextIndex = ctx.state.runtimeOptions.findIndex((option) => option.id === previousId);
ctx.state.runtimeOptionSelectedIndex = nextIndex >= 0 ? nextIndex : 0;
renderRuntimeOptionsList();
@@ -120,20 +107,14 @@ export function createRuntimeOptionsModal(
if (!option || option.allowedValues.length === 0) return;
const currentValue = getRuntimeOptionDisplayValue(option);
const currentIndex = option.allowedValues.findIndex(
(value) => value === currentValue,
);
const currentIndex = option.allowedValues.findIndex((value) => value === currentValue);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex =
direction === 1
? (safeIndex + 1) % option.allowedValues.length
: (safeIndex - 1 + option.allowedValues.length) %
option.allowedValues.length;
: (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length;
ctx.state.runtimeOptionDraftValues.set(
option.id,
option.allowedValues[nextIndex],
);
ctx.state.runtimeOptionDraftValues.set(option.id, option.allowedValues[nextIndex]);
renderRuntimeOptionsList();
setRuntimeOptionsStatus(
`Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`,
@@ -145,23 +126,22 @@ export function createRuntimeOptionsModal(
if (!option) return;
const nextValue = getRuntimeOptionDisplayValue(option);
const result: RuntimeOptionApplyResult =
await window.electronAPI.setRuntimeOptionValue(option.id, nextValue);
const result: RuntimeOptionApplyResult = await window.electronAPI.setRuntimeOptionValue(
option.id,
nextValue,
);
if (!result.ok) {
setRuntimeOptionsStatus(result.error || "Failed to apply option", true);
setRuntimeOptionsStatus(result.error || 'Failed to apply option', true);
return;
}
if (result.option) {
ctx.state.runtimeOptionDraftValues.set(
result.option.id,
result.option.value,
);
ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value);
}
const latest = await window.electronAPI.getRuntimeOptions();
updateRuntimeOptions(latest);
setRuntimeOptionsStatus(result.osdMessage || "Option applied.");
setRuntimeOptionsStatus(result.osdMessage || 'Option applied.');
}
function closeRuntimeOptionsModal(): void {
@@ -170,17 +150,14 @@ export function createRuntimeOptionsModal(
ctx.state.runtimeOptionsModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.runtimeOptionsModal.classList.add("hidden");
ctx.dom.runtimeOptionsModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("runtime-options");
ctx.dom.runtimeOptionsModal.classList.add('hidden');
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('runtime-options');
setRuntimeOptionsStatus("");
setRuntimeOptionsStatus('');
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
@@ -193,27 +170,27 @@ export function createRuntimeOptionsModal(
ctx.state.runtimeOptionsModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.runtimeOptionsModal.classList.remove("hidden");
ctx.dom.runtimeOptionsModal.setAttribute("aria-hidden", "false");
ctx.dom.overlay.classList.add('interactive');
ctx.dom.runtimeOptionsModal.classList.remove('hidden');
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
setRuntimeOptionsStatus(
"Use arrow keys. Click value to cycle. Enter or double-click to apply.",
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
}
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
closeRuntimeOptionsModal();
return true;
}
if (
e.key === "ArrowDown" ||
e.key === "j" ||
e.key === "J" ||
(e.ctrlKey && (e.key === "n" || e.key === "N"))
e.key === 'ArrowDown' ||
e.key === 'j' ||
e.key === 'J' ||
(e.ctrlKey && (e.key === 'n' || e.key === 'N'))
) {
e.preventDefault();
if (ctx.state.runtimeOptions.length > 0) {
@@ -227,10 +204,10 @@ export function createRuntimeOptionsModal(
}
if (
e.key === "ArrowUp" ||
e.key === "k" ||
e.key === "K" ||
(e.ctrlKey && (e.key === "p" || e.key === "P"))
e.key === 'ArrowUp' ||
e.key === 'k' ||
e.key === 'K' ||
(e.ctrlKey && (e.key === 'p' || e.key === 'P'))
) {
e.preventDefault();
if (ctx.state.runtimeOptions.length > 0) {
@@ -243,19 +220,19 @@ export function createRuntimeOptionsModal(
return true;
}
if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") {
if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
e.preventDefault();
cycleRuntimeDraftValue(1);
return true;
}
if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") {
if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') {
e.preventDefault();
cycleRuntimeDraftValue(-1);
return true;
}
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
void applySelectedRuntimeOption();
return true;
@@ -265,7 +242,7 @@ export function createRuntimeOptionsModal(
}
function wireDomEvents(): void {
ctx.dom.runtimeOptionsClose.addEventListener("click", () => {
ctx.dom.runtimeOptionsClose.addEventListener('click', () => {
closeRuntimeOptionsModal();
});
}

View File

@@ -1,10 +1,10 @@
import type { Keybinding } from "../../types";
import type { ShortcutsConfig } from "../../types";
import { SPECIAL_COMMANDS } from "../../config/definitions";
import type { ModalStateReader, RendererContext } from "../context";
import type { Keybinding } from '../../types';
import type { ShortcutsConfig } from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import type { ModalStateReader, RendererContext } from '../context';
type SessionHelpBindingInfo = {
bindingKey: "KeyH" | "KeyK";
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
fallbackUnavailable: boolean;
};
@@ -19,98 +19,92 @@ type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
type RuntimeShortcutConfig = Omit<
Required<ShortcutsConfig>,
"multiCopyTimeoutMs"
>;
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, 'multiCopyTimeoutMs'>;
const HEX_COLOR_RE =
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
const FALLBACK_COLORS = {
knownWordColor: "#a6da95",
nPlusOneColor: "#c6a0f6",
jlptN1Color: "#ed8796",
jlptN2Color: "#f5a97f",
jlptN3Color: "#f9e2af",
jlptN4Color: "#a6e3a1",
jlptN5Color: "#8aadf4",
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
};
const KEY_NAME_MAP: Record<string, string> = {
Space: "Space",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Escape: "Esc",
Tab: "Tab",
Enter: "Enter",
CommandOrControl: "Cmd/Ctrl",
Ctrl: "Ctrl",
Control: "Ctrl",
Command: "Cmd",
Cmd: "Cmd",
Shift: "Shift",
Alt: "Alt",
Super: "Meta",
Meta: "Meta",
Backspace: "Backspace",
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Cmd: 'Cmd',
Shift: 'Shift',
Alt: 'Alt',
Super: 'Meta',
Meta: 'Meta',
Backspace: 'Backspace',
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== "string") return fallback;
if (typeof value !== 'string') return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith("Key")) return token.slice(3);
if (token.startsWith("Digit")) return token.slice(5);
if (token.startsWith("Numpad")) return token.slice(6);
if (token.startsWith('Key')) return token.slice(3);
if (token.startsWith('Digit')) return token.slice(5);
if (token.startsWith('Numpad')) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split("+");
const parts = rawBinding.split('+');
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
return normalized.join(" + ");
return normalized.join(' + ');
}
const OVERLAY_SHORTCUTS: Array<{
key: keyof RuntimeShortcutConfig;
label: string;
}> = [
{ key: "copySubtitle", label: "Copy subtitle" },
{ key: "copySubtitleMultiple", label: "Copy subtitle (multi)" },
{ key: 'copySubtitle', label: 'Copy subtitle' },
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
{
key: "updateLastCardFromClipboard",
label: "Update last card from clipboard",
key: 'updateLastCardFromClipboard',
label: 'Update last card from clipboard',
},
{ key: "triggerFieldGrouping", label: "Trigger field grouping" },
{ key: "triggerSubsync", label: "Open subtitle sync controls" },
{ key: "mineSentence", label: "Mine sentence" },
{ key: "mineSentenceMultiple", label: "Mine sentence (multi)" },
{ key: "toggleSecondarySub", label: "Toggle secondary subtitle mode" },
{ key: "markAudioCard", label: "Mark audio card" },
{ key: "openRuntimeOptions", label: "Open runtime options" },
{ key: "openJimaku", label: "Open jimaku" },
{ key: "toggleVisibleOverlayGlobal", label: "Show/hide visible overlay" },
{ key: "toggleInvisibleOverlayGlobal", label: "Show/hide invisible overlay" },
{ key: 'triggerFieldGrouping', label: 'Trigger field grouping' },
{ key: 'triggerSubsync', label: 'Open subtitle sync controls' },
{ key: 'mineSentence', label: 'Mine sentence' },
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
{ key: 'markAudioCard', label: 'Mark audio card' },
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
{ key: 'toggleInvisibleOverlayGlobal', label: 'Show/hide invisible overlay' },
];
function buildOverlayShortcutSections(
shortcuts: RuntimeShortcutConfig,
): SessionHelpSection[] {
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {
const rows: SessionHelpItem[] = [];
for (const shortcut of OVERLAY_SHORTCUTS) {
const keybind = shortcuts[shortcut.key];
if (typeof keybind !== "string") continue;
if (typeof keybind !== 'string') continue;
if (keybind.trim().length === 0) continue;
rows.push({
@@ -120,71 +114,63 @@ function buildOverlayShortcutSections(
}
if (rows.length === 0) return [];
return [{ title: "Overlay shortcuts", rows }];
return [{ title: 'Overlay shortcuts', rows }];
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== "string") return "Unknown action";
if (typeof first !== 'string') return 'Unknown action';
if (first === "cycle" && command[1] === "pause") return "Toggle playback";
if (first === "seek" && typeof command[1] === "number") {
return `Seek ${command[1] > 0 ? "+" : ""}${command[1]} second(s)`;
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
if (first === 'seek' && typeof command[1] === 'number') {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === "sub-seek" && typeof command[1] === "number") {
if (first === 'sub-seek' && typeof command[1] === 'number') {
return `Shift subtitle by ${command[1]} ms`;
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER)
return "Open subtitle sync controls";
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN)
return "Open runtime options";
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE)
return "Replay current subtitle";
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE)
return "Play next subtitle";
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(":");
return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`;
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(" ")}`;
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== "string") return "Other shortcuts";
if (typeof first !== 'string') return 'Other shortcuts';
if (
first === "cycle" ||
first === "seek" ||
first === "sub-seek" ||
first === 'cycle' ||
first === 'seek' ||
first === 'sub-seek' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return "Playback and navigation";
return 'Playback and navigation';
}
if (
first === "show-text" ||
first === "show-progress" ||
first.startsWith("osd")
) {
return "Visual feedback";
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
return 'Visual feedback';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return "Subtitle sync";
return 'Subtitle sync';
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return "Runtime settings";
return 'Runtime settings';
}
if (first === "quit") return "System actions";
return "Other shortcuts";
if (first === 'quit') return 'System actions';
return 'Other shortcuts';
}
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
@@ -200,12 +186,12 @@ function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
}
const sectionOrder = [
"Playback and navigation",
"Visual feedback",
"Subtitle sync",
"Runtime settings",
"System actions",
"Other shortcuts",
'Playback and navigation',
'Visual feedback',
'Subtitle sync',
'Runtime settings',
'System actions',
'Other shortcuts',
];
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
@@ -231,99 +217,54 @@ function buildColorSection(style: {
};
}): SessionHelpSection {
return {
title: "Color legend",
title: 'Color legend',
rows: [
{
shortcut: "Known words",
action: normalizeColor(
style.knownWordColor,
FALLBACK_COLORS.knownWordColor,
),
color: normalizeColor(
style.knownWordColor,
FALLBACK_COLORS.knownWordColor,
),
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: "N+1 words",
action: normalizeColor(
style.nPlusOneColor,
FALLBACK_COLORS.nPlusOneColor,
),
color: normalizeColor(
style.nPlusOneColor,
FALLBACK_COLORS.nPlusOneColor,
),
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: "JLPT N1",
action: normalizeColor(
style.jlptColors?.N1,
FALLBACK_COLORS.jlptN1Color,
),
color: normalizeColor(
style.jlptColors?.N1,
FALLBACK_COLORS.jlptN1Color,
),
shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: "JLPT N2",
action: normalizeColor(
style.jlptColors?.N2,
FALLBACK_COLORS.jlptN2Color,
),
color: normalizeColor(
style.jlptColors?.N2,
FALLBACK_COLORS.jlptN2Color,
),
shortcut: 'JLPT N2',
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: "JLPT N3",
action: normalizeColor(
style.jlptColors?.N3,
FALLBACK_COLORS.jlptN3Color,
),
color: normalizeColor(
style.jlptColors?.N3,
FALLBACK_COLORS.jlptN3Color,
),
shortcut: 'JLPT N3',
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: "JLPT N4",
action: normalizeColor(
style.jlptColors?.N4,
FALLBACK_COLORS.jlptN4Color,
),
color: normalizeColor(
style.jlptColors?.N4,
FALLBACK_COLORS.jlptN4Color,
),
shortcut: 'JLPT N4',
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: "JLPT N5",
action: normalizeColor(
style.jlptColors?.N5,
FALLBACK_COLORS.jlptN5Color,
),
color: normalizeColor(
style.jlptColors?.N5,
FALLBACK_COLORS.jlptN5Color,
),
shortcut: 'JLPT N5',
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
function filterSections(
sections: SessionHelpSection[],
query: string,
): SessionHelpSection[] {
function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, "ctrl")
.replace(/cmd\/ctrl/gu, "ctrl")
.replace(/[\s+\-_/]/gu, "");
.replace(/commandorcontrol/gu, 'ctrl')
.replace(/cmd\/ctrl/gu, 'ctrl')
.replace(/[\s+\-_/]/gu, '');
const normalized = normalize(query);
if (!normalized) return sections;
@@ -346,41 +287,36 @@ function filterSections(
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === "KeyK" && info.fallbackUsed) {
return info.fallbackUnavailable
? "Y-K (fallback and conflict noted)"
: "Y-K (fallback)";
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
}
return "Y-H";
return 'Y-H';
}
function createShortcutRow(
row: SessionHelpItem,
globalIndex: number,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "session-help-item";
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-item';
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement("div");
left.className = "session-help-item-left";
const shortcut = document.createElement("span");
shortcut.className = "session-help-key";
const left = document.createElement('div');
left.className = 'session-help-item-left';
const shortcut = document.createElement('span');
shortcut.className = 'session-help-key';
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement("div");
right.className = "session-help-item-right";
const action = document.createElement("span");
action.className = "session-help-action";
const right = document.createElement('div');
right.className = 'session-help-item-right';
const action = document.createElement('span');
action.className = 'session-help-action';
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement("span");
dot.className = "session-help-color-dot";
const dot = document.createElement('span');
dot.className = 'session-help-color-dot';
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
@@ -391,16 +327,16 @@ function createShortcutRow(
}
const SECTION_ICON: Record<string, string> = {
"MPV shortcuts": "⚙",
"Playback and navigation": "▶",
"Visual feedback": "◉",
"Subtitle sync": "⟲",
"Runtime settings": "⚙",
"System actions": "◆",
"Other shortcuts": "…",
"Overlay shortcuts (configurable)": "✦",
"Overlay shortcuts": "✦",
"Color legend": "◈",
'MPV shortcuts': '⚙',
'Playback and navigation': '▶',
'Visual feedback': '◉',
'Subtitle sync': '⟲',
'Runtime settings': '⚙',
'System actions': '◆',
'Other shortcuts': '…',
'Overlay shortcuts (configurable)': '✦',
'Overlay shortcuts': '✦',
'Color legend': '◈',
};
function createSectionNode(
@@ -408,17 +344,17 @@ function createSectionNode(
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement("section");
sectionNode.className = "session-help-section";
const sectionNode = document.createElement('section');
sectionNode.className = 'session-help-section';
const title = document.createElement("h3");
title.className = "session-help-section-title";
const icon = SECTION_ICON[section.title] ?? "•";
const title = document.createElement('h3');
title.className = 'session-help-section-title';
const icon = SECTION_ICON[section.title] ?? '•';
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement("div");
list.className = "session-help-item-list";
const list = document.createElement('div');
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = globalIndexMap[sectionIndex] + rowIndex;
@@ -433,17 +369,17 @@ function createSectionNode(
export function createSessionHelpModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let priorFocus: Element | null = null;
let openBinding: SessionHelpBindingInfo = {
bindingKey: "KeyH",
bindingKey: 'KeyH',
fallbackUsed: false,
fallbackUnavailable: false,
};
let helpFilterValue = "";
let helpFilterValue = '';
let helpSections: SessionHelpSection[] = [];
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
@@ -453,7 +389,7 @@ export function createSessionHelpModal(
function getItems(): HTMLButtonElement[] {
return Array.from(
ctx.dom.sessionHelpContent.querySelectorAll(".session-help-item"),
ctx.dom.sessionHelpContent.querySelectorAll('.session-help-item'),
) as HTMLButtonElement[];
}
@@ -466,21 +402,19 @@ export function createSessionHelpModal(
ctx.state.sessionHelpSelectedIndex = next;
items.forEach((item, idx) => {
item.classList.toggle("active", idx === next);
item.classList.toggle('active', idx === next);
item.tabIndex = idx === next ? 0 : -1;
});
const activeItem = items[next];
activeItem.focus({ preventScroll: true });
activeItem.scrollIntoView({
block: "nearest",
inline: "nearest",
block: 'nearest',
inline: 'nearest',
});
}
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
return (
target instanceof Element && ctx.dom.sessionHelpModal.contains(target)
);
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
}
function focusFallbackTarget(): boolean {
@@ -537,30 +471,22 @@ export function createSessionHelpModal(
running += section.rows.length;
}
ctx.dom.sessionHelpContent.innerHTML = "";
ctx.dom.sessionHelpContent.innerHTML = '';
sections.forEach((section, sectionIndex) => {
const sectionNode = createSectionNode(
section,
sectionIndex,
indexOffsets,
);
const sectionNode = createSectionNode(section, sectionIndex, indexOffsets);
ctx.dom.sessionHelpContent.appendChild(sectionNode);
});
if (getItems().length === 0) {
ctx.dom.sessionHelpContent.classList.add(
"session-help-content-no-results",
);
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = helpFilterValue
? "No matching shortcuts found."
: "No active session shortcuts found.";
? 'No matching shortcuts found.'
: 'No active session shortcuts found.';
ctx.state.sessionHelpSelectedIndex = 0;
return;
}
ctx.dom.sessionHelpContent.classList.remove(
"session-help-content-no-results",
);
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
if (isFilterInputFocused()) return;
@@ -578,23 +504,14 @@ export function createSessionHelpModal(
requestOverlayFocus();
enforceModalFocus();
};
ctx.dom.sessionHelpModal.addEventListener(
"pointerdown",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener(
"pointerdown",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.removeEventListener(
"click",
modalPointerFocusGuard,
);
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener('click', modalPointerFocusGuard);
modalPointerFocusGuard = null;
}
@@ -605,22 +522,22 @@ export function createSessionHelpModal(
requestOverlayFocus();
enforceModalFocus();
};
window.addEventListener("blur", windowFocusGuard);
window.addEventListener("focus", windowFocusGuard);
window.addEventListener('blur', windowFocusGuard);
window.addEventListener('focus', windowFocusGuard);
}
function stopFocusRecoveryGuards(): void {
if (!windowFocusGuard) return;
window.removeEventListener("blur", windowFocusGuard);
window.removeEventListener("focus", windowFocusGuard);
window.removeEventListener('blur', windowFocusGuard);
window.removeEventListener('focus', windowFocusGuard);
windowFocusGuard = null;
}
function showRenderError(message: string): void {
helpSections = [];
helpFilterValue = "";
ctx.dom.sessionHelpFilter.value = "";
ctx.dom.sessionHelpContent.classList.add("session-help-content-no-results");
helpFilterValue = '';
ctx.dom.sessionHelpFilter.value = '';
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = message;
ctx.state.sessionHelpSelectedIndex = 0;
}
@@ -636,34 +553,29 @@ export function createSessionHelpModal(
const bindingSections = buildBindingSections(keybindings);
if (bindingSections.length > 0) {
const playback = bindingSections.find(
(section) => section.title === "Playback and navigation",
(section) => section.title === 'Playback and navigation',
);
if (playback) {
playback.title = "MPV shortcuts";
playback.title = 'MPV shortcuts';
}
}
const shortcutSections = buildOverlayShortcutSections(shortcuts);
if (shortcutSections.length > 0) {
shortcutSections[0].title = "Overlay shortcuts (configurable)";
shortcutSections[0].title = 'Overlay shortcuts (configurable)';
}
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];
applyFilterAndRender();
return true;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Unable to load session help data.";
const message = error instanceof Error ? error.message : 'Unable to load session help data.';
showRenderError(`Session help failed to load: ${message}`);
return false;
}
}
async function openSessionHelpModal(
opening: SessionHelpBindingInfo,
): Promise<void> {
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
openBinding = opening;
priorFocus = document.activeElement;
@@ -672,31 +584,30 @@ export function createSessionHelpModal(
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) {
ctx.dom.sessionHelpWarning.textContent =
"Both Y-H and Y-K are bound; Y-K remains the fallback for this session.";
'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.';
} else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent =
"Y-H is already bound; using Y-K as fallback.";
ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.';
} else {
ctx.dom.sessionHelpWarning.textContent = "";
ctx.dom.sessionHelpWarning.textContent = '';
}
if (dataLoaded) {
ctx.dom.sessionHelpStatus.textContent =
"Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.";
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
} else {
ctx.dom.sessionHelpStatus.textContent =
"Session help data is unavailable right now. Press Esc to close.";
'Session help data is unavailable right now. Press Esc to close.';
ctx.dom.sessionHelpWarning.textContent =
"Unable to load latest shortcut settings from the runtime.";
'Unable to load latest shortcut settings from the runtime.';
}
ctx.state.sessionHelpModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.sessionHelpModal.classList.remove("hidden");
ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "false");
ctx.dom.sessionHelpModal.setAttribute("tabindex", "-1");
ctx.dom.sessionHelpFilter.value = "";
helpFilterValue = "";
ctx.dom.overlay.classList.add('interactive');
ctx.dom.sessionHelpModal.classList.remove('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
ctx.dom.sessionHelpFilter.value = '';
helpFilterValue = '';
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
@@ -709,7 +620,7 @@ export function createSessionHelpModal(
enforceModalFocus();
}
};
document.addEventListener("focusin", focusGuard);
document.addEventListener('focusin', focusGuard);
}
addPointerFocusListener();
@@ -724,17 +635,14 @@ export function createSessionHelpModal(
ctx.state.sessionHelpModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add("hidden");
ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "true");
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
ctx.dom.sessionHelpModal.classList.add('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
if (focusGuard) {
document.removeEventListener("focusin", focusGuard);
document.removeEventListener('focusin', focusGuard);
focusGuard = null;
}
removePointerFocusListener();
@@ -750,17 +658,14 @@ export function createSessionHelpModal(
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
ctx.dom.sessionHelpFilter.value = "";
helpFilterValue = "";
ctx.dom.sessionHelpFilter.value = '';
helpFilterValue = '';
window.focus();
}
@@ -768,15 +673,15 @@ export function createSessionHelpModal(
if (!ctx.state.sessionHelpModalOpen) return false;
if (isFilterInputFocused()) {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
if (!helpFilterValue) {
closeSessionHelpModal();
return true;
}
helpFilterValue = "";
ctx.dom.sessionHelpFilter.value = "";
helpFilterValue = '';
ctx.dom.sessionHelpFilter.value = '';
applyFilterAndRender();
focusFallbackTarget();
return true;
@@ -784,7 +689,7 @@ export function createSessionHelpModal(
return false;
}
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
closeSessionHelpModal();
return true;
@@ -793,7 +698,7 @@ export function createSessionHelpModal(
const items = getItems();
if (items.length === 0) return true;
if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
focusFilterInput();
return true;
@@ -801,13 +706,13 @@ export function createSessionHelpModal(
const key = e.key.toLowerCase();
if (key === "arrowdown" || key === "j" || key === "l") {
if (key === 'arrowdown' || key === 'j' || key === 'l') {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
return true;
}
if (key === "arrowup" || key === "k" || key === "h") {
if (key === 'arrowup' || key === 'k' || key === 'h') {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
return true;
@@ -817,35 +722,29 @@ export function createSessionHelpModal(
}
function wireDomEvents(): void {
ctx.dom.sessionHelpFilter.addEventListener("input", () => {
ctx.dom.sessionHelpFilter.addEventListener('input', () => {
helpFilterValue = ctx.dom.sessionHelpFilter.value;
applyFilterAndRender();
});
ctx.dom.sessionHelpFilter.addEventListener(
"keydown",
(event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
focusFallbackTarget();
}
},
);
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
focusFallbackTarget();
}
});
ctx.dom.sessionHelpContent.addEventListener(
"click",
(event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest(".session-help-item") as HTMLElement | null;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return;
setSelected(index);
},
);
ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest('.session-help-item') as HTMLElement | null;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
if (!Number.isFinite(index)) return;
setSelected(index);
});
ctx.dom.sessionHelpClose.addEventListener("click", () => {
ctx.dom.sessionHelpClose.addEventListener('click', () => {
closeSessionHelpModal();
});
}

View File

@@ -1,33 +1,32 @@
import type { SubsyncManualPayload } from "../../types";
import type { ModalStateReader, RendererContext } from "../context";
import type { SubsyncManualPayload } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
export function createSubsyncModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setSubsyncStatus(message: string, isError = false): void {
ctx.dom.subsyncStatus.textContent = message;
ctx.dom.subsyncStatus.classList.toggle("error", isError);
ctx.dom.subsyncStatus.classList.toggle('error', isError);
}
function updateSubsyncSourceVisibility(): void {
const useAlass = ctx.dom.subsyncEngineAlass.checked;
ctx.dom.subsyncSourceLabel.classList.toggle("hidden", !useAlass);
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
}
function renderSubsyncSourceTracks(): void {
ctx.dom.subsyncSourceSelect.innerHTML = "";
ctx.dom.subsyncSourceSelect.innerHTML = '';
for (const track of ctx.state.subsyncSourceTracks) {
const option = document.createElement("option");
const option = document.createElement('option');
option.value = String(track.id);
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 {
@@ -36,15 +35,12 @@ export function createSubsyncModal(
ctx.state.subsyncModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.subsyncModal.classList.add("hidden");
ctx.dom.subsyncModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("subsync");
ctx.dom.subsyncModal.classList.add('hidden');
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('subsync');
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
@@ -64,30 +60,30 @@ export function createSubsyncModal(
setSubsyncStatus(
hasSources
? "Choose engine and source, then run."
: "No source subtitles available for alass. Use ffsubsync.",
? 'Choose engine and source, then run.'
: 'No source subtitles available for alass. Use ffsubsync.',
false,
);
ctx.state.subsyncModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.subsyncModal.classList.remove("hidden");
ctx.dom.subsyncModal.setAttribute("aria-hidden", "false");
ctx.dom.overlay.classList.add('interactive');
ctx.dom.subsyncModal.classList.remove('hidden');
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
}
async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return;
const engine = ctx.dom.subsyncEngineAlass.checked ? "alass" : "ffsubsync";
const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync';
const sourceTrackId =
engine === "alass" && ctx.dom.subsyncSourceSelect.value
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
: null;
if (engine === "alass" && !Number.isFinite(sourceTrackId)) {
setSubsyncStatus("Select a source subtitle track for alass.", true);
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) {
setSubsyncStatus('Select a source subtitle track for alass.', true);
return;
}
@@ -107,13 +103,13 @@ export function createSubsyncModal(
}
function handleSubsyncKeydown(e: KeyboardEvent): boolean {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
closeSubsyncModal();
return true;
}
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
void runSubsyncManualFromModal();
return true;
@@ -123,16 +119,16 @@ export function createSubsyncModal(
}
function wireDomEvents(): void {
ctx.dom.subsyncCloseButton.addEventListener("click", () => {
ctx.dom.subsyncCloseButton.addEventListener('click', () => {
closeSubsyncModal();
});
ctx.dom.subsyncEngineAlass.addEventListener("change", () => {
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
updateSubsyncSourceVisibility();
});
ctx.dom.subsyncEngineFfsubsync.addEventListener("change", () => {
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
updateSubsyncSourceVisibility();
});
ctx.dom.subsyncRunButton.addEventListener("click", () => {
ctx.dom.subsyncRunButton.addEventListener('click', () => {
void runSubsyncManualFromModal();
});
}

View File

@@ -1,5 +1,5 @@
import type { OverlayContentMeasurement, OverlayContentRect } from "../types";
import type { RendererContext } from "./context";
import type { OverlayContentMeasurement, OverlayContentRect } from '../types';
import type { RendererContext } from './context';
const MEASUREMENT_DEBOUNCE_MS = 80;
@@ -26,10 +26,7 @@ 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);
@@ -51,9 +48,7 @@ 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;
}
@@ -61,13 +56,9 @@ function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
if (secondaryHasContent) {
const secondaryRect = toMeasuredRect(
ctx.dom.secondarySubContainer.getBoundingClientRect(),
);
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
if (secondaryRect) {
combinedRect = combinedRect
? unionRects(combinedRect, secondaryRect)
: secondaryRect;
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
}
}

View File

@@ -1 +1 @@
export { createPositioningController } from "./positioning/controller.js";
export { createPositioningController } from './positioning/controller.js';

View File

@@ -1,19 +1,19 @@
import type { ModalStateReader, RendererContext } from "../context";
import type { ModalStateReader, RendererContext } from '../context';
import {
createInMemorySubtitlePositionController,
type SubtitlePositionController,
} from "./position-state.js";
} from './position-state.js';
import {
createInvisibleOffsetController,
type InvisibleOffsetController,
} from "./invisible-offset.js";
} from './invisible-offset.js';
import {
createMpvSubtitleLayoutController,
type MpvSubtitleLayoutController,
} from "./invisible-layout.js";
} from './invisible-layout.js';
type PositioningControllerOptions = {
modalStateReader: Pick<ModalStateReader, "isAnySettingsModalOpen">;
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>;
applySubtitleFontSize: (fontSize: number) => void;
};
@@ -22,26 +22,15 @@ export function createPositioningController(
options: PositioningControllerOptions,
) {
const visible = createInMemorySubtitlePositionController(ctx);
const invisibleOffset = createInvisibleOffsetController(
ctx,
options.modalStateReader,
);
const invisibleLayout = createMpvSubtitleLayoutController(
ctx,
options.applySubtitleFontSize,
{
applyInvisibleSubtitleOffsetPosition:
invisibleOffset.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud:
invisibleOffset.updateInvisiblePositionEditHud,
},
);
const invisibleOffset = createInvisibleOffsetController(ctx, options.modalStateReader);
const invisibleLayout = createMpvSubtitleLayoutController(ctx, options.applySubtitleFontSize, {
applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
});
return {
...visible,
...invisibleOffset,
...invisibleLayout,
} as SubtitlePositionController &
InvisibleOffsetController &
MpvSubtitleLayoutController;
} as SubtitlePositionController & InvisibleOffsetController & MpvSubtitleLayoutController;
}

View File

@@ -1,10 +1,10 @@
import type { MpvSubtitleRenderMetrics } from "../../types";
import type { RendererContext } from "../context";
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5;
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";
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,
@@ -17,32 +17,32 @@ export function applyContainerBaseLayout(
): void {
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
ctx.dom.subtitleContainer.style.position = "absolute";
ctx.dom.subtitleContainer.style.position = 'absolute';
ctx.dom.subtitleContainer.style.maxWidth = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.width = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.padding = "0";
ctx.dom.subtitleContainer.style.background = "transparent";
ctx.dom.subtitleContainer.style.marginBottom = "0";
ctx.dom.subtitleContainer.style.pointerEvents = "none";
ctx.dom.subtitleContainer.style.padding = '0';
ctx.dom.subtitleContainer.style.background = 'transparent';
ctx.dom.subtitleContainer.style.marginBottom = '0';
ctx.dom.subtitleContainer.style.pointerEvents = 'none';
ctx.dom.subtitleContainer.style.left = `${leftInset + marginX}px`;
ctx.dom.subtitleContainer.style.right = "";
ctx.dom.subtitleContainer.style.transform = "";
ctx.dom.subtitleContainer.style.textAlign = "";
ctx.dom.subtitleContainer.style.right = '';
ctx.dom.subtitleContainer.style.transform = '';
ctx.dom.subtitleContainer.style.textAlign = '';
if (hAlign === 0) {
ctx.dom.subtitleContainer.style.textAlign = "left";
ctx.dom.subtitleRoot.style.textAlign = "left";
ctx.dom.subtitleContainer.style.textAlign = 'left';
ctx.dom.subtitleRoot.style.textAlign = 'left';
} else if (hAlign === 2) {
ctx.dom.subtitleContainer.style.textAlign = "right";
ctx.dom.subtitleRoot.style.textAlign = "right";
ctx.dom.subtitleContainer.style.textAlign = 'right';
ctx.dom.subtitleRoot.style.textAlign = 'right';
} else {
ctx.dom.subtitleContainer.style.textAlign = "center";
ctx.dom.subtitleRoot.style.textAlign = "center";
ctx.dom.subtitleContainer.style.textAlign = 'center';
ctx.dom.subtitleRoot.style.textAlign = 'center';
}
ctx.dom.subtitleRoot.style.display = "inline-block";
ctx.dom.subtitleRoot.style.maxWidth = "100%";
ctx.dom.subtitleRoot.style.pointerEvents = "auto";
ctx.dom.subtitleRoot.style.display = 'inline-block';
ctx.dom.subtitleRoot.style.maxWidth = '100%';
ctx.dom.subtitleRoot.style.pointerEvents = 'auto';
}
export function applyVerticalPosition(
@@ -59,38 +59,30 @@ export function applyVerticalPosition(
): 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(
0,
params.topInset + params.marginY - baselineCompensationPx,
)}px`;
ctx.dom.subtitleContainer.style.bottom = "";
ctx.dom.subtitleContainer.style.bottom = '';
return;
}
if (params.vAlign === 1) {
ctx.dom.subtitleContainer.style.top = "50%";
ctx.dom.subtitleContainer.style.bottom = "";
ctx.dom.subtitleContainer.style.transform = "translateY(-50%)";
ctx.dom.subtitleContainer.style.top = '50%';
ctx.dom.subtitleContainer.style.bottom = '';
ctx.dom.subtitleContainer.style.transform = 'translateY(-50%)';
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,
params.bottomInset + effectiveMargin + baselineCompensationPx,
);
const bottomPx = Math.max(0, params.bottomInset + effectiveMargin + baselineCompensationPx);
ctx.dom.subtitleContainer.style.top = "";
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
}
@@ -98,7 +90,7 @@ function resolveFontFamily(rawFont: string): string {
const strippedFont = rawFont
.replace(
/\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i,
"",
'',
)
.trim();
@@ -107,11 +99,8 @@ function resolveFontFamily(rawFont: string): string {
: `"${rawFont}", sans-serif`;
}
function resolveLineHeight(
lineCount: number,
isMacOSPlatform: boolean,
): string {
if (!isMacOSPlatform) return "normal";
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;
return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE;
@@ -126,20 +115,15 @@ function resolveLetterSpacing(
return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`;
}
return isMacOSPlatform ? "-0.02em" : "0px";
return isMacOSPlatform ? '-0.02em' : '0px';
}
function applyComputedLineHeightCompensation(
ctx: RendererContext,
effectiveFontSize: number,
): void {
const computedLineHeight = parseFloat(
getComputedStyle(ctx.dom.subtitleRoot).lineHeight,
);
if (
!Number.isFinite(computedLineHeight) ||
computedLineHeight <= effectiveFontSize
) {
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) {
return;
}
@@ -182,31 +166,21 @@ export function applyTypography(
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
ctx.dom.subtitleRoot.style.setProperty(
"line-height",
'line-height',
resolveLineHeight(lineCount, isMacOSPlatform),
isMacOSPlatform ? "important" : "",
);
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(
params.metrics.subFont,
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
ctx.dom.subtitleRoot.style.setProperty(
"letter-spacing",
resolveLetterSpacing(
params.metrics.subSpacing,
params.pxPerScaledPixel,
isMacOSPlatform,
),
isMacOSPlatform ? "important" : "",
'letter-spacing',
resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform),
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.transform = "";
ctx.dom.subtitleRoot.style.transformOrigin = "";
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.transform = '';
ctx.dom.subtitleRoot.style.transformOrigin = '';
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
applyMacOSAdjustments(ctx);

View File

@@ -1,5 +1,5 @@
import type { MpvSubtitleRenderMetrics } from "../../types";
import type { RendererContext } from "../context";
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
export type SubtitleAlignment = { hAlign: 0 | 1 | 2; vAlign: 0 | 1 | 2 };
@@ -30,10 +30,9 @@ export function calculateOsdScale(
return devicePixelRatio;
}
const ratios = [
dims.w / Math.max(1, viewportWidth),
dims.h / Math.max(1, viewportHeight),
].filter((value) => Number.isFinite(value) && value > 0);
const ratios = [dims.w / Math.max(1, viewportWidth), dims.h / Math.max(1, viewportHeight)].filter(
(value) => Number.isFinite(value) && value > 0,
);
const avgRatio =
ratios.length > 0
@@ -74,10 +73,7 @@ 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;
@@ -91,10 +87,7 @@ 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,
@@ -119,16 +112,11 @@ 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,

View File

@@ -1,14 +1,11 @@
import type { MpvSubtitleRenderMetrics } from "../../types";
import type { RendererContext } from "../context";
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
import {
applyContainerBaseLayout,
applyTypography,
applyVerticalPosition,
} from "./invisible-layout-helpers.js";
import {
calculateSubtitleMetrics,
calculateSubtitlePosition,
} from "./invisible-layout-metrics.js";
} from './invisible-layout-helpers.js';
import { calculateSubtitleMetrics, calculateSubtitlePosition } from './invisible-layout-metrics.js';
export type MpvSubtitleLayoutController = {
applyInvisibleSubtitleLayoutFromMpvMetrics: (
@@ -32,20 +29,12 @@ 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",
`${effectiveBorderSize}px`,
);
document.documentElement.style.setProperty('--sub-border-size', `${effectiveBorderSize}px`);
applyContainerBaseLayout(ctx, {
horizontalAvailable: Math.max(
@@ -73,26 +62,18 @@ export function createMpvSubtitleLayoutController(
effectiveFontSize: geometry.effectiveFontSize,
});
ctx.state.invisibleLayoutBaseLeftPx =
parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
ctx.state.invisibleLayoutBaseBottomPx = Number.isFinite(parsedBottom)
? parsedBottom
: null;
ctx.state.invisibleLayoutBaseBottomPx = Number.isFinite(parsedBottom) ? parsedBottom : null;
const parsedTop = parseFloat(ctx.dom.subtitleContainer.style.top);
ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop)
? parsedTop
: null;
ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop) ? parsedTop : null;
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 {

View File

@@ -1,11 +1,8 @@
import type { SubtitlePosition } from "../../types";
import type { ModalStateReader, RendererContext } from "../context";
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;
@@ -26,17 +23,15 @@ function createEditPositionText(ctx: RendererContext): string {
}
function applyOffsetByBasePosition(ctx: RendererContext): void {
const nextLeft =
ctx.state.invisibleLayoutBaseLeftPx + ctx.state.invisibleSubtitleOffsetXPx;
const nextLeft = ctx.state.invisibleLayoutBaseLeftPx + ctx.state.invisibleSubtitleOffsetXPx;
ctx.dom.subtitleContainer.style.left = `${nextLeft}px`;
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 = "";
ctx.dom.subtitleContainer.style.top = '';
return;
}
@@ -45,36 +40,31 @@ function applyOffsetByBasePosition(ctx: RendererContext): void {
0,
ctx.state.invisibleLayoutBaseTopPx - ctx.state.invisibleSubtitleOffsetYPx,
)}px`;
ctx.dom.subtitleContainer.style.bottom = "";
ctx.dom.subtitleContainer.style.bottom = '';
}
}
export function createInvisibleOffsetController(
ctx: RendererContext,
modalStateReader: Pick<ModalStateReader, "isAnySettingsModalOpen">,
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>,
): InvisibleOffsetController {
function setInvisiblePositionEditMode(enabled: boolean): void {
if (!ctx.platform.isInvisibleLayer) return;
if (ctx.state.invisiblePositionEditMode === enabled) return;
ctx.state.invisiblePositionEditMode = enabled;
document.body.classList.toggle("invisible-position-edit", enabled);
document.body.classList.toggle('invisible-position-edit', enabled);
if (enabled) {
ctx.state.invisiblePositionEditStartX =
ctx.state.invisibleSubtitleOffsetXPx;
ctx.state.invisiblePositionEditStartY =
ctx.state.invisibleSubtitleOffsetYPx;
ctx.dom.overlay.classList.add("interactive");
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()
) {
ctx.dom.overlay.classList.remove("interactive");
if (!ctx.state.isOverSubtitle && !modalStateReader.isAnySettingsModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
@@ -86,8 +76,7 @@ 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 {
@@ -98,11 +87,7 @@ export function createInvisibleOffsetController(
position: SubtitlePosition | null,
source: string,
): void {
if (
position &&
typeof position.yPercent === "number" &&
Number.isFinite(position.yPercent)
) {
if (position && typeof position.yPercent === 'number' && Number.isFinite(position.yPercent)) {
ctx.state.persistedSubtitlePosition = {
...ctx.state.persistedSubtitlePosition,
yPercent: position.yPercent,
@@ -111,12 +96,12 @@ export function createInvisibleOffsetController(
if (position) {
const nextX =
typeof position.invisibleOffsetXPx === "number" &&
typeof position.invisibleOffsetXPx === 'number' &&
Number.isFinite(position.invisibleOffsetXPx)
? position.invisibleOffsetXPx
: 0;
const nextY =
typeof position.invisibleOffsetYPx === "number" &&
typeof position.invisibleOffsetYPx === 'number' &&
Number.isFinite(position.invisibleOffsetYPx)
? position.invisibleOffsetYPx
: 0;
@@ -129,7 +114,7 @@ export function createInvisibleOffsetController(
applyOffsetByBasePosition(ctx);
console.log(
"[invisible-overlay] Applied subtitle offset from",
'[invisible-overlay] Applied subtitle offset from',
source,
`${ctx.state.invisibleSubtitleOffsetXPx}px`,
`${ctx.state.invisibleSubtitleOffsetYPx}px`,
@@ -148,19 +133,17 @@ 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);
}
function setupInvisiblePositionEditHud(): void {
if (!ctx.platform.isInvisibleLayer) return;
const hud = document.createElement("div");
hud.id = "invisiblePositionEditHud";
hud.className = "invisible-position-edit-hud";
const hud = document.createElement('div');
hud.id = 'invisiblePositionEditHud';
hud.className = 'invisible-position-edit-hud';
ctx.dom.overlay.appendChild(hud);
ctx.state.invisiblePositionEditHud = hud;
updateInvisiblePositionEditHud();

View File

@@ -1,35 +1,22 @@
import type { SubtitlePosition } from "../../types";
import type { RendererContext } from "../context";
import type { SubtitlePosition } from '../../types';
import type { RendererContext } from '../context';
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)
) {
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
if (!position || typeof position.yPercent !== 'number' || !Number.isFinite(position.yPercent)) {
return ctx.state.persistedSubtitlePosition.yPercent;
}
@@ -39,13 +26,9 @@ function getPersistedYPercent(
function getPersistedOffset(
ctx: RendererContext,
position: SubtitlePosition | null,
key: "invisibleOffsetXPx" | "invisibleOffsetYPx",
key: 'invisibleOffsetXPx' | 'invisibleOffsetYPx',
): number {
if (
position &&
typeof position[key] === "number" &&
Number.isFinite(position[key])
) {
if (position && typeof position[key] === 'number' && Number.isFinite(position[key])) {
return position[key];
}
@@ -58,8 +41,8 @@ function updatePersistedSubtitlePosition(
): void {
ctx.state.persistedSubtitlePosition = {
yPercent: getPersistedYPercent(ctx, position),
invisibleOffsetXPx: getPersistedOffset(ctx, position, "invisibleOffsetXPx"),
invisibleOffsetYPx: getPersistedOffset(ctx, position, "invisibleOffsetYPx"),
invisibleOffsetXPx: getPersistedOffset(ctx, position, 'invisibleOffsetXPx'),
invisibleOffsetYPx: getPersistedOffset(ctx, position, 'invisibleOffsetYPx'),
};
}
@@ -69,17 +52,15 @@ function getNextPersistedPosition(
): SubtitlePosition {
return {
yPercent:
typeof patch.yPercent === "number" && Number.isFinite(patch.yPercent)
typeof patch.yPercent === 'number' && Number.isFinite(patch.yPercent)
? patch.yPercent
: ctx.state.persistedSubtitlePosition.yPercent,
invisibleOffsetXPx:
typeof patch.invisibleOffsetXPx === "number" &&
Number.isFinite(patch.invisibleOffsetXPx)
typeof patch.invisibleOffsetXPx === 'number' && Number.isFinite(patch.invisibleOffsetXPx)
? patch.invisibleOffsetXPx
: (ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0),
invisibleOffsetYPx:
typeof patch.invisibleOffsetYPx === "number" &&
Number.isFinite(patch.invisibleOffsetYPx)
typeof patch.invisibleOffsetYPx === 'number' && Number.isFinite(patch.invisibleOffsetYPx)
? patch.invisibleOffsetYPx
: (ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0),
};
@@ -93,11 +74,8 @@ 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;
}
@@ -106,43 +84,32 @@ export function createInMemorySubtitlePositionController(
ctx.state.currentYPercent = clampedPercent;
const marginBottom = (clampedPercent / 100) * window.innerHeight;
ctx.dom.subtitleContainer.style.position = "";
ctx.dom.subtitleContainer.style.left = "";
ctx.dom.subtitleContainer.style.top = "";
ctx.dom.subtitleContainer.style.right = "";
ctx.dom.subtitleContainer.style.transform = "";
ctx.dom.subtitleContainer.style.position = '';
ctx.dom.subtitleContainer.style.left = '';
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.right = '';
ctx.dom.subtitleContainer.style.transform = '';
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);
console.log(
"Applied subtitle position from",
source,
":",
position.yPercent,
"%",
);
console.log('Applied subtitle position from', source, ':', position.yPercent, '%');
return;
}
const defaultMarginBottom = 60;
const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100;
applyYPercent(defaultYPercent);
console.log("Applied default subtitle position from", source);
console.log('Applied default subtitle position from', source);
}
return {

View File

@@ -24,20 +24,20 @@ import type {
SubtitleData,
SubtitlePosition,
SubsyncManualPayload,
} from "../types";
import { createKeyboardHandlers } from "./handlers/keyboard.js";
import { createMouseHandlers } from "./handlers/mouse.js";
import { createJimakuModal } from "./modals/jimaku.js";
import { createKikuModal } from "./modals/kiku.js";
import { createSessionHelpModal } from "./modals/session-help.js";
import { createRuntimeOptionsModal } from "./modals/runtime-options.js";
import { createSubsyncModal } from "./modals/subsync.js";
import { createPositioningController } from "./positioning.js";
import { createOverlayContentMeasurementReporter } from "./overlay-content-measurement.js";
import { createRendererState } from "./state.js";
import { createSubtitleRenderer } from "./subtitle-render.js";
import { resolveRendererDom } from "./utils/dom.js";
import { resolvePlatformInfo } from "./utils/platform.js";
} from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createMouseHandlers } from './handlers/mouse.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import { resolveRendererDom } from './utils/dom.js';
import { resolvePlatformInfo } from './utils/platform.js';
const ctx = {
dom: resolveRendererDom(),
@@ -67,7 +67,7 @@ function isAnyModalOpen(): boolean {
function syncSettingsModalSubtitleSuppression(): void {
const suppressSubtitles = isAnySettingsModalOpen();
document.body.classList.toggle("settings-modal-open", suppressSubtitles);
document.body.classList.toggle('settings-modal-open', suppressSubtitles);
if (suppressSubtitles) {
ctx.state.isOverSubtitle = false;
}
@@ -109,8 +109,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
applyInvisibleSubtitleOffsetPosition:
positioning.applyInvisibleSubtitleOffsetPosition,
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
});
const mouseHandlers = createMouseHandlers(ctx, {
@@ -132,28 +131,20 @@ 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");
positioning.applyStoredSubtitlePosition(position, 'media-change');
}
measurementReporter.schedule();
});
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);
document.body.classList.toggle('debug-invisible-visualization', enabled);
});
}
@@ -170,34 +161,24 @@ 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
? ctx.dom.subtitleRoot
: 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,
);
hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
mouseHandlers.setupInvisibleHoverSelection();
positioning.setupInvisiblePositionEditHud();
mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver();
window.addEventListener("resize", () => {
window.addEventListener('resize', () => {
measurementReporter.schedule();
});
@@ -207,18 +188,13 @@ 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(
"Failed to load runtime options",
true,
);
window.electronAPI.notifyOverlayModalClosed("runtime-options");
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
window.electronAPI.notifyOverlayModalClosed('runtime-options');
syncSettingsModalSubtitleSuppression();
});
});
@@ -229,10 +205,7 @@ async function init(): Promise<void> {
subsyncModal.openSubsyncModal(payload);
});
window.electronAPI.onKikuFieldGroupingRequest(
(data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => {
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
kikuModal.openKikuFieldGroupingModal(data);
},
);
@@ -243,23 +216,21 @@ 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(
await window.electronAPI.getSubtitlePosition(),
"startup",
'startup',
);
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
await window.electronAPI.getMpvSubtitleRenderMetrics(),
"startup",
'startup',
);
} else {
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
"startup",
'startup',
);
measurementReporter.schedule();
}
@@ -271,8 +242,8 @@ async function init(): Promise<void> {
measurementReporter.emitNow();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
void init();
}

View File

@@ -9,15 +9,15 @@ import type {
RuntimeOptionValue,
SubtitlePosition,
SubsyncSourceTrack,
} from "../types";
} from '../types';
export type KikuModalStep = "select" | "preview";
export type KikuPreviewMode = "compact" | "full";
export type KikuModalStep = 'select' | 'preview';
export type KikuPreviewMode = 'compact' | 'full';
export type ChordAction =
| { type: "mpv"; command: string[] }
| { type: "electron"; action: () => void }
| { type: "noop" };
| { type: 'mpv'; command: string[] }
| { type: 'electron'; action: () => void }
| { type: 'noop' };
export type RendererState = {
isOverSubtitle: boolean;
@@ -81,7 +81,7 @@ export type RendererState = {
jlptN5Color: string;
frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number;
frequencyDictionaryMode: "single" | "banded";
frequencyDictionaryMode: 'single' | 'banded';
frequencyDictionarySingleColor: string;
frequencyDictionaryBand1Color: string;
frequencyDictionaryBand2Color: string;
@@ -115,8 +115,8 @@ export function createRendererState(): RendererState {
kikuSelectedCard: 1,
kikuOriginalData: null,
kikuDuplicateData: null,
kikuModalStep: "select",
kikuPreviewMode: "compact",
kikuModalStep: 'select',
kikuPreviewMode: 'compact',
kikuPendingChoice: null,
kikuPreviewCompactData: null,
kikuPreviewFullData: null,
@@ -145,25 +145,25 @@ export function createRendererState(): RendererState {
invisiblePositionEditHud: null,
currentInvisibleSubtitleLineCount: 1,
lastHoverSelectionKey: "",
lastHoverSelectionKey: '',
lastHoverSelectionNode: null,
knownWordColor: "#a6da95",
nPlusOneColor: "#c6a0f6",
jlptN1Color: "#ed8796",
jlptN2Color: "#f5a97f",
jlptN3Color: "#f9e2af",
jlptN4Color: "#a6e3a1",
jlptN5Color: "#8aadf4",
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: "single",
frequencyDictionarySingleColor: "#f5a97f",
frequencyDictionaryBand1Color: "#ed8796",
frequencyDictionaryBand2Color: "#f5a97f",
frequencyDictionaryBand3Color: "#f9e2af",
frequencyDictionaryBand4Color: "#a6e3a1",
frequencyDictionaryBand5Color: "#8aadf4",
frequencyDictionaryMode: 'single',
frequencyDictionarySingleColor: '#f5a97f',
frequencyDictionaryBand1Color: '#ed8796',
frequencyDictionaryBand2Color: '#f5a97f',
frequencyDictionaryBand3Color: '#f9e2af',
frequencyDictionaryBand4Color: '#a6e3a1',
frequencyDictionaryBand5Color: '#8aadf4',
keybindingsMap: new Map(),
chordPending: false,

View File

@@ -29,8 +29,7 @@ body {
overflow: hidden;
background: transparent;
font-family:
"Noto Sans CJK JP Regular", "Noto Sans CJK JP", "Arial Unicode MS", Arial,
sans-serif;
'Noto Sans CJK JP Regular', 'Noto Sans CJK JP', 'Arial Unicode MS', Arial, sans-serif;
}
#overlay {
@@ -392,7 +391,7 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot br {
display: block;
content: "";
content: '';
margin-bottom: 0.3em;
}
@@ -558,7 +557,7 @@ body.settings-modal-open #secondarySubContainer {
opacity: 1;
}
iframe[id^="yomitan-popup"] {
iframe[id^='yomitan-popup'] {
pointer-events: auto !important;
z-index: 2147483647 !important;
}
@@ -991,7 +990,9 @@ iframe[id^="yomitan-popup"] {
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
padding: 4px 9px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
border-radius: 999px;
background: rgba(137, 180, 255, 0.16);
border: 1px solid rgba(137, 180, 255, 0.35);

View File

@@ -1,17 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
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";
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: "",
surface: '',
reading: '',
headword: '',
startPos: 0,
endPos: 0,
partOfSpeech: PartOfSpeech.other,
@@ -25,14 +25,14 @@ function createToken(overrides: Partial<MergedToken>): MergedToken {
function extractClassBlock(cssText: string, selector: string): string {
const ruleRegex = /([^{}]+)\{([^}]*)\}/g;
let match: RegExpExecArray | null = null;
let fallbackBlock = "";
let fallbackBlock = '';
while ((match = ruleRegex.exec(cssText)) !== null) {
const selectorsBlock = match[1]?.trim() ?? "";
const selectorBlock = match[2] ?? "";
const selectorsBlock = match[1]?.trim() ?? '';
const selectorBlock = match[2] ?? '';
const selectors = selectorsBlock
.split(",")
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
@@ -51,221 +51,173 @@ function extractClassBlock(cssText: string, selector: string): string {
return fallbackBlock;
}
return "";
return '';
}
test("computeWordClass preserves known and n+1 classes while adding JLPT classes", () => {
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
const knownJlpt = createToken({
isKnown: true,
jlptLevel: "N1",
surface: "猫",
jlptLevel: 'N1',
surface: '猫',
});
const nPlusOneJlpt = createToken({
isNPlusOneTarget: true,
jlptLevel: "N2",
surface: "犬",
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",
);
assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1');
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
});
test("computeWordClass does not add frequency class to known or N+1 terms", () => {
test('computeWordClass does not add frequency class to known or N+1 terms', () => {
const known = createToken({
isKnown: true,
frequencyRank: 10,
surface: "既知",
surface: '既知',
});
const nPlusOne = createToken({
isNPlusOneTarget: true,
frequencyRank: 10,
surface: "目標",
surface: '目標',
});
const frequency = createToken({
frequencyRank: 10,
surface: "頻度",
surface: '頻度',
});
assert.equal(
computeWordClass(known, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
}),
"word word-known",
'word word-known',
);
assert.equal(
computeWordClass(nPlusOne, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
}),
"word word-n-plus-one",
'word word-n-plus-one',
);
assert.equal(
computeWordClass(frequency, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
}),
"word word-frequency-single",
'word word-frequency-single',
);
});
test("computeWordClass adds frequency class for single mode when rank is within topX", () => {
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
const token = createToken({
surface: "猫",
surface: '猫',
frequencyRank: 50,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
});
assert.equal(actual, "word word-frequency-single");
assert.equal(actual, 'word word-frequency-single');
});
test("computeWordClass adds frequency class when rank equals topX", () => {
test('computeWordClass adds frequency class when rank equals topX', () => {
const token = createToken({
surface: "水",
surface: '水',
frequencyRank: 100,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
});
assert.equal(actual, "word word-frequency-single");
assert.equal(actual, 'word word-frequency-single');
});
test("computeWordClass adds frequency class for banded mode", () => {
test('computeWordClass adds frequency class for banded mode', () => {
const token = createToken({
surface: "犬",
surface: '犬',
frequencyRank: 250,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 1000,
mode: "banded",
singleColor: "#000000",
bandedColors: [
"#111111",
"#222222",
"#333333",
"#444444",
"#555555",
] as const,
mode: 'banded',
singleColor: '#000000',
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const,
});
assert.equal(actual, "word word-frequency-band-2");
assert.equal(actual, 'word word-frequency-band-2');
});
test("computeWordClass uses configured band count for banded mode", () => {
test('computeWordClass uses configured band count for banded mode', () => {
const token = createToken({
surface: "犬",
surface: '犬',
frequencyRank: 2,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 4,
mode: "banded",
singleColor: "#000000",
bandedColors: ["#111111", "#222222", "#333333", "#444444", "#555555"],
mode: 'banded',
singleColor: '#000000',
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'],
} as any);
assert.equal(actual, "word word-frequency-band-3");
assert.equal(actual, 'word word-frequency-band-3');
});
test("computeWordClass skips frequency class when rank is out of topX", () => {
test('computeWordClass skips frequency class when rank is out of topX', () => {
const token = createToken({
surface: "犬",
surface: '犬',
frequencyRank: 1200,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 1000,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
});
assert.equal(actual, "word");
assert.equal(actual, 'word');
});
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");
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;
if (!fs.existsSync(cssPath)) {
assert.fail(
"JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.",
'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.',
);
}
const cssText = fs.readFileSync(cssPath, "utf-8");
const cssText = fs.readFileSync(cssPath, 'utf-8');
for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(
cssText,
`#subtitleRoot .word.word-jlpt-n${level}`,
);
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${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;/);
@@ -277,12 +229,12 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
const block = extractClassBlock(
cssText,
band === 1
? "#subtitleRoot .word.word-frequency-single"
? '#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`,
`frequency class word-frequency-${band === 1 ? 'single' : `band-${band}`} should exist`,
);
assert.match(block, /color:\s*var\(/);
}

View File

@@ -1,33 +1,27 @@
import type {
MergedToken,
SecondarySubMode,
SubtitleData,
SubtitleStyleConfig,
} from "../types";
import type { RendererContext } from "./context";
import type { MergedToken, SecondarySubMode, SubtitleData, SubtitleStyleConfig } from '../types';
import type { RendererContext } from './context';
type FrequencyRenderSettings = {
enabled: boolean;
topX: number;
mode: "single" | "banded";
mode: 'single' | 'banded';
singleColor: string;
bandedColors: [string, string, string, string, string];
};
function normalizeSubtitle(text: string, trim = true): string {
if (!text) return "";
if (!text) return '';
let normalized = text.replace(/\\N/g, "\n").replace(/\\n/g, "\n");
normalized = normalized.replace(/\{[^}]*\}/g, "");
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})$/;
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())
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim())
? value.trim()
: fallback;
}
@@ -35,13 +29,13 @@ function sanitizeHexColor(value: unknown, fallback: string): string {
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
enabled: false,
topX: 1000,
mode: "single",
singleColor: "#f5a97f",
bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
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) {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
@@ -49,8 +43,8 @@ function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
function sanitizeFrequencyBandedColors(
value: unknown,
fallback: FrequencyRenderSettings["bandedColors"],
): FrequencyRenderSettings["bandedColors"] {
fallback: FrequencyRenderSettings['bandedColors'],
): FrequencyRenderSettings['bandedColors'] {
if (!Array.isArray(value) || value.length !== 5) {
return fallback;
}
@@ -69,33 +63,27 @@ function getFrequencyDictionaryClass(
settings: FrequencyRenderSettings,
): string {
if (!settings.enabled) {
return "";
return '';
}
if (
typeof token.frequencyRank !== "number" ||
!Number.isFinite(token.frequencyRank)
) {
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,
);
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX);
if (rank > topX) {
return "";
return '';
}
if (settings.mode === "banded") {
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";
return 'word-frequency-single';
}
function renderWithTokens(
@@ -125,28 +113,25 @@ function renderWithTokens(
for (const token of tokens) {
const surface = token.surface;
if (surface.includes("\n")) {
const parts = surface.split("\n");
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,
);
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"));
fragment.appendChild(document.createElement('br'));
}
}
continue;
}
const span = document.createElement("span");
const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
span.textContent = surface;
if (token.reading) span.dataset.reading = token.reading;
@@ -168,22 +153,19 @@ export function computeWordClass(
frequencySettings?.bandedColors,
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
),
topX: sanitizeFrequencyTopX(
frequencySettings?.topX,
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
),
topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
singleColor: sanitizeHexColor(
frequencySettings?.singleColor,
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
),
};
const classes = ["word"];
const classes = ['word'];
if (token.isNPlusOneTarget) {
classes.push("word-n-plus-one");
classes.push('word-n-plus-one');
} else if (token.isKnown) {
classes.push("word-known");
classes.push('word-known');
}
if (token.jlptLevel) {
@@ -191,28 +173,25 @@ export function computeWordClass(
}
if (!token.isKnown && !token.isNPlusOneTarget) {
const frequencyClass = getFrequencyDictionaryClass(
token,
resolvedFrequencySettings,
);
const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings);
if (frequencyClass) {
classes.push(frequencyClass);
}
}
return classes.join(" ");
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"));
if (char === '\n') {
fragment.appendChild(document.createElement('br'));
continue;
}
const span = document.createElement("span");
span.className = "c";
const span = document.createElement('span');
span.className = 'c';
span.textContent = char;
fragment.appendChild(span);
}
@@ -220,17 +199,14 @@ function renderCharacterLevel(root: HTMLElement, text: string): void {
root.appendChild(fragment);
}
function renderPlainTextPreserveLineBreaks(
root: HTMLElement,
text: string,
): void {
const lines = text.split("\n");
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"));
fragment.appendChild(document.createElement('br'));
}
}
@@ -239,17 +215,17 @@ function renderPlainTextPreserveLineBreaks(
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.innerHTML = "";
ctx.state.lastHoverSelectionKey = "";
ctx.dom.subtitleRoot.innerHTML = '';
ctx.state.lastHoverSelectionKey = '';
ctx.state.lastHoverSelectionNode = null;
let text: string;
let tokens: MergedToken[] | null;
if (typeof data === "string") {
if (typeof data === 'string') {
text = data;
tokens = null;
} else if (data && typeof data === "object") {
} else if (data && typeof data === 'object') {
text = data.text;
tokens = data.tokens;
} else {
@@ -262,22 +238,15 @@ export function createSubtitleRenderer(ctx: RendererContext) {
const normalizedInvisible = normalizeSubtitle(text, false);
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
1,
normalizedInvisible.split("\n").length,
);
renderPlainTextPreserveLineBreaks(
ctx.dom.subtitleRoot,
normalizedInvisible,
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(),
);
renderWithTokens(ctx.dom.subtitleRoot, tokens, getFrequencyRenderSettings());
return;
}
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
@@ -300,33 +269,33 @@ export function createSubtitleRenderer(ctx: RendererContext) {
}
function renderSecondarySub(text: string): void {
ctx.dom.secondarySubRoot.innerHTML = "";
ctx.dom.secondarySubRoot.innerHTML = '';
if (!text) return;
const normalized = text
.replace(/\\N/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\{[^}]*\}/g, "")
.replace(/\\N/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\{[^}]*\}/g, '')
.trim();
if (!normalized) return;
const lines = normalized.split("\n");
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"));
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",
'secondary-sub-hidden',
'secondary-sub-visible',
'secondary-sub-hover',
);
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`);
}
@@ -334,37 +303,29 @@ export function createSubtitleRenderer(ctx: RendererContext) {
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`,
);
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.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;
}
const knownWordColor =
style.knownWordColor ?? ctx.state.knownWordColor ?? "#a6da95";
const nPlusOneColor =
style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? "#c6a0f6";
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",
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),
@@ -378,43 +339,21 @@ export function createSubtitleRenderer(ctx: RendererContext) {
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.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,
);
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,
@@ -449,27 +388,27 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.frequencyDictionaryBand5Color,
] = frequencyBandedColors;
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-frequency-single-color",
'--subtitle-frequency-single-color',
frequencySingleColor,
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-frequency-band-1-color",
'--subtitle-frequency-band-1-color',
frequencyBandedColors[0],
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-frequency-band-2-color",
'--subtitle-frequency-band-2-color',
frequencyBandedColors[1],
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-frequency-band-3-color",
'--subtitle-frequency-band-3-color',
frequencyBandedColors[2],
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-frequency-band-4-color",
'--subtitle-frequency-band-4-color',
frequencyBandedColors[3],
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-frequency-band-5-color",
'--subtitle-frequency-band-5-color',
frequencyBandedColors[4],
);
@@ -492,8 +431,7 @@ 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;
}
}

View File

@@ -74,104 +74,68 @@ function getRequiredElement<T extends HTMLElement>(id: string): T {
export function resolveRendererDom(): RendererDom {
return {
subtitleRoot: getRequiredElement<HTMLElement>("subtitleRoot"),
subtitleContainer: getRequiredElement<HTMLElement>("subtitleContainer"),
overlay: getRequiredElement<HTMLElement>("overlay"),
secondarySubContainer: getRequiredElement<HTMLElement>(
"secondarySubContainer",
),
secondarySubRoot: getRequiredElement<HTMLElement>("secondarySubRoot"),
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
overlay: getRequiredElement<HTMLElement>('overlay'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
jimakuModal: getRequiredElement<HTMLDivElement>("jimakuModal"),
jimakuTitleInput: getRequiredElement<HTMLInputElement>("jimakuTitle"),
jimakuSeasonInput: getRequiredElement<HTMLInputElement>("jimakuSeason"),
jimakuEpisodeInput: getRequiredElement<HTMLInputElement>("jimakuEpisode"),
jimakuSearchButton: getRequiredElement<HTMLButtonElement>("jimakuSearch"),
jimakuCloseButton: getRequiredElement<HTMLButtonElement>("jimakuClose"),
jimakuStatus: getRequiredElement<HTMLDivElement>("jimakuStatus"),
jimakuEntriesSection: getRequiredElement<HTMLDivElement>(
"jimakuEntriesSection",
),
jimakuEntriesList: getRequiredElement<HTMLUListElement>("jimakuEntries"),
jimakuFilesSection:
getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
jimakuFilesList: getRequiredElement<HTMLUListElement>("jimakuFiles"),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>("jimakuBroaden"),
jimakuModal: getRequiredElement<HTMLDivElement>('jimakuModal'),
jimakuTitleInput: getRequiredElement<HTMLInputElement>('jimakuTitle'),
jimakuSeasonInput: getRequiredElement<HTMLInputElement>('jimakuSeason'),
jimakuEpisodeInput: getRequiredElement<HTMLInputElement>('jimakuEpisode'),
jimakuSearchButton: getRequiredElement<HTMLButtonElement>('jimakuSearch'),
jimakuCloseButton: getRequiredElement<HTMLButtonElement>('jimakuClose'),
jimakuStatus: getRequiredElement<HTMLDivElement>('jimakuStatus'),
jimakuEntriesSection: getRequiredElement<HTMLDivElement>('jimakuEntriesSection'),
jimakuEntriesList: getRequiredElement<HTMLUListElement>('jimakuEntries'),
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",
),
kikuCard1Sentence: getRequiredElement<HTMLDivElement>("kikuCard1Sentence"),
kikuCard2Sentence: getRequiredElement<HTMLDivElement>("kikuCard2Sentence"),
kikuCard1Meta: getRequiredElement<HTMLDivElement>("kikuCard1Meta"),
kikuCard2Meta: getRequiredElement<HTMLDivElement>("kikuCard2Meta"),
kikuConfirmButton:
getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
kikuCancelButton: getRequiredElement<HTMLButtonElement>("kikuCancelButton"),
kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>(
"kikuDeleteDuplicate",
),
kikuSelectionStep: getRequiredElement<HTMLDivElement>("kikuSelectionStep"),
kikuPreviewStep: getRequiredElement<HTMLDivElement>("kikuPreviewStep"),
kikuPreviewJson: getRequiredElement<HTMLPreElement>("kikuPreviewJson"),
kikuPreviewCompactButton:
getRequiredElement<HTMLButtonElement>("kikuPreviewCompact"),
kikuPreviewFullButton:
getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
kikuPreviewError: getRequiredElement<HTMLDivElement>("kikuPreviewError"),
kikuBackButton: getRequiredElement<HTMLButtonElement>("kikuBackButton"),
kikuFinalConfirmButton: getRequiredElement<HTMLButtonElement>(
"kikuFinalConfirmButton",
),
kikuFinalCancelButton: getRequiredElement<HTMLButtonElement>(
"kikuFinalCancelButton",
),
kikuHint: getRequiredElement<HTMLDivElement>("kikuHint"),
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
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'),
kikuCancelButton: getRequiredElement<HTMLButtonElement>('kikuCancelButton'),
kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>('kikuDeleteDuplicate'),
kikuSelectionStep: getRequiredElement<HTMLDivElement>('kikuSelectionStep'),
kikuPreviewStep: getRequiredElement<HTMLDivElement>('kikuPreviewStep'),
kikuPreviewJson: getRequiredElement<HTMLPreElement>('kikuPreviewJson'),
kikuPreviewCompactButton: getRequiredElement<HTMLButtonElement>('kikuPreviewCompact'),
kikuPreviewFullButton: getRequiredElement<HTMLButtonElement>('kikuPreviewFull'),
kikuPreviewError: getRequiredElement<HTMLDivElement>('kikuPreviewError'),
kikuBackButton: getRequiredElement<HTMLButtonElement>('kikuBackButton'),
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",
),
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"),
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
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"),
sessionHelpStatus: getRequiredElement<HTMLDivElement>("sessionHelpStatus"),
sessionHelpFilter:
getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
sessionHelpContent:
getRequiredElement<HTMLDivElement>("sessionHelpContent"),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
sessionHelpWarning: getRequiredElement<HTMLDivElement>('sessionHelpWarning'),
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'),
};
}

View File

@@ -1,4 +1,4 @@
export type OverlayLayer = "visible" | "invisible";
export type OverlayLayer = 'visible' | 'invisible';
export type PlatformInfo = {
overlayLayer: OverlayLayer;
@@ -14,21 +14,19 @@ export type PlatformInfo = {
export function resolvePlatformInfo(): PlatformInfo {
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
const overlayLayerFromQuery =
new URLSearchParams(window.location.search).get("layer") === "invisible"
? "invisible"
: "visible";
new URLSearchParams(window.location.search).get('layer') === 'invisible'
? 'invisible'
: 'visible';
const overlayLayer: OverlayLayer =
overlayLayerFromPreload === "visible" ||
overlayLayerFromPreload === "invisible"
overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'invisible'
? overlayLayerFromPreload
: overlayLayerFromQuery;
const isInvisibleLayer = overlayLayer === "invisible";
const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux");
const isInvisibleLayer = overlayLayer === 'invisible';
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform =
navigator.platform.toLowerCase().includes("mac") ||
/mac/i.test(navigator.userAgent);
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
return {
overlayLayer,
@@ -36,7 +34,7 @@ export function resolvePlatformInfo(): PlatformInfo {
isLinuxPlatform,
isMacOSPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform,
invisiblePositionEditToggleCode: "KeyP",
invisiblePositionEditToggleCode: 'KeyP',
invisiblePositionStepPx: 1,
invisiblePositionStepFastPx: 4,
};