mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 18:12:08 -07:00
feat(subtitle-sidebar): add sidebar config surface (#28)
This commit is contained in:
@@ -27,6 +27,7 @@ export function createKeyboardHandlers(
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
openControllerSelectModal: () => void;
|
||||
openControllerDebugModal: () => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
@@ -181,6 +182,26 @@ export function createKeyboardHandlers(
|
||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
|
||||
const toggleKey = ctx.state.subtitleSidebarToggleKey;
|
||||
if (!toggleKey) return false;
|
||||
const isBackslashConfigured = toggleKey === 'Backslash' || toggleKey === '\\';
|
||||
const isBackslashLikeCode = ['Backslash', 'IntlBackslash', 'IntlYen'].includes(e.code);
|
||||
const keyMatches =
|
||||
toggleKey === e.code ||
|
||||
(isBackslashConfigured && isBackslashLikeCode) ||
|
||||
(isBackslashConfigured && e.key === '\\') ||
|
||||
(toggleKey.length === 1 && e.key === toggleKey);
|
||||
|
||||
return (
|
||||
keyMatches &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!e.repeat
|
||||
);
|
||||
}
|
||||
|
||||
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.code === ctx.state.statsToggleKey &&
|
||||
@@ -838,6 +859,12 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubtitleSidebarToggle(e)) {
|
||||
e.preventDefault();
|
||||
options.toggleSubtitleSidebarModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||
!isControllerModalShortcut(e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SubtitleSidebarConfig } from '../../types';
|
||||
import { createMouseHandlers } from './mouse.js';
|
||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
@@ -39,6 +40,7 @@ function createMouseTestContext() {
|
||||
const overlayClassList = createClassList();
|
||||
const subtitleRootClassList = createClassList();
|
||||
const subtitleContainerClassList = createClassList();
|
||||
const secondarySubContainerClassList = createClassList();
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
@@ -54,6 +56,7 @@ function createMouseTestContext() {
|
||||
addEventListener: () => {},
|
||||
},
|
||||
secondarySubContainer: {
|
||||
classList: secondarySubContainerClassList,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
},
|
||||
@@ -63,6 +66,9 @@ function createMouseTestContext() {
|
||||
},
|
||||
state: {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||
isDragging: false,
|
||||
dragStartY: 0,
|
||||
startYPercent: 0,
|
||||
@@ -72,7 +78,7 @@ function createMouseTestContext() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('auto-pause on subtitle hover pauses on enter and resumes on leave when enabled', async () => {
|
||||
test('secondary hover pauses on enter, reveals secondary subtitle, and resumes on leave when enabled', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
|
||||
@@ -92,8 +98,10 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handleMouseEnter();
|
||||
await handlers.handleMouseLeave();
|
||||
await handlers.handleSecondaryMouseEnter();
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
|
||||
await handlers.handleSecondaryMouseLeave();
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||
|
||||
assert.deepEqual(mpvCommands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
@@ -101,6 +109,68 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
|
||||
]);
|
||||
});
|
||||
|
||||
test('moving between primary and secondary subtitle containers keeps the hover pause active', 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,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handleSecondaryMouseEnter();
|
||||
await handlers.handleSecondaryMouseLeave({
|
||||
relatedTarget: ctx.dom.subtitleContainer,
|
||||
} as unknown as MouseEvent);
|
||||
await handlers.handlePrimaryMouseEnter({
|
||||
relatedTarget: ctx.dom.secondarySubContainer,
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, true);
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
test('secondary leave toward primary subtitle container clears the secondary hover class', 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,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handleSecondaryMouseEnter();
|
||||
await handlers.handleSecondaryMouseLeave({
|
||||
relatedTarget: ctx.dom.subtitleContainer,
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, false);
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
test('auto-pause on subtitle hover skips when playback is already paused', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
@@ -127,6 +197,36 @@ test('auto-pause on subtitle hover skips when playback is already paused', async
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
|
||||
test('primary hover pauses on enter without revealing secondary subtitle', 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,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
await handlers.handlePrimaryMouseEnter();
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||
await handlers.handlePrimaryMouseLeave();
|
||||
|
||||
assert.deepEqual(mpvCommands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto-pause on subtitle hover is skipped when disabled in config', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
@@ -153,6 +253,67 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async ()
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
|
||||
test('subtitle leave restores passthrough while embedded sidebar is open but not hovered', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
ctx.state.isOverSubtitle = true;
|
||||
ctx.state.subtitleSidebarModalOpen = true;
|
||||
ctx.state.subtitleSidebarConfig = {
|
||||
enabled: true,
|
||||
autoOpen: false,
|
||||
layout: 'embedded',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
maxWidth: 360,
|
||||
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: {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
ignoreMouseCalls.push([ignore, options]);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => true,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
|
||||
await handlers.handlePrimaryMouseLeave();
|
||||
|
||||
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
@@ -25,6 +26,19 @@ export function createMouseHandlers(
|
||||
let pausedBySubtitleHover = false;
|
||||
let pausedByYomitanPopup = false;
|
||||
|
||||
function isWithinOtherSubtitleContainer(
|
||||
relatedTarget: EventTarget | null,
|
||||
otherContainer: HTMLElement,
|
||||
): boolean {
|
||||
if (relatedTarget === otherContainer) {
|
||||
return true;
|
||||
}
|
||||
if (typeof Node !== 'undefined' && relatedTarget instanceof Node) {
|
||||
return otherContainer.contains(relatedTarget);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function maybeResumeHoverPause(): void {
|
||||
if (!pausedBySubtitleHover) return;
|
||||
if (pausedByYomitanPopup) return;
|
||||
@@ -80,10 +94,7 @@ export function createMouseHandlers(
|
||||
function enablePopupInteraction(): void {
|
||||
yomitanPopupVisible = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
if (ctx.platform.isMacOSPlatform) {
|
||||
window.focus();
|
||||
}
|
||||
@@ -101,20 +112,18 @@ export function createMouseHandlers(
|
||||
popupPauseRequestId += 1;
|
||||
maybeResumeYomitanPopupPause();
|
||||
maybeResumeHoverPause();
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
async function handleMouseEnter(): Promise<void> {
|
||||
async function handleMouseEnter(
|
||||
_event?: MouseEvent,
|
||||
showSecondaryHover = false,
|
||||
): Promise<void> {
|
||||
ctx.state.isOverSubtitle = true;
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
if (showSecondaryHover) {
|
||||
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
||||
}
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
|
||||
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
|
||||
return;
|
||||
@@ -124,6 +133,10 @@ export function createMouseHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (pausedBySubtitleHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++hoverPauseRequestId;
|
||||
let paused: boolean | null = null;
|
||||
try {
|
||||
@@ -141,8 +154,26 @@ export function createMouseHandlers(
|
||||
pausedBySubtitleHover = true;
|
||||
}
|
||||
|
||||
async function handleMouseLeave(): Promise<void> {
|
||||
async function handleMouseLeave(
|
||||
_event?: MouseEvent,
|
||||
hideSecondaryHover = false,
|
||||
): Promise<void> {
|
||||
const relatedTarget = _event?.relatedTarget ?? null;
|
||||
const otherContainer = hideSecondaryHover
|
||||
? ctx.dom.subtitleContainer
|
||||
: ctx.dom.secondarySubContainer;
|
||||
if (relatedTarget && isWithinOtherSubtitleContainer(relatedTarget, otherContainer)) {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
if (hideSecondaryHover) {
|
||||
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.isOverSubtitle = false;
|
||||
if (hideSecondaryHover) {
|
||||
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
||||
}
|
||||
hoverPauseRequestId += 1;
|
||||
maybeResumeHoverPause();
|
||||
if (yomitanPopupVisible) return;
|
||||
@@ -246,6 +277,10 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
return {
|
||||
handlePrimaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, false),
|
||||
handlePrimaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, false),
|
||||
handleSecondaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, true),
|
||||
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setupDragging,
|
||||
|
||||
@@ -256,6 +256,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtitleSidebarModal" class="modal hidden subtitle-sidebar-modal" aria-hidden="true">
|
||||
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Subtitle Sidebar</div>
|
||||
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-sidebar-body">
|
||||
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
|
||||
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content session-help-content">
|
||||
<div class="modal-header">
|
||||
|
||||
2052
src/renderer/modals/subtitle-sidebar.test.ts
Normal file
2052
src/renderer/modals/subtitle-sidebar.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
599
src/renderer/modals/subtitle-sidebar.ts
Normal file
599
src/renderer/modals/subtitle-sidebar.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import type {
|
||||
SubtitleCue,
|
||||
SubtitleData,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
|
||||
const MANUAL_SCROLL_HOLD_MS = 1500;
|
||||
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
||||
const CLICK_SEEK_OFFSET_SEC = 0.08;
|
||||
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
||||
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
||||
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
const left = a[i]!;
|
||||
const right = b[i]!;
|
||||
if (
|
||||
left.startTime !== right.startTime ||
|
||||
left.endTime !== right.endTime ||
|
||||
left.text !== right.text
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeCueText(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
function formatCueTimestamp(seconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return [
|
||||
String(hours).padStart(2, '0'),
|
||||
String(mins).padStart(2, '0'),
|
||||
String(secs).padStart(2, '0'),
|
||||
].join(':');
|
||||
}
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function findActiveSubtitleCueIndex(
|
||||
cues: SubtitleCue[],
|
||||
current: { text: string; startTime?: number | null } | null,
|
||||
currentTimeSec: number | null = null,
|
||||
preferredCueIndex: number = -1,
|
||||
): number {
|
||||
if (cues.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
|
||||
const activeOrUpcomingCue = cues.findIndex(
|
||||
(cue) =>
|
||||
cue.endTime > currentTimeSec &&
|
||||
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
|
||||
);
|
||||
if (activeOrUpcomingCue >= 0) {
|
||||
return activeOrUpcomingCue;
|
||||
}
|
||||
|
||||
const nextCue = cues.findIndex((cue) => cue.endTime > currentTimeSec);
|
||||
if (nextCue >= 0) {
|
||||
return nextCue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
|
||||
const timingMatch = cues.findIndex(
|
||||
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
|
||||
);
|
||||
if (timingMatch >= 0) {
|
||||
return timingMatch;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedText = normalizeCueText(current.text);
|
||||
if (!normalizedText) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const matchingIndices: number[] = [];
|
||||
for (const [index, cue] of cues.entries()) {
|
||||
if (normalizeCueText(cue.text) === normalizedText) {
|
||||
matchingIndices.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingIndices.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const hasTiming =
|
||||
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
|
||||
|
||||
if (preferredCueIndex >= 0) {
|
||||
if (!hasTiming && currentTimeSec === null) {
|
||||
const forwardMatches = matchingIndices.filter((index) => index >= preferredCueIndex);
|
||||
if (forwardMatches.length > 0) {
|
||||
return forwardMatches[0]!;
|
||||
}
|
||||
if (matchingIndices.includes(preferredCueIndex)) {
|
||||
return preferredCueIndex;
|
||||
}
|
||||
return matchingIndices[matchingIndices.length - 1] ?? -1;
|
||||
}
|
||||
|
||||
let nearestIndex = matchingIndices[0]!;
|
||||
let nearestDistance = Math.abs(nearestIndex - preferredCueIndex);
|
||||
for (const matchIndex of matchingIndices) {
|
||||
const distance = Math.abs(matchIndex - preferredCueIndex);
|
||||
if (distance < nearestDistance) {
|
||||
nearestIndex = matchIndex;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
return matchingIndices[0]!;
|
||||
}
|
||||
|
||||
export function createSubtitleSidebarModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
},
|
||||
) {
|
||||
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastAppliedVideoMarginRatio: number | null = null;
|
||||
let subtitleSidebarHoverRequestId = 0;
|
||||
let disposeDomEvents: (() => void) | null = null;
|
||||
|
||||
function restoreEmbeddedSidebarPassthrough(): void {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
function setStatus(message: string): void {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = message;
|
||||
}
|
||||
|
||||
function getReservedSidebarWidthPx(): number {
|
||||
const config = ctx.state.subtitleSidebarConfig;
|
||||
if (!config || config.layout !== 'embedded' || !ctx.state.subtitleSidebarModalOpen) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const measuredWidth = ctx.dom.subtitleSidebarContent.getBoundingClientRect().width;
|
||||
if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
|
||||
return measuredWidth;
|
||||
}
|
||||
|
||||
return Math.max(EMBEDDED_SIDEBAR_MIN_WIDTH_PX, config.maxWidth);
|
||||
}
|
||||
|
||||
function syncEmbeddedSidebarLayout(): void {
|
||||
const config = ctx.state.subtitleSidebarConfig;
|
||||
const wantsEmbedded = Boolean(
|
||||
config && config.layout === 'embedded' && ctx.state.subtitleSidebarModalOpen,
|
||||
);
|
||||
|
||||
if (wantsEmbedded) {
|
||||
ctx.dom.subtitleSidebarContent.classList.add('subtitle-sidebar-content-embedded');
|
||||
ctx.dom.subtitleSidebarModal.classList.add('subtitle-sidebar-modal-embedded');
|
||||
document.body.classList.add('subtitle-sidebar-embedded-open');
|
||||
} else {
|
||||
ctx.dom.subtitleSidebarContent.classList.remove('subtitle-sidebar-content-embedded');
|
||||
ctx.dom.subtitleSidebarModal.classList.remove('subtitle-sidebar-modal-embedded');
|
||||
document.body.classList.remove('subtitle-sidebar-embedded-open');
|
||||
}
|
||||
const reservedWidthPx = wantsEmbedded ? getReservedSidebarWidthPx() : 0;
|
||||
const embedded = wantsEmbedded && reservedWidthPx > 0;
|
||||
document.documentElement.style.setProperty(
|
||||
'--subtitle-sidebar-reserved-width',
|
||||
`${Math.max(0, Math.round(reservedWidthPx))}px`,
|
||||
);
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const ratio =
|
||||
embedded && Number.isFinite(viewportWidth) && viewportWidth > 0
|
||||
? Math.min(EMBEDDED_SIDEBAR_MAX_RATIO, reservedWidthPx / viewportWidth)
|
||||
: 0;
|
||||
|
||||
if (
|
||||
lastAppliedVideoMarginRatio !== null &&
|
||||
Math.abs(ratio - lastAppliedVideoMarginRatio) < 0.0001
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedVideoMarginRatio = ratio;
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'video-margin-ratio-right',
|
||||
Number(ratio.toFixed(4)),
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'osd-align-x',
|
||||
'left',
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'osd-align-y',
|
||||
'top',
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'user-data/osc/margins',
|
||||
JSON.stringify({
|
||||
l: 0,
|
||||
r: Number(ratio.toFixed(4)),
|
||||
t: 0,
|
||||
b: 0,
|
||||
}),
|
||||
]);
|
||||
if (ratio === 0) {
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'video-pan-x', 0]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfig(snapshot: SubtitleSidebarSnapshot): void {
|
||||
ctx.state.subtitleSidebarConfig = snapshot.config;
|
||||
ctx.state.subtitleSidebarToggleKey = snapshot.config.toggleKey;
|
||||
ctx.state.subtitleSidebarPauseVideoOnHover = snapshot.config.pauseVideoOnHover;
|
||||
ctx.state.subtitleSidebarAutoScroll = snapshot.config.autoScroll;
|
||||
const style = ctx.dom.subtitleSidebarModal.style;
|
||||
style.setProperty('--subtitle-sidebar-max-width', `${snapshot.config.maxWidth}px`);
|
||||
style.setProperty('--subtitle-sidebar-opacity', String(snapshot.config.opacity));
|
||||
style.setProperty('--subtitle-sidebar-background-color', snapshot.config.backgroundColor);
|
||||
style.setProperty('--subtitle-sidebar-text-color', snapshot.config.textColor);
|
||||
style.setProperty('--subtitle-sidebar-font-family', snapshot.config.fontFamily);
|
||||
style.setProperty('--subtitle-sidebar-font-size', `${snapshot.config.fontSize}px`);
|
||||
style.setProperty('--subtitle-sidebar-timestamp-color', snapshot.config.timestampColor);
|
||||
style.setProperty('--subtitle-sidebar-active-line-color', snapshot.config.activeLineColor);
|
||||
style.setProperty(
|
||||
'--subtitle-sidebar-active-background-color',
|
||||
snapshot.config.activeLineBackgroundColor,
|
||||
);
|
||||
style.setProperty(
|
||||
'--subtitle-sidebar-hover-background-color',
|
||||
snapshot.config.hoverLineBackgroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
function seekToCue(cue: SubtitleCue): void {
|
||||
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'seek',
|
||||
Math.max(cue.startTime, targetTime),
|
||||
'absolute+exact',
|
||||
]);
|
||||
}
|
||||
|
||||
function getCueRowLabel(cue: SubtitleCue): string {
|
||||
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
|
||||
}
|
||||
|
||||
function resumeSubtitleSidebarHoverPause(): void {
|
||||
subtitleSidebarHoverRequestId += 1;
|
||||
if (!ctx.state.subtitleSidebarPausedByHover) {
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.subtitleSidebarPausedByHover = false;
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
}
|
||||
|
||||
function maybeAutoScrollActiveCue(
|
||||
previousActiveCueIndex: number,
|
||||
behavior: ScrollBehavior = 'smooth',
|
||||
force = false,
|
||||
): void {
|
||||
if (
|
||||
!ctx.state.subtitleSidebarAutoScroll ||
|
||||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
||||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
||||
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = ctx.dom.subtitleSidebarList;
|
||||
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetScrollTop =
|
||||
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
|
||||
const nextScrollTop = Math.max(0, targetScrollTop);
|
||||
if (previousActiveCueIndex < 0) {
|
||||
list.scrollTop = nextScrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
list.scrollTo({
|
||||
top: nextScrollTop,
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
|
||||
function renderCueList(): void {
|
||||
ctx.dom.subtitleSidebarList.innerHTML = '';
|
||||
for (const [index, cue] of ctx.state.subtitleSidebarCues.entries()) {
|
||||
const row = document.createElement('li');
|
||||
row.className = 'subtitle-sidebar-item';
|
||||
row.classList.toggle('active', index === ctx.state.subtitleSidebarActiveCueIndex);
|
||||
row.dataset.index = String(index);
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.setAttribute('aria-label', getCueRowLabel(cue));
|
||||
row.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
seekToCue(cue);
|
||||
});
|
||||
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'subtitle-sidebar-timestamp';
|
||||
timestamp.textContent = formatCueTimestamp(cue.startTime);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'subtitle-sidebar-text';
|
||||
text.textContent = cue.text;
|
||||
|
||||
row.appendChild(timestamp);
|
||||
row.appendChild(text);
|
||||
|
||||
ctx.dom.subtitleSidebarList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveCueClasses(previousActiveCueIndex: number): void {
|
||||
if (previousActiveCueIndex >= 0) {
|
||||
const previous = ctx.dom.subtitleSidebarList.children[previousActiveCueIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
previous?.classList.remove('active');
|
||||
}
|
||||
|
||||
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
|
||||
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
current?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveCue(
|
||||
current: { text: string; startTime?: number | null } | null,
|
||||
currentTimeSec: number | null = null,
|
||||
): void {
|
||||
const previousActiveCueIndex = ctx.state.subtitleSidebarActiveCueIndex;
|
||||
ctx.state.subtitleSidebarActiveCueIndex = findActiveSubtitleCueIndex(
|
||||
ctx.state.subtitleSidebarCues,
|
||||
current,
|
||||
currentTimeSec,
|
||||
previousActiveCueIndex,
|
||||
);
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
syncActiveCueClasses(previousActiveCueIndex);
|
||||
maybeAutoScrollActiveCue(previousActiveCueIndex);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||
applyConfig(snapshot);
|
||||
if (!snapshot.config.enabled) {
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
ctx.state.subtitleSidebarCues = [];
|
||||
ctx.state.subtitleSidebarModalOpen = false;
|
||||
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||
stopSnapshotPolling();
|
||||
updateActiveCue(null, snapshot.currentTimeSec ?? null);
|
||||
setStatus('Subtitle sidebar disabled in config.');
|
||||
syncEmbeddedSidebarLayout();
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
|
||||
if (cuesChanged) {
|
||||
ctx.state.subtitleSidebarCues = snapshot.cues;
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
renderCueList();
|
||||
}
|
||||
}
|
||||
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
|
||||
syncEmbeddedSidebarLayout();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function startSnapshotPolling(): void {
|
||||
stopSnapshotPolling();
|
||||
|
||||
const pollOnce = async (): Promise<void> => {
|
||||
try {
|
||||
await refreshSnapshot();
|
||||
} catch {
|
||||
// Keep polling; a transient IPC failure should not stop updates.
|
||||
}
|
||||
|
||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||
snapshotPollInterval = null;
|
||||
return;
|
||||
}
|
||||
|
||||
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopSnapshotPolling(): void {
|
||||
if (!snapshotPollInterval) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(snapshotPollInterval);
|
||||
snapshotPollInterval = null;
|
||||
}
|
||||
|
||||
async function openSubtitleSidebarModal(): Promise<void> {
|
||||
const snapshot = await refreshSnapshot();
|
||||
if (!snapshot.config.enabled) {
|
||||
setStatus('Subtitle sidebar disabled in config.');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.subtitleSidebarList.innerHTML = '';
|
||||
if (snapshot.cues.length === 0) {
|
||||
setStatus('No parsed subtitle cues available.');
|
||||
} else {
|
||||
setStatus(`${snapshot.cues.length} parsed subtitle lines`);
|
||||
}
|
||||
|
||||
ctx.state.subtitleSidebarModalOpen = true;
|
||||
ctx.state.isOverSubtitleSidebar = false;
|
||||
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
|
||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
|
||||
renderCueList();
|
||||
syncActiveCueClasses(-1);
|
||||
maybeAutoScrollActiveCue(-1, 'auto', true);
|
||||
startSnapshotPolling();
|
||||
syncEmbeddedSidebarLayout();
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
}
|
||||
|
||||
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
|
||||
const snapshot = await refreshSnapshot();
|
||||
if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) {
|
||||
return;
|
||||
}
|
||||
await openSubtitleSidebarModal();
|
||||
}
|
||||
|
||||
function closeSubtitleSidebarModal(): void {
|
||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||
return;
|
||||
}
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
ctx.state.isOverSubtitleSidebar = false;
|
||||
ctx.state.subtitleSidebarModalOpen = false;
|
||||
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||
stopSnapshotPolling();
|
||||
syncEmbeddedSidebarLayout();
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
}
|
||||
|
||||
async function toggleSubtitleSidebarModal(): Promise<void> {
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
closeSubtitleSidebarModal();
|
||||
return;
|
||||
}
|
||||
await openSubtitleSidebarModal();
|
||||
}
|
||||
|
||||
function handleSubtitleUpdated(data: SubtitleData): void {
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateActiveCue(
|
||||
{ text: data.text, startTime: data.startTime },
|
||||
data.startTime ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
if (disposeDomEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.subtitleSidebarClose.addEventListener('click', () => {
|
||||
closeSubtitleSidebarModal();
|
||||
});
|
||||
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const row = target.closest<HTMLElement>('.subtitle-sidebar-item');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const index = Number.parseInt(row.dataset.index ?? '', 10);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
|
||||
return;
|
||||
}
|
||||
const cue = ctx.state.subtitleSidebarCues[index];
|
||||
if (!cue) {
|
||||
return;
|
||||
}
|
||||
seekToCue(cue);
|
||||
});
|
||||
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
|
||||
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
|
||||
});
|
||||
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
|
||||
ctx.state.isOverSubtitleSidebar = true;
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++subtitleSidebarHoverRequestId;
|
||||
let paused: boolean | null | undefined;
|
||||
try {
|
||||
paused = await window.electronAPI.getPlaybackPaused();
|
||||
} catch {
|
||||
paused = undefined;
|
||||
}
|
||||
if (requestId !== subtitleSidebarHoverRequestId) {
|
||||
return;
|
||||
}
|
||||
if (paused === false) {
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
ctx.state.subtitleSidebarPausedByHover = true;
|
||||
}
|
||||
});
|
||||
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
|
||||
ctx.state.isOverSubtitleSidebar = false;
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
});
|
||||
const resizeHandler = () => {
|
||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||
return;
|
||||
}
|
||||
syncEmbeddedSidebarLayout();
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
disposeDomEvents = () => {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
disposeDomEvents = null;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
autoOpenSubtitleSidebarOnStartup,
|
||||
openSubtitleSidebarModal,
|
||||
closeSubtitleSidebarModal,
|
||||
toggleSubtitleSidebarModal,
|
||||
refreshSubtitleSidebarSnapshot: refreshSnapshot,
|
||||
wireDomEvents,
|
||||
disposeDomEvents: () => {
|
||||
disposeDomEvents?.();
|
||||
},
|
||||
handleSubtitleUpdated,
|
||||
seekToCue,
|
||||
};
|
||||
}
|
||||
42
src/renderer/overlay-mouse-ignore.ts
Normal file
42
src/renderer/overlay-mouse-ignore.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { RendererContext } from './context';
|
||||
import type { RendererState } from './state';
|
||||
|
||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
const embeddedSidebarOpen =
|
||||
state.subtitleSidebarModalOpen && state.subtitleSidebarConfig?.layout === 'embedded';
|
||||
|
||||
return Boolean(
|
||||
state.controllerSelectModalOpen ||
|
||||
state.controllerDebugModalOpen ||
|
||||
state.jimakuModalOpen ||
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
state.sessionHelpModalOpen ||
|
||||
(state.subtitleSidebarModalOpen && !embeddedSidebarOpen),
|
||||
);
|
||||
}
|
||||
|
||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||
const shouldStayInteractive =
|
||||
ctx.state.isOverSubtitle ||
|
||||
ctx.state.isOverSubtitleSidebar ||
|
||||
ctx.state.yomitanPopupVisible ||
|
||||
isBlockingOverlayModalOpen(ctx.state);
|
||||
|
||||
if (shouldStayInteractive) {
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
} else {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
if (!ctx.platform?.shouldToggleMouseIgnore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStayInteractive) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
@@ -34,10 +34,12 @@ import { createControllerSelectModal } from './modals/controller-select.js';
|
||||
import { createJimakuModal } from './modals/jimaku.js';
|
||||
import { createKikuModal } from './modals/kiku.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||
import { createRendererState } from './state.js';
|
||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||
@@ -78,7 +80,8 @@ function isAnyModalOpen(): boolean {
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.runtimeOptionsModalOpen ||
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen
|
||||
ctx.state.sessionHelpModalOpen ||
|
||||
ctx.state.subtitleSidebarModalOpen
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +117,9 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
});
|
||||
const kikuModal = createKikuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -143,6 +149,9 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
},
|
||||
toggleSubtitleSidebarModal: () => {
|
||||
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||
},
|
||||
});
|
||||
const mouseHandlers = createMouseHandlers(ctx, {
|
||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||
@@ -183,6 +192,7 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
||||
function getActiveModal(): string | null {
|
||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
@@ -198,6 +208,9 @@ function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.controllerDebugModalOpen) {
|
||||
controllerDebugModal.closeControllerDebugModal();
|
||||
}
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
subtitleSidebarModal.closeSubtitleSidebarModal();
|
||||
}
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
jimakuModal.closeJimakuModal();
|
||||
}
|
||||
@@ -468,6 +481,7 @@ async function init(): Promise<void> {
|
||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||
keyboardHandlers.handleSubtitleContentUpdated();
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
subtitleSidebarModal.handleSubtitleUpdated(data);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
@@ -508,10 +522,10 @@ async function init(): Promise<void> {
|
||||
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
|
||||
measurementReporter.schedule();
|
||||
|
||||
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);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleSecondaryMouseEnter);
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
|
||||
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
@@ -528,6 +542,10 @@ async function init(): Promise<void> {
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
subtitleSidebarModal.disposeDomEvents();
|
||||
});
|
||||
|
||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||
runGuarded('runtime-options:changed', () => {
|
||||
@@ -539,6 +557,11 @@ async function init(): Promise<void> {
|
||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
|
||||
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
|
||||
ctx.state.subtitleSidebarPauseVideoOnHover = payload.subtitleSidebar.pauseVideoOnHover;
|
||||
ctx.state.subtitleSidebarAutoScroll = payload.subtitleSidebar.autoScroll;
|
||||
void subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
@@ -555,6 +578,8 @@ async function init(): Promise<void> {
|
||||
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
|
||||
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
@@ -563,7 +588,7 @@ async function init(): Promise<void> {
|
||||
measurementReporter.schedule();
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
measurementReporter.emitNow();
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
SubtitleSidebarConfig,
|
||||
SubtitleCue,
|
||||
SubsyncSourceTrack,
|
||||
} from '../types';
|
||||
|
||||
@@ -23,6 +25,7 @@ export type ChordAction =
|
||||
|
||||
export type RendererState = {
|
||||
isOverSubtitle: boolean;
|
||||
isOverSubtitleSidebar: boolean;
|
||||
isDragging: boolean;
|
||||
dragStartY: number;
|
||||
startYPercent: number;
|
||||
@@ -58,6 +61,7 @@ export type RendererState = {
|
||||
|
||||
controllerSelectModalOpen: boolean;
|
||||
controllerDebugModalOpen: boolean;
|
||||
subtitleSidebarModalOpen: boolean;
|
||||
controllerDeviceSelectedIndex: number;
|
||||
controllerConfig: ResolvedControllerConfig | null;
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
@@ -67,6 +71,14 @@ export type RendererState = {
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
subtitleSidebarCues: SubtitleCue[];
|
||||
subtitleSidebarActiveCueIndex: number;
|
||||
subtitleSidebarToggleKey: string;
|
||||
subtitleSidebarPauseVideoOnHover: boolean;
|
||||
subtitleSidebarAutoScroll: boolean;
|
||||
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
|
||||
subtitleSidebarManualScrollUntilMs: number;
|
||||
subtitleSidebarPausedByHover: boolean;
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
@@ -104,6 +116,7 @@ export type RendererState = {
|
||||
export function createRendererState(): RendererState {
|
||||
return {
|
||||
isOverSubtitle: false,
|
||||
isOverSubtitleSidebar: false,
|
||||
isDragging: false,
|
||||
dragStartY: 0,
|
||||
startYPercent: 0,
|
||||
@@ -139,6 +152,7 @@ export function createRendererState(): RendererState {
|
||||
|
||||
controllerSelectModalOpen: false,
|
||||
controllerDebugModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
controllerDeviceSelectedIndex: 0,
|
||||
controllerConfig: null,
|
||||
connectedGamepads: [],
|
||||
@@ -148,6 +162,14 @@ export function createRendererState(): RendererState {
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
subtitleSidebarCues: [],
|
||||
subtitleSidebarActiveCueIndex: -1,
|
||||
subtitleSidebarToggleKey: 'Backslash',
|
||||
subtitleSidebarPauseVideoOnHover: false,
|
||||
subtitleSidebarAutoScroll: true,
|
||||
subtitleSidebarConfig: null,
|
||||
subtitleSidebarManualScrollUntilMs: 0,
|
||||
subtitleSidebarPausedByHover: false,
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
|
||||
@@ -40,6 +40,10 @@ body {
|
||||
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--subtitle-sidebar-reserved-width: 0px;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -294,13 +298,19 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||
}
|
||||
|
||||
#subtitleContainer {
|
||||
max-width: 80%;
|
||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||
margin-bottom: 60px;
|
||||
padding: 12px 20px;
|
||||
background: rgb(30, 32, 48, 0.88);
|
||||
border-radius: 8px;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#subtitleRoot {
|
||||
@@ -705,20 +715,26 @@ body.platform-macos.layer-visible #subtitleRoot {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body.subtitle-sidebar-embedded-open #secondarySubContainer {
|
||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||
transform: translateX(calc(-50% - (var(--subtitle-sidebar-reserved-width) * 0.5)));
|
||||
}
|
||||
|
||||
#secondarySubContainer {
|
||||
--secondary-sub-background-color: transparent;
|
||||
--secondary-sub-backdrop-filter: none;
|
||||
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 80%;
|
||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||
padding: 10px 18px;
|
||||
background: var(--secondary-sub-background-color, transparent);
|
||||
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||
border-radius: 8px;
|
||||
pointer-events: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
body.layer-modal #subtitleContainer,
|
||||
@@ -763,6 +779,14 @@ body.settings-modal-open #secondarySubContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
||||
left: 0;
|
||||
right: var(--subtitle-sidebar-reserved-width);
|
||||
max-width: none;
|
||||
padding-right: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -789,11 +813,13 @@ body.settings-modal-open #secondarySubContainer {
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover:hover {
|
||||
#secondarySubContainer.secondary-sub-hover:hover,
|
||||
#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot {
|
||||
#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot,
|
||||
#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot {
|
||||
background: var(--secondary-sub-background-color, transparent);
|
||||
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||
@@ -1362,6 +1388,206 @@ iframe[id^='yomitan-popup'] {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-modal {
|
||||
inset: 0;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-content {
|
||||
width: min(var(--subtitle-sidebar-max-width, 420px), 92vw);
|
||||
max-height: calc(100vh - 28px);
|
||||
height: auto;
|
||||
margin-left: auto;
|
||||
font-family:
|
||||
var(
|
||||
--subtitle-sidebar-font-family,
|
||||
'M PLUS 1',
|
||||
'Noto Sans CJK JP',
|
||||
'Hiragino Sans',
|
||||
sans-serif
|
||||
);
|
||||
font-size: var(--subtitle-sidebar-font-size, 16px);
|
||||
background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9));
|
||||
color: var(--subtitle-sidebar-text-color, #cad3f5);
|
||||
border: 1px solid rgba(110, 115, 141, 0.18);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(183, 189, 248, 0.06);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
opacity: var(--subtitle-sidebar-opacity, 0.95);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-content .modal-header {
|
||||
padding: 10px 14px 8px;
|
||||
border-bottom: 1px solid rgba(110, 115, 141, 0.14);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-content .modal-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: #b8c0e0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-content .modal-close {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(73, 77, 100, 0.5);
|
||||
border: 1px solid rgba(110, 115, 141, 0.2);
|
||||
color: #a5adcb;
|
||||
transition: all 140ms ease;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-content .modal-close:hover {
|
||||
background: rgba(91, 96, 120, 0.6);
|
||||
color: #cad3f5;
|
||||
border-color: rgba(110, 115, 141, 0.35);
|
||||
}
|
||||
|
||||
body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
width: min(var(--subtitle-sidebar-max-width, 420px), 44vw);
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
box-shadow:
|
||||
-12px 0 32px rgba(0, 0, 0, 0.3),
|
||||
-1px 0 0 rgba(110, 115, 141, 0.12);
|
||||
}
|
||||
|
||||
.subtitle-sidebar-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-content .runtime-options-status {
|
||||
font-size: 11px;
|
||||
padding: 4px 14px;
|
||||
color: #6e738d;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-list {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(110, 115, 141, 0.25);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(110, 115, 141, 0.4);
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item:hover {
|
||||
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item:focus-visible {
|
||||
outline: 2px solid var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||
outline-offset: -2px;
|
||||
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item.active {
|
||||
background: var(--subtitle-sidebar-active-background-color, rgba(138, 173, 244, 0.12));
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-timestamp {
|
||||
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--subtitle-sidebar-timestamp-color, #6e738d);
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item:hover .subtitle-sidebar-timestamp {
|
||||
color: var(--subtitle-sidebar-timestamp-color, #a5adcb);
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item.active .subtitle-sidebar-timestamp {
|
||||
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.subtitle-sidebar-item.active .subtitle-sidebar-text {
|
||||
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||
}
|
||||
|
||||
.subtitle-sidebar-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
font-size: 1em;
|
||||
color: var(--subtitle-sidebar-text-color, #cad3f5);
|
||||
}
|
||||
|
||||
.session-help-content {
|
||||
width: min(760px, 92%);
|
||||
max-height: 84%;
|
||||
|
||||
@@ -977,6 +977,30 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
);
|
||||
assert.match(secondaryHoverBaseBlock, /background:\s*transparent;/);
|
||||
|
||||
const secondaryEmbeddedHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
|
||||
);
|
||||
assert.match(
|
||||
secondaryEmbeddedHoverBlock,
|
||||
/right:\s*var\(--subtitle-sidebar-reserved-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
secondaryEmbeddedHoverBlock,
|
||||
/max-width:\s*none;/,
|
||||
);
|
||||
assert.match(
|
||||
secondaryEmbeddedHoverBlock,
|
||||
/transform:\s*none;/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
secondaryEmbeddedHoverBlock,
|
||||
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
|
||||
);
|
||||
|
||||
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
||||
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
||||
|
||||
const secondaryHoverVisibleBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot',
|
||||
@@ -990,6 +1014,25 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
|
||||
);
|
||||
|
||||
const secondaryHoverActiveBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active',
|
||||
);
|
||||
assert.match(secondaryHoverActiveBlock, /opacity:\s*1;/);
|
||||
|
||||
const secondaryHoverActiveRootBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot',
|
||||
);
|
||||
assert.match(
|
||||
secondaryHoverActiveRootBlock,
|
||||
/background:\s*var\(--secondary-sub-background-color,\s*transparent\);/,
|
||||
);
|
||||
assert.match(
|
||||
secondaryHoverActiveRootBlock,
|
||||
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
cssText,
|
||||
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,
|
||||
|
||||
@@ -74,6 +74,11 @@ export type RendererDom = {
|
||||
controllerDebugAxes: HTMLPreElement;
|
||||
controllerDebugButtons: HTMLPreElement;
|
||||
controllerDebugButtonIndices: HTMLPreElement;
|
||||
subtitleSidebarModal: HTMLDivElement;
|
||||
subtitleSidebarContent: HTMLDivElement;
|
||||
subtitleSidebarClose: HTMLButtonElement;
|
||||
subtitleSidebarStatus: HTMLDivElement;
|
||||
subtitleSidebarList: HTMLUListElement;
|
||||
|
||||
sessionHelpModal: HTMLDivElement;
|
||||
sessionHelpClose: HTMLButtonElement;
|
||||
@@ -171,6 +176,11 @@ export function resolveRendererDom(): RendererDom {
|
||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
||||
'controllerDebugButtonIndices',
|
||||
),
|
||||
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
|
||||
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
|
||||
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
|
||||
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
|
||||
Reference in New Issue
Block a user