mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Overlay 2.0 (#12)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Keybinding } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
import { hasYomitanPopupIframe, isYomitanPopupIframe } from '../yomitan-popup.js';
|
||||
|
||||
export function createKeyboardHandlers(
|
||||
ctx: RendererContext,
|
||||
@@ -14,11 +15,6 @@ export function createKeyboardHandlers(
|
||||
fallbackUsed: boolean;
|
||||
fallbackUnavailable: boolean;
|
||||
}) => void;
|
||||
saveInvisiblePositionEdit: () => void;
|
||||
cancelInvisiblePositionEdit: () => void;
|
||||
setInvisiblePositionEditMode: (enabled: boolean) => void;
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
},
|
||||
) {
|
||||
@@ -32,9 +28,6 @@ export function createKeyboardHandlers(
|
||||
['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'] }],
|
||||
@@ -46,10 +39,9 @@ export function createKeyboardHandlers(
|
||||
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')) {
|
||||
if (isYomitanPopupIframe(target)) return true;
|
||||
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||
return true;
|
||||
}
|
||||
if (target.closest && target.closest('iframe[id^="yomitan-popup"]')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,15 +55,6 @@ export function createKeyboardHandlers(
|
||||
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;
|
||||
@@ -113,69 +96,6 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -188,9 +108,7 @@ export function createKeyboardHandlers(
|
||||
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 (hasYomitanPopupIframe(document)) return;
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
@@ -252,13 +170,7 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.code === 'KeyA' &&
|
||||
!e.repeat
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.code === 'KeyA' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
options.appendClipboardVideoToQueue();
|
||||
return;
|
||||
|
||||
172
src/renderer/handlers/mouse.test.ts
Normal file
172
src/renderer/handlers/mouse.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createMouseHandlers } from './mouse.js';
|
||||
|
||||
function createClassList() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
add: (...tokens: string[]) => {
|
||||
for (const token of tokens) {
|
||||
classes.add(token);
|
||||
}
|
||||
},
|
||||
remove: (...tokens: string[]) => {
|
||||
for (const token of tokens) {
|
||||
classes.delete(token);
|
||||
}
|
||||
},
|
||||
contains: (token: string) => classes.has(token),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createMouseTestContext() {
|
||||
const overlayClassList = createClassList();
|
||||
const subtitleRootClassList = createClassList();
|
||||
const subtitleContainerClassList = createClassList();
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: {
|
||||
classList: overlayClassList,
|
||||
},
|
||||
subtitleRoot: {
|
||||
classList: subtitleRootClassList,
|
||||
},
|
||||
subtitleContainer: {
|
||||
classList: subtitleContainerClassList,
|
||||
style: { cursor: '' },
|
||||
addEventListener: () => {},
|
||||
},
|
||||
secondarySubContainer: {
|
||||
addEventListener: () => {},
|
||||
},
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: false,
|
||||
isMacOSPlatform: false,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isDragging: false,
|
||||
dragStartY: 0,
|
||||
startYPercent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('auto-pause on subtitle hover pauses on enter and resumes on leave when enabled', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handleMouseEnter();
|
||||
await handlers.handleMouseLeave();
|
||||
|
||||
assert.deepEqual(mpvCommands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto-pause on subtitle hover skips when playback is already paused', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||
getPlaybackPaused: async () => true,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handleMouseEnter();
|
||||
await handlers.handleMouseLeave();
|
||||
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
|
||||
test('auto-pause on subtitle hover is skipped when disabled in config', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handleMouseEnter();
|
||||
await handlers.handleMouseLeave();
|
||||
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
|
||||
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const deferred = createDeferred<boolean | null>();
|
||||
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||
getPlaybackPaused: async () => deferred.promise,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
const enterPromise = handlers.handleMouseEnter();
|
||||
await handlers.handleMouseLeave();
|
||||
deferred.resolve(false);
|
||||
await enterPromise;
|
||||
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
@@ -1,37 +1,46 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
hasYomitanPopupIframe,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
|
||||
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;
|
||||
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
},
|
||||
) {
|
||||
const wordSegmenter =
|
||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
: null;
|
||||
let yomitanPopupVisible = false;
|
||||
let hoverPauseRequestId = 0;
|
||||
let pausedBySubtitleHover = false;
|
||||
|
||||
function handleMouseEnter(): void {
|
||||
ctx.state.isOverSubtitle = true;
|
||||
function enablePopupInteraction(): void {
|
||||
yomitanPopupVisible = true;
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
if (ctx.platform.isMacOSPlatform) {
|
||||
window.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (
|
||||
!yomitanPopup &&
|
||||
!options.modalStateReader.isAnyModalOpen() &&
|
||||
!ctx.state.invisiblePositionEditMode
|
||||
) {
|
||||
function disablePopupInteractionIfIdle(): void {
|
||||
if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) {
|
||||
yomitanPopupVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
yomitanPopupVisible = false;
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
@@ -39,6 +48,45 @@ export function createMouseHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMouseEnter(): Promise<void> {
|
||||
ctx.state.isOverSubtitle = true;
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
if (!options.getSubtitleHoverAutoPauseEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++hoverPauseRequestId;
|
||||
let paused: boolean | null = null;
|
||||
try {
|
||||
paused = await options.getPlaybackPaused();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (requestId !== hoverPauseRequestId || !ctx.state.isOverSubtitle) {
|
||||
return;
|
||||
}
|
||||
if (paused !== false) {
|
||||
return;
|
||||
}
|
||||
options.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
pausedBySubtitleHover = true;
|
||||
}
|
||||
|
||||
async function handleMouseLeave(): Promise<void> {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
hoverPauseRequestId += 1;
|
||||
if (pausedBySubtitleHover) {
|
||||
pausedBySubtitleHover = false;
|
||||
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||
}
|
||||
if (yomitanPopupVisible) return;
|
||||
disablePopupInteractionIfIdle();
|
||||
}
|
||||
|
||||
function setupDragging(): void {
|
||||
ctx.dom.subtitleContainer.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
@@ -75,184 +123,8 @@ export function createMouseHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
}
|
||||
@@ -271,39 +143,31 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
function setupYomitanObserver(): void {
|
||||
yomitanPopupVisible = hasYomitanPopupIframe(document);
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||
enablePopupInteraction();
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||
disablePopupInteractionIfIdle();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
if (isYomitanPopupIframe(element)) {
|
||||
enablePopupInteraction();
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isYomitanPopupIframe(element)) {
|
||||
disablePopupInteractionIfIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -319,8 +183,6 @@ export function createMouseHandlers(
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setupDragging,
|
||||
setupInvisibleHoverSelection,
|
||||
setupInvisibleTokenHoverReporter,
|
||||
setupResizeHandler,
|
||||
setupSelectionObserver,
|
||||
setupYomitanObserver,
|
||||
|
||||
Reference in New Issue
Block a user