mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)
This commit is contained in:
@@ -103,12 +103,14 @@ function installKeyboardTestGlobals() {
|
||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
|
||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||
const previousElement = (globalThis as { Element?: unknown }).Element;
|
||||
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
|
||||
const interactionActivations: string[] = [];
|
||||
let sessionBindings: CompiledSessionBinding[] = [];
|
||||
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
@@ -179,6 +181,14 @@ function installKeyboardTestGlobals() {
|
||||
}
|
||||
}
|
||||
|
||||
class TestElement {
|
||||
tagName = 'DIV';
|
||||
|
||||
closest(_selector: string): unknown {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||
configurable: true,
|
||||
value: TestCustomEvent,
|
||||
@@ -189,6 +199,11 @@ function installKeyboardTestGlobals() {
|
||||
value: TestMouseEvent,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'Element', {
|
||||
configurable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -242,6 +257,10 @@ function installKeyboardTestGlobals() {
|
||||
focusMainWindowCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
||||
interactionActivations.push('activate-playback-window');
|
||||
return true;
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => {
|
||||
openedModalNotifications.push(modal);
|
||||
},
|
||||
@@ -303,6 +322,18 @@ function installKeyboardTestGlobals() {
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchDocumentMouseDown(event: { button: number; target?: unknown }): void {
|
||||
const listeners = documentListeners.get('mousedown') ?? [];
|
||||
const mouseEvent = {
|
||||
button: event.button,
|
||||
target: event.target ?? null,
|
||||
preventDefault: () => {},
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(mouseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchFocusInOnPopup(): void {
|
||||
const listeners = documentListeners.get('focusin') ?? [];
|
||||
const focusEvent = {
|
||||
@@ -335,6 +366,10 @@ function installKeyboardTestGlobals() {
|
||||
configurable: true,
|
||||
value: previousMouseEvent,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Element', {
|
||||
configurable: true,
|
||||
value: previousElement,
|
||||
});
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
@@ -348,10 +383,12 @@ function installKeyboardTestGlobals() {
|
||||
mpvCommands,
|
||||
sessionActions,
|
||||
overlay,
|
||||
interactionActivations,
|
||||
overlayFocusCalls,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
windowFocusCalls: () => windowFocusCalls,
|
||||
dispatchKeydown,
|
||||
dispatchDocumentMouseDown,
|
||||
dispatchFocusInOnPopup,
|
||||
dispatchWindowEvent,
|
||||
setPopupVisible: (value: boolean) => {
|
||||
@@ -369,6 +406,11 @@ function installKeyboardTestGlobals() {
|
||||
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||
sessionBindings = value;
|
||||
},
|
||||
createInteractiveTarget: () => {
|
||||
const target = new TestElement();
|
||||
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
|
||||
return target;
|
||||
},
|
||||
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
|
||||
getSessionBindingsImpl = value;
|
||||
},
|
||||
@@ -565,6 +607,39 @@ test('mpv input forwarding waits for session bindings before resolving setup', a
|
||||
}
|
||||
});
|
||||
|
||||
test('right-clicking non-interactive overlay content raises playback window before toggling pause', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchDocumentMouseDown({ button: 2 });
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.interactionActivations, ['activate-playback-window']);
|
||||
assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['cycle', 'pause']]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('right-clicking interactive overlay controls does not raise playback window or toggle pause', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const interactiveTarget = testGlobals.createInteractiveTarget();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchDocumentMouseDown({ button: 2, target: interactiveTarget });
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.interactionActivations, []);
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
let calls = 0;
|
||||
|
||||
@@ -1218,7 +1218,12 @@ export function createKeyboardHandlers(
|
||||
document.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if (e.button === 2 && !isInteractiveTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
void window.electronAPI
|
||||
.activatePlaybackWindowForOverlayInteraction()
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
const modalNotifications: string[] = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [
|
||||
@@ -280,6 +281,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
sendMpvCommand: (command: Array<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal: string) => {
|
||||
modalNotifications.push(`open:${modal}`);
|
||||
},
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
modalNotifications.push(`close:${modal}`);
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
},
|
||||
});
|
||||
@@ -329,9 +336,13 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
},
|
||||
state,
|
||||
};
|
||||
const visibilityChanges: boolean[] = [];
|
||||
|
||||
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
onVisibilityChanged: (visible) => {
|
||||
visibilityChanges.push(visible);
|
||||
},
|
||||
});
|
||||
|
||||
await modal.openSubtitleSidebarModal();
|
||||
@@ -345,9 +356,14 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
assert.equal(contentStyleValues.get('font-size'), '22px');
|
||||
assert.equal(contentStyle.color, '#ffffff');
|
||||
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
|
||||
assert.deepEqual(visibilityChanges, [true]);
|
||||
|
||||
modal.seekToCue(snapshot.cues[0]!);
|
||||
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
|
||||
|
||||
modal.closeSubtitleSidebarModal();
|
||||
assert.deepEqual(visibilityChanges, [true, false]);
|
||||
assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
@@ -760,6 +776,104 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle sidebar auto-open restores previously open sidebar after renderer replacement', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
},
|
||||
config: {
|
||||
enabled: true,
|
||||
autoOpen: false,
|
||||
layout: 'overlay',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
maxWidth: 420,
|
||||
opacity: 0.92,
|
||||
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"Iosevka Aile", sans-serif',
|
||||
fontSize: 17,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
sendMpvCommand: () => {},
|
||||
} as unknown as ElectronAPI,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createCueRow(),
|
||||
body: {
|
||||
classList: createClassList(),
|
||||
},
|
||||
documentElement: {
|
||||
style: {
|
||||
setProperty: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
const modalClassList = createClassList(['hidden']);
|
||||
const cueList = createListStub();
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
subtitleSidebarModal: {
|
||||
classList: modalClassList,
|
||||
setAttribute: () => {},
|
||||
style: { setProperty: () => {} },
|
||||
addEventListener: () => {},
|
||||
},
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 420 }),
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
subtitleSidebarList: cueList,
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
shouldRestoreOpenOnStartup: async () => true,
|
||||
});
|
||||
|
||||
await modal.autoOpenSubtitleSidebarOnStartup();
|
||||
|
||||
assert.equal(state.subtitleSidebarModalOpen, true);
|
||||
assert.equal(modalClassList.contains('hidden'), false);
|
||||
assert.equal(cueList.children.length, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle sidebar refresh closes and clears state when config becomes disabled', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
|
||||
@@ -196,6 +196,8 @@ export function createSubtitleSidebarModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
onVisibilityChanged?: (visible: boolean) => void;
|
||||
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -648,13 +650,16 @@ export function createSubtitleSidebarModal(
|
||||
startSnapshotPolling();
|
||||
syncEmbeddedSidebarLayout();
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
|
||||
options.onVisibilityChanged?.(true);
|
||||
}
|
||||
|
||||
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
|
||||
const snapshot = await refreshSnapshot();
|
||||
const shouldRestoreOpen = (await options.shouldRestoreOpenOnStartup?.()) === true;
|
||||
if (
|
||||
!snapshot.config.enabled ||
|
||||
!snapshot.config.autoOpen ||
|
||||
(!snapshot.config.autoOpen && !shouldRestoreOpen) ||
|
||||
ctx.state.subtitleSidebarModalOpen
|
||||
) {
|
||||
return;
|
||||
@@ -677,6 +682,8 @@ export function createSubtitleSidebarModal(
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
window.electronAPI.notifyOverlayModalClosed?.('subtitle-sidebar');
|
||||
options.onVisibilityChanged?.(false);
|
||||
}
|
||||
|
||||
async function toggleSubtitleSidebarModal(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
|
||||
function makeElement(textContent: string, rect: DOMRect): HTMLElement {
|
||||
return {
|
||||
textContent,
|
||||
getBoundingClientRect: () => rect,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test('overlay measurement reports primary and secondary subtitle bars as separate interactive rects', () => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const reports: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
innerWidth: 1920,
|
||||
innerHeight: 1080,
|
||||
electronAPI: {
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
reports.push(payload);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const reporter = createOverlayContentMeasurementReporter({
|
||||
platform: { overlayLayer: 'visible' },
|
||||
dom: {
|
||||
subtitleRoot: makeElement('primary', {
|
||||
left: 810,
|
||||
top: 910,
|
||||
width: 300,
|
||||
height: 48,
|
||||
} as DOMRect),
|
||||
subtitleContainer: makeElement('primary', {
|
||||
left: 760,
|
||||
top: 890,
|
||||
width: 400,
|
||||
height: 92,
|
||||
} as DOMRect),
|
||||
secondarySubRoot: makeElement('English', {
|
||||
left: 850,
|
||||
top: 50,
|
||||
width: 220,
|
||||
height: 34,
|
||||
} as DOMRect),
|
||||
secondarySubContainer: makeElement('English', {
|
||||
left: 700,
|
||||
top: 40,
|
||||
width: 520,
|
||||
height: 70,
|
||||
} as DOMRect),
|
||||
},
|
||||
} as never);
|
||||
|
||||
reporter.emitNow();
|
||||
|
||||
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
|
||||
if (typeof measuredAtMs !== 'number') {
|
||||
assert.fail('Expected report timestamp.');
|
||||
}
|
||||
|
||||
assert.deepEqual(reports, [
|
||||
{
|
||||
layer: 'visible',
|
||||
measuredAtMs,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 942 },
|
||||
interactiveRects: [
|
||||
{ x: 760, y: 890, width: 400, height: 92 },
|
||||
{ x: 700, y: 40, width: 520, height: 70 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay measurement includes open subtitle sidebar bounds as an interactive rect', () => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const reports: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
innerWidth: 1920,
|
||||
innerHeight: 1080,
|
||||
electronAPI: {
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
reports.push(payload);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const reporter = createOverlayContentMeasurementReporter({
|
||||
platform: { overlayLayer: 'visible' },
|
||||
state: { subtitleSidebarModalOpen: true },
|
||||
dom: {
|
||||
subtitleRoot: makeElement('', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
} as DOMRect),
|
||||
subtitleContainer: makeElement('', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
} as DOMRect),
|
||||
secondarySubRoot: makeElement('', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
} as DOMRect),
|
||||
secondarySubContainer: makeElement('', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
} as DOMRect),
|
||||
subtitleSidebarContent: makeElement('sidebar', {
|
||||
left: 1500,
|
||||
top: 60,
|
||||
width: 380,
|
||||
height: 900,
|
||||
} as DOMRect),
|
||||
},
|
||||
} as never);
|
||||
|
||||
reporter.emitNow();
|
||||
|
||||
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
|
||||
if (typeof measuredAtMs !== 'number') {
|
||||
assert.fail('Expected report timestamp.');
|
||||
}
|
||||
|
||||
assert.deepEqual(reports, [
|
||||
{
|
||||
layer: 'visible',
|
||||
measuredAtMs,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 1500, y: 60, width: 380, height: 900 },
|
||||
interactiveRects: [{ x: 1500, y: 60, width: 380, height: 900 }],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -47,25 +47,43 @@ function hasVisibleTextContent(element: HTMLElement): boolean {
|
||||
return Boolean(element.textContent && element.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
|
||||
let combinedRect: OverlayContentRect | null = null;
|
||||
function hasArea(rect: OverlayContentRect): boolean {
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
|
||||
const rects: OverlayContentRect[] = [];
|
||||
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
|
||||
if (subtitleHasContent) {
|
||||
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
|
||||
if (subtitleRect) {
|
||||
combinedRect = subtitleRect;
|
||||
const subtitleRect = toMeasuredRect(ctx.dom.subtitleContainer.getBoundingClientRect());
|
||||
if (subtitleRect && hasArea(subtitleRect)) {
|
||||
rects.push(subtitleRect);
|
||||
}
|
||||
}
|
||||
|
||||
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
|
||||
if (secondaryHasContent) {
|
||||
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
|
||||
if (secondaryRect) {
|
||||
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
|
||||
if (secondaryRect && hasArea(secondaryRect)) {
|
||||
rects.push(secondaryRect);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.state?.subtitleSidebarModalOpen) {
|
||||
const sidebarRect = toMeasuredRect(ctx.dom.subtitleSidebarContent.getBoundingClientRect());
|
||||
if (sidebarRect && hasArea(sidebarRect)) {
|
||||
rects.push(sidebarRect);
|
||||
}
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
function collectContentRect(rects: OverlayContentRect[]): OverlayContentRect | null {
|
||||
let combinedRect: OverlayContentRect | null = null;
|
||||
for (const rect of rects) {
|
||||
combinedRect = combinedRect ? unionRects(combinedRect, rect) : rect;
|
||||
}
|
||||
if (!combinedRect) {
|
||||
return null;
|
||||
}
|
||||
@@ -86,6 +104,7 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interactiveRects = collectInteractiveRects(ctx);
|
||||
const measurement: OverlayContentMeasurement = {
|
||||
layer: ctx.platform.overlayLayer,
|
||||
measuredAtMs: Date.now(),
|
||||
@@ -94,7 +113,8 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
|
||||
height: window.innerHeight,
|
||||
},
|
||||
// Explicit null rect signals "no content yet", and main should use fallback bounds.
|
||||
contentRect: collectContentRect(ctx),
|
||||
contentRect: collectContentRect(interactiveRects),
|
||||
interactiveRects,
|
||||
};
|
||||
|
||||
window.electronAPI.reportOverlayContentBounds(measurement);
|
||||
|
||||
@@ -15,17 +15,30 @@ function createClassList() {
|
||||
};
|
||||
}
|
||||
|
||||
function replaceGlobalProperty(key: 'window' | 'document', value: unknown): () => void {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis, key, original);
|
||||
return;
|
||||
}
|
||||
delete (globalThis as Record<string, unknown>)[key];
|
||||
};
|
||||
}
|
||||
|
||||
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
const restoreWindow = replaceGlobalProperty('window', {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -58,21 +71,18 @@ test('idle visible overlay starts click-through on platforms that toggle mouse i
|
||||
assert.equal(classList.contains('interactive'), false);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||
} finally {
|
||||
Object.assign(globalThis, { window: originalWindow });
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
const restoreWindow = replaceGlobalProperty('window', {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -105,36 +115,32 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
||||
assert.equal(classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
} finally {
|
||||
Object.assign(globalThis, { window: originalWindow });
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
|
||||
const classList = createClassList();
|
||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
const restoreWindow = replaceGlobalProperty('window', {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
},
|
||||
document: {
|
||||
querySelectorAll: (selector: string) =>
|
||||
selector ===
|
||||
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
||||
? [{ getAttribute: () => 'true' }]
|
||||
: [],
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'visible',
|
||||
display: 'block',
|
||||
opacity: '1',
|
||||
}),
|
||||
});
|
||||
const restoreDocument = replaceGlobalProperty('document', {
|
||||
querySelectorAll: (selector: string) =>
|
||||
selector ===
|
||||
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
||||
? [{ getAttribute: () => 'true' }]
|
||||
: [],
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -165,6 +171,97 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu
|
||||
assert.equal(classList.contains('interactive'), true);
|
||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||
} finally {
|
||||
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
|
||||
restoreDocument();
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
|
||||
const classList = createClassList();
|
||||
const interactiveHints: boolean[] = [];
|
||||
|
||||
const restoreWindow = replaceGlobalProperty('window', {
|
||||
electronAPI: {
|
||||
reportOverlayInteractive: (interactive: boolean) => {
|
||||
interactiveHints.push(interactive);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
syncOverlayMouseIgnoreState({
|
||||
dom: {
|
||||
overlay: { classList },
|
||||
},
|
||||
platform: {
|
||||
isLinuxPlatform: true,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: true,
|
||||
isOverSubtitleSidebar: false,
|
||||
yomitanPopupVisible: false,
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
jimakuModalOpen: false,
|
||||
youtubePickerModalOpen: false,
|
||||
kikuModalOpen: false,
|
||||
runtimeOptionsModalOpen: false,
|
||||
subsyncModalOpen: false,
|
||||
sessionHelpModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
subtitleSidebarConfig: null,
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(classList.contains('interactive'), false);
|
||||
assert.deepEqual(interactiveHints, [false]);
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux modal state reports whole-window interactive hint', () => {
|
||||
const classList = createClassList();
|
||||
const interactiveHints: boolean[] = [];
|
||||
|
||||
const restoreWindow = replaceGlobalProperty('window', {
|
||||
electronAPI: {
|
||||
reportOverlayInteractive: (interactive: boolean) => {
|
||||
interactiveHints.push(interactive);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
syncOverlayMouseIgnoreState({
|
||||
dom: {
|
||||
overlay: { classList },
|
||||
},
|
||||
platform: {
|
||||
isLinuxPlatform: true,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: false,
|
||||
yomitanPopupVisible: false,
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
jimakuModalOpen: false,
|
||||
youtubePickerModalOpen: false,
|
||||
kikuModalOpen: false,
|
||||
runtimeOptionsModalOpen: true,
|
||||
subsyncModalOpen: false,
|
||||
sessionHelpModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
subtitleSidebarConfig: null,
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(classList.contains('interactive'), true);
|
||||
assert.deepEqual(interactiveHints, [true]);
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,18 +26,26 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||
}
|
||||
|
||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||
const shouldKeepWindowInteractive =
|
||||
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
|
||||
const shouldStayInteractive =
|
||||
ctx.state.isOverSubtitle ||
|
||||
ctx.state.isOverSubtitleSidebar ||
|
||||
isYomitanPopupInteractionActive(ctx.state) ||
|
||||
isBlockingOverlayModalOpen(ctx.state);
|
||||
ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive;
|
||||
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
|
||||
? shouldKeepWindowInteractive
|
||||
: shouldStayInteractive;
|
||||
|
||||
if (shouldStayInteractive) {
|
||||
if (shouldMarkOverlayInteractive) {
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
} else {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
if (!ctx.platform?.shouldToggleMouseIgnore) {
|
||||
// On Linux the main process owns window passthrough via a cursor poll (Electron can't
|
||||
// forward mouse-move through a click-through window on X11). Report the interactive hint
|
||||
// only for popups/modals that sit off measured hit rects; subtitles/sidebar use the poll.
|
||||
if (ctx.platform?.isLinuxPlatform) {
|
||||
window.electronAPI.reportOverlayInteractive?.(shouldKeepWindowInteractive);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const rendererSource = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/renderer/renderer.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function indexOfRequired(pattern: string): number {
|
||||
const index = rendererSource.indexOf(pattern);
|
||||
assert.notEqual(index, -1, `Expected renderer.ts to contain ${pattern}`);
|
||||
return index;
|
||||
}
|
||||
|
||||
test('renderer applies subtitle style and position before first subtitle paint', () => {
|
||||
const styleIndex = indexOfRequired(
|
||||
'const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();',
|
||||
);
|
||||
const positionIndex = indexOfRequired(
|
||||
"await window.electronAPI.getSubtitlePosition(),\n 'startup',",
|
||||
);
|
||||
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
|
||||
const currentSubtitleIndex = indexOfRequired(
|
||||
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
|
||||
);
|
||||
|
||||
assert.ok(styleIndex < listenerIndex);
|
||||
assert.ok(positionIndex < listenerIndex);
|
||||
assert.ok(styleIndex < currentSubtitleIndex);
|
||||
assert.ok(positionIndex < currentSubtitleIndex);
|
||||
});
|
||||
|
||||
test('renderer renders initial subtitle snapshot before subscribing to live subtitle updates', () => {
|
||||
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
|
||||
const currentSubtitleIndex = indexOfRequired(
|
||||
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
|
||||
);
|
||||
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
|
||||
|
||||
assert.ok(currentSubtitleIndex < initialRenderIndex);
|
||||
assert.ok(initialRenderIndex < listenerIndex);
|
||||
});
|
||||
|
||||
test('renderer reports subtitle bounds immediately after initial subtitle layout', () => {
|
||||
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
|
||||
const initialLayoutIndex = indexOfRequired(
|
||||
'subtitleRenderer.renderSubtitle(initialSubtitle);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
|
||||
);
|
||||
const immediateMeasurementIndex = indexOfRequired(
|
||||
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
|
||||
);
|
||||
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
|
||||
|
||||
assert.equal(initialRenderIndex, initialLayoutIndex);
|
||||
assert.ok(initialLayoutIndex < immediateMeasurementIndex);
|
||||
assert.ok(immediateMeasurementIndex < listenerIndex);
|
||||
});
|
||||
|
||||
test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
|
||||
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
|
||||
const liveLayoutIndex = indexOfRequired(
|
||||
'subtitleRenderer.renderSubtitle(data);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
|
||||
);
|
||||
const immediateMeasurementIndex = indexOfRequired(
|
||||
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
|
||||
);
|
||||
const sidebarUpdateIndex = indexOfRequired('subtitleSidebarModal.handleSubtitleUpdated(data);');
|
||||
|
||||
assert.equal(liveRenderIndex, liveLayoutIndex);
|
||||
assert.ok(liveLayoutIndex < immediateMeasurementIndex);
|
||||
assert.ok(immediateMeasurementIndex < sidebarUpdateIndex);
|
||||
});
|
||||
|
||||
test('renderer restores subtitle sidebar open state only on visible overlay layer', () => {
|
||||
const sidebarRestoreIndex = indexOfRequired(
|
||||
"ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen())",
|
||||
);
|
||||
const sidebarModalIndex = indexOfRequired('const subtitleSidebarModal = createSubtitleSidebarModal');
|
||||
|
||||
assert.ok(sidebarModalIndex < sidebarRestoreIndex);
|
||||
});
|
||||
+32
-23
@@ -142,6 +142,11 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
});
|
||||
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
shouldRestoreOpenOnStartup: async () =>
|
||||
ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen()),
|
||||
onVisibilityChanged: () => {
|
||||
measurementReporter.emitNow();
|
||||
},
|
||||
});
|
||||
const kikuModal = createKikuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
@@ -596,15 +601,16 @@ async function init(): Promise<void> {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
subtitleSidebarModal.handleSubtitleUpdated(data);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
|
||||
runGuarded('subtitle-position:update', () => {
|
||||
@@ -618,8 +624,6 @@ async function init(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
let initialSubtitle: SubtitleData | string = '';
|
||||
try {
|
||||
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
||||
@@ -629,7 +633,20 @@ async function init(): Promise<void> {
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||
measurementReporter.schedule();
|
||||
positioning.applyYPercent(positioning.getCurrentYPercent());
|
||||
measurementReporter.emitNow();
|
||||
|
||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||
runGuarded('subtitle:update', () => {
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
positioning.applyYPercent(positioning.getCurrentYPercent());
|
||||
measurementReporter.emitNow();
|
||||
subtitleSidebarModal.handleSubtitleUpdated(data);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onSecondarySub((text: string) => {
|
||||
runGuarded('secondary-subtitle:update', () => {
|
||||
@@ -713,18 +730,9 @@ async function init(): Promise<void> {
|
||||
}
|
||||
startControllerPolling();
|
||||
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
|
||||
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
|
||||
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
'startup',
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
measurementReporter.emitNow();
|
||||
}
|
||||
|
||||
@@ -775,7 +783,8 @@ function setupDragDropToMpvQueue(): void {
|
||||
|
||||
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
|
||||
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
|
||||
const appendDroppedVideos = event.shiftKey;
|
||||
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, appendDroppedVideos);
|
||||
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
||||
for (const command of loadCommands) {
|
||||
window.electronAPI.sendMpvCommand(command);
|
||||
@@ -785,7 +794,7 @@ function setupDragDropToMpvQueue(): void {
|
||||
}
|
||||
const osdParts: string[] = [];
|
||||
if (loadCommands.length > 0) {
|
||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||
const action = appendDroppedVideos ? 'Queued' : 'Loaded';
|
||||
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (subtitleCommands.length > 0) {
|
||||
|
||||
@@ -1218,6 +1218,11 @@ body.settings-modal-open #secondarySubContainer {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body.settings-modal-open .subtitle-sidebar-modal {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.secondary-sub-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class FakeElement {
|
||||
dataset: Record<string, string> = {};
|
||||
style = new FakeStyleDeclaration();
|
||||
className = '';
|
||||
replaceChildrenCalls = 0;
|
||||
private ownTextContent = '';
|
||||
|
||||
constructor(public tagName: string) {}
|
||||
@@ -97,6 +98,7 @@ class FakeElement {
|
||||
}
|
||||
|
||||
replaceChildren(): void {
|
||||
this.replaceChildrenCalls += 1;
|
||||
this.childNodes = [];
|
||||
this.ownTextContent = '';
|
||||
}
|
||||
@@ -347,6 +349,130 @@ test('renderSubtitle skips character image when name-match rendering is disabled
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle skips identical primary subtitle DOM replacement', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.renderSubtitle({ text: '字幕', tokens: null });
|
||||
renderer.renderSubtitle({ text: '字幕', tokens: null });
|
||||
renderer.renderSubtitle({ text: '字幕2', tokens: null });
|
||||
|
||||
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
|
||||
assert.equal(subtitleRoot.textContent, '字幕2');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle keeps tokenized subtitle when stale plain payload repeats same text', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.renderSubtitle({
|
||||
text: 'アクア',
|
||||
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
|
||||
});
|
||||
renderer.renderSubtitle({ text: 'アクア', tokens: null });
|
||||
|
||||
assert.equal(subtitleRoot.replaceChildrenCalls, 1);
|
||||
assert.equal(collectWordNodes(subtitleRoot).length, 1);
|
||||
assert.equal(subtitleRoot.textContent, 'アクア');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle accepts repeated plain payload after style invalidates tokenized render', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.renderSubtitle({
|
||||
text: 'アクア',
|
||||
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
|
||||
});
|
||||
renderer.applySubtitleStyle({ fontColor: '#fff' } as never);
|
||||
renderer.renderSubtitle({ text: 'アクア', tokens: null });
|
||||
|
||||
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
|
||||
assert.equal(collectWordNodes(subtitleRoot).length, 0);
|
||||
assert.equal(subtitleRoot.textContent, 'アクア');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle re-renders identical text after style changes affect token output', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: {
|
||||
...createRendererState(),
|
||||
nameMatchEnabled: false,
|
||||
},
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
} as never;
|
||||
const subtitle = {
|
||||
text: 'アクア',
|
||||
tokens: [
|
||||
{
|
||||
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
|
||||
isNameMatch: true,
|
||||
} as MergedToken,
|
||||
],
|
||||
};
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.renderSubtitle(subtitle);
|
||||
renderer.applySubtitleStyle({ nameMatchEnabled: true } as never);
|
||||
renderer.renderSubtitle(subtitle);
|
||||
|
||||
const [word] = collectWordNodes(subtitleRoot);
|
||||
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
|
||||
assert.ok(word?.className.includes('word-name-match'));
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderer content security policy allows data URL character images', () => {
|
||||
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
|
||||
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
|
||||
@@ -1231,6 +1357,13 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
|
||||
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
|
||||
|
||||
const sidebarSettingsModalBlock = extractClassBlock(
|
||||
cssText,
|
||||
'body.settings-modal-open .subtitle-sidebar-modal',
|
||||
);
|
||||
assert.match(sidebarSettingsModalBlock, /display:\s*none !important;/);
|
||||
assert.match(sidebarSettingsModalBlock, /pointer-events:\s*none !important;/);
|
||||
|
||||
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
||||
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
||||
|
||||
|
||||
@@ -653,9 +653,32 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
|
||||
}
|
||||
|
||||
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
function renderSubtitle(data: SubtitleData | string): void {
|
||||
ctx.dom.subtitleRoot.replaceChildren();
|
||||
let lastPrimarySubtitleRenderKey: string | null = null;
|
||||
let lastPrimarySubtitleNormalizedText: string | null = null;
|
||||
let lastPrimarySubtitleRenderedTokenized = false;
|
||||
|
||||
function getPrimarySubtitleRenderKey(
|
||||
text: string,
|
||||
normalized: string,
|
||||
tokens: MergedToken[] | null,
|
||||
): string {
|
||||
if (!shouldRenderTokenizedSubtitle(tokens?.length ?? 0) || !tokens) {
|
||||
return JSON.stringify({
|
||||
mode: 'plain',
|
||||
text: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
mode: 'tokens',
|
||||
text,
|
||||
tokens,
|
||||
settings: getTokenRenderSettings(),
|
||||
preserveSubtitleLineBreaks: ctx.state.preserveSubtitleLineBreaks,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSubtitle(data: SubtitleData | string): void {
|
||||
let text: string;
|
||||
let tokens: MergedToken[] | null;
|
||||
|
||||
@@ -669,9 +692,30 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
const hasRenderableTokens =
|
||||
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
|
||||
if (
|
||||
lastPrimarySubtitleRenderKey !== null &&
|
||||
!hasRenderableTokens &&
|
||||
lastPrimarySubtitleRenderedTokenized &&
|
||||
normalized === lastPrimarySubtitleNormalizedText
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderKey = getPrimarySubtitleRenderKey(text, normalized, tokens);
|
||||
if (renderKey === lastPrimarySubtitleRenderKey) {
|
||||
return;
|
||||
}
|
||||
lastPrimarySubtitleRenderKey = renderKey;
|
||||
lastPrimarySubtitleNormalizedText = normalized;
|
||||
lastPrimarySubtitleRenderedTokenized = hasRenderableTokens;
|
||||
|
||||
ctx.dom.subtitleRoot.replaceChildren();
|
||||
|
||||
if (!text) return;
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
@@ -753,6 +797,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
|
||||
function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void {
|
||||
if (!style) return;
|
||||
lastPrimarySubtitleRenderKey = null;
|
||||
|
||||
const styleDeclarations = style as Record<string, unknown>;
|
||||
applyInlineStyleDeclarations(ctx.dom.subtitleRoot, styleDeclarations, CONTAINER_STYLE_KEYS);
|
||||
|
||||
Reference in New Issue
Block a user