feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

View 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,
};
}

View 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,
};
}