mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 06:22:44 -08:00
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
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 getTextOffsetWithinSubtitleRoot(targetNode: Text, targetOffset: number): number | null {
|
|
const clampedTargetOffset = Math.max(0, Math.min(targetOffset, targetNode.data.length));
|
|
const walker = document.createTreeWalker(ctx.dom.subtitleRoot, NodeFilter.SHOW_ALL);
|
|
let totalOffset = 0;
|
|
|
|
let node: Node | null = walker.currentNode;
|
|
while (node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const textNode = node as Text;
|
|
if (textNode === targetNode) {
|
|
return totalOffset + clampedTargetOffset;
|
|
}
|
|
totalOffset += textNode.data.length;
|
|
} else if (
|
|
node.nodeType === Node.ELEMENT_NODE &&
|
|
(node as Element).tagName.toUpperCase() === 'BR'
|
|
) {
|
|
totalOffset += 1;
|
|
}
|
|
node = walker.nextNode();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveHoveredInvisibleTokenIndex(event: MouseEvent): number | null {
|
|
if (!(event.target instanceof Node)) {
|
|
return null;
|
|
}
|
|
if (!ctx.dom.subtitleRoot.contains(event.target)) {
|
|
return null;
|
|
}
|
|
if (ctx.state.invisibleTokenHoverRanges.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
|
|
if (!caretRange) {
|
|
return null;
|
|
}
|
|
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) {
|
|
return null;
|
|
}
|
|
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) {
|
|
return null;
|
|
}
|
|
|
|
const textOffset = getTextOffsetWithinSubtitleRoot(
|
|
caretRange.startContainer as Text,
|
|
caretRange.startOffset,
|
|
);
|
|
if (textOffset === null) {
|
|
return null;
|
|
}
|
|
|
|
for (const range of ctx.state.invisibleTokenHoverRanges) {
|
|
if (textOffset >= range.start && textOffset < range.end) {
|
|
return range.tokenIndex;
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
const tokenIndex = resolveHoveredInvisibleTokenIndex(event);
|
|
if (tokenIndex === null) {
|
|
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,
|
|
};
|
|
}
|