mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
Overlay 2.0 (#12)
This commit is contained in:
@@ -2,6 +2,12 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createRendererRecoveryController } from './error-recovery.js';
|
||||
import {
|
||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||
hasYomitanPopupIframe,
|
||||
isYomitanPopupIframe,
|
||||
} from './yomitan-popup.js';
|
||||
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
|
||||
import { resolvePlatformInfo } from './utils/platform.js';
|
||||
|
||||
test('handleError logs context and recovers overlay state', () => {
|
||||
@@ -26,7 +32,6 @@ test('handleError logs context and recovers overlay state', () => {
|
||||
secondarySubtitlePreview: 'secondary',
|
||||
isOverlayInteractive: true,
|
||||
isOverSubtitle: true,
|
||||
invisiblePositionEditMode: false,
|
||||
overlayLayer: 'visible',
|
||||
}),
|
||||
logError: (payload) => {
|
||||
@@ -72,8 +77,7 @@ test('handleError normalizes non-Error values', () => {
|
||||
secondarySubtitlePreview: '',
|
||||
isOverlayInteractive: false,
|
||||
isOverSubtitle: false,
|
||||
invisiblePositionEditMode: false,
|
||||
overlayLayer: 'invisible',
|
||||
overlayLayer: 'visible',
|
||||
}),
|
||||
logError: (payload) => {
|
||||
payloads.push(payload);
|
||||
@@ -107,7 +111,6 @@ test('nested recovery errors are ignored while current recovery is active', () =
|
||||
secondarySubtitlePreview: '',
|
||||
isOverlayInteractive: true,
|
||||
isOverSubtitle: false,
|
||||
invisiblePositionEditMode: true,
|
||||
overlayLayer: 'visible',
|
||||
}),
|
||||
logError: (payload) => {
|
||||
@@ -130,7 +133,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getOverlayLayer: () => 'invisible',
|
||||
getOverlayLayer: () => 'modal',
|
||||
},
|
||||
location: { search: '?layer=visible' },
|
||||
},
|
||||
@@ -146,7 +149,6 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
|
||||
try {
|
||||
const info = resolvePlatformInfo();
|
||||
assert.equal(info.overlayLayer, 'visible');
|
||||
assert.equal(info.isInvisibleLayer, false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
@@ -156,7 +158,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => {
|
||||
test('resolvePlatformInfo ignores legacy secondary layer and falls back to visible', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
|
||||
@@ -179,9 +181,8 @@ test('resolvePlatformInfo supports secondary layer and disables mouse-ignore tog
|
||||
|
||||
try {
|
||||
const info = resolvePlatformInfo();
|
||||
assert.equal(info.overlayLayer, 'secondary');
|
||||
assert.equal(info.isSecondaryLayer, true);
|
||||
assert.equal(info.shouldToggleMouseIgnore, false);
|
||||
assert.equal(info.overlayLayer, 'visible');
|
||||
assert.equal(info.shouldToggleMouseIgnore, true);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
@@ -225,3 +226,88 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
|
||||
const createElement = (options: {
|
||||
tagName: string;
|
||||
id?: string;
|
||||
classNames?: string[];
|
||||
}): Element =>
|
||||
({
|
||||
tagName: options.tagName,
|
||||
id: options.id ?? '',
|
||||
classList: {
|
||||
contains: (className: string) => (options.classNames ?? []).includes(className),
|
||||
},
|
||||
}) as unknown as Element;
|
||||
|
||||
assert.equal(
|
||||
isYomitanPopupIframe(
|
||||
createElement({
|
||||
tagName: 'IFRAME',
|
||||
classNames: ['yomitan-popup'],
|
||||
}),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isYomitanPopupIframe(
|
||||
createElement({
|
||||
tagName: 'IFRAME',
|
||||
id: 'yomitan-popup-123',
|
||||
}),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isYomitanPopupIframe(
|
||||
createElement({
|
||||
tagName: 'IFRAME',
|
||||
id: 'something-else',
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
||||
let selector = '';
|
||||
const root = {
|
||||
querySelector: (value: string) => {
|
||||
selector = value;
|
||||
return {};
|
||||
},
|
||||
} as unknown as ParentNode;
|
||||
|
||||
assert.equal(hasYomitanPopupIframe(root), true);
|
||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
});
|
||||
|
||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||
const activeItem = {
|
||||
scrollIntoView: (options?: ScrollIntoViewOptions) => {
|
||||
calls.push({ block: options?.block });
|
||||
},
|
||||
};
|
||||
|
||||
const list = {
|
||||
querySelector: (selector: string) => {
|
||||
assert.equal(selector, '.runtime-options-item.active');
|
||||
return activeItem as unknown as Element;
|
||||
},
|
||||
};
|
||||
|
||||
scrollActiveRuntimeOptionIntoView(list);
|
||||
assert.deepEqual(calls, [{ block: 'nearest' }]);
|
||||
});
|
||||
|
||||
test('scrollActiveRuntimeOptionIntoView no-ops without active option', () => {
|
||||
const list = {
|
||||
querySelector: () => null,
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
scrollActiveRuntimeOptionIntoView(list);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,7 @@ export type RendererRecoverySnapshot = {
|
||||
secondarySubtitlePreview: string;
|
||||
isOverlayInteractive: boolean;
|
||||
isOverSubtitle: boolean;
|
||||
invisiblePositionEditMode: boolean;
|
||||
overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||
overlayLayer: 'visible' | 'modal';
|
||||
};
|
||||
|
||||
type NormalizedRendererError = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { RuntimeOptionApplyResult, RuntimeOptionState, RuntimeOptionValue } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
type RuntimeOptionsListLike = Pick<HTMLUListElement, 'querySelector'>;
|
||||
|
||||
export function scrollActiveRuntimeOptionIntoView(list: RuntimeOptionsListLike): void {
|
||||
const active = list.querySelector('.runtime-options-item.active');
|
||||
if (!active) return;
|
||||
|
||||
const maybeScrollable = active as unknown as {
|
||||
scrollIntoView?: (options?: ScrollIntoViewOptions) => void;
|
||||
};
|
||||
if (typeof maybeScrollable.scrollIntoView !== 'function') return;
|
||||
|
||||
maybeScrollable.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
export function createRuntimeOptionsModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -82,6 +96,8 @@ export function createRuntimeOptionsModal(
|
||||
|
||||
ctx.dom.runtimeOptionsList.appendChild(li);
|
||||
});
|
||||
|
||||
scrollActiveRuntimeOptionIntoView(ctx.dom.runtimeOptionsList);
|
||||
}
|
||||
|
||||
function updateRuntimeOptions(optionsList: RuntimeOptionState[]): void {
|
||||
@@ -162,8 +178,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,9 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import type { RendererContext } from '../context';
|
||||
import {
|
||||
createInMemorySubtitlePositionController,
|
||||
type SubtitlePositionController,
|
||||
} from './position-state.js';
|
||||
import {
|
||||
createInvisibleOffsetController,
|
||||
type InvisibleOffsetController,
|
||||
} from './invisible-offset.js';
|
||||
import {
|
||||
createMpvSubtitleLayoutController,
|
||||
type MpvSubtitleLayoutController,
|
||||
} from './invisible-layout.js';
|
||||
|
||||
type PositioningControllerOptions = {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>;
|
||||
applySubtitleFontSize: (fontSize: number) => void;
|
||||
};
|
||||
|
||||
export function createPositioningController(
|
||||
ctx: RendererContext,
|
||||
options: PositioningControllerOptions,
|
||||
) {
|
||||
const visible = createInMemorySubtitlePositionController(ctx);
|
||||
const invisibleOffset = createInvisibleOffsetController(ctx, options.modalStateReader);
|
||||
const invisibleLayout = createMpvSubtitleLayoutController(ctx, options.applySubtitleFontSize, {
|
||||
applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
|
||||
});
|
||||
|
||||
return {
|
||||
...visible,
|
||||
...invisibleOffset,
|
||||
...invisibleLayout,
|
||||
} as SubtitlePositionController & InvisibleOffsetController & MpvSubtitleLayoutController;
|
||||
export function createPositioningController(ctx: RendererContext): SubtitlePositionController {
|
||||
return createInMemorySubtitlePositionController(ctx);
|
||||
}
|
||||
|
||||
@@ -1,187 +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 = '0.92';
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.2';
|
||||
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.3';
|
||||
|
||||
export function applyContainerBaseLayout(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
horizontalAvailable: number;
|
||||
leftInset: number;
|
||||
marginX: number;
|
||||
hAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = 'absolute';
|
||||
ctx.dom.subtitleContainer.style.maxWidth = `${horizontalAvailable}px`;
|
||||
ctx.dom.subtitleContainer.style.width = `${horizontalAvailable}px`;
|
||||
ctx.dom.subtitleContainer.style.padding = '0';
|
||||
ctx.dom.subtitleContainer.style.background = 'transparent';
|
||||
ctx.dom.subtitleContainer.style.marginBottom = '0';
|
||||
ctx.dom.subtitleContainer.style.pointerEvents = 'none';
|
||||
ctx.dom.subtitleContainer.style.left = `${leftInset + marginX}px`;
|
||||
ctx.dom.subtitleContainer.style.right = '';
|
||||
ctx.dom.subtitleContainer.style.transform = '';
|
||||
ctx.dom.subtitleContainer.style.textAlign = '';
|
||||
|
||||
if (hAlign === 0) {
|
||||
ctx.dom.subtitleContainer.style.textAlign = 'left';
|
||||
ctx.dom.subtitleRoot.style.textAlign = 'left';
|
||||
} else if (hAlign === 2) {
|
||||
ctx.dom.subtitleContainer.style.textAlign = 'right';
|
||||
ctx.dom.subtitleRoot.style.textAlign = 'right';
|
||||
} else {
|
||||
ctx.dom.subtitleContainer.style.textAlign = 'center';
|
||||
ctx.dom.subtitleRoot.style.textAlign = 'center';
|
||||
}
|
||||
|
||||
ctx.dom.subtitleRoot.style.display = 'inline-block';
|
||||
ctx.dom.subtitleRoot.style.maxWidth = '100%';
|
||||
ctx.dom.subtitleRoot.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
export function applyVerticalPosition(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
renderAreaHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
marginY: number;
|
||||
effectiveFontSize: number;
|
||||
borderPx: number;
|
||||
shadowPx: number;
|
||||
vAlign: 0 | 1 | 2;
|
||||
},
|
||||
): void {
|
||||
const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset);
|
||||
const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5);
|
||||
|
||||
if (params.vAlign === 2) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(
|
||||
0,
|
||||
params.topInset + params.marginY - baselineCompensationPx,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.bottom = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.vAlign === 1) {
|
||||
ctx.dom.subtitleContainer.style.top = '50%';
|
||||
ctx.dom.subtitleContainer.style.bottom = '';
|
||||
ctx.dom.subtitleContainer.style.transform = 'translateY(-50%)';
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorY =
|
||||
params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx;
|
||||
const bottomPx = Math.max(0, params.renderAreaHeight - anchorY);
|
||||
|
||||
ctx.dom.subtitleContainer.style.top = '';
|
||||
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
|
||||
}
|
||||
|
||||
function resolveFontFamily(rawFont: string): string {
|
||||
const strippedFont = rawFont
|
||||
.replace(
|
||||
/\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i,
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
|
||||
return strippedFont !== rawFont
|
||||
? `"${rawFont}", "${strippedFont}", sans-serif`
|
||||
: `"${rawFont}", sans-serif`;
|
||||
}
|
||||
|
||||
function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string {
|
||||
if (!isMacOSPlatform) return 'normal';
|
||||
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
|
||||
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
|
||||
return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE;
|
||||
}
|
||||
|
||||
function resolveLetterSpacing(
|
||||
spacing: number,
|
||||
pxPerScaledPixel: number,
|
||||
isMacOSPlatform: boolean,
|
||||
): string {
|
||||
if (Math.abs(spacing) > 0.0001) {
|
||||
return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`;
|
||||
}
|
||||
|
||||
return isMacOSPlatform ? '-0.02em' : '0px';
|
||||
}
|
||||
|
||||
function applyComputedLineHeightCompensation(
|
||||
ctx: RendererContext,
|
||||
effectiveFontSize: number,
|
||||
): void {
|
||||
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
|
||||
if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const halfLeading = (computedLineHeight - effectiveFontSize) / 2;
|
||||
if (halfLeading <= 0.5) return;
|
||||
|
||||
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
if (Number.isFinite(currentBottom)) {
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`;
|
||||
}
|
||||
|
||||
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
|
||||
if (Number.isFinite(currentTop)) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMacOSAdjustments(ctx: RendererContext): void {
|
||||
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
|
||||
if (!isMacOSPlatform) return;
|
||||
|
||||
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
if (!Number.isFinite(currentBottom)) return;
|
||||
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
|
||||
0,
|
||||
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
|
||||
)}px`;
|
||||
}
|
||||
|
||||
export function applyTypography(
|
||||
ctx: RendererContext,
|
||||
params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
pxPerScaledPixel: number;
|
||||
effectiveFontSize: number;
|
||||
},
|
||||
): void {
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
|
||||
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'line-height',
|
||||
resolveLineHeight(lineCount, isMacOSPlatform),
|
||||
isMacOSPlatform ? 'important' : '',
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'letter-spacing',
|
||||
resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform),
|
||||
isMacOSPlatform ? 'important' : '',
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none';
|
||||
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? '700' : '400';
|
||||
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal';
|
||||
ctx.dom.subtitleRoot.style.transform = '';
|
||||
ctx.dom.subtitleRoot.style.transformOrigin = '';
|
||||
|
||||
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
|
||||
applyMacOSAdjustments(ctx);
|
||||
}
|
||||
@@ -1,133 +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;
|
||||
|
||||
return avgRatio > 1.25 ? avgRatio : 1;
|
||||
}
|
||||
|
||||
export function calculateSubtitlePosition(
|
||||
_metrics: MpvSubtitleRenderMetrics,
|
||||
_scale: number,
|
||||
alignment: number,
|
||||
): SubtitleAlignment {
|
||||
return {
|
||||
hAlign: ((alignment - 1) % 3) as 0 | 1 | 2,
|
||||
vAlign: Math.floor((alignment - 1) / 3) as 0 | 1 | 2,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLinePadding(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
pxPerScaledPixel: number,
|
||||
): { marginY: number; marginX: number } {
|
||||
return {
|
||||
marginY: metrics.subMarginY * pxPerScaledPixel,
|
||||
marginX: Math.max(0, metrics.subMarginX * pxPerScaledPixel),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPlatformFontCompensation(
|
||||
fontSizePx: number,
|
||||
isMacOSPlatform: boolean,
|
||||
): number {
|
||||
return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx;
|
||||
}
|
||||
|
||||
function calculateGeometry(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
osdToCssScale: number,
|
||||
): Omit<SubtitleLayoutGeometry, 'marginY' | 'marginX' | 'pxPerScaledPixel' | 'effectiveFontSize'> {
|
||||
const dims = metrics.osdDimensions;
|
||||
const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight;
|
||||
const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth;
|
||||
const videoLeftInset = dims ? dims.ml / osdToCssScale : 0;
|
||||
const videoRightInset = dims ? dims.mr / osdToCssScale : 0;
|
||||
const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
|
||||
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
|
||||
|
||||
const anchorToVideoArea = !metrics.subUseMargins;
|
||||
const leftInset = anchorToVideoArea ? videoLeftInset : 0;
|
||||
const rightInset = anchorToVideoArea ? videoRightInset : 0;
|
||||
const topInset = anchorToVideoArea ? videoTopInset : 0;
|
||||
const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
|
||||
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
|
||||
|
||||
return {
|
||||
renderAreaHeight,
|
||||
renderAreaWidth,
|
||||
leftInset,
|
||||
rightInset,
|
||||
topInset,
|
||||
bottomInset,
|
||||
horizontalAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateSubtitleMetrics(
|
||||
ctx: RendererContext,
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
): SubtitleLayoutGeometry {
|
||||
const osdToCssScale = calculateOsdScale(
|
||||
metrics,
|
||||
ctx.platform.isMacOSPlatform,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
const geometry = calculateGeometry(metrics, osdToCssScale);
|
||||
const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
|
||||
const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight;
|
||||
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
|
||||
const computedFontSize =
|
||||
metrics.subFontSize * metrics.subScale * (ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
|
||||
const effectiveFontSize = applyPlatformFontCompensation(
|
||||
computedFontSize,
|
||||
ctx.platform.isMacOSPlatform,
|
||||
);
|
||||
const spacing = resolveLinePadding(metrics, pxPerScaledPixel);
|
||||
|
||||
return {
|
||||
...geometry,
|
||||
marginY: spacing.marginY,
|
||||
marginX: spacing.marginX,
|
||||
pxPerScaledPixel,
|
||||
effectiveFontSize,
|
||||
};
|
||||
}
|
||||
@@ -1,85 +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,
|
||||
});
|
||||
|
||||
applyVerticalPosition(ctx, {
|
||||
metrics,
|
||||
renderAreaHeight: geometry.renderAreaHeight,
|
||||
topInset: geometry.topInset,
|
||||
bottomInset: geometry.bottomInset,
|
||||
marginY: geometry.marginY,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
borderPx: effectiveBorderSize,
|
||||
shadowPx: effectiveShadowOffset,
|
||||
vAlign: alignment.vAlign,
|
||||
});
|
||||
|
||||
applyTypography(ctx, {
|
||||
metrics,
|
||||
pxPerScaledPixel: geometry.pxPerScaledPixel,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
});
|
||||
|
||||
ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
|
||||
|
||||
const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
ctx.state.invisibleLayoutBaseBottomPx = Number.isFinite(parsedBottom) ? parsedBottom : null;
|
||||
|
||||
const parsedTop = parseFloat(ctx.dom.subtitleContainer.style.top);
|
||||
ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop) ? parsedTop : null;
|
||||
|
||||
options.applyInvisibleSubtitleOffsetPosition();
|
||||
options.updateInvisiblePositionEditHud();
|
||||
|
||||
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source);
|
||||
}
|
||||
|
||||
return {
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
import type {
|
||||
KikuDuplicateCardInfo,
|
||||
MpvSubtitleRenderMetrics,
|
||||
RuntimeOptionState,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
@@ -84,10 +83,7 @@ function syncSettingsModalSubtitleSuppression(): void {
|
||||
|
||||
const subtitleRenderer = createSubtitleRenderer(ctx);
|
||||
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
|
||||
const positioning = createPositioningController(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen },
|
||||
applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize,
|
||||
});
|
||||
const positioning = createPositioningController(ctx);
|
||||
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -115,24 +111,19 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
|
||||
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
|
||||
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
|
||||
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
},
|
||||
});
|
||||
const mouseHandlers = createMouseHandlers(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics:
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics,
|
||||
applyYPercent: positioning.applyYPercent,
|
||||
getCurrentYPercent: positioning.getCurrentYPercent,
|
||||
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
||||
reportHoveredTokenIndex: (tokenIndex: number | null) => {
|
||||
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
|
||||
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
sendMpvCommand: (command) => {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,9 +170,6 @@ function dismissActiveUiAfterError(): void {
|
||||
|
||||
function restoreOverlayInteractionAfterError(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
if (ctx.state.invisiblePositionEditMode) {
|
||||
positioning.setInvisiblePositionEditMode(false);
|
||||
}
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
@@ -212,7 +200,6 @@ const recovery = createRendererRecoveryController({
|
||||
secondarySubtitlePreview: lastSecondarySubtitlePreview,
|
||||
isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'),
|
||||
isOverSubtitle: ctx.state.isOverSubtitle,
|
||||
invisiblePositionEditMode: ctx.state.invisiblePositionEditMode,
|
||||
overlayLayer: ctx.platform.overlayLayer,
|
||||
}),
|
||||
logError: (payload) => {
|
||||
@@ -222,6 +209,41 @@ const recovery = createRendererRecoveryController({
|
||||
|
||||
registerRendererGlobalErrorHandlers(window, recovery);
|
||||
|
||||
function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuardedAsync('runtime-options:open', async () => {
|
||||
try {
|
||||
await runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
} catch {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenJimaku(() => {
|
||||
runGuarded('jimaku:open', () => {
|
||||
jimakuModal.openJimakuModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('jimaku');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||
runGuarded('subsync:manual-open', () => {
|
||||
subsyncModal.openSubsyncModal(payload);
|
||||
window.electronAPI.notifyOverlayModalOpened('subsync');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onKikuFieldGroupingRequest(
|
||||
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
|
||||
runGuarded('kiku:field-grouping-open', () => {
|
||||
kikuModal.openKikuFieldGroupingModal(data);
|
||||
window.electronAPI.notifyOverlayModalOpened('kiku');
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function runGuarded(action: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
@@ -238,8 +260,13 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
|
||||
});
|
||||
}
|
||||
|
||||
registerModalOpenHandlers();
|
||||
|
||||
async function init(): Promise<void> {
|
||||
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
||||
if (ctx.platform.isMacOSPlatform) {
|
||||
document.body.classList.add('platform-macos');
|
||||
}
|
||||
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
@@ -255,29 +282,11 @@ async function init(): Promise<void> {
|
||||
|
||||
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
|
||||
runGuarded('subtitle-position:update', () => {
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change');
|
||||
} else {
|
||||
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
||||
}
|
||||
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => {
|
||||
runGuarded('mpv-metrics:update', () => {
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event');
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
|
||||
runGuarded('overlay-debug-visualization:update', () => {
|
||||
document.body.classList.toggle('debug-invisible-visualization', enabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
|
||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||
@@ -301,17 +310,11 @@ async function init(): Promise<void> {
|
||||
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
|
||||
measurementReporter.schedule();
|
||||
|
||||
const hoverTarget = ctx.platform.isInvisibleLayer
|
||||
? ctx.dom.subtitleRoot
|
||||
: ctx.dom.subtitleContainer;
|
||||
hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
|
||||
hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
||||
|
||||
mouseHandlers.setupInvisibleHoverSelection();
|
||||
mouseHandlers.setupInvisibleTokenHoverReporter();
|
||||
positioning.setupInvisiblePositionEditHud();
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
mouseHandlers.setupYomitanObserver();
|
||||
@@ -339,59 +342,17 @@ async function init(): Promise<void> {
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuardedAsync('runtime-options:open', async () => {
|
||||
try {
|
||||
await runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
} catch {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenJimaku(() => {
|
||||
runGuarded('jimaku:open', () => {
|
||||
jimakuModal.openJimakuModal();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||
runGuarded('subsync:manual-open', () => {
|
||||
subsyncModal.openSubsyncModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onKikuFieldGroupingRequest(
|
||||
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
|
||||
runGuarded('kiku:field-grouping-open', () => {
|
||||
kikuModal.openKikuFieldGroupingModal(data);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!ctx.platform.isInvisibleLayer) {
|
||||
mouseHandlers.setupDragging();
|
||||
}
|
||||
mouseHandlers.setupDragging();
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
positioning.applyInvisibleStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
await window.electronAPI.getMpvSubtitleRenderMetrics(),
|
||||
'startup',
|
||||
);
|
||||
} else {
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
}
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
@@ -412,7 +373,7 @@ function setupDragDropToMpvQueue(): void {
|
||||
|
||||
const clearDropInteractive = (): void => {
|
||||
dragDepth = 0;
|
||||
if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) {
|
||||
if (isAnyModalOpen() || ctx.state.isOverSubtitle) {
|
||||
return;
|
||||
}
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
MpvSubtitleRenderMetrics,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
@@ -57,22 +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;
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
jlptN1Color: string;
|
||||
@@ -81,6 +64,7 @@ export type RendererState = {
|
||||
jlptN4Color: string;
|
||||
jlptN5Color: string;
|
||||
preserveSubtitleLineBreaks: boolean;
|
||||
autoPauseVideoOnSubtitleHover: boolean;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
frequencyDictionaryTopX: number;
|
||||
frequencyDictionaryMode: 'single' | 'banded';
|
||||
@@ -135,22 +119,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,
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
jlptN1Color: '#ed8796',
|
||||
@@ -159,6 +127,7 @@ export function createRendererState(): RendererState {
|
||||
jlptN4Color: '#a6e3a1',
|
||||
jlptN5Color: '#8aadf4',
|
||||
preserveSubtitleLineBreaks: false,
|
||||
autoPauseVideoOnSubtitleHover: false,
|
||||
frequencyDictionaryEnabled: false,
|
||||
frequencyDictionaryTopX: 1000,
|
||||
frequencyDictionaryMode: 'single',
|
||||
|
||||
@@ -279,7 +279,9 @@ body {
|
||||
#subtitleRoot {
|
||||
text-align: center;
|
||||
font-size: 35px;
|
||||
line-height: 1.5;
|
||||
line-height: var(--visible-sub-line-height, 1.32);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: keep-all;
|
||||
color: #cad3f5;
|
||||
--subtitle-known-word-color: #a6da95;
|
||||
--subtitle-n-plus-one-color: #c6a0f6;
|
||||
@@ -288,6 +290,8 @@ body {
|
||||
--subtitle-jlpt-n3-color: #f9e2af;
|
||||
--subtitle-jlpt-n4-color: #a6e3a1;
|
||||
--subtitle-jlpt-n5-color: #8aadf4;
|
||||
--subtitle-hover-token-color: #f4dbd6;
|
||||
--subtitle-hover-token-background-color: rgba(54, 58, 79, 0.84);
|
||||
--subtitle-frequency-single-color: #f5a97f;
|
||||
--subtitle-frequency-band-1-color: #ed8796;
|
||||
--subtitle-frequency-band-2-color: #f5a97f;
|
||||
@@ -300,6 +304,7 @@ body {
|
||||
/* Enable text selection for Yomitan */
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
|
||||
#subtitleRoot:empty {
|
||||
@@ -318,16 +323,76 @@ body.settings-modal-open #subtitleContainer {
|
||||
#subtitleRoot .c {
|
||||
display: inline;
|
||||
position: relative;
|
||||
color: inherit;
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .c:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#subtitleRoot .word {
|
||||
display: inline;
|
||||
position: relative;
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word[data-frequency-rank]::before {
|
||||
content: attr(data-frequency-rank);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 17, 26, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
color: #f5f5f5;
|
||||
font-size: 0.48em;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#subtitleRoot .word[data-frequency-rank]:hover::before {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
#subtitleRoot .word[data-jlpt-level]::after {
|
||||
content: attr(data-jlpt-level);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -0.42em;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
font-size: 0.42em;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.85),
|
||||
0 0 3px rgba(0, 0, 0, 0.65);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#subtitleRoot .word[data-jlpt-level]:hover::after {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-known {
|
||||
@@ -341,7 +406,6 @@ body.settings-modal-open #subtitleContainer {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
@@ -349,8 +413,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
@@ -358,8 +425,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
@@ -367,8 +437,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
@@ -376,8 +449,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5 {
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
@@ -385,6 +461,10 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single,
|
||||
#subtitleRoot .word.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-frequency-band-2,
|
||||
@@ -418,15 +498,142 @@ body.settings-modal-open #subtitleContainer {
|
||||
color: var(--subtitle-frequency-band-5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
#subtitleRoot
|
||||
.word:not(.word-known):not(.word-n-plus-one):not(.word-frequency-single):not(
|
||||
.word-frequency-band-1
|
||||
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
|
||||
.word-frequency-band-5
|
||||
):hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
border-radius: 3px;
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-known:hover,
|
||||
#subtitleRoot .word.word-n-plus-one:hover,
|
||||
#subtitleRoot .word.word-frequency-single:hover,
|
||||
#subtitleRoot .word.word-frequency-band-1:hover,
|
||||
#subtitleRoot .word.word-frequency-band-2:hover,
|
||||
#subtitleRoot .word.word-frequency-band-3:hover,
|
||||
#subtitleRoot .word.word-frequency-band-4:hover,
|
||||
#subtitleRoot .word.word-frequency-band-5:hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
border-radius: 3px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-known .c:hover,
|
||||
#subtitleRoot .word.word-n-plus-one .c:hover,
|
||||
#subtitleRoot .word.word-frequency-single .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-1 .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-2 .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-3 .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-4 .c:hover,
|
||||
#subtitleRoot .word.word-frequency-band-5 .c:hover {
|
||||
background: transparent;
|
||||
color: inherit !important;
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
}
|
||||
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not(
|
||||
.word-frequency-band-2
|
||||
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover {
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot::selection,
|
||||
#subtitleRoot .word::selection,
|
||||
#subtitleRoot .c::selection {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot *::selection {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)) !important;
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-known::selection,
|
||||
#subtitleRoot .word.word-known .c::selection {
|
||||
color: var(--subtitle-known-word-color, #a6da95) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-n-plus-one::selection,
|
||||
#subtitleRoot .word.word-n-plus-one .c::selection {
|
||||
color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single::selection,
|
||||
#subtitleRoot .word.word-frequency-single .c::selection {
|
||||
color: var(--subtitle-frequency-single-color, #f5a97f) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-single-color, #f5a97f) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-1::selection,
|
||||
#subtitleRoot .word.word-frequency-band-1 .c::selection {
|
||||
color: var(--subtitle-frequency-band-1-color, #ed8796) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-1-color, #ed8796) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-2::selection,
|
||||
#subtitleRoot .word.word-frequency-band-2 .c::selection {
|
||||
color: var(--subtitle-frequency-band-2-color, #f5a97f) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-2-color, #f5a97f) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-3::selection,
|
||||
#subtitleRoot .word.word-frequency-band-3 .c::selection {
|
||||
color: var(--subtitle-frequency-band-3-color, #f9e2af) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-3-color, #f9e2af) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-4::selection,
|
||||
#subtitleRoot .word.word-frequency-band-4 .c::selection {
|
||||
color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-4-color, #a6e3a1) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-5::selection,
|
||||
#subtitleRoot .word.word-frequency-band-5 .c::selection {
|
||||
color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not(
|
||||
.word-frequency-band-2
|
||||
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection,
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not(
|
||||
.word-frequency-band-2
|
||||
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)
|
||||
.c::selection {
|
||||
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot br {
|
||||
display: block;
|
||||
content: '';
|
||||
margin-bottom: 0.3em;
|
||||
margin-bottom: var(--visible-sub-line-gap, 0.08em);
|
||||
}
|
||||
|
||||
body.platform-macos.layer-visible #subtitleRoot {
|
||||
--visible-sub-line-height: 1.64;
|
||||
--visible-sub-line-gap: 0.54em;
|
||||
}
|
||||
|
||||
#subtitleRoot.has-selection .word:hover,
|
||||
@@ -434,93 +641,6 @@ body.settings-modal-open #subtitleContainer {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleContainer {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot,
|
||||
body.layer-invisible #subtitleRoot .word,
|
||||
body.layer-invisible #subtitleRoot .c {
|
||||
color: transparent !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-text-stroke: 0 !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
background: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
line-height: normal !important;
|
||||
font-kerning: auto;
|
||||
letter-spacing: normal;
|
||||
font-variant-ligatures: normal;
|
||||
font-feature-settings: normal;
|
||||
text-rendering: auto;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot br {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot .word:hover,
|
||||
body.layer-invisible #subtitleRoot .c:hover,
|
||||
body.layer-invisible #subtitleRoot.has-selection .word:hover,
|
||||
body.layer-invisible #subtitleRoot.has-selection .c:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body.layer-invisible #subtitleRoot::selection,
|
||||
body.layer-invisible #subtitleRoot .word::selection,
|
||||
body.layer-invisible #subtitleRoot .c::selection {
|
||||
background: transparent !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot,
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
|
||||
color: #ed8796 !important;
|
||||
-webkit-text-fill-color: #ed8796 !important;
|
||||
-webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
|
||||
paint-order: stroke fill !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
.invisible-position-edit-hud {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 30;
|
||||
max-width: min(90vw, 1100px);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(22, 24, 36, 0.88);
|
||||
border: 1px solid rgba(130, 150, 255, 0.55);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
body.layer-invisible.invisible-position-edit .invisible-position-edit-hud {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot,
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot .word,
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot .c {
|
||||
color: #ed8796 !important;
|
||||
-webkit-text-fill-color: #ed8796 !important;
|
||||
-webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
|
||||
paint-order: stroke fill !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
#secondarySubContainer {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
@@ -533,40 +653,6 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .c {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.layer-visible #secondarySubContainer,
|
||||
body.layer-invisible #secondarySubContainer {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.layer-secondary #subtitleContainer,
|
||||
body.layer-secondary .modal,
|
||||
body.layer-secondary .overlay-error-toast {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.layer-secondary #overlay {
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body.layer-secondary #secondarySubContainer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: none;
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.layer-modal #subtitleContainer,
|
||||
body.layer-modal #secondarySubContainer {
|
||||
display: none !important;
|
||||
@@ -592,10 +678,6 @@ body.layer-modal #overlay {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
body.layer-secondary #secondarySubRoot {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#secondarySubRoot:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -639,11 +721,7 @@ body.settings-modal-open #secondarySubContainer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.layer-secondary #secondarySubContainer.secondary-sub-hover {
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
iframe.yomitan-popup,
|
||||
iframe[id^='yomitan-popup'] {
|
||||
pointer-events: auto !important;
|
||||
z-index: 2147483647 !important;
|
||||
|
||||
@@ -5,7 +5,16 @@ import path from 'node:path';
|
||||
|
||||
import type { MergedToken } from '../types';
|
||||
import { PartOfSpeech } from '../types.js';
|
||||
import { alignTokensToSourceText, computeWordClass, normalizeSubtitle } from './subtitle-render.js';
|
||||
import {
|
||||
alignTokensToSourceText,
|
||||
buildSubtitleTokenHoverRanges,
|
||||
computeWordClass,
|
||||
getFrequencyRankLabelForToken,
|
||||
getJlptLevelLabelForToken,
|
||||
normalizeSubtitle,
|
||||
sanitizeSubtitleHoverTokenColor,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
} from './subtitle-render.js';
|
||||
|
||||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
@@ -70,7 +79,7 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
|
||||
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
|
||||
});
|
||||
|
||||
test('computeWordClass does not add frequency class to known or N+1 terms', () => {
|
||||
test('computeWordClass keeps known/N+1 color classes exclusive over frequency classes', () => {
|
||||
const known = createToken({
|
||||
isKnown: true,
|
||||
frequencyRank: 10,
|
||||
@@ -203,6 +212,48 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
|
||||
assert.equal(actual, 'word');
|
||||
});
|
||||
|
||||
test('getFrequencyRankLabelForToken returns rank only for frequency-colored tokens', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: 'single' as const,
|
||||
singleColor: '#000000',
|
||||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
],
|
||||
};
|
||||
const frequencyToken = createToken({ surface: '頻度', frequencyRank: 20 });
|
||||
const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 });
|
||||
const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 });
|
||||
|
||||
assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20');
|
||||
assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null);
|
||||
});
|
||||
|
||||
test('getJlptLevelLabelForToken returns level when token has jlpt metadata', () => {
|
||||
const jlptToken = createToken({ surface: '語彙', jlptLevel: 'N2' });
|
||||
const noJlptToken = createToken({ surface: '語彙' });
|
||||
|
||||
assert.equal(getJlptLevelLabelForToken(jlptToken), 'N2');
|
||||
assert.equal(getJlptLevelLabelForToken(noJlptToken), null);
|
||||
});
|
||||
|
||||
test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => {
|
||||
assert.equal(sanitizeSubtitleHoverTokenColor('#000000'), '#f4dbd6');
|
||||
assert.equal(sanitizeSubtitleHoverTokenColor('000000'), '#f4dbd6');
|
||||
assert.equal(sanitizeSubtitleHoverTokenColor('#0000'), '#f4dbd6');
|
||||
});
|
||||
|
||||
test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
|
||||
assert.equal(sanitizeSubtitleHoverTokenColor('#ff00ff'), '#ff00ff');
|
||||
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
|
||||
@@ -225,7 +276,10 @@ test('alignTokensToSourceText treats whitespace-only token surfaces as plain tex
|
||||
createToken({ surface: '体が耐えきれず死に至るが…' }),
|
||||
];
|
||||
|
||||
const segments = alignTokensToSourceText(tokens, '常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…');
|
||||
const segments = alignTokensToSourceText(
|
||||
tokens,
|
||||
'常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…',
|
||||
);
|
||||
assert.deepEqual(
|
||||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||||
['token', 'text: ', 'token', 'text:\n', 'token'],
|
||||
@@ -248,6 +302,29 @@ test('alignTokensToSourceText avoids duplicate tail when later token surface doe
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSubtitleTokenHoverRanges tracks token offsets across text separators', () => {
|
||||
const tokens = [createToken({ surface: 'キリキリと' }), createToken({ surface: 'かかってこい' })];
|
||||
|
||||
const ranges = buildSubtitleTokenHoverRanges(tokens, 'キリキリと\nかかってこい');
|
||||
assert.deepEqual(ranges, [
|
||||
{ start: 0, end: 5, tokenIndex: 0 },
|
||||
{ start: 6, end: 12, tokenIndex: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildSubtitleTokenHoverRanges ignores unmatched token surfaces', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: '君たちが潰した拠点に' }),
|
||||
createToken({ surface: '教団の主力は1人もいない' }),
|
||||
];
|
||||
|
||||
const ranges = buildSubtitleTokenHoverRanges(
|
||||
tokens,
|
||||
'君たちが潰した拠点に\n教団の主力は1人もいない',
|
||||
);
|
||||
assert.deepEqual(ranges, [{ start: 0, end: 10, tokenIndex: 0 }]);
|
||||
});
|
||||
|
||||
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
|
||||
assert.equal(
|
||||
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),
|
||||
@@ -255,11 +332,16 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
|
||||
assert.equal(shouldRenderTokenizedSubtitle(5), true);
|
||||
assert.equal(shouldRenderTokenizedSubtitle(0), false);
|
||||
});
|
||||
|
||||
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
|
||||
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
|
||||
|
||||
const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
|
||||
const cssPath = fs.existsSync(srcCssPath) ? srcCssPath : distCssPath;
|
||||
if (!fs.existsSync(cssPath)) {
|
||||
assert.fail(
|
||||
'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.',
|
||||
@@ -274,7 +356,7 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
assert.match(block, /text-decoration-thickness:\s*2px;/);
|
||||
assert.match(block, /text-underline-offset:\s*4px;/);
|
||||
assert.match(block, /color:\s*inherit;/);
|
||||
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
|
||||
}
|
||||
|
||||
for (let band = 1; band <= 5; band += 1) {
|
||||
@@ -290,4 +372,134 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
);
|
||||
assert.match(block, /color:\s*var\(/);
|
||||
}
|
||||
|
||||
const visibleMacBlock = extractClassBlock(
|
||||
cssText,
|
||||
'body.platform-macos.layer-visible #subtitleRoot',
|
||||
);
|
||||
assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/);
|
||||
assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/);
|
||||
|
||||
const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot');
|
||||
assert.match(subtitleRootBlock, /--subtitle-hover-token-color:\s*#f4dbd6;/);
|
||||
assert.match(
|
||||
subtitleRootBlock,
|
||||
/--subtitle-hover-token-background-color:\s*rgba\(54,\s*58,\s*79,\s*0\.84\);/,
|
||||
);
|
||||
assert.match(subtitleRootBlock, /-webkit-text-fill-color:\s*currentColor;/);
|
||||
|
||||
const charBlock = extractClassBlock(cssText, '#subtitleRoot .c');
|
||||
assert.match(charBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
||||
|
||||
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
|
||||
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
||||
|
||||
const frequencyTooltipBaseBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word[data-frequency-rank]::before',
|
||||
);
|
||||
assert.match(frequencyTooltipBaseBlock, /content:\s*attr\(data-frequency-rank\);/);
|
||||
assert.match(frequencyTooltipBaseBlock, /opacity:\s*0;/);
|
||||
assert.match(frequencyTooltipBaseBlock, /pointer-events:\s*none;/);
|
||||
|
||||
const frequencyTooltipHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word[data-frequency-rank]:hover::before',
|
||||
);
|
||||
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
|
||||
|
||||
const jlptTooltipBaseBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word[data-jlpt-level]::after',
|
||||
);
|
||||
assert.match(jlptTooltipBaseBlock, /content:\s*attr\(data-jlpt-level\);/);
|
||||
assert.match(jlptTooltipBaseBlock, /bottom:\s*-\s*0\.42em;/);
|
||||
assert.match(jlptTooltipBaseBlock, /opacity:\s*0;/);
|
||||
assert.match(jlptTooltipBaseBlock, /pointer-events:\s*none;/);
|
||||
|
||||
const jlptTooltipHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word[data-jlpt-level]:hover::after',
|
||||
);
|
||||
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
|
||||
|
||||
assert.match(
|
||||
cssText,
|
||||
/#subtitleRoot\s+\.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover');
|
||||
assert.match(
|
||||
coloredWordHoverBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
);
|
||||
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
|
||||
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/);
|
||||
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
|
||||
assert.doesNotMatch(
|
||||
coloredWordHoverBlock,
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color/,
|
||||
);
|
||||
|
||||
const coloredWordSelectionBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word.word-known::selection',
|
||||
);
|
||||
assert.match(
|
||||
coloredWordSelectionBlock,
|
||||
/color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
coloredWordSelectionBlock,
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/,
|
||||
);
|
||||
|
||||
const coloredCharHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word.word-known .c:hover',
|
||||
);
|
||||
assert.match(coloredCharHoverBlock, /background:\s*transparent;/);
|
||||
assert.match(coloredCharHoverBlock, /color:\s*inherit\s*!important;/);
|
||||
|
||||
assert.match(
|
||||
cssText,
|
||||
/\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\):hover\s*\{[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
cssText,
|
||||
/\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\)::selection[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
|
||||
assert.match(
|
||||
selectionBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
);
|
||||
assert.match(
|
||||
selectionBlock,
|
||||
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
selectionBlock,
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
const descendantSelectionBlock = extractClassBlock(cssText, '#subtitleRoot *::selection');
|
||||
assert.match(
|
||||
descendantSelectionBlock,
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
descendantSelectionBlock,
|
||||
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
assert.match(
|
||||
descendantSelectionBlock,
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
cssText,
|
||||
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,16 @@ type FrequencyRenderSettings = {
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
|
||||
export type SubtitleTokenHoverRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
tokenIndex: number;
|
||||
};
|
||||
|
||||
export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean {
|
||||
return tokenCount > 0;
|
||||
}
|
||||
|
||||
function isWhitespaceOnly(value: string): boolean {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
@@ -27,6 +37,8 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks
|
||||
}
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
const SAFE_CSS_COLOR_PATTERN =
|
||||
/^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/;
|
||||
|
||||
function sanitizeHexColor(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim())
|
||||
@@ -34,6 +46,30 @@ function sanitizeHexColor(value: unknown, fallback: string): string {
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function sanitizeSubtitleHoverTokenColor(value: unknown): string {
|
||||
const sanitized = sanitizeHexColor(value, '#f4dbd6');
|
||||
const normalized = sanitized.replace(/^#/, '').toLowerCase();
|
||||
if (
|
||||
normalized === '000' ||
|
||||
normalized === '0000' ||
|
||||
normalized === '000000' ||
|
||||
normalized === '00000000'
|
||||
) {
|
||||
return '#f4dbd6';
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeSubtitleHoverTokenBackgroundColor(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return 'rgba(54, 58, 79, 0.84)';
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && SAFE_CSS_COLOR_PATTERN.test(trimmed)
|
||||
? trimmed
|
||||
: 'rgba(54, 58, 79, 0.84)';
|
||||
}
|
||||
|
||||
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
enabled: false,
|
||||
topX: 1000,
|
||||
@@ -66,6 +102,54 @@ function sanitizeFrequencyBandedColors(
|
||||
];
|
||||
}
|
||||
|
||||
function applyInlineStyleDeclarations(
|
||||
target: HTMLElement,
|
||||
declarations: Record<string, unknown>,
|
||||
excludedKeys: ReadonlySet<string> = new Set<string>(),
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(declarations)) {
|
||||
if (excludedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (value === null || value === undefined || typeof value === 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cssValue = String(value);
|
||||
if (key.includes('-')) {
|
||||
target.style.setProperty(key, cssValue);
|
||||
if (key === '--webkit-text-stroke') {
|
||||
target.style.setProperty('-webkit-text-stroke', cssValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const styleTarget = target.style as unknown as Record<string, string>;
|
||||
styleTarget[key] = cssValue;
|
||||
}
|
||||
}
|
||||
|
||||
function pickInlineStyleDeclarations(
|
||||
declarations: Record<string, unknown>,
|
||||
includedKeys: ReadonlySet<string>,
|
||||
): Record<string, unknown> {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(declarations)) {
|
||||
if (!includedKeys.has(key)) continue;
|
||||
picked[key] = value;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
const CONTAINER_STYLE_KEYS = new Set<string>([
|
||||
'background',
|
||||
'backgroundColor',
|
||||
'backdropFilter',
|
||||
'WebkitBackdropFilter',
|
||||
'webkitBackdropFilter',
|
||||
'-webkit-backdrop-filter',
|
||||
]);
|
||||
|
||||
function getFrequencyDictionaryClass(
|
||||
token: MergedToken,
|
||||
settings: FrequencyRenderSettings,
|
||||
@@ -94,6 +178,47 @@ function getFrequencyDictionaryClass(
|
||||
return 'word-frequency-single';
|
||||
}
|
||||
|
||||
function getNormalizedFrequencyRank(token: MergedToken): number | null {
|
||||
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.floor(token.frequencyRank));
|
||||
}
|
||||
|
||||
export function getFrequencyRankLabelForToken(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
): string | null {
|
||||
if (token.isNPlusOneTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedFrequencySettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencySettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencySettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
if (!getFrequencyDictionaryClass(token, resolvedFrequencySettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rank = getNormalizedFrequencyRank(token);
|
||||
return rank === null ? null : String(rank);
|
||||
}
|
||||
|
||||
export function getJlptLevelLabelForToken(token: MergedToken): string | null {
|
||||
return token.jlptLevel ?? null;
|
||||
}
|
||||
|
||||
function renderWithTokens(
|
||||
root: HTMLElement,
|
||||
tokens: MergedToken[],
|
||||
@@ -137,6 +262,17 @@ function renderWithTokens(
|
||||
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
const frequencyRankLabel = getFrequencyRankLabelForToken(
|
||||
token,
|
||||
resolvedFrequencyRenderSettings,
|
||||
);
|
||||
if (frequencyRankLabel) {
|
||||
span.dataset.frequencyRank = frequencyRankLabel;
|
||||
}
|
||||
const jlptLevelLabel = getJlptLevelLabelForToken(token);
|
||||
if (jlptLevelLabel) {
|
||||
span.dataset.jlptLevel = jlptLevelLabel;
|
||||
}
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
@@ -165,6 +301,17 @@ function renderWithTokens(
|
||||
span.dataset.tokenIndex = String(index);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
const frequencyRankLabel = getFrequencyRankLabelForToken(
|
||||
token,
|
||||
resolvedFrequencyRenderSettings,
|
||||
);
|
||||
if (frequencyRankLabel) {
|
||||
span.dataset.frequencyRank = frequencyRankLabel;
|
||||
}
|
||||
const jlptLevelLabel = getJlptLevelLabelForToken(token);
|
||||
if (jlptLevelLabel) {
|
||||
span.dataset.jlptLevel = jlptLevelLabel;
|
||||
}
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
@@ -218,6 +365,40 @@ export function alignTokensToSourceText(
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function buildSubtitleTokenHoverRanges(
|
||||
tokens: MergedToken[],
|
||||
sourceText: string,
|
||||
): SubtitleTokenHoverRange[] {
|
||||
if (tokens.length === 0 || sourceText.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = alignTokensToSourceText(tokens, sourceText);
|
||||
const ranges: SubtitleTokenHoverRange[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
cursor += segment.text.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenLength = segment.token.surface.length;
|
||||
if (tokenLength <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: cursor,
|
||||
end: cursor + tokenLength,
|
||||
tokenIndex: segment.tokenIndex,
|
||||
});
|
||||
cursor += tokenLength;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function computeWordClass(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
@@ -292,9 +473,6 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
|
||||
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
function renderSubtitle(data: SubtitleData | string): void {
|
||||
ctx.dom.subtitleRoot.innerHTML = '';
|
||||
ctx.state.lastHoverSelectionKey = '';
|
||||
ctx.state.lastHoverSelectionNode = null;
|
||||
ctx.state.lastHoveredTokenIndex = null;
|
||||
|
||||
let text: string;
|
||||
let tokens: MergedToken[] | null;
|
||||
@@ -311,28 +489,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
|
||||
if (!text) return;
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
const normalizedInvisible = normalizeSubtitle(text, false);
|
||||
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
|
||||
1,
|
||||
normalizedInvisible.split('\n').length,
|
||||
);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
text,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
if (tokens && tokens.length > 0) {
|
||||
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
@@ -403,17 +561,26 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
|
||||
if (!style) return;
|
||||
|
||||
const styleDeclarations = style as Record<string, unknown>;
|
||||
applyInlineStyleDeclarations(ctx.dom.subtitleRoot, styleDeclarations, CONTAINER_STYLE_KEYS);
|
||||
applyInlineStyleDeclarations(
|
||||
ctx.dom.subtitleContainer,
|
||||
pickInlineStyleDeclarations(styleDeclarations, CONTAINER_STYLE_KEYS),
|
||||
);
|
||||
|
||||
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
|
||||
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
|
||||
if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor;
|
||||
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
if (style.backgroundColor) {
|
||||
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
|
||||
if (style.fontColor) {
|
||||
ctx.dom.subtitleRoot.style.color = style.fontColor;
|
||||
}
|
||||
|
||||
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = String(style.fontWeight);
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
|
||||
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
|
||||
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
|
||||
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
|
||||
style.hoverTokenBackgroundColor,
|
||||
);
|
||||
const jlptColors = {
|
||||
N1: ctx.state.jlptN1Color ?? '#ed8796',
|
||||
N2: ctx.state.jlptN2Color ?? '#f5a97f',
|
||||
@@ -435,12 +602,18 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.state.nPlusOneColor = nPlusOneColor;
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
'--subtitle-hover-token-background-color',
|
||||
hoverTokenBackgroundColor,
|
||||
);
|
||||
ctx.state.jlptN1Color = jlptColors.N1;
|
||||
ctx.state.jlptN2Color = jlptColors.N2;
|
||||
ctx.state.jlptN3Color = jlptColors.N3;
|
||||
ctx.state.jlptN4Color = jlptColors.N4;
|
||||
ctx.state.jlptN5Color = jlptColors.N5;
|
||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
||||
@@ -510,6 +683,17 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
|
||||
applyInlineStyleDeclarations(
|
||||
ctx.dom.secondarySubRoot,
|
||||
secondaryStyleDeclarations,
|
||||
CONTAINER_STYLE_KEYS,
|
||||
);
|
||||
applyInlineStyleDeclarations(
|
||||
ctx.dom.secondarySubContainer,
|
||||
pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
|
||||
);
|
||||
|
||||
if (secondaryStyle.fontFamily) {
|
||||
ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily;
|
||||
}
|
||||
@@ -520,14 +704,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.dom.secondarySubRoot.style.color = secondaryStyle.fontColor;
|
||||
}
|
||||
if (secondaryStyle.fontWeight) {
|
||||
ctx.dom.secondarySubRoot.style.fontWeight = secondaryStyle.fontWeight;
|
||||
ctx.dom.secondarySubRoot.style.fontWeight = String(secondaryStyle.fontWeight);
|
||||
}
|
||||
if (secondaryStyle.fontStyle) {
|
||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||
}
|
||||
if (secondaryStyle.backgroundColor) {
|
||||
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||
export type OverlayLayer = 'visible' | 'modal';
|
||||
|
||||
export type PlatformInfo = {
|
||||
overlayLayer: OverlayLayer;
|
||||
isInvisibleLayer: boolean;
|
||||
isSecondaryLayer: boolean;
|
||||
isModalLayer: boolean;
|
||||
isLinuxPlatform: boolean;
|
||||
isMacOSPlatform: boolean;
|
||||
shouldToggleMouseIgnore: boolean;
|
||||
invisiblePositionEditToggleCode: string;
|
||||
invisiblePositionStepPx: number;
|
||||
invisiblePositionStepFastPx: number;
|
||||
};
|
||||
|
||||
export function resolvePlatformInfo(): PlatformInfo {
|
||||
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
|
||||
const queryLayer = new URLSearchParams(window.location.search).get('layer');
|
||||
const overlayLayerFromQuery: OverlayLayer | null =
|
||||
queryLayer === 'visible' ||
|
||||
queryLayer === 'invisible' ||
|
||||
queryLayer === 'secondary' ||
|
||||
queryLayer === 'modal'
|
||||
? queryLayer
|
||||
: null;
|
||||
queryLayer === 'visible' || queryLayer === 'modal' ? queryLayer : null;
|
||||
|
||||
const overlayLayer: OverlayLayer =
|
||||
overlayLayerFromQuery ??
|
||||
(overlayLayerFromPreload === 'visible' ||
|
||||
overlayLayerFromPreload === 'invisible' ||
|
||||
overlayLayerFromPreload === 'secondary' ||
|
||||
overlayLayerFromPreload === 'modal'
|
||||
(overlayLayerFromPreload === 'visible' || overlayLayerFromPreload === 'modal'
|
||||
? overlayLayerFromPreload
|
||||
: 'visible');
|
||||
|
||||
const isInvisibleLayer = overlayLayer === 'invisible';
|
||||
const isSecondaryLayer = overlayLayer === 'secondary';
|
||||
const isModalLayer = overlayLayer === 'modal';
|
||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
||||
const isMacOSPlatform =
|
||||
@@ -42,14 +27,9 @@ export function resolvePlatformInfo(): PlatformInfo {
|
||||
|
||||
return {
|
||||
overlayLayer,
|
||||
isInvisibleLayer,
|
||||
isSecondaryLayer,
|
||||
isModalLayer,
|
||||
isLinuxPlatform,
|
||||
isMacOSPlatform,
|
||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer,
|
||||
invisiblePositionEditToggleCode: 'KeyP',
|
||||
invisiblePositionStepPx: 1,
|
||||
invisiblePositionStepFastPx: 4,
|
||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
|
||||
};
|
||||
}
|
||||
|
||||
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