mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 18:22:41 -08:00
refactor: remove invisible subtitle overlay code
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);
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
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;
|
||||
},
|
||||
) {
|
||||
const wordSegmenter =
|
||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
: null;
|
||||
let yomitanPopupVisible = false;
|
||||
|
||||
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 disablePopupInteractionIfIdle(): void {
|
||||
if (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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(): void {
|
||||
ctx.state.isOverSubtitle = true;
|
||||
@@ -26,17 +56,8 @@ export function createMouseHandlers(
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
if (yomitanPopupVisible) return;
|
||||
disablePopupInteractionIfIdle();
|
||||
}
|
||||
|
||||
function setupDragging(): void {
|
||||
@@ -75,238 +96,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 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());
|
||||
});
|
||||
}
|
||||
@@ -325,39 +116,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -373,8 +156,6 @@ export function createMouseHandlers(
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setupDragging,
|
||||
setupInvisibleHoverSelection,
|
||||
setupInvisibleTokenHoverReporter,
|
||||
setupResizeHandler,
|
||||
setupSelectionObserver,
|
||||
setupYomitanObserver,
|
||||
|
||||
@@ -251,7 +251,6 @@ export function createJimakuModal(
|
||||
}
|
||||
|
||||
function openJimakuModal(): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.jimakuModalOpen) return;
|
||||
|
||||
ctx.state.jimakuModalOpen = true;
|
||||
|
||||
@@ -66,7 +66,6 @@ export function createKikuModal(
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = true;
|
||||
|
||||
@@ -162,8 +162,6 @@ export function createRuntimeOptionsModal(
|
||||
}
|
||||
|
||||
async function openRuntimeOptionsModal(): Promise<void> {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
const optionsList = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(optionsList);
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ const OVERLAY_SHORTCUTS: Array<{
|
||||
{ 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[] {
|
||||
|
||||
@@ -45,8 +45,6 @@ export function createSubsyncModal(
|
||||
}
|
||||
|
||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { RendererContext } from './context';
|
||||
|
||||
const MEASUREMENT_DEBOUNCE_MS = 80;
|
||||
|
||||
function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' {
|
||||
return layer === 'visible' || layer === 'invisible';
|
||||
function isMeasurableOverlayLayer(layer: string): layer is 'visible' {
|
||||
return layer === 'visible';
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
|
||||
34
src/renderer/overlay-legacy-cleanup.test.ts
Normal file
34
src/renderer/overlay-legacy-cleanup.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('keyboard chord map no longer emits legacy invisible overlay script messages', () => {
|
||||
const keyboardSource = readWorkspaceFile('src/renderer/handlers/keyboard.ts');
|
||||
assert.doesNotMatch(keyboardSource, /subminer-toggle-invisible/);
|
||||
assert.doesNotMatch(keyboardSource, /subminer-show-invisible/);
|
||||
assert.doesNotMatch(keyboardSource, /subminer-hide-invisible/);
|
||||
});
|
||||
|
||||
test('overlay layer contracts no longer advertise invisible renderer layer', () => {
|
||||
const typesSource = readWorkspaceFile('src/types.ts');
|
||||
assert.doesNotMatch(typesSource, /export type OverlayLayer = 'visible' \| 'invisible'/);
|
||||
assert.doesNotMatch(
|
||||
typesSource,
|
||||
/getOverlayLayer:\s*\(\)\s*=>\s*'visible'\s*\|\s*'invisible'\s*\|\s*'modal'\s*\|\s*null/,
|
||||
);
|
||||
});
|
||||
|
||||
test('renderer stylesheet no longer contains invisible-layer selectors', () => {
|
||||
const cssSource = readWorkspaceFile('src/renderer/style.css');
|
||||
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
|
||||
});
|
||||
|
||||
test('top-level docs avoid stale overlay-layers wording', () => {
|
||||
const docsReadmeSource = readWorkspaceFile('docs/README.md');
|
||||
assert.doesNotMatch(docsReadmeSource, /overlay layers/i);
|
||||
});
|
||||
@@ -1,36 +1,11 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import type { RendererContext } from '../context';
|
||||
import {
|
||||
createInMemorySubtitlePositionController,
|
||||
type SubtitlePositionController,
|
||||
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;
|
||||
): SubtitlePositionController {
|
||||
return createInMemorySubtitlePositionController(ctx);
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import {
|
||||
applyTypography,
|
||||
applyVerticalPosition,
|
||||
resolveBaselineCompensationPx,
|
||||
} from './invisible-layout-helpers.js';
|
||||
|
||||
const METRICS: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
subFontSize: 38,
|
||||
subScale: 1,
|
||||
subMarginY: 34,
|
||||
subMarginX: 19,
|
||||
subFont: 'sans-serif',
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 2.5,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: 'yes',
|
||||
subScaleByWindow: true,
|
||||
subUseMargins: true,
|
||||
osdHeight: 720,
|
||||
osdDimensions: null,
|
||||
};
|
||||
|
||||
type TypographyTestContext = {
|
||||
dom: {
|
||||
subtitleRoot: { style: CSSStyleDeclaration };
|
||||
subtitleContainer: { style: CSSStyleDeclaration };
|
||||
};
|
||||
state: {
|
||||
currentInvisibleSubtitleLineCount: number;
|
||||
invisibleMeasuredDescentPx: number | null;
|
||||
};
|
||||
platform: {
|
||||
isMacOSPlatform: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function withMockedComputedLineHeight(lineHeightPx: number, callback: () => void): void {
|
||||
const originalGetComputedStyle = (globalThis as { getComputedStyle?: unknown }).getComputedStyle;
|
||||
Object.defineProperty(globalThis, 'getComputedStyle', {
|
||||
configurable: true,
|
||||
value: () =>
|
||||
({
|
||||
lineHeight: `${lineHeightPx}px`,
|
||||
}) as CSSStyleDeclaration,
|
||||
});
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
if (typeof originalGetComputedStyle === 'function') {
|
||||
Object.defineProperty(globalThis, 'getComputedStyle', {
|
||||
configurable: true,
|
||||
value: originalGetComputedStyle,
|
||||
});
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'getComputedStyle');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStyle(initial: Record<string, string> = {}): CSSStyleDeclaration {
|
||||
const values: Record<string, string> = { ...initial };
|
||||
const target = {
|
||||
setProperty: (name: string, value: string) => {
|
||||
values[name] = value;
|
||||
},
|
||||
getPropertyValue: (name: string) => values[name] ?? '',
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
|
||||
return new Proxy(target, {
|
||||
get(obj, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
if (prop in obj) return obj[prop as keyof CSSStyleDeclaration];
|
||||
return values[prop] ?? '';
|
||||
}
|
||||
return obj[prop as keyof CSSStyleDeclaration];
|
||||
},
|
||||
set(_obj, prop, value) {
|
||||
if (typeof prop === 'string') {
|
||||
values[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createContext(options: {
|
||||
isMacOSPlatform: boolean;
|
||||
lineCount: number;
|
||||
bottomPx?: number;
|
||||
topPx?: number;
|
||||
}): TypographyTestContext {
|
||||
const subtitleRoot = { style: createStyle() };
|
||||
const subtitleContainer = {
|
||||
style: createStyle({
|
||||
bottom: typeof options.bottomPx === 'number' ? `${options.bottomPx}px` : '',
|
||||
top: typeof options.topPx === 'number' ? `${options.topPx}px` : '',
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
dom: { subtitleRoot, subtitleContainer },
|
||||
state: {
|
||||
currentInvisibleSubtitleLineCount: options.lineCount,
|
||||
invisibleMeasuredDescentPx: null,
|
||||
},
|
||||
platform: {
|
||||
isMacOSPlatform: options.isMacOSPlatform,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveBaselineCompensationPx uses measured descent when present', () => {
|
||||
const compensation = resolveBaselineCompensationPx(10, 2.5, 1);
|
||||
assert.equal(compensation, 16);
|
||||
});
|
||||
|
||||
test('resolveBaselineCompensationPx falls back to border and shadow compensation when descent missing', () => {
|
||||
const compensation = resolveBaselineCompensationPx(null, 2.5, 1);
|
||||
assert.equal(compensation, 17.5);
|
||||
});
|
||||
|
||||
test('applyTypography keeps macOS default letter spacing neutral when mpv spacing is zero', () => {
|
||||
const ctx = createContext({
|
||||
isMacOSPlatform: true,
|
||||
lineCount: 1,
|
||||
bottomPx: 120,
|
||||
});
|
||||
|
||||
withMockedComputedLineHeight(34, () => {
|
||||
applyTypography(ctx as never, {
|
||||
metrics: { ...METRICS, subSpacing: 0 },
|
||||
pxPerScaledPixel: 1,
|
||||
effectiveFontSize: 34,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '0px');
|
||||
});
|
||||
|
||||
test('applyTypography applies full mpv letter spacing scale on macOS', () => {
|
||||
const ctx = createContext({
|
||||
isMacOSPlatform: true,
|
||||
lineCount: 1,
|
||||
bottomPx: 120,
|
||||
});
|
||||
|
||||
withMockedComputedLineHeight(34, () => {
|
||||
applyTypography(ctx as never, {
|
||||
metrics: { ...METRICS, subSpacing: 1.5 },
|
||||
pxPerScaledPixel: 2,
|
||||
effectiveFontSize: 34,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '3px');
|
||||
});
|
||||
|
||||
test('applyTypography uses macOS multiline-tuned line-height for invisible overlay', () => {
|
||||
const ctx = createContext({
|
||||
isMacOSPlatform: true,
|
||||
lineCount: 3,
|
||||
bottomPx: 120,
|
||||
});
|
||||
|
||||
withMockedComputedLineHeight(34, () => {
|
||||
applyTypography(ctx as never, {
|
||||
metrics: METRICS,
|
||||
pxPerScaledPixel: 1,
|
||||
effectiveFontSize: 34,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '1.62');
|
||||
});
|
||||
|
||||
test('applyVerticalPosition uses subtitle position margin and baseline compensation', () => {
|
||||
const ctx = createContext({
|
||||
isMacOSPlatform: true,
|
||||
lineCount: 1,
|
||||
});
|
||||
|
||||
applyVerticalPosition(ctx as never, {
|
||||
metrics: { ...METRICS, subPos: 90 },
|
||||
renderAreaHeight: 720,
|
||||
topInset: 0,
|
||||
bottomInset: 10,
|
||||
marginY: 34,
|
||||
borderPx: 2.5,
|
||||
shadowPx: 0,
|
||||
measuredDescentPx: null,
|
||||
vAlign: 0,
|
||||
});
|
||||
|
||||
const bottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
assert.ok(Number.isFinite(bottom));
|
||||
assert.ok(bottom > 90 && bottom < 105);
|
||||
});
|
||||
|
||||
test('applyVerticalPosition uses measured descent consistently across line counts', () => {
|
||||
const single = createContext({
|
||||
isMacOSPlatform: true,
|
||||
lineCount: 1,
|
||||
});
|
||||
const dense = createContext({
|
||||
isMacOSPlatform: true,
|
||||
lineCount: 3,
|
||||
});
|
||||
|
||||
applyVerticalPosition(single as never, {
|
||||
metrics: METRICS,
|
||||
renderAreaHeight: 720,
|
||||
topInset: 0,
|
||||
bottomInset: 0,
|
||||
marginY: 34,
|
||||
borderPx: 2.5,
|
||||
shadowPx: 0,
|
||||
measuredDescentPx: 12,
|
||||
vAlign: 0,
|
||||
});
|
||||
applyVerticalPosition(dense as never, {
|
||||
metrics: METRICS,
|
||||
renderAreaHeight: 720,
|
||||
topInset: 0,
|
||||
bottomInset: 0,
|
||||
marginY: 34,
|
||||
borderPx: 2.5,
|
||||
shadowPx: 0,
|
||||
measuredDescentPx: 12,
|
||||
vAlign: 0,
|
||||
});
|
||||
|
||||
const singleBottom = parseFloat(single.dom.subtitleContainer.style.bottom);
|
||||
const denseBottom = parseFloat(dense.dom.subtitleContainer.style.bottom);
|
||||
assert.equal(singleBottom, denseBottom);
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
|
||||
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5;
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '1.08';
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.35';
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.48';
|
||||
|
||||
let fontMetricsCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
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;
|
||||
borderPx: number;
|
||||
shadowPx: number;
|
||||
measuredDescentPx: number | null;
|
||||
vAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const baselineCompensationPx = resolveBaselineCompensationPx(
|
||||
params.measuredDescentPx,
|
||||
params.borderPx,
|
||||
params.shadowPx,
|
||||
);
|
||||
|
||||
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 subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
|
||||
const effectiveMargin = Math.max(params.marginY, subPosMargin);
|
||||
const bottomPx = Math.max(0, params.bottomInset + effectiveMargin + baselineCompensationPx);
|
||||
|
||||
ctx.dom.subtitleContainer.style.top = '';
|
||||
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
|
||||
}
|
||||
|
||||
export function resolveBaselineCompensationPx(
|
||||
measuredDescentPx: number | null,
|
||||
borderPx: number,
|
||||
shadowPx: number,
|
||||
): number {
|
||||
const outlineCompensationPx = Math.max(0, borderPx * 2 + shadowPx);
|
||||
if (typeof measuredDescentPx === 'number' && Number.isFinite(measuredDescentPx) && measuredDescentPx > 0) {
|
||||
return Math.max(0, measuredDescentPx + outlineCompensationPx);
|
||||
}
|
||||
|
||||
return Math.max(0, (borderPx + shadowPx) * 5);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export function resolveInvisibleLineHeight(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,
|
||||
): string {
|
||||
if (Math.abs(spacing) > 0.0001) {
|
||||
return `${spacing * pxPerScaledPixel}px`;
|
||||
}
|
||||
|
||||
return '0px';
|
||||
}
|
||||
|
||||
function measureFontDescentPx(ctx: RendererContext): number | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const computedStyle = getComputedStyle(ctx.dom.subtitleRoot);
|
||||
const font = computedStyle.font?.trim();
|
||||
if (!font) return null;
|
||||
|
||||
if (!fontMetricsCanvas) {
|
||||
fontMetricsCanvas = document.createElement('canvas');
|
||||
}
|
||||
|
||||
const context = fontMetricsCanvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
|
||||
context.font = font;
|
||||
const metrics = context.measureText('Hg漢あ');
|
||||
if (!Number.isFinite(metrics.actualBoundingBoxDescent) || metrics.actualBoundingBoxDescent <= 0) {
|
||||
return null;
|
||||
}
|
||||
return metrics.actualBoundingBoxDescent;
|
||||
}
|
||||
|
||||
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 isMacOSPlatform = ctx.platform.isMacOSPlatform;
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const invisibleLineHeight = resolveInvisibleLineHeight(lineCount, isMacOSPlatform);
|
||||
|
||||
ctx.dom.subtitleRoot.style.setProperty('--invisible-sub-line-height', invisibleLineHeight);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'line-height',
|
||||
invisibleLineHeight,
|
||||
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 ? 'important' : '',
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none';
|
||||
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? '700' : '400';
|
||||
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal';
|
||||
ctx.dom.subtitleRoot.style.transform = '';
|
||||
ctx.dom.subtitleRoot.style.transformOrigin = '';
|
||||
ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx);
|
||||
|
||||
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
|
||||
applyMacOSAdjustments(ctx);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { afterEach, test } from 'node:test';
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import {
|
||||
applyPlatformFontCompensation,
|
||||
calculateOsdScale,
|
||||
calculateSubtitleMetrics,
|
||||
} from './invisible-layout-metrics';
|
||||
|
||||
const BASE_METRICS: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
subFontSize: 40,
|
||||
subScale: 1,
|
||||
subMarginY: 34,
|
||||
subMarginX: 19,
|
||||
subFont: 'sans-serif',
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 2,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: 'yes',
|
||||
subScaleByWindow: false,
|
||||
subUseMargins: true,
|
||||
osdHeight: 720,
|
||||
osdDimensions: {
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
ml: 100,
|
||||
mr: 100,
|
||||
mt: 80,
|
||||
mb: 60,
|
||||
},
|
||||
};
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
function setWindowDimensions(width: number, height: number, devicePixelRatio: number): void {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
innerWidth: width,
|
||||
innerHeight: height,
|
||||
devicePixelRatio,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
});
|
||||
|
||||
test('calculateSubtitleMetrics uses video insets for scale-by-video even when subUseMargins is true', () => {
|
||||
setWindowDimensions(1920, 1080, 1);
|
||||
|
||||
const ctx = {
|
||||
platform: {
|
||||
isMacOSPlatform: false,
|
||||
isLinuxPlatform: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const result = calculateSubtitleMetrics(ctx as never, BASE_METRICS);
|
||||
|
||||
const expectedPxPerScaledPixel = (1080 - 80 - 60) / 720;
|
||||
assert.equal(result.pxPerScaledPixel, expectedPxPerScaledPixel);
|
||||
assert.equal(result.effectiveFontSize, BASE_METRICS.subFontSize * expectedPxPerScaledPixel);
|
||||
});
|
||||
|
||||
test('calculateSubtitleMetrics keeps osd insets for positioning even when subUseMargins is true', () => {
|
||||
setWindowDimensions(1920, 1080, 1);
|
||||
|
||||
const ctx = {
|
||||
platform: {
|
||||
isMacOSPlatform: false,
|
||||
isLinuxPlatform: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const result = calculateSubtitleMetrics(ctx as never, BASE_METRICS);
|
||||
|
||||
assert.equal(result.leftInset, 100);
|
||||
assert.equal(result.rightInset, 100);
|
||||
assert.equal(result.topInset, 80);
|
||||
assert.equal(result.bottomInset, 60);
|
||||
assert.equal(result.horizontalAvailable, 1720);
|
||||
});
|
||||
|
||||
test('applyPlatformFontCompensation applies calibrated macOS factor', () => {
|
||||
assert.equal(applyPlatformFontCompensation(100, true), 82);
|
||||
assert.equal(applyPlatformFontCompensation(100, false), 100);
|
||||
});
|
||||
|
||||
test('calculateOsdScale snaps near-DPR macOS ratios to devicePixelRatio', () => {
|
||||
const metrics = {
|
||||
...BASE_METRICS,
|
||||
osdDimensions: {
|
||||
w: 3024,
|
||||
h: 1701,
|
||||
ml: 116,
|
||||
mr: 116,
|
||||
mt: 28,
|
||||
mb: 28,
|
||||
},
|
||||
};
|
||||
|
||||
const scale = calculateOsdScale(metrics, true, 1728, 972, 2);
|
||||
assert.equal(scale, 2);
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
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;
|
||||
|
||||
const candidates = [1, devicePixelRatio].filter((candidate, index, list) => {
|
||||
if (!Number.isFinite(candidate) || candidate <= 0) return false;
|
||||
return list.indexOf(candidate) === index;
|
||||
});
|
||||
|
||||
const snappedScale = candidates.reduce((best, candidate) => {
|
||||
const bestDistance = Math.abs(avgRatio - best);
|
||||
const candidateDistance = Math.abs(avgRatio - candidate);
|
||||
return candidateDistance < bestDistance ? candidate : best;
|
||||
}, candidates[0] ?? 1);
|
||||
|
||||
if (Math.abs(avgRatio - snappedScale) <= 0.35) {
|
||||
return snappedScale;
|
||||
}
|
||||
|
||||
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.82 : 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;
|
||||
|
||||
// Keep layout anchored to the same drawable video region represented by osd-dimensions.
|
||||
const leftInset = videoLeftInset;
|
||||
const rightInset = videoRightInset;
|
||||
const topInset = videoTopInset;
|
||||
const bottomInset = videoBottomInset;
|
||||
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 rawVideoTopInset = metrics.osdDimensions ? metrics.osdDimensions.mt / osdToCssScale : 0;
|
||||
const rawVideoBottomInset = metrics.osdDimensions ? metrics.osdDimensions.mb / osdToCssScale : 0;
|
||||
const videoHeight = geometry.renderAreaHeight - rawVideoTopInset - rawVideoBottomInset;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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,
|
||||
});
|
||||
|
||||
applyTypography(ctx, {
|
||||
metrics,
|
||||
pxPerScaledPixel: geometry.pxPerScaledPixel,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
});
|
||||
|
||||
applyVerticalPosition(ctx, {
|
||||
metrics,
|
||||
renderAreaHeight: geometry.renderAreaHeight,
|
||||
topInset: geometry.topInset,
|
||||
bottomInset: geometry.bottomInset,
|
||||
marginY: geometry.marginY,
|
||||
borderPx: effectiveBorderSize,
|
||||
shadowPx: effectiveShadowOffset,
|
||||
measuredDescentPx: ctx.state.invisibleMeasuredDescentPx,
|
||||
vAlign: alignment.vAlign,
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (source !== 'subtitle-change') {
|
||||
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics,
|
||||
};
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -23,25 +23,12 @@ function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition |
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,14 +41,6 @@ function getNextPersistedPosition(
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
MpvSubtitleRenderMetrics,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
@@ -57,29 +56,6 @@ export type RendererState = {
|
||||
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;
|
||||
invisibleTokenHoverSourceText: string;
|
||||
invisibleTokenHoverRanges: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
tokenIndex: number;
|
||||
}>;
|
||||
invisibleMeasuredDescentPx: number | null;
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
jlptN1Color: string;
|
||||
@@ -142,25 +118,6 @@ export function createRendererState(): RendererState {
|
||||
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,
|
||||
invisibleTokenHoverSourceText: '',
|
||||
invisibleTokenHoverRanges: [],
|
||||
invisibleMeasuredDescentPx: null,
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
jlptN1Color: '#ed8796',
|
||||
|
||||
16
src/renderer/yomitan-popup.ts
Normal file
16
src/renderer/yomitan-popup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
||||
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
||||
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||
|
||||
export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
if (element.tagName.toUpperCase() !== 'IFRAME') return false;
|
||||
|
||||
const hasModernPopupClass = element.classList?.contains('yomitan-popup') ?? false;
|
||||
const hasLegacyPopupId = (element.id ?? '').startsWith('yomitan-popup');
|
||||
return hasModernPopupClass || hasLegacyPopupId;
|
||||
}
|
||||
|
||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
||||
}
|
||||
Reference in New Issue
Block a user