feat: bind overlay state to secondary subtitle mpv visibility

This commit is contained in:
2026-02-26 16:40:51 -08:00
parent a33a87bf8f
commit fa0cb00f70
48 changed files with 1231 additions and 1070 deletions

View File

@@ -2,6 +2,11 @@ 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 { resolvePlatformInfo } from './utils/platform.js';
test('handleError logs context and recovers overlay state', () => {
@@ -26,7 +31,6 @@ test('handleError logs context and recovers overlay state', () => {
secondarySubtitlePreview: 'secondary',
isOverlayInteractive: true,
isOverSubtitle: true,
invisiblePositionEditMode: false,
overlayLayer: 'visible',
}),
logError: (payload) => {
@@ -72,8 +76,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 +110,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 +132,7 @@ test('resolvePlatformInfo prefers query layer over preload layer', () => {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'invisible',
getOverlayLayer: () => 'modal',
},
location: { search: '?layer=visible' },
},
@@ -146,7 +148,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 +157,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 +180,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 +225,59 @@ 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);
});

View File

@@ -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 = {

View File

@@ -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,25 +111,15 @@ 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);
},
});
let lastSubtitlePreview = '';
@@ -179,9 +165,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 +195,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 +204,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,6 +255,8 @@ 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) {
@@ -252,41 +271,17 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(data.text);
}
subtitleRenderer.renderSubtitle(data);
if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics,
'subtitle-change',
);
}
measurementReporter.schedule();
});
});
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);
@@ -310,17 +305,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();
@@ -348,59 +337,14 @@ 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 });
@@ -421,7 +365,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');

View File

@@ -280,6 +280,8 @@ body {
text-align: center;
font-size: 35px;
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,21 @@ 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.word-known {
@@ -418,9 +428,103 @@ 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::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 br {
@@ -439,93 +543,6 @@ body.platform-macos.layer-visible #subtitleRoot {
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: var(--invisible-sub-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: var(--sub-border-size, 2px) 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: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important;
paint-order: stroke fill !important;
text-shadow: none !important;
}
#secondarySubContainer {
position: absolute;
top: 40px;
@@ -538,40 +555,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;
@@ -597,10 +580,6 @@ body.layer-modal #overlay {
cursor: text;
}
body.layer-secondary #secondarySubRoot {
max-width: 100%;
}
#secondarySubRoot:empty {
display: none;
}
@@ -644,11 +623,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;

View File

@@ -10,9 +10,9 @@ import {
buildInvisibleTokenHoverRanges,
computeWordClass,
normalizeSubtitle,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle,
} from './subtitle-render.js';
import { resolveInvisibleLineHeight } from './positioning/invisible-layout-helpers.js';
function createToken(overrides: Partial<MergedToken>): MergedToken {
return {
@@ -210,6 +210,17 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
assert.equal(actual, 'word');
});
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: 'キリキリと' }),
@@ -285,20 +296,16 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
);
});
test('shouldRenderTokenizedSubtitle disables token rendering on invisible layer', () => {
assert.equal(shouldRenderTokenizedSubtitle(true, 5), false);
});
test('shouldRenderTokenizedSubtitle enables token rendering on visible layer when tokens exist', () => {
assert.equal(shouldRenderTokenizedSubtitle(false, 5), true);
assert.equal(shouldRenderTokenizedSubtitle(false, 0), false);
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.',
@@ -330,31 +337,86 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
assert.match(block, /color:\s*var\(/);
}
const invisibleBlock = extractClassBlock(
cssText,
'body.layer-invisible #subtitleRoot',
);
assert.match(
invisibleBlock,
/line-height:\s*var\(--invisible-sub-line-height,\s*normal\)\s*!important;/,
);
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;/);
});
test('invisible overlay uses looser line height on macOS for multi-line subtitles', () => {
assert.equal(resolveInvisibleLineHeight(1, true), '1.08');
assert.equal(resolveInvisibleLineHeight(2, true), '1.5');
assert.equal(resolveInvisibleLineHeight(3, true), '1.62');
});
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;/);
test('invisible overlay keeps default line height on non-macOS platforms', () => {
assert.equal(resolveInvisibleLineHeight(1, false), 'normal');
assert.equal(resolveInvisibleLineHeight(2, false), 'normal');
assert.equal(resolveInvisibleLineHeight(4, false), 'normal');
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;/);
assert.match(
cssText,
/#subtitleRoot \.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;/);
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,
);
});

View File

@@ -15,11 +15,8 @@ export type InvisibleTokenHoverRange = {
tokenIndex: number;
};
export function shouldRenderTokenizedSubtitle(
isInvisibleLayer: boolean,
tokenCount: number,
): boolean {
return !isInvisibleLayer && tokenCount > 0;
export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean {
return tokenCount > 0;
}
function isWhitespaceOnly(value: string): boolean {
@@ -47,6 +44,23 @@ 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 ? trimmed : 'rgba(54, 58, 79, 0.84)';
}
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
enabled: false,
topX: 1000,
@@ -79,6 +93,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.startsWith('-') || 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,
@@ -337,11 +399,8 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
}
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
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;
@@ -358,22 +417,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (!text) return;
if (ctx.platform.isInvisibleLayer) {
// Keep natural kerning/shaping in invisible layer to match mpv glyph placement.
const normalizedInvisible = normalizeSubtitle(text, false);
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
1,
normalizedInvisible.split('\n').length,
);
ctx.state.invisibleTokenHoverSourceText = normalizedInvisible;
ctx.state.invisibleTokenHoverRanges =
tokens && tokens.length > 0 ? buildInvisibleTokenHoverRanges(tokens, normalizedInvisible) : [];
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (shouldRenderTokenizedSubtitle(ctx.platform.isInvisibleLayer, tokens?.length ?? 0) && tokens) {
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
renderWithTokens(
ctx.dom.subtitleRoot,
tokens,
@@ -444,17 +489,30 @@ 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.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;
}
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',
@@ -476,6 +534,11 @@ 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;
@@ -551,6 +614,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;
}
@@ -566,9 +640,6 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (secondaryStyle.fontStyle) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
}
if (secondaryStyle.backgroundColor) {
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
}
}
return {

View File

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