mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
14
src/renderer/context.ts
Normal file
14
src/renderer/context.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { RendererState } from './state';
|
||||
import type { RendererDom } from './utils/dom';
|
||||
import type { PlatformInfo } from './utils/platform';
|
||||
|
||||
export type RendererContext = {
|
||||
dom: RendererDom;
|
||||
platform: PlatformInfo;
|
||||
state: RendererState;
|
||||
};
|
||||
|
||||
export type ModalStateReader = {
|
||||
isAnySettingsModalOpen: () => boolean;
|
||||
isAnyModalOpen: () => boolean;
|
||||
};
|
||||
192
src/renderer/error-recovery.test.ts
Normal file
192
src/renderer/error-recovery.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createRendererRecoveryController } from './error-recovery.js';
|
||||
import { resolvePlatformInfo } from './utils/platform.js';
|
||||
|
||||
test('handleError logs context and recovers overlay state', () => {
|
||||
const payloads: unknown[] = [];
|
||||
let dismissed = 0;
|
||||
let restored = 0;
|
||||
const shown: string[] = [];
|
||||
|
||||
const controller = createRendererRecoveryController({
|
||||
dismissActiveUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
restoreOverlayInteraction: () => {
|
||||
restored += 1;
|
||||
},
|
||||
showToast: (message) => {
|
||||
shown.push(message);
|
||||
},
|
||||
getSnapshot: () => ({
|
||||
activeModal: 'jimaku',
|
||||
subtitlePreview: '字幕テキスト',
|
||||
secondarySubtitlePreview: 'secondary',
|
||||
isOverlayInteractive: true,
|
||||
isOverSubtitle: true,
|
||||
invisiblePositionEditMode: false,
|
||||
overlayLayer: 'visible',
|
||||
}),
|
||||
logError: (payload) => {
|
||||
payloads.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
controller.handleError(new Error('renderer boom'), {
|
||||
source: 'callback',
|
||||
action: 'onSubtitle',
|
||||
});
|
||||
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(restored, 1);
|
||||
assert.equal(shown.length, 1);
|
||||
assert.match(shown[0]!, /recovered/i);
|
||||
assert.equal(payloads.length, 1);
|
||||
|
||||
const payload = payloads[0] as {
|
||||
context: { action: string };
|
||||
error: { message: string; stack: string | null };
|
||||
snapshot: { activeModal: string | null; subtitlePreview: string };
|
||||
};
|
||||
assert.equal(payload.context.action, 'onSubtitle');
|
||||
assert.equal(payload.snapshot.activeModal, 'jimaku');
|
||||
assert.equal(payload.snapshot.subtitlePreview, '字幕テキスト');
|
||||
assert.equal(payload.error.message, 'renderer boom');
|
||||
assert.ok(
|
||||
typeof payload.error.stack === 'string' && payload.error.stack.includes('renderer boom'),
|
||||
);
|
||||
});
|
||||
|
||||
test('handleError normalizes non-Error values', () => {
|
||||
const payloads: unknown[] = [];
|
||||
|
||||
const controller = createRendererRecoveryController({
|
||||
dismissActiveUi: () => {},
|
||||
restoreOverlayInteraction: () => {},
|
||||
showToast: () => {},
|
||||
getSnapshot: () => ({
|
||||
activeModal: null,
|
||||
subtitlePreview: '',
|
||||
secondarySubtitlePreview: '',
|
||||
isOverlayInteractive: false,
|
||||
isOverSubtitle: false,
|
||||
invisiblePositionEditMode: false,
|
||||
overlayLayer: 'invisible',
|
||||
}),
|
||||
logError: (payload) => {
|
||||
payloads.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
controller.handleError({ code: 500, reason: 'timeout' }, { source: 'callback', action: 'modal' });
|
||||
|
||||
const payload = payloads[0] as { error: { message: string; stack: string | null } };
|
||||
assert.equal(payload.error.message, JSON.stringify({ code: 500, reason: 'timeout' }));
|
||||
assert.equal(payload.error.stack, null);
|
||||
});
|
||||
|
||||
test('nested recovery errors are ignored while current recovery is active', () => {
|
||||
const payloads: unknown[] = [];
|
||||
let restored = 0;
|
||||
|
||||
let controllerRef: ReturnType<typeof createRendererRecoveryController> | null = null;
|
||||
|
||||
const controller = createRendererRecoveryController({
|
||||
dismissActiveUi: () => {
|
||||
controllerRef?.handleError(new Error('nested'), { source: 'callback', action: 'nested' });
|
||||
},
|
||||
restoreOverlayInteraction: () => {
|
||||
restored += 1;
|
||||
},
|
||||
showToast: () => {},
|
||||
getSnapshot: () => ({
|
||||
activeModal: 'runtime-options',
|
||||
subtitlePreview: '',
|
||||
secondarySubtitlePreview: '',
|
||||
isOverlayInteractive: true,
|
||||
isOverSubtitle: false,
|
||||
invisiblePositionEditMode: true,
|
||||
overlayLayer: 'visible',
|
||||
}),
|
||||
logError: (payload) => {
|
||||
payloads.push(payload);
|
||||
},
|
||||
});
|
||||
controllerRef = controller;
|
||||
|
||||
controller.handleError(new Error('outer'), { source: 'callback', action: 'outer' });
|
||||
|
||||
assert.equal(payloads.length, 1);
|
||||
assert.equal(restored, 1);
|
||||
});
|
||||
|
||||
test('resolvePlatformInfo prefers query layer over preload layer', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getOverlayLayer: () => 'invisible',
|
||||
},
|
||||
location: { search: '?layer=visible' },
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: 'MacIntel',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh)',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const info = resolvePlatformInfo();
|
||||
assert.equal(info.overlayLayer, 'visible');
|
||||
assert.equal(info.isInvisibleLayer, false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getOverlayLayer: () => 'secondary',
|
||||
},
|
||||
location: { search: '' },
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: 'MacIntel',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh)',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const info = resolvePlatformInfo();
|
||||
assert.equal(info.overlayLayer, 'secondary');
|
||||
assert.equal(info.isSecondaryLayer, true);
|
||||
assert.equal(info.shouldToggleMouseIgnore, false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: previousNavigator,
|
||||
});
|
||||
}
|
||||
});
|
||||
177
src/renderer/error-recovery.ts
Normal file
177
src/renderer/error-recovery.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
export type RendererErrorSource =
|
||||
| 'callback'
|
||||
| 'window.onerror'
|
||||
| 'window.unhandledrejection'
|
||||
| 'bootstrap';
|
||||
|
||||
export type RendererRecoveryContext = {
|
||||
source: RendererErrorSource;
|
||||
action: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RendererRecoverySnapshot = {
|
||||
activeModal: string | null;
|
||||
subtitlePreview: string;
|
||||
secondarySubtitlePreview: string;
|
||||
isOverlayInteractive: boolean;
|
||||
isOverSubtitle: boolean;
|
||||
invisiblePositionEditMode: boolean;
|
||||
overlayLayer: 'visible' | 'invisible' | 'secondary';
|
||||
};
|
||||
|
||||
type NormalizedRendererError = {
|
||||
message: string;
|
||||
stack: string | null;
|
||||
};
|
||||
|
||||
export type RendererRecoveryLogPayload = {
|
||||
kind: 'renderer-overlay-recovery';
|
||||
context: RendererRecoveryContext;
|
||||
error: NormalizedRendererError;
|
||||
snapshot: RendererRecoverySnapshot;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
type RendererRecoveryDeps = {
|
||||
dismissActiveUi: () => void;
|
||||
restoreOverlayInteraction: () => void;
|
||||
showToast: (message: string) => void;
|
||||
getSnapshot: () => RendererRecoverySnapshot;
|
||||
logError: (payload: RendererRecoveryLogPayload) => void;
|
||||
toastMessage?: string;
|
||||
};
|
||||
|
||||
type RendererRecoveryController = {
|
||||
handleError: (error: unknown, context: RendererRecoveryContext) => void;
|
||||
};
|
||||
|
||||
type RendererRecoveryWindow = Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
||||
|
||||
const DEFAULT_TOAST_MESSAGE = 'Renderer error recovered. Overlay is still running.';
|
||||
|
||||
function normalizeRendererError(error: unknown): NormalizedRendererError {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message || 'Unknown renderer error',
|
||||
stack: typeof error.stack === 'string' ? error.stack : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return {
|
||||
message: error,
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
try {
|
||||
return {
|
||||
message: JSON.stringify(error),
|
||||
stack: null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
message: '[unserializable error object]',
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: String(error),
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRendererRecoveryController(
|
||||
deps: RendererRecoveryDeps,
|
||||
): RendererRecoveryController {
|
||||
let inRecovery = false;
|
||||
|
||||
const toastMessage = deps.toastMessage ?? DEFAULT_TOAST_MESSAGE;
|
||||
|
||||
const invokeRecoveryStep = (
|
||||
step: 'dismissActiveUi' | 'restoreOverlayInteraction' | 'showToast',
|
||||
fn: () => void,
|
||||
): void => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
try {
|
||||
deps.logError({
|
||||
kind: 'renderer-overlay-recovery',
|
||||
context: {
|
||||
source: 'callback',
|
||||
action: `recovery-step:${step}`,
|
||||
},
|
||||
error: normalizeRendererError(error),
|
||||
snapshot: deps.getSnapshot(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// Avoid recursive failures from logging inside the recovery path.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: unknown, context: RendererRecoveryContext): void => {
|
||||
if (inRecovery) {
|
||||
return;
|
||||
}
|
||||
|
||||
inRecovery = true;
|
||||
try {
|
||||
deps.logError({
|
||||
kind: 'renderer-overlay-recovery',
|
||||
context,
|
||||
error: normalizeRendererError(error),
|
||||
snapshot: deps.getSnapshot(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
invokeRecoveryStep('dismissActiveUi', deps.dismissActiveUi);
|
||||
invokeRecoveryStep('restoreOverlayInteraction', deps.restoreOverlayInteraction);
|
||||
invokeRecoveryStep('showToast', () => deps.showToast(toastMessage));
|
||||
} finally {
|
||||
inRecovery = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { handleError };
|
||||
}
|
||||
|
||||
export function registerRendererGlobalErrorHandlers(
|
||||
recoveryWindow: RendererRecoveryWindow,
|
||||
controller: RendererRecoveryController,
|
||||
): () => void {
|
||||
const onError = (event: Event): void => {
|
||||
const errorEvent = event as ErrorEvent;
|
||||
controller.handleError(errorEvent.error ?? errorEvent.message, {
|
||||
source: 'window.onerror',
|
||||
action: 'global-error',
|
||||
details: {
|
||||
filename: errorEvent.filename,
|
||||
lineno: errorEvent.lineno,
|
||||
colno: errorEvent.colno,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onUnhandledRejection = (event: Event): void => {
|
||||
const rejectionEvent = event as PromiseRejectionEvent;
|
||||
controller.handleError(rejectionEvent.reason, {
|
||||
source: 'window.unhandledrejection',
|
||||
action: 'global-unhandledrejection',
|
||||
});
|
||||
};
|
||||
|
||||
recoveryWindow.addEventListener('error', onError);
|
||||
recoveryWindow.addEventListener('unhandledrejection', onUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
recoveryWindow.removeEventListener('error', onError);
|
||||
recoveryWindow.removeEventListener('unhandledrejection', onUnhandledRejection);
|
||||
};
|
||||
}
|
||||
BIN
src/renderer/fonts/MPLUS1[wght].ttf
Normal file
BIN
src/renderer/fonts/MPLUS1[wght].ttf
Normal file
Binary file not shown.
303
src/renderer/handlers/keyboard.ts
Normal file
303
src/renderer/handlers/keyboard.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import type { Keybinding } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
|
||||
export function createKeyboardHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
}) => void;
|
||||
saveInvisiblePositionEdit: () => void;
|
||||
cancelInvisiblePositionEdit: () => void;
|
||||
setInvisiblePositionEditMode: (enabled: boolean) => void;
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
{ type: 'mpv' | 'electron'; command?: string[]; action?: () => void }
|
||||
>([
|
||||
['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 (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||
if (target.tagName === 'IFRAME' && target.id?.startsWith('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');
|
||||
parts.push(e.code);
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.code === ctx.platform.invisiblePositionEditToggleCode &&
|
||||
!e.altKey &&
|
||||
e.shiftKey &&
|
||||
(e.ctrlKey || e.metaKey)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSessionHelpChordBinding(): {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
} {
|
||||
const firstChoice = 'KeyH';
|
||||
if (!ctx.state.keybindingsMap.has('KeyH')) {
|
||||
return {
|
||||
bindingKey: firstChoice,
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.state.keybindingsMap.has('KeyK')) {
|
||||
return {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bindingKey: 'KeyK',
|
||||
fallbackUsed: true,
|
||||
fallbackUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function applySessionHelpChordBinding(): void {
|
||||
CHORD_MAP.delete('KeyH');
|
||||
CHORD_MAP.delete('KeyK');
|
||||
const info = resolveSessionHelpChordBinding();
|
||||
CHORD_MAP.set(info.bindingKey, {
|
||||
type: 'electron',
|
||||
action: () => {
|
||||
options.openSessionHelpModal(info);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.platform.isInvisibleLayer) return false;
|
||||
|
||||
if (isInvisiblePositionToggleShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (ctx.state.invisiblePositionEditMode) {
|
||||
options.cancelInvisiblePositionEdit();
|
||||
} else {
|
||||
options.setInvisiblePositionEditMode(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ctx.state.invisiblePositionEditMode) return false;
|
||||
|
||||
const step = e.shiftKey
|
||||
? ctx.platform.invisiblePositionStepFastPx
|
||||
: ctx.platform.invisiblePositionStepPx;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
options.cancelInvisiblePositionEdit();
|
||||
return true;
|
||||
}
|
||||
|
||||
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.preventDefault();
|
||||
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') {
|
||||
ctx.state.invisibleSubtitleOffsetYPx -= step;
|
||||
} 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') {
|
||||
ctx.state.invisibleSubtitleOffsetXPx += step;
|
||||
}
|
||||
options.applyInvisibleSubtitleOffsetPosition();
|
||||
options.updateInvisiblePositionEditHud();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetChord(): void {
|
||||
ctx.state.chordPending = false;
|
||||
if (ctx.state.chordTimeout !== null) {
|
||||
clearTimeout(ctx.state.chordTimeout);
|
||||
ctx.state.chordTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
updateKeybindings(await window.electronAPI.getKeybindings());
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (yomitanPopup) return;
|
||||
if (handleInvisiblePositionEditKeydown(e)) return;
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.subsyncModalOpen) {
|
||||
options.handleSubsyncKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.kikuModalOpen) {
|
||||
options.handleKikuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.sessionHelpModalOpen) {
|
||||
options.handleSessionHelpKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.chordPending) {
|
||||
const modifierKeys = [
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
];
|
||||
if (modifierKeys.includes(e.code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const secondKey = keyEventToString(e);
|
||||
const action = CHORD_MAP.get(secondKey);
|
||||
resetChord();
|
||||
if (action) {
|
||||
if (action.type === 'mpv' && action.command) {
|
||||
window.electronAPI.sendMpvCommand(action.command);
|
||||
} else if (action.type === 'electron' && action.action) {
|
||||
action.action();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === 'KeyY' && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !e.repeat) {
|
||||
e.preventDefault();
|
||||
applySessionHelpChordBinding();
|
||||
ctx.state.chordPending = true;
|
||||
ctx.state.chordTimeout = setTimeout(() => {
|
||||
resetChord();
|
||||
}, CHORD_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.code === 'KeyA' &&
|
||||
!e.repeat
|
||||
) {
|
||||
e.preventDefault();
|
||||
options.appendClipboardVideoToQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const command = ctx.state.keybindingsMap.get(keyString);
|
||||
|
||||
if (command) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if (e.button === 2 && !isInteractiveTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', (e: Event) => {
|
||||
if (!isInteractiveTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateKeybindings(keybindings: Keybinding[]): void {
|
||||
ctx.state.keybindingsMap = new Map();
|
||||
for (const binding of keybindings) {
|
||||
if (binding.command) {
|
||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setupMpvInputForwarding,
|
||||
updateKeybindings,
|
||||
};
|
||||
}
|
||||
328
src/renderer/handlers/mouse.ts
Normal file
328
src/renderer/handlers/mouse.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createMouseHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: ModalStateReader;
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void;
|
||||
applyYPercent: (yPercent: number) => void;
|
||||
getCurrentYPercent: () => number;
|
||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||
reportHoveredTokenIndex: (tokenIndex: number | null) => void;
|
||||
},
|
||||
) {
|
||||
const wordSegmenter =
|
||||
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');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (
|
||||
!yomitanPopup &&
|
||||
!options.modalStateReader.isAnyModalOpen() &&
|
||||
!ctx.state.invisiblePositionEditMode
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupDragging(): void {
|
||||
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';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
if (!ctx.state.isDragging) return;
|
||||
|
||||
const deltaY = ctx.state.dragStartY - e.clientY;
|
||||
const deltaPercent = (deltaY / window.innerHeight) * 100;
|
||||
const newYPercent = ctx.state.startYPercent + deltaPercent;
|
||||
|
||||
options.applyYPercent(newYPercent);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', (e: MouseEvent) => {
|
||||
if (ctx.state.isDragging && e.button === 2) {
|
||||
ctx.state.isDragging = false;
|
||||
ctx.dom.subtitleContainer.style.cursor = '';
|
||||
|
||||
const yPercent = options.getCurrentYPercent();
|
||||
options.persistSubtitlePositionPatch({ yPercent });
|
||||
}
|
||||
});
|
||||
|
||||
ctx.dom.subtitleContainer.addEventListener('contextmenu', (e: Event) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
function getCaretTextPointRange(clientX: number, clientY: number): Range | null {
|
||||
const documentWithCaretApi = document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offsetNode: Node; offset: number } | null;
|
||||
};
|
||||
|
||||
if (typeof documentWithCaretApi.caretRangeFromPoint === 'function') {
|
||||
return documentWithCaretApi.caretRangeFromPoint(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);
|
||||
range.collapse(true);
|
||||
return range;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWordBoundsAtOffset(
|
||||
text: string,
|
||||
offset: number,
|
||||
): { start: number; end: number } | null {
|
||||
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;
|
||||
|
||||
if (wordSegmenter) {
|
||||
for (const part of wordSegmenter.segment(text)) {
|
||||
const start = part.index;
|
||||
const end = start + part.segment.length;
|
||||
if (probeIndex >= start && probeIndex < end) {
|
||||
if (part.isWordLike === false) return null;
|
||||
return { start, end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isBoundary = (char: string): boolean =>
|
||||
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char);
|
||||
|
||||
const probeChar = text[probeIndex];
|
||||
if (!probeChar || isBoundary(probeChar)) return null;
|
||||
|
||||
let start = probeIndex;
|
||||
while (start > 0 && !isBoundary(text[start - 1] ?? '')) {
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
let end = probeIndex + 1;
|
||||
while (end < text.length && !isBoundary(text[end] ?? '')) {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if (end <= start) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function updateHoverWordSelection(event: MouseEvent): void {
|
||||
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
|
||||
if (event.buttons !== 0) return;
|
||||
if (!(event.target instanceof Node)) return;
|
||||
if (!ctx.dom.subtitleRoot.contains(event.target)) return;
|
||||
|
||||
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
|
||||
if (!caretRange) return;
|
||||
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return;
|
||||
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return;
|
||||
|
||||
const textNode = caretRange.startContainer as Text;
|
||||
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset);
|
||||
if (!wordBounds) return;
|
||||
|
||||
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
|
||||
wordBounds.start,
|
||||
wordBounds.end,
|
||||
)}`;
|
||||
if (
|
||||
selectionKey === ctx.state.lastHoverSelectionKey &&
|
||||
textNode === ctx.state.lastHoverSelectionNode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, wordBounds.start);
|
||||
range.setEnd(textNode, wordBounds.end);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ctx.state.lastHoverSelectionKey = selectionKey;
|
||||
ctx.state.lastHoverSelectionNode = textNode;
|
||||
}
|
||||
|
||||
function setupInvisibleHoverSelection(): void {
|
||||
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
updateHoverWordSelection(event);
|
||||
});
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
|
||||
ctx.state.lastHoverSelectionKey = '';
|
||||
ctx.state.lastHoverSelectionNode = null;
|
||||
});
|
||||
}
|
||||
|
||||
function setupInvisibleTokenHoverReporter(): void {
|
||||
if (!ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
let pendingNullHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearPendingNullHoverTimer = (): void => {
|
||||
if (pendingNullHoverTimer !== null) {
|
||||
clearTimeout(pendingNullHoverTimer);
|
||||
pendingNullHoverTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const reportHoveredToken = (tokenIndex: number | null): void => {
|
||||
if (ctx.state.lastHoveredTokenIndex === tokenIndex) return;
|
||||
ctx.state.lastHoveredTokenIndex = tokenIndex;
|
||||
options.reportHoveredTokenIndex(tokenIndex);
|
||||
};
|
||||
|
||||
const queueNullHoveredToken = (): void => {
|
||||
if (pendingNullHoverTimer !== null) return;
|
||||
pendingNullHoverTimer = setTimeout(() => {
|
||||
pendingNullHoverTimer = null;
|
||||
reportHoveredToken(null);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Element)) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest<HTMLElement>('.word[data-token-index]');
|
||||
if (!target || !ctx.dom.subtitleRoot.contains(target)) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
const rawTokenIndex = target.dataset.tokenIndex;
|
||||
const tokenIndex = rawTokenIndex ? Number.parseInt(rawTokenIndex, 10) : Number.NaN;
|
||||
if (!Number.isInteger(tokenIndex) || tokenIndex < 0) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
clearPendingNullHoverTimer();
|
||||
reportHoveredToken(tokenIndex);
|
||||
});
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
|
||||
clearPendingNullHoverTimer();
|
||||
reportHoveredToken(null);
|
||||
});
|
||||
}
|
||||
|
||||
function setupResizeHandler(): void {
|
||||
window.addEventListener('resize', () => {
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
if (!ctx.state.mpvSubtitleRenderMetrics) return;
|
||||
options.applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
ctx.state.mpvSubtitleRenderMetrics,
|
||||
'resize',
|
||||
);
|
||||
return;
|
||||
}
|
||||
options.applyYPercent(options.getCurrentYPercent());
|
||||
});
|
||||
}
|
||||
|
||||
function setupSelectionObserver(): void {
|
||||
document.addEventListener('selectionchange', () => {
|
||||
const selection = window.getSelection();
|
||||
const hasSelection = selection && selection.rangeCount > 0 && !selection.isCollapsed;
|
||||
|
||||
if (hasSelection) {
|
||||
ctx.dom.subtitleRoot.classList.add('has-selection');
|
||||
} else {
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupYomitanObserver(): void {
|
||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const element = node as Element;
|
||||
if (
|
||||
element.tagName === 'IFRAME' &&
|
||||
element.id &&
|
||||
element.id.startsWith('yomitan-popup')
|
||||
) {
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const element = node as Element;
|
||||
if (
|
||||
element.tagName === 'IFRAME' &&
|
||||
element.id &&
|
||||
element.id.startsWith('yomitan-popup')
|
||||
) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, {
|
||||
forward: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setupDragging,
|
||||
setupInvisibleHoverSelection,
|
||||
setupInvisibleTokenHoverReporter,
|
||||
setupResizeHandler,
|
||||
setupSelectionObserver,
|
||||
setupYomitanObserver,
|
||||
};
|
||||
}
|
||||
220
src/renderer/index.html
Normal file
220
src/renderer/index.html
Normal file
@@ -0,0 +1,220 @@
|
||||
<!--
|
||||
SubMiner - All-in-one sentence mining overlay
|
||||
Copyright (C) 2024 sudacode
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
|
||||
/>
|
||||
<title>SubMiner</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||
<div id="overlay" tabindex="-1">
|
||||
<div
|
||||
id="overlayErrorToast"
|
||||
class="overlay-error-toast hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div id="secondarySubContainer" class="secondary-sub-hidden">
|
||||
<div id="secondarySubRoot"></div>
|
||||
</div>
|
||||
<div id="subtitleContainer">
|
||||
<div id="subtitleRoot"></div>
|
||||
</div>
|
||||
<div id="jimakuModal" class="modal hidden" aria-hidden="true">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="jimaku-form">
|
||||
<label class="jimaku-field">
|
||||
<span>Title</span>
|
||||
<input id="jimakuTitle" type="text" placeholder="Anime title" />
|
||||
</label>
|
||||
<label class="jimaku-field">
|
||||
<span>Season</span>
|
||||
<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" />
|
||||
</label>
|
||||
<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">
|
||||
<div class="jimaku-section-title">Entries</div>
|
||||
<ul id="jimakuEntries" class="jimaku-list"></ul>
|
||||
</div>
|
||||
<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">
|
||||
Broaden search (all files)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Duplicate Card Detected</div>
|
||||
</div>
|
||||
<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.
|
||||
</div>
|
||||
<div class="kiku-cards-container">
|
||||
<div id="kikuCard1" class="kiku-card active" tabindex="0">
|
||||
<div class="kiku-card-label">1 — Original Card</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 — New Card</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>
|
||||
</div>
|
||||
<div class="kiku-footer">
|
||||
<label class="kiku-delete-toggle">
|
||||
<input id="kikuDeleteDuplicate" type="checkbox" checked />
|
||||
Delete duplicate card after merge
|
||||
</label>
|
||||
<button id="kikuConfirmButton" class="kiku-confirm-button" type="button">
|
||||
Continue
|
||||
</button>
|
||||
<button id="kikuCancelButton" class="kiku-cancel-button" type="button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kikuPreviewStep" class="hidden">
|
||||
<div class="kiku-preview-header">
|
||||
<div class="kiku-preview-title">Final Merge Preview</div>
|
||||
<div class="kiku-preview-toggle">
|
||||
<button id="kikuPreviewCompact" type="button">Compact</button>
|
||||
<button id="kikuPreviewFull" type="button">Full</button>
|
||||
</div>
|
||||
</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">
|
||||
Confirm Merge
|
||||
</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 · Enter to confirm · Esc to cancel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="runtimeOptionsModal" class="modal hidden" aria-hidden="true">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="runtimeOptionsHint" class="runtime-options-hint">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<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>
|
||||
</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 />
|
||||
alass
|
||||
</label>
|
||||
<label class="subsync-radio">
|
||||
<input id="subsyncEngineFfsubsync" type="radio" name="subsyncEngine" />
|
||||
ffsubsync
|
||||
</label>
|
||||
</div>
|
||||
<label id="subsyncSourceLabel" class="subsync-field">
|
||||
<span>Source Subtitle (for alass)</span>
|
||||
<select id="subsyncSourceSelect"></select>
|
||||
</label>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="sessionHelpShortcut" class="session-help-shortcut"></div>
|
||||
<div id="sessionHelpWarning" class="session-help-warning"></div>
|
||||
<div id="sessionHelpStatus" class="session-help-status"></div>
|
||||
<input
|
||||
id="sessionHelpFilter"
|
||||
class="session-help-filter"
|
||||
type="text"
|
||||
placeholder="Type / to search shortcuts"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<div id="sessionHelpContent" class="session-help-content-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
384
src/renderer/modals/jimaku.ts
Normal file
384
src/renderer/modals/jimaku.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type {
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuMediaInfo,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createJimakuModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
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)';
|
||||
}
|
||||
|
||||
function resetJimakuLists(): void {
|
||||
ctx.state.jimakuEntries = [];
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
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');
|
||||
}
|
||||
|
||||
function formatEntryLabel(entry: JimakuEntry): string {
|
||||
if (entry.english_name && entry.english_name !== entry.name) {
|
||||
return `${entry.name} / ${entry.english_name}`;
|
||||
}
|
||||
return entry.name;
|
||||
}
|
||||
|
||||
function renderEntries(): void {
|
||||
ctx.dom.jimakuEntriesList.innerHTML = '';
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
ctx.dom.jimakuEntriesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuEntriesSection.classList.remove('hidden');
|
||||
ctx.state.jimakuEntries.forEach((entry, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = formatEntryLabel(entry);
|
||||
|
||||
if (entry.japanese_name) {
|
||||
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.addEventListener('click', () => {
|
||||
selectEntry(index);
|
||||
});
|
||||
|
||||
ctx.dom.jimakuEntriesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (!Number.isFinite(size)) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = size;
|
||||
let idx = 0;
|
||||
while (value >= 1024 && idx < units.length - 1) {
|
||||
value /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function renderFiles(): void {
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
if (ctx.state.jimakuFiles.length === 0) {
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuFilesSection.classList.remove('hidden');
|
||||
ctx.state.jimakuFiles.forEach((file, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = file.name;
|
||||
|
||||
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.addEventListener('click', () => {
|
||||
void selectFile(index);
|
||||
});
|
||||
|
||||
ctx.dom.jimakuFilesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(): { query: string; episode: number | null } {
|
||||
const title = ctx.dom.jimakuTitleInput.value.trim();
|
||||
const episode = ctx.dom.jimakuEpisodeInput.value
|
||||
? Number.parseInt(ctx.dom.jimakuEpisodeInput.value, 10)
|
||||
: null;
|
||||
return { query: title, episode: Number.isFinite(episode) ? episode : null };
|
||||
}
|
||||
|
||||
async function performJimakuSearch(): Promise<void> {
|
||||
const { query, episode } = getSearchQuery();
|
||||
if (!query) {
|
||||
setJimakuStatus('Enter a title before searching.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
resetJimakuLists();
|
||||
setJimakuStatus('Searching Jimaku...');
|
||||
ctx.state.currentEpisodeFilter = episode;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
ctx.state.jimakuEntries = response.data;
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
setJimakuStatus('No entries found.');
|
||||
return;
|
||||
}
|
||||
|
||||
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...');
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedFileIndex = 0;
|
||||
|
||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
} else {
|
||||
setJimakuStatus('No files found.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
setJimakuStatus('Select a subtitle file.');
|
||||
renderFiles();
|
||||
if (ctx.state.jimakuFiles.length === 1) {
|
||||
await selectFile(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.jimakuEntries.length) return;
|
||||
|
||||
ctx.state.selectedEntryIndex = index;
|
||||
ctx.state.currentEntryId = ctx.state.jimakuEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
void loadFiles(ctx.state.currentEntryId, ctx.state.currentEpisodeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(index: number): Promise<void> {
|
||||
if (index < 0 || index >= ctx.state.jimakuFiles.length) return;
|
||||
|
||||
ctx.state.selectedFileIndex = index;
|
||||
renderFiles();
|
||||
|
||||
if (ctx.state.currentEntryId === null) {
|
||||
setJimakuStatus('Select an entry first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = ctx.state.jimakuFiles[index]!;
|
||||
setJimakuStatus('Downloading subtitle...');
|
||||
|
||||
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({
|
||||
entryId: ctx.state.currentEntryId,
|
||||
url: file.url,
|
||||
name: file.name,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const retry = result.error.retryAfter
|
||||
? ` Retry after ${result.error.retryAfter.toFixed(1)}s.`
|
||||
: '';
|
||||
setJimakuStatus(`${result.error.error}${retry}`, true);
|
||||
}
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
if (!active) return false;
|
||||
const tag = active.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea';
|
||||
}
|
||||
|
||||
function openJimakuModal(): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.jimakuModalOpen) return;
|
||||
|
||||
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');
|
||||
|
||||
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.state.currentEpisodeFilter = info.episode ?? null;
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
void performJimakuSearch();
|
||||
} else if (info.title) {
|
||||
setJimakuStatus('Check title/season/episode and press Search.');
|
||||
} else {
|
||||
setJimakuStatus('Enter title/season/episode and press Search.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setJimakuStatus('Failed to load media info.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeJimakuModal(): void {
|
||||
if (!ctx.state.jimakuModalOpen) return;
|
||||
|
||||
ctx.state.jimakuModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
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');
|
||||
}
|
||||
|
||||
resetJimakuLists();
|
||||
}
|
||||
|
||||
function handleJimakuKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeJimakuModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTextInputFocused()) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void performJimakuSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
ctx.state.selectedFileIndex = Math.min(
|
||||
ctx.state.jimakuFiles.length - 1,
|
||||
ctx.state.selectedFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
ctx.state.selectedEntryIndex = Math.min(
|
||||
ctx.state.jimakuEntries.length - 1,
|
||||
ctx.state.selectedEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
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);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
void selectFile(ctx.state.selectedFileIndex);
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
selectEntry(ctx.state.selectedEntryIndex);
|
||||
} else {
|
||||
void performJimakuSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.jimakuSearchButton.addEventListener('click', () => {
|
||||
void performJimakuSearch();
|
||||
});
|
||||
ctx.dom.jimakuCloseButton.addEventListener('click', () => {
|
||||
closeJimakuModal();
|
||||
});
|
||||
ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
ctx.dom.jimakuBroadenButton.classList.add('hidden');
|
||||
void loadFiles(ctx.state.currentEntryId, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeJimakuModal,
|
||||
handleJimakuKeydown,
|
||||
openJimakuModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
299
src/renderer/modals/kiku.ts
Normal file
299
src/renderer/modals/kiku.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type {
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewResponse,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createKikuModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
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(' | ');
|
||||
}
|
||||
|
||||
function updateKikuCardSelection(): void {
|
||||
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 {
|
||||
ctx.state.kikuModalStep = step;
|
||||
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';
|
||||
}
|
||||
|
||||
function updateKikuPreviewToggle(): void {
|
||||
ctx.dom.kikuPreviewCompactButton.classList.toggle(
|
||||
'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.kikuPreviewCompactData
|
||||
: ctx.state.kikuPreviewFullData;
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.kikuPreviewError.textContent = message;
|
||||
ctx.dom.kikuPreviewError.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openKikuFieldGroupingModal(data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = true;
|
||||
ctx.state.kikuOriginalData = data.original;
|
||||
ctx.state.kikuDuplicateData = data.duplicate;
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
|
||||
ctx.dom.kikuCard1Expression.textContent = data.original.expression;
|
||||
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.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';
|
||||
|
||||
renderKikuPreview();
|
||||
setKikuPreviewError(null);
|
||||
setKikuModalStep('select');
|
||||
updateKikuCardSelection();
|
||||
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.kikuModal.classList.remove('hidden');
|
||||
ctx.dom.kikuModal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function closeKikuFieldGroupingModal(): void {
|
||||
if (!ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.kikuModal.classList.add('hidden');
|
||||
ctx.dom.kikuModal.setAttribute('aria-hidden', 'true');
|
||||
|
||||
setKikuPreviewError(null);
|
||||
ctx.dom.kikuPreviewJson.textContent = '';
|
||||
|
||||
ctx.state.kikuPendingChoice = null;
|
||||
ctx.state.kikuPreviewCompactData = null;
|
||||
ctx.state.kikuPreviewFullData = null;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmKikuSelection(): Promise<void> {
|
||||
if (!ctx.state.kikuOriginalData || !ctx.state.kikuDuplicateData) return;
|
||||
|
||||
const keepData =
|
||||
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuOriginalData : ctx.state.kikuDuplicateData;
|
||||
const deleteData =
|
||||
ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuDuplicateData : ctx.state.kikuOriginalData;
|
||||
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: keepData.noteId,
|
||||
deleteNoteId: deleteData.noteId,
|
||||
deleteDuplicate: ctx.dom.kikuDeleteDuplicateCheckbox.checked,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
ctx.state.kikuPendingChoice = choice;
|
||||
setKikuPreviewError(null);
|
||||
ctx.dom.kikuConfirmButton.disabled = true;
|
||||
|
||||
try {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.kikuPreviewCompactData = preview.compact || {};
|
||||
ctx.state.kikuPreviewFullData = preview.full || {};
|
||||
ctx.state.kikuPreviewMode = 'compact';
|
||||
renderKikuPreview();
|
||||
setKikuModalStep('preview');
|
||||
} finally {
|
||||
ctx.dom.kikuConfirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmKikuMerge(): void {
|
||||
if (!ctx.state.kikuPendingChoice) return;
|
||||
window.electronAPI.kikuFieldGroupingRespond(ctx.state.kikuPendingChoice);
|
||||
closeKikuFieldGroupingModal();
|
||||
}
|
||||
|
||||
function goBackFromKikuPreview(): void {
|
||||
setKikuPreviewError(null);
|
||||
setKikuModalStep('select');
|
||||
}
|
||||
|
||||
function cancelKikuFieldGrouping(): void {
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
};
|
||||
|
||||
window.electronAPI.kikuFieldGroupingRespond(choice);
|
||||
closeKikuFieldGroupingModal();
|
||||
}
|
||||
|
||||
function handleKikuKeydown(e: KeyboardEvent): boolean {
|
||||
if (ctx.state.kikuModalStep === 'preview') {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelKikuFieldGrouping();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
goBackFromKikuPreview();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
confirmKikuMerge();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelKikuFieldGrouping();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === '1') {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === '2') {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
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') {
|
||||
e.preventDefault();
|
||||
void confirmKikuSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.kikuCard1.addEventListener('click', () => {
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
updateKikuCardSelection();
|
||||
});
|
||||
ctx.dom.kikuCard1.addEventListener('dblclick', () => {
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
|
||||
ctx.dom.kikuCard2.addEventListener('click', () => {
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
updateKikuCardSelection();
|
||||
});
|
||||
ctx.dom.kikuCard2.addEventListener('dblclick', () => {
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
|
||||
ctx.dom.kikuConfirmButton.addEventListener('click', () => {
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
ctx.dom.kikuCancelButton.addEventListener('click', () => {
|
||||
cancelKikuFieldGrouping();
|
||||
});
|
||||
ctx.dom.kikuBackButton.addEventListener('click', () => {
|
||||
goBackFromKikuPreview();
|
||||
});
|
||||
ctx.dom.kikuFinalConfirmButton.addEventListener('click', () => {
|
||||
confirmKikuMerge();
|
||||
});
|
||||
ctx.dom.kikuFinalCancelButton.addEventListener('click', () => {
|
||||
cancelKikuFieldGrouping();
|
||||
});
|
||||
|
||||
ctx.dom.kikuPreviewCompactButton.addEventListener('click', () => {
|
||||
ctx.state.kikuPreviewMode = 'compact';
|
||||
renderKikuPreview();
|
||||
});
|
||||
ctx.dom.kikuPreviewFullButton.addEventListener('click', () => {
|
||||
ctx.state.kikuPreviewMode = 'full';
|
||||
renderKikuPreview();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cancelKikuFieldGrouping,
|
||||
closeKikuFieldGroupingModal,
|
||||
handleKikuKeydown,
|
||||
openKikuFieldGroupingModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
258
src/renderer/modals/runtime-options.ts
Normal file
258
src/renderer/modals/runtime-options.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { RuntimeOptionApplyResult, RuntimeOptionState, RuntimeOptionValue } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createRuntimeOptionsModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex] ?? null;
|
||||
}
|
||||
|
||||
function renderRuntimeOptionsList(): void {
|
||||
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 label = document.createElement('div');
|
||||
label.className = 'runtime-options-label';
|
||||
label.textContent = option.label;
|
||||
|
||||
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';
|
||||
|
||||
const allowed = document.createElement('div');
|
||||
allowed.className = 'runtime-options-allowed';
|
||||
allowed.textContent = `Allowed: ${option.allowedValues
|
||||
.map((entry) => formatRuntimeOptionValue(entry))
|
||||
.join(' | ')}`;
|
||||
|
||||
li.appendChild(label);
|
||||
li.appendChild(value);
|
||||
li.appendChild(allowed);
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
renderRuntimeOptionsList();
|
||||
});
|
||||
li.addEventListener('dblclick', () => {
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
void applySelectedRuntimeOption();
|
||||
});
|
||||
|
||||
value.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
cycleRuntimeDraftValue(1);
|
||||
});
|
||||
value.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
cycleRuntimeDraftValue(-1);
|
||||
});
|
||||
|
||||
ctx.dom.runtimeOptionsList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRuntimeOptions(optionsList: RuntimeOptionState[]): void {
|
||||
const previousId =
|
||||
ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex]?.id ??
|
||||
ctx.state.runtimeOptions[0]?.id;
|
||||
|
||||
ctx.state.runtimeOptions = optionsList;
|
||||
ctx.state.runtimeOptionDraftValues.clear();
|
||||
|
||||
for (const option of ctx.state.runtimeOptions) {
|
||||
ctx.state.runtimeOptionDraftValues.set(option.id, option.value);
|
||||
}
|
||||
|
||||
const nextIndex = ctx.state.runtimeOptions.findIndex((option) => option.id === previousId);
|
||||
ctx.state.runtimeOptionSelectedIndex = nextIndex >= 0 ? nextIndex : 0;
|
||||
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
|
||||
function cycleRuntimeDraftValue(direction: 1 | -1): void {
|
||||
const option = getSelectedRuntimeOption();
|
||||
if (!option || option.allowedValues.length === 0) return;
|
||||
|
||||
const currentValue = getRuntimeOptionDisplayValue(option);
|
||||
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;
|
||||
|
||||
const nextValue = option.allowedValues[nextIndex];
|
||||
if (nextValue === undefined) return;
|
||||
ctx.state.runtimeOptionDraftValues.set(option.id, nextValue);
|
||||
renderRuntimeOptionsList();
|
||||
setRuntimeOptionsStatus(`Selected ${option.label}: ${formatRuntimeOptionValue(nextValue)}`);
|
||||
}
|
||||
|
||||
async function applySelectedRuntimeOption(): Promise<void> {
|
||||
const option = getSelectedRuntimeOption();
|
||||
if (!option) return;
|
||||
|
||||
const nextValue = getRuntimeOptionDisplayValue(option);
|
||||
const result: RuntimeOptionApplyResult = await window.electronAPI.setRuntimeOptionValue(
|
||||
option.id,
|
||||
nextValue,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setRuntimeOptionsStatus(result.error || 'Failed to apply option', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.option) {
|
||||
ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value);
|
||||
}
|
||||
|
||||
const latest = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(latest);
|
||||
setRuntimeOptionsStatus(result.osdMessage || 'Option applied.');
|
||||
}
|
||||
|
||||
function closeRuntimeOptionsModal(): void {
|
||||
if (!ctx.state.runtimeOptionsModalOpen) return;
|
||||
|
||||
ctx.state.runtimeOptionsModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.runtimeOptionsModal.classList.add('hidden');
|
||||
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
|
||||
setRuntimeOptionsStatus('');
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
async function openRuntimeOptionsModal(): Promise<void> {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
const optionsList = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(optionsList);
|
||||
|
||||
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');
|
||||
|
||||
setRuntimeOptionsStatus(
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
|
||||
);
|
||||
}
|
||||
|
||||
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {
|
||||
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.preventDefault();
|
||||
if (ctx.state.runtimeOptions.length > 0) {
|
||||
ctx.state.runtimeOptionSelectedIndex = Math.min(
|
||||
ctx.state.runtimeOptions.length - 1,
|
||||
ctx.state.runtimeOptionSelectedIndex + 1,
|
||||
);
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
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) {
|
||||
ctx.state.runtimeOptionSelectedIndex = Math.max(
|
||||
0,
|
||||
ctx.state.runtimeOptionSelectedIndex - 1,
|
||||
);
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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') {
|
||||
e.preventDefault();
|
||||
cycleRuntimeDraftValue(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void applySelectedRuntimeOption();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.runtimeOptionsClose.addEventListener('click', () => {
|
||||
closeRuntimeOptionsModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeRuntimeOptionsModal,
|
||||
handleRuntimeOptionsKeydown,
|
||||
openRuntimeOptionsModal,
|
||||
setRuntimeOptionsStatus,
|
||||
updateRuntimeOptions,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
759
src/renderer/modals/session-help.ts
Normal file
759
src/renderer/modals/session-help.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
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';
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
};
|
||||
|
||||
type SessionHelpItem = {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
type SessionHelpSection = {
|
||||
title: string;
|
||||
rows: SessionHelpItem[];
|
||||
};
|
||||
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})$/;
|
||||
|
||||
// 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',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
function normalizeColor(value: unknown, fallback: string): string {
|
||||
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);
|
||||
return token;
|
||||
}
|
||||
|
||||
function formatKeybinding(rawBinding: string): string {
|
||||
const parts = rawBinding.split('+');
|
||||
const key = parts.pop();
|
||||
if (!key) return rawBinding;
|
||||
const normalized = [...parts, normalizeKeyToken(key)];
|
||||
return normalized.join(' + ');
|
||||
}
|
||||
|
||||
const OVERLAY_SHORTCUTS: Array<{
|
||||
key: keyof RuntimeShortcutConfig;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: 'copySubtitle', label: 'Copy subtitle' },
|
||||
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
|
||||
{
|
||||
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' },
|
||||
];
|
||||
|
||||
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 (keybind.trim().length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
shortcut: formatKeybinding(keybind),
|
||||
action: shortcut.label,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
return [{ title: 'Overlay shortcuts', rows }];
|
||||
}
|
||||
|
||||
function describeCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
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 === '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.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
|
||||
}
|
||||
|
||||
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 (
|
||||
first === 'cycle' ||
|
||||
first === 'seek' ||
|
||||
first === 'sub-seek' ||
|
||||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
|
||||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
|
||||
) {
|
||||
return 'Playback and navigation';
|
||||
}
|
||||
|
||||
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
|
||||
return 'Visual feedback';
|
||||
}
|
||||
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
|
||||
return 'Subtitle sync';
|
||||
}
|
||||
|
||||
if (
|
||||
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
return 'Runtime settings';
|
||||
}
|
||||
|
||||
if (first === 'quit') return 'System actions';
|
||||
return 'Other shortcuts';
|
||||
}
|
||||
|
||||
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
|
||||
const grouped = new Map<string, SessionHelpItem[]>();
|
||||
|
||||
for (const binding of keybindings) {
|
||||
const section = sectionForCommand(binding.command ?? []);
|
||||
const row: SessionHelpItem = {
|
||||
shortcut: formatKeybinding(binding.key),
|
||||
action: describeCommand(binding.command ?? []),
|
||||
};
|
||||
grouped.set(section, [...(grouped.get(section) ?? []), row]);
|
||||
}
|
||||
|
||||
const sectionOrder = [
|
||||
'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]);
|
||||
const bIdx = sectionOrder.indexOf(b[0]);
|
||||
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
|
||||
if (aIdx === -1) return 1;
|
||||
if (bIdx === -1) return -1;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
|
||||
return sectionEntries.map(([title, rows]) => ({ title, rows }));
|
||||
}
|
||||
|
||||
function buildColorSection(style: {
|
||||
knownWordColor?: unknown;
|
||||
nPlusOneColor?: unknown;
|
||||
jlptColors?: {
|
||||
N1?: unknown;
|
||||
N2?: unknown;
|
||||
N3?: unknown;
|
||||
N4?: unknown;
|
||||
N5?: unknown;
|
||||
};
|
||||
}): SessionHelpSection {
|
||||
return {
|
||||
title: 'Color legend',
|
||||
rows: [
|
||||
{
|
||||
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: '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 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 N5',
|
||||
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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, '');
|
||||
const normalized = normalize(query);
|
||||
if (!normalized) return sections;
|
||||
|
||||
return sections
|
||||
.map((section) => {
|
||||
if (normalize(section.title).includes(normalized)) {
|
||||
return section;
|
||||
}
|
||||
|
||||
const rows = section.rows.filter(
|
||||
(row) =>
|
||||
normalize(row.shortcut).includes(normalized) ||
|
||||
normalize(row.action).includes(normalized),
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return { ...section, rows };
|
||||
})
|
||||
.filter((section): section is SessionHelpSection => section !== null)
|
||||
.filter((section) => section.rows.length > 0);
|
||||
}
|
||||
|
||||
function formatBindingHint(info: SessionHelpBindingInfo): string {
|
||||
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
|
||||
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
|
||||
}
|
||||
return 'Y-H';
|
||||
}
|
||||
|
||||
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';
|
||||
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';
|
||||
action.textContent = row.action;
|
||||
right.appendChild(action);
|
||||
|
||||
if (row.color) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'session-help-color-dot';
|
||||
dot.style.backgroundColor = row.color;
|
||||
right.insertBefore(dot, action);
|
||||
}
|
||||
|
||||
button.appendChild(left);
|
||||
button.appendChild(right);
|
||||
return button;
|
||||
}
|
||||
|
||||
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': '◈',
|
||||
};
|
||||
|
||||
function createSectionNode(
|
||||
section: SessionHelpSection,
|
||||
sectionIndex: number,
|
||||
globalIndexMap: number[],
|
||||
): HTMLElement {
|
||||
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] ?? '•';
|
||||
title.textContent = `${icon} ${section.title}`;
|
||||
sectionNode.appendChild(title);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'session-help-item-list';
|
||||
|
||||
section.rows.forEach((row, rowIndex) => {
|
||||
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
|
||||
const button = createShortcutRow(row, globalIndex);
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
sectionNode.appendChild(list);
|
||||
return sectionNode;
|
||||
}
|
||||
|
||||
export function createSessionHelpModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let priorFocus: Element | null = null;
|
||||
let openBinding: SessionHelpBindingInfo = {
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
};
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let focusGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let isRecoveringModalFocus = false;
|
||||
let lastFocusRecoveryAt = 0;
|
||||
|
||||
function getItems(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.sessionHelpContent.querySelectorAll('.session-help-item'),
|
||||
) as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function setSelected(index: number): void {
|
||||
const items = getItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const wrappedIndex = index % items.length;
|
||||
const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex;
|
||||
ctx.state.sessionHelpSelectedIndex = next;
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
item.classList.toggle('active', idx === next);
|
||||
item.tabIndex = idx === next ? 0 : -1;
|
||||
});
|
||||
const activeItem = items[next];
|
||||
if (!activeItem) return;
|
||||
activeItem.focus({ preventScroll: true });
|
||||
activeItem.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
const items = getItems();
|
||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
||||
if (firstItem) {
|
||||
firstItem.focus({ preventScroll: true });
|
||||
return document.activeElement === firstItem;
|
||||
}
|
||||
|
||||
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
|
||||
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
|
||||
return document.activeElement === ctx.dom.sessionHelpClose;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
|
||||
if (isRecoveringModalFocus) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFocusRecoveryAt < 120) return;
|
||||
|
||||
isRecoveringModalFocus = true;
|
||||
lastFocusRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
|
||||
window.setTimeout(() => {
|
||||
isRecoveringModalFocus = false;
|
||||
}, 120);
|
||||
}
|
||||
}
|
||||
|
||||
function isFilterInputFocused(): boolean {
|
||||
return document.activeElement === ctx.dom.sessionHelpFilter;
|
||||
}
|
||||
|
||||
function focusFilterInput(): void {
|
||||
ctx.dom.sessionHelpFilter.focus({ preventScroll: true });
|
||||
ctx.dom.sessionHelpFilter.select();
|
||||
}
|
||||
|
||||
function applyFilterAndRender(): void {
|
||||
const sections = filterSections(helpSections, helpFilterValue);
|
||||
const indexOffsets: number[] = [];
|
||||
let running = 0;
|
||||
for (const section of sections) {
|
||||
indexOffsets.push(running);
|
||||
running += section.rows.length;
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpContent.innerHTML = '';
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
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.textContent = helpFilterValue
|
||||
? 'No matching shortcuts found.'
|
||||
: 'No active session shortcuts found.';
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
|
||||
|
||||
if (isFilterInputFocused()) return;
|
||||
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
|
||||
function addPointerFocusListener(): void {
|
||||
if (modalPointerFocusGuard) return;
|
||||
|
||||
modalPointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
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);
|
||||
modalPointerFocusGuard = null;
|
||||
}
|
||||
|
||||
function startFocusRecoveryGuards(): void {
|
||||
if (windowFocusGuard) return;
|
||||
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
|
||||
function stopFocusRecoveryGuards(): void {
|
||||
if (!windowFocusGuard) return;
|
||||
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');
|
||||
ctx.dom.sessionHelpContent.textContent = message;
|
||||
ctx.state.sessionHelpSelectedIndex = 0;
|
||||
}
|
||||
|
||||
async function render(): Promise<boolean> {
|
||||
try {
|
||||
const [keybindings, styleConfig, shortcuts] = await Promise.all([
|
||||
window.electronAPI.getKeybindings(),
|
||||
window.electronAPI.getSubtitleStyle(),
|
||||
window.electronAPI.getConfiguredShortcuts(),
|
||||
]);
|
||||
|
||||
const bindingSections = buildBindingSections(keybindings);
|
||||
if (bindingSections.length > 0) {
|
||||
const playback = bindingSections.find(
|
||||
(section) => section.title === 'Playback and navigation',
|
||||
);
|
||||
if (playback) {
|
||||
playback.title = 'MPV shortcuts';
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutSections = buildOverlayShortcutSections(shortcuts);
|
||||
if (shortcutSections.length > 0) {
|
||||
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.';
|
||||
showRenderError(`Session help failed to load: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
|
||||
openBinding = opening;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
const dataLoaded = await render();
|
||||
|
||||
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.';
|
||||
} else if (openBinding.fallbackUsed) {
|
||||
ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.';
|
||||
} else {
|
||||
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.';
|
||||
} else {
|
||||
ctx.dom.sessionHelpStatus.textContent =
|
||||
'Session help data is unavailable right now. Press Esc to close.';
|
||||
ctx.dom.sessionHelpWarning.textContent =
|
||||
'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 = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
if (focusGuard === null) {
|
||||
focusGuard = (event: FocusEvent) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusGuard);
|
||||
}
|
||||
|
||||
addPointerFocusListener();
|
||||
startFocusRecoveryGuards();
|
||||
requestOverlayFocus();
|
||||
window.focus();
|
||||
enforceModalFocus();
|
||||
}
|
||||
|
||||
function closeSessionHelpModal(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (focusGuard) {
|
||||
document.removeEventListener('focusin', focusGuard);
|
||||
focusGuard = null;
|
||||
}
|
||||
removePointerFocusListener();
|
||||
stopFocusRecoveryGuards();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.dom.overlay instanceof HTMLElement) {
|
||||
// Overlay remains `tabindex="-1"` to allow programmatic focus for fallback.
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
helpFilterValue = '';
|
||||
window.focus();
|
||||
}
|
||||
|
||||
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.sessionHelpModalOpen) return false;
|
||||
|
||||
if (isFilterInputFocused()) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (!helpFilterValue) {
|
||||
closeSessionHelpModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
helpFilterValue = '';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
applyFilterAndRender();
|
||||
focusFallbackTarget();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeSessionHelpModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
const items = getItems();
|
||||
if (items.length === 0) return true;
|
||||
|
||||
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
focusFilterInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (key === 'arrowdown' || key === 'j' || key === 'l') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowup' || key === 'k' || key === 'h') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
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.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', () => {
|
||||
closeSessionHelpModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeSessionHelpModal,
|
||||
handleSessionHelpKeydown,
|
||||
openSessionHelpModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
142
src/renderer/modals/subsync.ts
Normal file
142
src/renderer/modals/subsync.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export function createSubsyncModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
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);
|
||||
}
|
||||
|
||||
function updateSubsyncSourceVisibility(): void {
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
|
||||
}
|
||||
|
||||
function renderSubsyncSourceTracks(): void {
|
||||
ctx.dom.subsyncSourceSelect.innerHTML = '';
|
||||
for (const track of ctx.state.subsyncSourceTracks) {
|
||||
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;
|
||||
}
|
||||
|
||||
function closeSubsyncModal(): void {
|
||||
if (!ctx.state.subsyncModalOpen) return;
|
||||
|
||||
ctx.state.subsyncModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||
|
||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
||||
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources;
|
||||
|
||||
renderSubsyncSourceTracks();
|
||||
updateSubsyncSourceVisibility();
|
||||
|
||||
setSubsyncStatus(
|
||||
hasSources
|
||||
? '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');
|
||||
}
|
||||
|
||||
async function runSubsyncManualFromModal(): Promise<void> {
|
||||
if (ctx.state.subsyncSubmitting) return;
|
||||
|
||||
const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync';
|
||||
const sourceTrackId =
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.subsyncSubmitting = true;
|
||||
ctx.dom.subsyncRunButton.disabled = true;
|
||||
|
||||
closeSubsyncModal();
|
||||
try {
|
||||
await window.electronAPI.runSubsyncManual({
|
||||
engine,
|
||||
sourceTrackId,
|
||||
});
|
||||
} finally {
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubsyncKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeSubsyncModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void runSubsyncManualFromModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.subsyncCloseButton.addEventListener('click', () => {
|
||||
closeSubsyncModal();
|
||||
});
|
||||
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
});
|
||||
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
});
|
||||
ctx.dom.subsyncRunButton.addEventListener('click', () => {
|
||||
void runSubsyncManualFromModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeSubsyncModal,
|
||||
handleSubsyncKeydown,
|
||||
openSubsyncModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
117
src/renderer/overlay-content-measurement.ts
Normal file
117
src/renderer/overlay-content-measurement.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { OverlayContentMeasurement, OverlayContentRect } from '../types';
|
||||
import type { RendererContext } from './context';
|
||||
|
||||
const MEASUREMENT_DEBOUNCE_MS = 80;
|
||||
|
||||
function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' {
|
||||
return layer === 'visible' || layer === 'invisible';
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function toMeasuredRect(rect: DOMRect): OverlayContentRect | null {
|
||||
if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = Math.max(0, rect.width);
|
||||
const height = Math.max(0, rect.height);
|
||||
|
||||
return {
|
||||
x: round2(rect.left),
|
||||
y: round2(rect.top),
|
||||
width: round2(width),
|
||||
height: round2(height),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const bottom = Math.max(a.y + a.height, b.y + b.height);
|
||||
return {
|
||||
x: round2(left),
|
||||
y: round2(top),
|
||||
width: round2(Math.max(0, right - left)),
|
||||
height: round2(Math.max(0, bottom - top)),
|
||||
};
|
||||
}
|
||||
|
||||
function hasVisibleTextContent(element: HTMLElement): boolean {
|
||||
return Boolean(element.textContent && element.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
|
||||
let combinedRect: OverlayContentRect | null = null;
|
||||
|
||||
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
|
||||
if (subtitleHasContent) {
|
||||
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
|
||||
if (subtitleRect) {
|
||||
combinedRect = subtitleRect;
|
||||
}
|
||||
}
|
||||
|
||||
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
|
||||
if (secondaryHasContent) {
|
||||
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
|
||||
if (secondaryRect) {
|
||||
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
|
||||
}
|
||||
}
|
||||
|
||||
if (!combinedRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: combinedRect.x,
|
||||
y: combinedRect.y,
|
||||
width: round2(Math.max(0, combinedRect.width)),
|
||||
height: round2(Math.max(0, combinedRect.height)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
|
||||
let debounceTimer: number | null = null;
|
||||
|
||||
function emitNow(): void {
|
||||
if (!isMeasurableOverlayLayer(ctx.platform.overlayLayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurement: OverlayContentMeasurement = {
|
||||
layer: ctx.platform.overlayLayer,
|
||||
measuredAtMs: Date.now(),
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
// Explicit null rect signals "no content yet", and main should use fallback bounds.
|
||||
contentRect: collectContentRect(ctx),
|
||||
};
|
||||
|
||||
window.electronAPI.reportOverlayContentBounds(measurement);
|
||||
}
|
||||
|
||||
function schedule(): void {
|
||||
if (debounceTimer !== null) {
|
||||
window.clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
emitNow();
|
||||
}, MEASUREMENT_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
emitNow,
|
||||
schedule,
|
||||
};
|
||||
}
|
||||
1
src/renderer/positioning.ts
Normal file
1
src/renderer/positioning.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createPositioningController } from './positioning/controller.js';
|
||||
36
src/renderer/positioning/controller.ts
Normal file
36
src/renderer/positioning/controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
createInMemorySubtitlePositionController,
|
||||
type SubtitlePositionController,
|
||||
} from './position-state.js';
|
||||
import {
|
||||
createInvisibleOffsetController,
|
||||
type InvisibleOffsetController,
|
||||
} from './invisible-offset.js';
|
||||
import {
|
||||
createMpvSubtitleLayoutController,
|
||||
type MpvSubtitleLayoutController,
|
||||
} from './invisible-layout.js';
|
||||
|
||||
type PositioningControllerOptions = {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>;
|
||||
applySubtitleFontSize: (fontSize: number) => void;
|
||||
};
|
||||
|
||||
export function createPositioningController(
|
||||
ctx: RendererContext,
|
||||
options: PositioningControllerOptions,
|
||||
) {
|
||||
const visible = createInMemorySubtitlePositionController(ctx);
|
||||
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;
|
||||
}
|
||||
187
src/renderer/positioning/invisible-layout-helpers.ts
Normal file
187
src/renderer/positioning/invisible-layout-helpers.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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';
|
||||
|
||||
export function applyContainerBaseLayout(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
horizontalAvailable: number;
|
||||
leftInset: number;
|
||||
marginX: number;
|
||||
hAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = 'absolute';
|
||||
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.left = `${leftInset + marginX}px`;
|
||||
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';
|
||||
} else if (hAlign === 2) {
|
||||
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.subtitleRoot.style.display = 'inline-block';
|
||||
ctx.dom.subtitleRoot.style.maxWidth = '100%';
|
||||
ctx.dom.subtitleRoot.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
export function applyVerticalPosition(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
renderAreaHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
marginY: number;
|
||||
effectiveFontSize: number;
|
||||
borderPx: number;
|
||||
shadowPx: number;
|
||||
vAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset);
|
||||
const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5);
|
||||
|
||||
if (params.vAlign === 2) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(
|
||||
0,
|
||||
params.topInset + params.marginY - baselineCompensationPx,
|
||||
)}px`;
|
||||
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%)';
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorY =
|
||||
params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx;
|
||||
const bottomPx = Math.max(0, params.renderAreaHeight - anchorY);
|
||||
|
||||
ctx.dom.subtitleContainer.style.top = '';
|
||||
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return strippedFont !== rawFont
|
||||
? `"${rawFont}", "${strippedFont}", sans-serif`
|
||||
: `"${rawFont}", sans-serif`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function resolveLetterSpacing(
|
||||
spacing: number,
|
||||
pxPerScaledPixel: number,
|
||||
isMacOSPlatform: boolean,
|
||||
): string {
|
||||
if (Math.abs(spacing) > 0.0001) {
|
||||
return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`;
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const halfLeading = (computedLineHeight - effectiveFontSize) / 2;
|
||||
if (halfLeading <= 0.5) return;
|
||||
|
||||
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
if (Number.isFinite(currentBottom)) {
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`;
|
||||
}
|
||||
|
||||
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
|
||||
if (Number.isFinite(currentTop)) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMacOSAdjustments(ctx: RendererContext): void {
|
||||
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
|
||||
if (!isMacOSPlatform) return;
|
||||
|
||||
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
if (!Number.isFinite(currentBottom)) return;
|
||||
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
|
||||
0,
|
||||
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
|
||||
)}px`;
|
||||
}
|
||||
|
||||
export function applyTypography(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
pxPerScaledPixel: number;
|
||||
effectiveFontSize: number;
|
||||
},
|
||||
): void {
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
|
||||
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'line-height',
|
||||
resolveLineHeight(lineCount, isMacOSPlatform),
|
||||
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' : '',
|
||||
);
|
||||
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);
|
||||
}
|
||||
133
src/renderer/positioning/invisible-layout-metrics.ts
Normal file
133
src/renderer/positioning/invisible-layout-metrics.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
|
||||
export type SubtitleAlignment = { hAlign: 0 | 1 | 2; vAlign: 0 | 1 | 2 };
|
||||
|
||||
export type SubtitleLayoutGeometry = {
|
||||
renderAreaHeight: number;
|
||||
renderAreaWidth: number;
|
||||
leftInset: number;
|
||||
rightInset: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
horizontalAvailable: number;
|
||||
marginY: number;
|
||||
marginX: number;
|
||||
pxPerScaledPixel: number;
|
||||
effectiveFontSize: number;
|
||||
};
|
||||
|
||||
export function calculateOsdScale(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
isMacOSPlatform: boolean,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
devicePixelRatio: number,
|
||||
): number {
|
||||
const dims = metrics.osdDimensions;
|
||||
|
||||
if (!isMacOSPlatform || !dims) {
|
||||
return devicePixelRatio;
|
||||
}
|
||||
|
||||
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
|
||||
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
|
||||
: devicePixelRatio;
|
||||
|
||||
return avgRatio > 1.25 ? avgRatio : 1;
|
||||
}
|
||||
|
||||
export function calculateSubtitlePosition(
|
||||
_metrics: MpvSubtitleRenderMetrics,
|
||||
_scale: number,
|
||||
alignment: number,
|
||||
): SubtitleAlignment {
|
||||
return {
|
||||
hAlign: ((alignment - 1) % 3) as 0 | 1 | 2,
|
||||
vAlign: Math.floor((alignment - 1) / 3) as 0 | 1 | 2,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLinePadding(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
pxPerScaledPixel: number,
|
||||
): { marginY: number; marginX: number } {
|
||||
return {
|
||||
marginY: metrics.subMarginY * pxPerScaledPixel,
|
||||
marginX: Math.max(0, metrics.subMarginX * pxPerScaledPixel),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPlatformFontCompensation(
|
||||
fontSizePx: number,
|
||||
isMacOSPlatform: boolean,
|
||||
): number {
|
||||
return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx;
|
||||
}
|
||||
|
||||
function calculateGeometry(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
osdToCssScale: number,
|
||||
): 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;
|
||||
const videoLeftInset = dims ? dims.ml / osdToCssScale : 0;
|
||||
const videoRightInset = dims ? dims.mr / osdToCssScale : 0;
|
||||
const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
|
||||
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
|
||||
|
||||
const anchorToVideoArea = !metrics.subUseMargins;
|
||||
const leftInset = anchorToVideoArea ? videoLeftInset : 0;
|
||||
const rightInset = anchorToVideoArea ? videoRightInset : 0;
|
||||
const topInset = anchorToVideoArea ? videoTopInset : 0;
|
||||
const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
|
||||
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
|
||||
|
||||
return {
|
||||
renderAreaHeight,
|
||||
renderAreaWidth,
|
||||
leftInset,
|
||||
rightInset,
|
||||
topInset,
|
||||
bottomInset,
|
||||
horizontalAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateSubtitleMetrics(
|
||||
ctx: RendererContext,
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
): SubtitleLayoutGeometry {
|
||||
const osdToCssScale = calculateOsdScale(
|
||||
metrics,
|
||||
ctx.platform.isMacOSPlatform,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
const geometry = calculateGeometry(metrics, osdToCssScale);
|
||||
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);
|
||||
const effectiveFontSize = applyPlatformFontCompensation(
|
||||
computedFontSize,
|
||||
ctx.platform.isMacOSPlatform,
|
||||
);
|
||||
const spacing = resolveLinePadding(metrics, pxPerScaledPixel);
|
||||
|
||||
return {
|
||||
...geometry,
|
||||
marginY: spacing.marginY,
|
||||
marginX: spacing.marginX,
|
||||
pxPerScaledPixel,
|
||||
effectiveFontSize,
|
||||
};
|
||||
}
|
||||
85
src/renderer/positioning/invisible-layout.ts
Normal file
85
src/renderer/positioning/invisible-layout.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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';
|
||||
|
||||
export type MpvSubtitleLayoutController = {
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
source: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function createMpvSubtitleLayoutController(
|
||||
ctx: RendererContext,
|
||||
applySubtitleFontSize: (fontSize: number) => void,
|
||||
options: {
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
},
|
||||
): MpvSubtitleLayoutController {
|
||||
function applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
source: string,
|
||||
): void {
|
||||
ctx.state.mpvSubtitleRenderMetrics = metrics;
|
||||
|
||||
const geometry = calculateSubtitleMetrics(ctx, metrics);
|
||||
const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2);
|
||||
|
||||
applySubtitleFontSize(geometry.effectiveFontSize);
|
||||
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel;
|
||||
const effectiveShadowOffset = metrics.subShadowOffset * geometry.pxPerScaledPixel;
|
||||
|
||||
document.documentElement.style.setProperty('--sub-border-size', `${effectiveBorderSize}px`);
|
||||
|
||||
applyContainerBaseLayout(ctx, {
|
||||
horizontalAvailable: Math.max(
|
||||
0,
|
||||
geometry.horizontalAvailable - Math.round(geometry.marginX * 2),
|
||||
),
|
||||
leftInset: geometry.leftInset,
|
||||
marginX: geometry.marginX,
|
||||
hAlign: alignment.hAlign,
|
||||
});
|
||||
|
||||
applyVerticalPosition(ctx, {
|
||||
metrics,
|
||||
renderAreaHeight: geometry.renderAreaHeight,
|
||||
topInset: geometry.topInset,
|
||||
bottomInset: geometry.bottomInset,
|
||||
marginY: geometry.marginY,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
borderPx: effectiveBorderSize,
|
||||
shadowPx: effectiveShadowOffset,
|
||||
vAlign: alignment.vAlign,
|
||||
});
|
||||
|
||||
applyTypography(ctx, {
|
||||
metrics,
|
||||
pxPerScaledPixel: geometry.pxPerScaledPixel,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const parsedTop = parseFloat(ctx.dom.subtitleContainer.style.top);
|
||||
ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop) ? parsedTop : null;
|
||||
|
||||
options.applyInvisibleSubtitleOffsetPosition();
|
||||
options.updateInvisiblePositionEditHud();
|
||||
|
||||
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source);
|
||||
}
|
||||
|
||||
return {
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics,
|
||||
};
|
||||
}
|
||||
161
src/renderer/positioning/invisible-offset.ts
Normal file
161
src/renderer/positioning/invisible-offset.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { SubtitlePosition } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
export type InvisibleOffsetController = {
|
||||
applyInvisibleStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
setInvisiblePositionEditMode: (enabled: boolean) => void;
|
||||
saveInvisiblePositionEdit: () => void;
|
||||
cancelInvisiblePositionEdit: () => void;
|
||||
setupInvisiblePositionEditHud: () => void;
|
||||
};
|
||||
|
||||
function formatEditHudText(offsetX: number, offsetY: number): string {
|
||||
return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`;
|
||||
}
|
||||
|
||||
function createEditPositionText(ctx: RendererContext): string {
|
||||
return formatEditHudText(
|
||||
ctx.state.invisibleSubtitleOffsetXPx,
|
||||
ctx.state.invisibleSubtitleOffsetYPx,
|
||||
);
|
||||
}
|
||||
|
||||
function applyOffsetByBasePosition(ctx: RendererContext): void {
|
||||
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,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.top = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.invisibleLayoutBaseTopPx !== null) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(
|
||||
0,
|
||||
ctx.state.invisibleLayoutBaseTopPx - ctx.state.invisibleSubtitleOffsetYPx,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.bottom = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvisibleOffsetController(
|
||||
ctx: RendererContext,
|
||||
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);
|
||||
|
||||
if (enabled) {
|
||||
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.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function updateInvisiblePositionEditHud(): void {
|
||||
if (!ctx.state.invisiblePositionEditHud) return;
|
||||
ctx.state.invisiblePositionEditHud.textContent = createEditPositionText(ctx);
|
||||
}
|
||||
|
||||
function applyInvisibleSubtitleOffsetPosition(): void {
|
||||
applyOffsetByBasePosition(ctx);
|
||||
}
|
||||
|
||||
function applyInvisibleStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
if (position && typeof position.yPercent === 'number' && Number.isFinite(position.yPercent)) {
|
||||
ctx.state.persistedSubtitlePosition = {
|
||||
...ctx.state.persistedSubtitlePosition,
|
||||
yPercent: position.yPercent,
|
||||
};
|
||||
}
|
||||
|
||||
if (position) {
|
||||
const nextX =
|
||||
typeof position.invisibleOffsetXPx === 'number' &&
|
||||
Number.isFinite(position.invisibleOffsetXPx)
|
||||
? position.invisibleOffsetXPx
|
||||
: 0;
|
||||
const nextY =
|
||||
typeof position.invisibleOffsetYPx === 'number' &&
|
||||
Number.isFinite(position.invisibleOffsetYPx)
|
||||
? position.invisibleOffsetYPx
|
||||
: 0;
|
||||
ctx.state.invisibleSubtitleOffsetXPx = nextX;
|
||||
ctx.state.invisibleSubtitleOffsetYPx = nextY;
|
||||
} else {
|
||||
ctx.state.invisibleSubtitleOffsetXPx = 0;
|
||||
ctx.state.invisibleSubtitleOffsetYPx = 0;
|
||||
}
|
||||
|
||||
applyOffsetByBasePosition(ctx);
|
||||
console.log(
|
||||
'[invisible-overlay] Applied subtitle offset from',
|
||||
source,
|
||||
`${ctx.state.invisibleSubtitleOffsetXPx}px`,
|
||||
`${ctx.state.invisibleSubtitleOffsetYPx}px`,
|
||||
);
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function saveInvisiblePositionEdit(): void {
|
||||
const nextPosition = {
|
||||
yPercent: ctx.state.persistedSubtitlePosition.yPercent,
|
||||
invisibleOffsetXPx: ctx.state.invisibleSubtitleOffsetXPx,
|
||||
invisibleOffsetYPx: ctx.state.invisibleSubtitleOffsetYPx,
|
||||
};
|
||||
window.electronAPI.saveSubtitlePosition(nextPosition);
|
||||
setInvisiblePositionEditMode(false);
|
||||
}
|
||||
|
||||
function cancelInvisiblePositionEdit(): void {
|
||||
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';
|
||||
ctx.dom.overlay.appendChild(hud);
|
||||
ctx.state.invisiblePositionEditHud = hud;
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
return {
|
||||
applyInvisibleStoredSubtitlePosition,
|
||||
applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud,
|
||||
setInvisiblePositionEditMode,
|
||||
saveInvisiblePositionEdit,
|
||||
cancelInvisiblePositionEdit,
|
||||
setupInvisiblePositionEditHud,
|
||||
};
|
||||
}
|
||||
120
src/renderer/positioning/position-state.ts
Normal file
120
src/renderer/positioning/position-state.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
|
||||
if (!position || typeof position.yPercent !== 'number' || !Number.isFinite(position.yPercent)) {
|
||||
return ctx.state.persistedSubtitlePosition.yPercent;
|
||||
}
|
||||
|
||||
return position.yPercent;
|
||||
}
|
||||
|
||||
function getPersistedOffset(
|
||||
position: SubtitlePosition | null,
|
||||
key: 'invisibleOffsetXPx' | 'invisibleOffsetYPx',
|
||||
): number {
|
||||
if (position && typeof position[key] === 'number' && Number.isFinite(position[key])) {
|
||||
return position[key];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function updatePersistedSubtitlePosition(
|
||||
ctx: RendererContext,
|
||||
position: SubtitlePosition | null,
|
||||
): void {
|
||||
ctx.state.persistedSubtitlePosition = {
|
||||
yPercent: getPersistedYPercent(ctx, position),
|
||||
invisibleOffsetXPx: getPersistedOffset(position, 'invisibleOffsetXPx'),
|
||||
invisibleOffsetYPx: getPersistedOffset(position, 'invisibleOffsetYPx'),
|
||||
};
|
||||
}
|
||||
|
||||
function getNextPersistedPosition(
|
||||
ctx: RendererContext,
|
||||
patch: Partial<SubtitlePosition>,
|
||||
): SubtitlePosition {
|
||||
return {
|
||||
yPercent:
|
||||
typeof patch.yPercent === 'number' && Number.isFinite(patch.yPercent)
|
||||
? patch.yPercent
|
||||
: ctx.state.persistedSubtitlePosition.yPercent,
|
||||
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)
|
||||
? patch.invisibleOffsetYPx
|
||||
: (ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function createInMemorySubtitlePositionController(
|
||||
ctx: RendererContext,
|
||||
): SubtitlePositionController {
|
||||
function getCurrentYPercent(): number {
|
||||
if (ctx.state.currentYPercent !== null) {
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
|
||||
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
|
||||
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
|
||||
function applyYPercent(yPercent: number): void {
|
||||
const clampedPercent = clampYPercent(yPercent);
|
||||
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.marginBottom = `${marginBottom}px`;
|
||||
}
|
||||
|
||||
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 {
|
||||
updatePersistedSubtitlePosition(ctx, position);
|
||||
if (position && position.yPercent !== undefined) {
|
||||
applyYPercent(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);
|
||||
}
|
||||
|
||||
return {
|
||||
applyStoredSubtitlePosition,
|
||||
getCurrentYPercent,
|
||||
applyYPercent,
|
||||
persistSubtitlePositionPatch,
|
||||
};
|
||||
}
|
||||
472
src/renderer/renderer.ts
Normal file
472
src/renderer/renderer.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* SubMiner - All-in-one sentence mining overlay
|
||||
* Copyright (C) 2024 sudacode
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {
|
||||
KikuDuplicateCardInfo,
|
||||
MpvSubtitleRenderMetrics,
|
||||
RuntimeOptionState,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
SubsyncManualPayload,
|
||||
ConfigHotReloadPayload,
|
||||
} 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 {
|
||||
createRendererRecoveryController,
|
||||
registerRendererGlobalErrorHandlers,
|
||||
} from './error-recovery.js';
|
||||
import { resolveRendererDom } from './utils/dom.js';
|
||||
import { resolvePlatformInfo } from './utils/platform.js';
|
||||
import {
|
||||
buildMpvLoadfileCommands,
|
||||
collectDroppedVideoPaths,
|
||||
} from '../core/services/overlay-drop.js';
|
||||
|
||||
const ctx = {
|
||||
dom: resolveRendererDom(),
|
||||
platform: resolvePlatformInfo(),
|
||||
state: createRendererState(),
|
||||
};
|
||||
|
||||
function isAnySettingsModalOpen(): boolean {
|
||||
return (
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen
|
||||
);
|
||||
}
|
||||
|
||||
function isAnyModalOpen(): boolean {
|
||||
return (
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen
|
||||
);
|
||||
}
|
||||
|
||||
function syncSettingsModalSubtitleSuppression(): void {
|
||||
const suppressSubtitles = isAnySettingsModalOpen();
|
||||
document.body.classList.toggle('settings-modal-open', suppressSubtitles);
|
||||
if (suppressSubtitles) {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
}
|
||||
}
|
||||
|
||||
const subtitleRenderer = createSubtitleRenderer(ctx);
|
||||
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
|
||||
const positioning = createPositioningController(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen },
|
||||
applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize,
|
||||
});
|
||||
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const kikuModal = createKikuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const jimakuModal = createJimakuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
|
||||
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
|
||||
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
|
||||
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
});
|
||||
const mouseHandlers = createMouseHandlers(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics:
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics,
|
||||
applyYPercent: positioning.applyYPercent,
|
||||
getCurrentYPercent: positioning.getCurrentYPercent,
|
||||
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
||||
reportHoveredTokenIndex: (tokenIndex: number | null) => {
|
||||
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
|
||||
},
|
||||
});
|
||||
|
||||
let lastSubtitlePreview = '';
|
||||
let lastSecondarySubtitlePreview = '';
|
||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function truncateForErrorLog(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= 180) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, 177)}...`;
|
||||
}
|
||||
|
||||
function getActiveModal(): string | null {
|
||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
||||
if (ctx.state.sessionHelpModalOpen) return 'session-help';
|
||||
return null;
|
||||
}
|
||||
|
||||
function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
jimakuModal.closeJimakuModal();
|
||||
}
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
||||
}
|
||||
if (ctx.state.subsyncModalOpen) {
|
||||
subsyncModal.closeSubsyncModal();
|
||||
}
|
||||
if (ctx.state.kikuModalOpen) {
|
||||
kikuModal.cancelKikuFieldGrouping();
|
||||
}
|
||||
if (ctx.state.sessionHelpModalOpen) {
|
||||
sessionHelpModal.closeSessionHelpModal();
|
||||
}
|
||||
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
|
||||
function restoreOverlayInteractionAfterError(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
if (ctx.state.invisiblePositionEditMode) {
|
||||
positioning.setInvisiblePositionEditMode(false);
|
||||
}
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
|
||||
function showOverlayErrorToast(message: string): void {
|
||||
if (overlayErrorToastTimeout) {
|
||||
clearTimeout(overlayErrorToastTimeout);
|
||||
overlayErrorToastTimeout = null;
|
||||
}
|
||||
ctx.dom.overlayErrorToast.textContent = message;
|
||||
ctx.dom.overlayErrorToast.classList.remove('hidden');
|
||||
overlayErrorToastTimeout = setTimeout(() => {
|
||||
ctx.dom.overlayErrorToast.classList.add('hidden');
|
||||
ctx.dom.overlayErrorToast.textContent = '';
|
||||
overlayErrorToastTimeout = null;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
const recovery = createRendererRecoveryController({
|
||||
dismissActiveUi: dismissActiveUiAfterError,
|
||||
restoreOverlayInteraction: restoreOverlayInteractionAfterError,
|
||||
showToast: showOverlayErrorToast,
|
||||
getSnapshot: () => ({
|
||||
activeModal: getActiveModal(),
|
||||
subtitlePreview: lastSubtitlePreview,
|
||||
secondarySubtitlePreview: lastSecondarySubtitlePreview,
|
||||
isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'),
|
||||
isOverSubtitle: ctx.state.isOverSubtitle,
|
||||
invisiblePositionEditMode: ctx.state.invisiblePositionEditMode,
|
||||
overlayLayer: ctx.platform.overlayLayer,
|
||||
}),
|
||||
logError: (payload) => {
|
||||
console.error('renderer overlay recovery', payload);
|
||||
},
|
||||
});
|
||||
|
||||
registerRendererGlobalErrorHandlers(window, recovery);
|
||||
|
||||
function runGuarded(action: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
recovery.handleError(error, { source: 'callback', action });
|
||||
}
|
||||
}
|
||||
|
||||
function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
|
||||
Promise.resolve()
|
||||
.then(fn)
|
||||
.catch((error) => {
|
||||
recovery.handleError(error, { source: 'callback', action });
|
||||
});
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
||||
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
if (typeof data === 'string') {
|
||||
lastSubtitlePreview = truncateForErrorLog(data);
|
||||
} else if (data && typeof data.text === 'string') {
|
||||
lastSubtitlePreview = truncateForErrorLog(data.text);
|
||||
}
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
|
||||
runGuarded('subtitle-position:update', () => {
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change');
|
||||
} else {
|
||||
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
||||
}
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => {
|
||||
runGuarded('mpv-metrics:update', () => {
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event');
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
|
||||
runGuarded('overlay-debug-visualization:update', () => {
|
||||
document.body.classList.toggle('debug-invisible-visualization', enabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
|
||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||
measurementReporter.schedule();
|
||||
|
||||
window.electronAPI.onSecondarySub((text: string) => {
|
||||
runGuarded('secondary-subtitle:update', () => {
|
||||
lastSecondarySubtitlePreview = truncateForErrorLog(text);
|
||||
subtitleRenderer.renderSecondarySub(text);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => {
|
||||
runGuarded('secondary-subtitle-mode:update', () => {
|
||||
subtitleRenderer.updateSecondarySubMode(mode);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
mouseHandlers.setupInvisibleHoverSelection();
|
||||
mouseHandlers.setupInvisibleTokenHoverReporter();
|
||||
positioning.setupInvisiblePositionEditHud();
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
mouseHandlers.setupYomitanObserver();
|
||||
setupDragDropToMpvQueue();
|
||||
window.addEventListener('resize', () => {
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
|
||||
jimakuModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
|
||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||
runGuarded('runtime-options:changed', () => {
|
||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||
runGuarded('config:hot-reload', () => {
|
||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuardedAsync('runtime-options:open', async () => {
|
||||
try {
|
||||
await runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
} catch {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenJimaku(() => {
|
||||
runGuarded('jimaku:open', () => {
|
||||
jimakuModal.openJimakuModal();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||
runGuarded('subsync:manual-open', () => {
|
||||
subsyncModal.openSubsyncModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onKikuFieldGroupingRequest(
|
||||
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
|
||||
runGuarded('kiku:field-grouping-open', () => {
|
||||
kikuModal.openKikuFieldGroupingModal(data);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!ctx.platform.isInvisibleLayer) {
|
||||
mouseHandlers.setupDragging();
|
||||
}
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
positioning.applyInvisibleStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
await window.electronAPI.getMpvSubtitleRenderMetrics(),
|
||||
'startup',
|
||||
);
|
||||
} else {
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
}
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
|
||||
measurementReporter.emitNow();
|
||||
}
|
||||
|
||||
function setupDragDropToMpvQueue(): void {
|
||||
let dragDepth = 0;
|
||||
|
||||
const setDropInteractive = (): void => {
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearDropInteractive = (): void => {
|
||||
dragDepth = 0;
|
||||
if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) {
|
||||
return;
|
||||
}
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dragenter', (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return;
|
||||
dragDepth += 1;
|
||||
setDropInteractive();
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', (event: DragEvent) => {
|
||||
if (dragDepth <= 0 || !event.dataTransfer) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.addEventListener('dragleave', () => {
|
||||
if (dragDepth <= 0) return;
|
||||
dragDepth -= 1;
|
||||
if (dragDepth === 0) {
|
||||
clearDropInteractive();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('drop', (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return;
|
||||
event.preventDefault();
|
||||
|
||||
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
||||
for (const command of loadCommands) {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
}
|
||||
if (loadCommands.length > 0) {
|
||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'show-text',
|
||||
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
||||
'1500',
|
||||
]);
|
||||
}
|
||||
|
||||
clearDropInteractive();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
runGuardedAsync('bootstrap:init', init);
|
||||
});
|
||||
} else {
|
||||
runGuardedAsync('bootstrap:init', init);
|
||||
}
|
||||
176
src/renderer/state.ts
Normal file
176
src/renderer/state.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
MpvSubtitleRenderMetrics,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
SubsyncSourceTrack,
|
||||
} from '../types';
|
||||
|
||||
export type KikuModalStep = 'select' | 'preview';
|
||||
export type KikuPreviewMode = 'compact' | 'full';
|
||||
|
||||
export type ChordAction =
|
||||
| { type: 'mpv'; command: string[] }
|
||||
| { type: 'electron'; action: () => void }
|
||||
| { type: 'noop' };
|
||||
|
||||
export type RendererState = {
|
||||
isOverSubtitle: boolean;
|
||||
isDragging: boolean;
|
||||
dragStartY: number;
|
||||
startYPercent: number;
|
||||
currentYPercent: number | null;
|
||||
persistedSubtitlePosition: SubtitlePosition;
|
||||
|
||||
jimakuModalOpen: boolean;
|
||||
jimakuEntries: JimakuEntry[];
|
||||
jimakuFiles: JimakuFileEntry[];
|
||||
selectedEntryIndex: number;
|
||||
selectedFileIndex: number;
|
||||
currentEpisodeFilter: number | null;
|
||||
currentEntryId: number | null;
|
||||
|
||||
kikuModalOpen: boolean;
|
||||
kikuSelectedCard: 1 | 2;
|
||||
kikuOriginalData: KikuDuplicateCardInfo | null;
|
||||
kikuDuplicateData: KikuDuplicateCardInfo | null;
|
||||
kikuModalStep: KikuModalStep;
|
||||
kikuPreviewMode: KikuPreviewMode;
|
||||
kikuPendingChoice: KikuFieldGroupingChoice | null;
|
||||
kikuPreviewCompactData: Record<string, unknown> | null;
|
||||
kikuPreviewFullData: Record<string, unknown> | null;
|
||||
|
||||
runtimeOptionsModalOpen: boolean;
|
||||
runtimeOptions: RuntimeOptionState[];
|
||||
runtimeOptionSelectedIndex: number;
|
||||
runtimeOptionDraftValues: Map<RuntimeOptionId, RuntimeOptionValue>;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
|
||||
mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics | null;
|
||||
invisiblePositionEditMode: boolean;
|
||||
invisiblePositionEditStartX: number;
|
||||
invisiblePositionEditStartY: number;
|
||||
invisibleSubtitleOffsetXPx: number;
|
||||
invisibleSubtitleOffsetYPx: number;
|
||||
invisibleLayoutBaseLeftPx: number;
|
||||
invisibleLayoutBaseBottomPx: number | null;
|
||||
invisibleLayoutBaseTopPx: number | null;
|
||||
invisiblePositionEditHud: HTMLDivElement | null;
|
||||
currentInvisibleSubtitleLineCount: number;
|
||||
|
||||
lastHoverSelectionKey: string;
|
||||
lastHoverSelectionNode: Text | null;
|
||||
lastHoveredTokenIndex: number | null;
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
jlptN1Color: string;
|
||||
jlptN2Color: string;
|
||||
jlptN3Color: string;
|
||||
jlptN4Color: string;
|
||||
jlptN5Color: string;
|
||||
preserveSubtitleLineBreaks: boolean;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
frequencyDictionaryTopX: number;
|
||||
frequencyDictionaryMode: 'single' | 'banded';
|
||||
frequencyDictionarySingleColor: string;
|
||||
frequencyDictionaryBand1Color: string;
|
||||
frequencyDictionaryBand2Color: string;
|
||||
frequencyDictionaryBand3Color: string;
|
||||
frequencyDictionaryBand4Color: string;
|
||||
frequencyDictionaryBand5Color: string;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
chordPending: boolean;
|
||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export function createRendererState(): RendererState {
|
||||
return {
|
||||
isOverSubtitle: false,
|
||||
isDragging: false,
|
||||
dragStartY: 0,
|
||||
startYPercent: 0,
|
||||
currentYPercent: null,
|
||||
persistedSubtitlePosition: { yPercent: 10 },
|
||||
|
||||
jimakuModalOpen: false,
|
||||
jimakuEntries: [],
|
||||
jimakuFiles: [],
|
||||
selectedEntryIndex: 0,
|
||||
selectedFileIndex: 0,
|
||||
currentEpisodeFilter: null,
|
||||
currentEntryId: null,
|
||||
|
||||
kikuModalOpen: false,
|
||||
kikuSelectedCard: 1,
|
||||
kikuOriginalData: null,
|
||||
kikuDuplicateData: null,
|
||||
kikuModalStep: 'select',
|
||||
kikuPreviewMode: 'compact',
|
||||
kikuPendingChoice: null,
|
||||
kikuPreviewCompactData: null,
|
||||
kikuPreviewFullData: null,
|
||||
|
||||
runtimeOptionsModalOpen: false,
|
||||
runtimeOptions: [],
|
||||
runtimeOptionSelectedIndex: 0,
|
||||
runtimeOptionDraftValues: new Map(),
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
|
||||
mpvSubtitleRenderMetrics: null,
|
||||
invisiblePositionEditMode: false,
|
||||
invisiblePositionEditStartX: 0,
|
||||
invisiblePositionEditStartY: 0,
|
||||
invisibleSubtitleOffsetXPx: 0,
|
||||
invisibleSubtitleOffsetYPx: 0,
|
||||
invisibleLayoutBaseLeftPx: 0,
|
||||
invisibleLayoutBaseBottomPx: null,
|
||||
invisibleLayoutBaseTopPx: null,
|
||||
invisiblePositionEditHud: null,
|
||||
currentInvisibleSubtitleLineCount: 1,
|
||||
|
||||
lastHoverSelectionKey: '',
|
||||
lastHoverSelectionNode: null,
|
||||
lastHoveredTokenIndex: null,
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
jlptN3Color: '#f9e2af',
|
||||
jlptN4Color: '#a6e3a1',
|
||||
jlptN5Color: '#8aadf4',
|
||||
preserveSubtitleLineBreaks: false,
|
||||
frequencyDictionaryEnabled: false,
|
||||
frequencyDictionaryTopX: 1000,
|
||||
frequencyDictionaryMode: 'single',
|
||||
frequencyDictionarySingleColor: '#f5a97f',
|
||||
frequencyDictionaryBand1Color: '#ed8796',
|
||||
frequencyDictionaryBand2Color: '#f5a97f',
|
||||
frequencyDictionaryBand3Color: '#f9e2af',
|
||||
frequencyDictionaryBand4Color: '#a6e3a1',
|
||||
frequencyDictionaryBand5Color: '#8aadf4',
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
chordPending: false,
|
||||
chordTimeout: null,
|
||||
};
|
||||
}
|
||||
1121
src/renderer/style.css
Normal file
1121
src/renderer/style.css
Normal file
File diff suppressed because it is too large
Load Diff
293
src/renderer/subtitle-render.test.ts
Normal file
293
src/renderer/subtitle-render.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
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 { alignTokensToSourceText, computeWordClass, normalizeSubtitle } from './subtitle-render.js';
|
||||
|
||||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
surface: '',
|
||||
reading: '',
|
||||
headword: '',
|
||||
startPos: 0,
|
||||
endPos: 0,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function extractClassBlock(cssText: string, selector: string): string {
|
||||
const ruleRegex = /([^{}]+)\{([^}]*)\}/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
let fallbackBlock = '';
|
||||
|
||||
while ((match = ruleRegex.exec(cssText)) !== null) {
|
||||
const selectorsBlock = match[1]?.trim() ?? '';
|
||||
const selectorBlock = match[2] ?? '';
|
||||
|
||||
const selectors = selectorsBlock
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (selectors.includes(selector)) {
|
||||
if (selectors.length === 1) {
|
||||
return selectorBlock;
|
||||
}
|
||||
|
||||
if (!fallbackBlock) {
|
||||
fallbackBlock = selectorBlock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackBlock) {
|
||||
return fallbackBlock;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||||
const knownJlpt = createToken({
|
||||
isKnown: true,
|
||||
jlptLevel: 'N1',
|
||||
surface: '猫',
|
||||
});
|
||||
const nPlusOneJlpt = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
jlptLevel: 'N2',
|
||||
surface: '犬',
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1');
|
||||
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
|
||||
});
|
||||
|
||||
test('computeWordClass does not add frequency class to known or N+1 terms', () => {
|
||||
const known = createToken({
|
||||
isKnown: true,
|
||||
frequencyRank: 10,
|
||||
surface: '既知',
|
||||
});
|
||||
const nPlusOne = createToken({
|
||||
isNPlusOneTarget: true,
|
||||
frequencyRank: 10,
|
||||
surface: '目標',
|
||||
});
|
||||
const frequency = createToken({
|
||||
frequencyRank: 10,
|
||||
surface: '頻度',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(known, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'word word-known',
|
||||
);
|
||||
assert.equal(
|
||||
computeWordClass(nPlusOne, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
}),
|
||||
'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,
|
||||
}),
|
||||
'word word-frequency-single',
|
||||
);
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
|
||||
const token = createToken({
|
||||
surface: '猫',
|
||||
frequencyRank: 50,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-single');
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class when rank equals topX', () => {
|
||||
const token = createToken({
|
||||
surface: '水',
|
||||
frequencyRank: 100,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-single');
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for banded mode', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 250,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: 'banded',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word word-frequency-band-2');
|
||||
});
|
||||
|
||||
test('computeWordClass uses configured band count for banded mode', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 2,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 4,
|
||||
mode: 'banded',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'],
|
||||
} as any);
|
||||
|
||||
assert.equal(actual, 'word word-frequency-band-3');
|
||||
});
|
||||
|
||||
test('computeWordClass skips frequency class when rank is out of topX', () => {
|
||||
const token = createToken({
|
||||
surface: '犬',
|
||||
frequencyRank: 1200,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(token, {
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: 'single',
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||||
});
|
||||
|
||||
assert.equal(actual, 'word');
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
|
||||
createToken({ surface: 'かかってこい', reading: 'かかってこい', headword: 'かかってこい' }),
|
||||
];
|
||||
|
||||
const segments = alignTokensToSourceText(tokens, 'キリキリと\nかかってこい');
|
||||
assert.deepEqual(
|
||||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||||
['token', 'text:\n', 'token'],
|
||||
);
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText treats whitespace-only token surfaces as plain text separators', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: '常人が使えば' }),
|
||||
createToken({ surface: ' ' }),
|
||||
createToken({ surface: 'その圧倒的な力に' }),
|
||||
createToken({ surface: '\n' }),
|
||||
createToken({ surface: '体が耐えきれず死に至るが…' }),
|
||||
];
|
||||
|
||||
const segments = alignTokensToSourceText(tokens, '常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…');
|
||||
assert.deepEqual(
|
||||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||||
['token', 'text: ', 'token', 'text:\n', 'token'],
|
||||
);
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText avoids duplicate tail when later token surface does not match source', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: '君たちが潰した拠点に' }),
|
||||
createToken({ surface: '教団の主力は1人もいない' }),
|
||||
];
|
||||
|
||||
const segments = alignTokensToSourceText(
|
||||
tokens,
|
||||
'君たちが潰した拠点に\n教団の主力は1人もいない',
|
||||
);
|
||||
assert.deepEqual(
|
||||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||||
['token', 'text:\n教団の主力は1人もいない'],
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
|
||||
assert.equal(
|
||||
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),
|
||||
'常人が使えば その圧倒的な力に 体が耐えきれず死に至るが…',
|
||||
);
|
||||
});
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
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}`);
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
assert.match(block, /text-decoration-thickness:\s*2px;/);
|
||||
assert.match(block, /text-underline-offset:\s*4px;/);
|
||||
assert.match(block, /color:\s*inherit;/);
|
||||
}
|
||||
|
||||
for (let band = 1; band <= 5; band += 1) {
|
||||
const block = extractClassBlock(
|
||||
cssText,
|
||||
band === 1
|
||||
? '#subtitleRoot .word.word-frequency-single'
|
||||
: `#subtitleRoot .word.word-frequency-band-${band}`,
|
||||
);
|
||||
assert.ok(
|
||||
block.length > 0,
|
||||
`frequency class word-frequency-${band === 1 ? 'single' : `band-${band}`} should exist`,
|
||||
);
|
||||
assert.match(block, /color:\s*var\(/);
|
||||
}
|
||||
});
|
||||
540
src/renderer/subtitle-render.ts
Normal file
540
src/renderer/subtitle-render.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import type { MergedToken, SecondarySubMode, SubtitleData, SubtitleStyleConfig } from '../types';
|
||||
import type { RendererContext } from './context';
|
||||
|
||||
type FrequencyRenderSettings = {
|
||||
enabled: boolean;
|
||||
topX: number;
|
||||
mode: 'single' | 'banded';
|
||||
singleColor: string;
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
|
||||
function isWhitespaceOnly(value: string): boolean {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
|
||||
export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string {
|
||||
if (!text) return '';
|
||||
|
||||
let normalized = text.replace(/\\N/g, '\n').replace(/\\n/g, '\n');
|
||||
normalized = normalized.replace(/\{[^}]*\}/g, '');
|
||||
if (collapseLineBreaks) {
|
||||
normalized = normalized.replace(/\n/g, ' ');
|
||||
normalized = normalized.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
return trim ? normalized.trim() : normalized;
|
||||
}
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function sanitizeHexColor(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim())
|
||||
? value.trim()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
enabled: false,
|
||||
topX: 1000,
|
||||
mode: 'single',
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||
};
|
||||
|
||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function sanitizeFrequencyBandedColors(
|
||||
value: unknown,
|
||||
fallback: FrequencyRenderSettings['bandedColors'],
|
||||
): FrequencyRenderSettings['bandedColors'] {
|
||||
if (!Array.isArray(value) || value.length !== 5) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return [
|
||||
sanitizeHexColor(value[0], fallback[0]),
|
||||
sanitizeHexColor(value[1], fallback[1]),
|
||||
sanitizeHexColor(value[2], fallback[2]),
|
||||
sanitizeHexColor(value[3], fallback[3]),
|
||||
sanitizeHexColor(value[4], fallback[4]),
|
||||
];
|
||||
}
|
||||
|
||||
function getFrequencyDictionaryClass(
|
||||
token: MergedToken,
|
||||
settings: FrequencyRenderSettings,
|
||||
): string {
|
||||
if (!settings.enabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
||||
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX);
|
||||
if (rank > topX) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (settings.mode === 'banded') {
|
||||
const bandCount = settings.bandedColors.length;
|
||||
const normalizedBand = Math.ceil((rank / topX) * bandCount);
|
||||
const band = Math.min(bandCount, Math.max(1, normalizedBand));
|
||||
return `word-frequency-band-${band}`;
|
||||
}
|
||||
|
||||
return 'word-frequency-single';
|
||||
}
|
||||
|
||||
function renderWithTokens(
|
||||
root: HTMLElement,
|
||||
tokens: MergedToken[],
|
||||
frequencyRenderSettings?: Partial<FrequencyRenderSettings>,
|
||||
sourceText?: string,
|
||||
preserveLineBreaks = false,
|
||||
): void {
|
||||
const resolvedFrequencyRenderSettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencyRenderSettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencyRenderSettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(
|
||||
frequencyRenderSettings?.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencyRenderSettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (preserveLineBreaks && sourceText) {
|
||||
const normalizedSource = normalizeSubtitle(sourceText, true, false);
|
||||
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = segment.token;
|
||||
const span = document.createElement('span');
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.textContent = token.surface;
|
||||
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const surface = token.surface.replace(/\n/g, ' ');
|
||||
if (!surface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWhitespaceOnly(surface)) {
|
||||
fragment.appendChild(document.createTextNode(surface));
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.textContent = surface;
|
||||
span.dataset.tokenIndex = String(index);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
type SubtitleRenderSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'token'; token: MergedToken; tokenIndex: number };
|
||||
|
||||
export function alignTokensToSourceText(
|
||||
tokens: MergedToken[],
|
||||
sourceText: string,
|
||||
): SubtitleRenderSegment[] {
|
||||
if (tokens.length === 0) {
|
||||
return sourceText ? [{ kind: 'text', text: sourceText }] : [];
|
||||
}
|
||||
|
||||
const segments: SubtitleRenderSegment[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
|
||||
const token = tokens[tokenIndex];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const surface = token.surface;
|
||||
if (!surface || isWhitespaceOnly(surface)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const foundIndex = sourceText.indexOf(surface, cursor);
|
||||
if (foundIndex < 0) {
|
||||
// Token text can diverge from source normalization (e.g., half/full-width forms).
|
||||
// Skip unmatched token to avoid duplicating visible tail text in preserve-line-break mode.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundIndex > cursor) {
|
||||
segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) });
|
||||
}
|
||||
|
||||
segments.push({ kind: 'token', token, tokenIndex });
|
||||
cursor = foundIndex + surface.length;
|
||||
}
|
||||
|
||||
if (cursor < sourceText.length) {
|
||||
segments.push({ kind: 'text', text: sourceText.slice(cursor) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function computeWordClass(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
): string {
|
||||
const resolvedFrequencySettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencySettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencySettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
const classes = ['word'];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
classes.push('word-n-plus-one');
|
||||
} else if (token.isKnown) {
|
||||
classes.push('word-known');
|
||||
}
|
||||
|
||||
if (token.jlptLevel) {
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
if (!token.isKnown && !token.isNPlusOneTarget) {
|
||||
const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings);
|
||||
if (frequencyClass) {
|
||||
classes.push(frequencyClass);
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
function renderCharacterLevel(root: HTMLElement, text: string): void {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const char of text) {
|
||||
if (char === '\n') {
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
continue;
|
||||
}
|
||||
const span = document.createElement('span');
|
||||
span.className = 'c';
|
||||
span.textContent = char;
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void {
|
||||
const lines = text.split('\n');
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
fragment.appendChild(document.createTextNode(lines[i] ?? ''));
|
||||
if (i < lines.length - 1) {
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
}
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
function renderSubtitle(data: SubtitleData | string): void {
|
||||
ctx.dom.subtitleRoot.innerHTML = '';
|
||||
ctx.state.lastHoverSelectionKey = '';
|
||||
ctx.state.lastHoverSelectionNode = null;
|
||||
ctx.state.lastHoveredTokenIndex = null;
|
||||
|
||||
let text: string;
|
||||
let tokens: MergedToken[] | null;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
text = data;
|
||||
tokens = null;
|
||||
} else if (data && typeof data === 'object') {
|
||||
text = data.text;
|
||||
tokens = data.tokens;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text) return;
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
const normalizedInvisible = normalizeSubtitle(text, false);
|
||||
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
|
||||
1,
|
||||
normalizedInvisible.split('\n').length,
|
||||
);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
text,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
text,
|
||||
ctx.state.preserveSubtitleLineBreaks,
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
|
||||
}
|
||||
|
||||
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> {
|
||||
return {
|
||||
enabled: ctx.state.frequencyDictionaryEnabled,
|
||||
topX: ctx.state.frequencyDictionaryTopX,
|
||||
mode: ctx.state.frequencyDictionaryMode,
|
||||
singleColor: ctx.state.frequencyDictionarySingleColor,
|
||||
bandedColors: [
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] as [string, string, string, string, string],
|
||||
};
|
||||
}
|
||||
|
||||
function renderSecondarySub(text: string): void {
|
||||
ctx.dom.secondarySubRoot.innerHTML = '';
|
||||
if (!text) return;
|
||||
|
||||
const normalized = text
|
||||
.replace(/\\N/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\{[^}]*\}/g, '')
|
||||
.trim();
|
||||
|
||||
if (!normalized) return;
|
||||
|
||||
const lines = normalized.split('\n');
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line) {
|
||||
ctx.dom.secondarySubRoot.appendChild(document.createTextNode(line));
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
ctx.dom.secondarySubRoot.appendChild(document.createElement('br'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSecondarySubMode(mode: SecondarySubMode): void {
|
||||
ctx.dom.secondarySubContainer.classList.remove(
|
||||
'secondary-sub-hidden',
|
||||
'secondary-sub-visible',
|
||||
'secondary-sub-hover',
|
||||
);
|
||||
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`);
|
||||
}
|
||||
|
||||
function applySubtitleFontSize(fontSize: number): void {
|
||||
const clampedSize = Math.max(10, fontSize);
|
||||
ctx.dom.subtitleRoot.style.fontSize = `${clampedSize}px`;
|
||||
document.documentElement.style.setProperty('--subtitle-font-size', `${clampedSize}px`);
|
||||
}
|
||||
|
||||
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
|
||||
if (!style) return;
|
||||
|
||||
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
|
||||
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
|
||||
if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor;
|
||||
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
if (style.backgroundColor) {
|
||||
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
|
||||
}
|
||||
|
||||
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
|
||||
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
|
||||
const jlptColors = {
|
||||
N1: ctx.state.jlptN1Color ?? '#ed8796',
|
||||
N2: ctx.state.jlptN2Color ?? '#f5a97f',
|
||||
N3: ctx.state.jlptN3Color ?? '#f9e2af',
|
||||
N4: ctx.state.jlptN4Color ?? '#a6e3a1',
|
||||
N5: ctx.state.jlptN5Color ?? '#8aadf4',
|
||||
...(style.jlptColors
|
||||
? {
|
||||
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
|
||||
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
|
||||
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
|
||||
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
|
||||
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
ctx.state.knownWordColor = knownWordColor;
|
||||
ctx.state.nPlusOneColor = nPlusOneColor;
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor);
|
||||
ctx.state.jlptN1Color = jlptColors.N1;
|
||||
ctx.state.jlptN2Color = jlptColors.N2;
|
||||
ctx.state.jlptN3Color = jlptColors.N3;
|
||||
ctx.state.jlptN4Color = jlptColors.N4;
|
||||
ctx.state.jlptN5Color = jlptColors.N5;
|
||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n4-color', jlptColors.N4);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n5-color', jlptColors.N5);
|
||||
const frequencyDictionarySettings = style.frequencyDictionary ?? {};
|
||||
const frequencyEnabled =
|
||||
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled;
|
||||
const frequencyTopX = sanitizeFrequencyTopX(
|
||||
frequencyDictionarySettings.topX,
|
||||
ctx.state.frequencyDictionaryTopX,
|
||||
);
|
||||
const frequencyMode = frequencyDictionarySettings.mode
|
||||
? frequencyDictionarySettings.mode
|
||||
: ctx.state.frequencyDictionaryMode;
|
||||
const frequencySingleColor = sanitizeHexColor(
|
||||
frequencyDictionarySettings.singleColor,
|
||||
ctx.state.frequencyDictionarySingleColor,
|
||||
);
|
||||
const frequencyBandedColors = sanitizeFrequencyBandedColors(
|
||||
frequencyDictionarySettings.bandedColors,
|
||||
[
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] as [string, string, string, string, string],
|
||||
);
|
||||
|
||||
ctx.state.frequencyDictionaryEnabled = frequencyEnabled;
|
||||
ctx.state.frequencyDictionaryTopX = frequencyTopX;
|
||||
ctx.state.frequencyDictionaryMode = frequencyMode;
|
||||
ctx.state.frequencyDictionarySingleColor = frequencySingleColor;
|
||||
[
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] = frequencyBandedColors;
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-frequency-single-color',
|
||||
frequencySingleColor,
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-frequency-band-1-color',
|
||||
frequencyBandedColors[0],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-frequency-band-2-color',
|
||||
frequencyBandedColors[1],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-frequency-band-3-color',
|
||||
frequencyBandedColors[2],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-frequency-band-4-color',
|
||||
frequencyBandedColors[3],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-frequency-band-5-color',
|
||||
frequencyBandedColors[4],
|
||||
);
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
if (secondaryStyle.fontFamily) {
|
||||
ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily;
|
||||
}
|
||||
if (secondaryStyle.fontSize) {
|
||||
ctx.dom.secondarySubRoot.style.fontSize = `${secondaryStyle.fontSize}px`;
|
||||
}
|
||||
if (secondaryStyle.fontColor) {
|
||||
ctx.dom.secondarySubRoot.style.color = secondaryStyle.fontColor;
|
||||
}
|
||||
if (secondaryStyle.fontWeight) {
|
||||
ctx.dom.secondarySubRoot.style.fontWeight = secondaryStyle.fontWeight;
|
||||
}
|
||||
if (secondaryStyle.fontStyle) {
|
||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||
}
|
||||
if (secondaryStyle.backgroundColor) {
|
||||
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applySubtitleFontSize,
|
||||
applySubtitleStyle,
|
||||
renderSecondarySub,
|
||||
renderSubtitle,
|
||||
updateSecondarySubMode,
|
||||
};
|
||||
}
|
||||
143
src/renderer/utils/dom.ts
Normal file
143
src/renderer/utils/dom.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export type RendererDom = {
|
||||
subtitleRoot: HTMLElement;
|
||||
subtitleContainer: HTMLElement;
|
||||
overlay: HTMLElement;
|
||||
overlayErrorToast: HTMLDivElement;
|
||||
secondarySubContainer: HTMLElement;
|
||||
secondarySubRoot: HTMLElement;
|
||||
|
||||
jimakuModal: HTMLDivElement;
|
||||
jimakuTitleInput: HTMLInputElement;
|
||||
jimakuSeasonInput: HTMLInputElement;
|
||||
jimakuEpisodeInput: HTMLInputElement;
|
||||
jimakuSearchButton: HTMLButtonElement;
|
||||
jimakuCloseButton: HTMLButtonElement;
|
||||
jimakuStatus: HTMLDivElement;
|
||||
jimakuEntriesSection: HTMLDivElement;
|
||||
jimakuEntriesList: HTMLUListElement;
|
||||
jimakuFilesSection: HTMLDivElement;
|
||||
jimakuFilesList: HTMLUListElement;
|
||||
jimakuBroadenButton: HTMLButtonElement;
|
||||
|
||||
kikuModal: HTMLDivElement;
|
||||
kikuCard1: HTMLDivElement;
|
||||
kikuCard2: HTMLDivElement;
|
||||
kikuCard1Expression: HTMLDivElement;
|
||||
kikuCard2Expression: HTMLDivElement;
|
||||
kikuCard1Sentence: HTMLDivElement;
|
||||
kikuCard2Sentence: HTMLDivElement;
|
||||
kikuCard1Meta: HTMLDivElement;
|
||||
kikuCard2Meta: HTMLDivElement;
|
||||
kikuConfirmButton: HTMLButtonElement;
|
||||
kikuCancelButton: HTMLButtonElement;
|
||||
kikuDeleteDuplicateCheckbox: HTMLInputElement;
|
||||
kikuSelectionStep: HTMLDivElement;
|
||||
kikuPreviewStep: HTMLDivElement;
|
||||
kikuPreviewJson: HTMLPreElement;
|
||||
kikuPreviewCompactButton: HTMLButtonElement;
|
||||
kikuPreviewFullButton: HTMLButtonElement;
|
||||
kikuPreviewError: HTMLDivElement;
|
||||
kikuBackButton: HTMLButtonElement;
|
||||
kikuFinalConfirmButton: HTMLButtonElement;
|
||||
kikuFinalCancelButton: HTMLButtonElement;
|
||||
kikuHint: HTMLDivElement;
|
||||
|
||||
runtimeOptionsModal: HTMLDivElement;
|
||||
runtimeOptionsClose: HTMLButtonElement;
|
||||
runtimeOptionsList: HTMLUListElement;
|
||||
runtimeOptionsStatus: HTMLDivElement;
|
||||
|
||||
subsyncModal: HTMLDivElement;
|
||||
subsyncCloseButton: HTMLButtonElement;
|
||||
subsyncEngineAlass: HTMLInputElement;
|
||||
subsyncEngineFfsubsync: HTMLInputElement;
|
||||
subsyncSourceLabel: HTMLLabelElement;
|
||||
subsyncSourceSelect: HTMLSelectElement;
|
||||
subsyncRunButton: HTMLButtonElement;
|
||||
subsyncStatus: HTMLDivElement;
|
||||
|
||||
sessionHelpModal: HTMLDivElement;
|
||||
sessionHelpClose: HTMLButtonElement;
|
||||
sessionHelpShortcut: HTMLDivElement;
|
||||
sessionHelpWarning: HTMLDivElement;
|
||||
sessionHelpStatus: HTMLDivElement;
|
||||
sessionHelpFilter: HTMLInputElement;
|
||||
sessionHelpContent: HTMLDivElement;
|
||||
};
|
||||
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing required DOM element #${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
export function resolveRendererDom(): RendererDom {
|
||||
return {
|
||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||
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'),
|
||||
|
||||
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'),
|
||||
|
||||
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'),
|
||||
};
|
||||
}
|
||||
48
src/renderer/utils/platform.ts
Normal file
48
src/renderer/utils/platform.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type OverlayLayer = 'visible' | 'invisible' | 'secondary';
|
||||
|
||||
export type PlatformInfo = {
|
||||
overlayLayer: OverlayLayer;
|
||||
isInvisibleLayer: boolean;
|
||||
isSecondaryLayer: boolean;
|
||||
isLinuxPlatform: boolean;
|
||||
isMacOSPlatform: boolean;
|
||||
shouldToggleMouseIgnore: boolean;
|
||||
invisiblePositionEditToggleCode: string;
|
||||
invisiblePositionStepPx: number;
|
||||
invisiblePositionStepFastPx: number;
|
||||
};
|
||||
|
||||
export function resolvePlatformInfo(): PlatformInfo {
|
||||
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
|
||||
const queryLayer = new URLSearchParams(window.location.search).get('layer');
|
||||
const overlayLayerFromQuery: OverlayLayer | null =
|
||||
queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary'
|
||||
? queryLayer
|
||||
: null;
|
||||
|
||||
const overlayLayer: OverlayLayer =
|
||||
overlayLayerFromQuery ??
|
||||
(overlayLayerFromPreload === 'visible' ||
|
||||
overlayLayerFromPreload === 'invisible' ||
|
||||
overlayLayerFromPreload === 'secondary'
|
||||
? overlayLayerFromPreload
|
||||
: 'visible');
|
||||
|
||||
const isInvisibleLayer = overlayLayer === 'invisible';
|
||||
const isSecondaryLayer = overlayLayer === 'secondary';
|
||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
||||
const isMacOSPlatform =
|
||||
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
||||
|
||||
return {
|
||||
overlayLayer,
|
||||
isInvisibleLayer,
|
||||
isSecondaryLayer,
|
||||
isLinuxPlatform,
|
||||
isMacOSPlatform,
|
||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer,
|
||||
invisiblePositionEditToggleCode: 'KeyP',
|
||||
invisiblePositionStepPx: 1,
|
||||
invisiblePositionStepFastPx: 4,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user