Prevent session help modal focus thr

This commit is contained in:
2026-02-16 00:08:52 -08:00
parent 1ab7e6e1da
commit f448106f92
3 changed files with 91 additions and 38 deletions

View File

@@ -21,6 +21,9 @@ export function createKeyboardHandlers(
updateInvisiblePositionEditHud: () => void; updateInvisiblePositionEditHud: () => void;
}, },
) { ) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000;
const CHORD_MAP = new Map<string, { type: "mpv" | "electron"; command?: string[]; action?: () => void }>([ const CHORD_MAP = new Map<string, { type: "mpv" | "electron"; command?: string[]; action?: () => void }>([
["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }], ["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }],
["Shift+KeyS", { type: "mpv", command: ["script-message", "subminer-stop"] }], ["Shift+KeyS", { type: "mpv", command: ["script-message", "subminer-stop"] }],
@@ -257,7 +260,7 @@ export function createKeyboardHandlers(
ctx.state.chordPending = true; ctx.state.chordPending = true;
ctx.state.chordTimeout = setTimeout(() => { ctx.state.chordTimeout = setTimeout(() => {
resetChord(); resetChord();
}, 1000); }, CHORD_TIMEOUT_MS);
return; return;
} }

View File

@@ -28,6 +28,7 @@
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<!-- Programmatic focus fallback target for Electron/window focus management. -->
<div id="overlay" tabindex="-1"> <div id="overlay" tabindex="-1">
<div id="secondarySubContainer" class="secondary-sub-hidden"> <div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div> <div id="secondarySubRoot"></div>

View File

@@ -24,6 +24,7 @@ type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, "multiCopyTimeoutMs
const HEX_COLOR_RE = const HEX_COLOR_RE =
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
const FALLBACK_COLORS = { const FALLBACK_COLORS = {
knownWordColor: "#a6da95", knownWordColor: "#a6da95",
nPlusOneColor: "#c6a0f6", nPlusOneColor: "#c6a0f6",
@@ -297,11 +298,15 @@ function formatBindingHint(info: SessionHelpBindingInfo): string {
return "Y-H"; return "Y-H";
} }
function createShortcutRow(row: SessionHelpItem): HTMLButtonElement { function createShortcutRow(
row: SessionHelpItem,
globalIndex: number,
): HTMLButtonElement {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "session-help-item"; button.className = "session-help-item";
button.tabIndex = -1; button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement("div"); const left = document.createElement("div");
left.className = "session-help-item-left"; left.className = "session-help-item-left";
@@ -345,7 +350,6 @@ const SECTION_ICON: Record<string, string> = {
function createSectionNode( function createSectionNode(
section: SessionHelpSection, section: SessionHelpSection,
sectionIndex: number, sectionIndex: number,
onSelect: (index: number) => void,
globalIndexMap: number[], globalIndexMap: number[],
): HTMLElement { ): HTMLElement {
const sectionNode = document.createElement("section"); const sectionNode = document.createElement("section");
@@ -361,9 +365,8 @@ function createSectionNode(
list.className = "session-help-item-list"; list.className = "session-help-item-list";
section.rows.forEach((row, rowIndex) => { section.rows.forEach((row, rowIndex) => {
const button = createShortcutRow(row);
const globalIndex = globalIndexMap[sectionIndex] + rowIndex; const globalIndex = globalIndexMap[sectionIndex] + rowIndex;
button.addEventListener("click", () => onSelect(globalIndex)); const button = createShortcutRow(row, globalIndex);
list.appendChild(button); list.appendChild(button);
}); });
@@ -389,6 +392,8 @@ export function createSessionHelpModal(
let focusGuard: ((event: FocusEvent) => void) | null = null; let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null; let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null; let modalPointerFocusGuard: ((event: Event) => void) | null = null;
let isRecoveringModalFocus = false;
let lastFocusRecoveryAt = 0;
function getItems(): HTMLButtonElement[] { function getItems(): HTMLButtonElement[] {
return Array.from( return Array.from(
@@ -400,8 +405,8 @@ export function createSessionHelpModal(
const items = getItems(); const items = getItems();
if (items.length === 0) return; if (items.length === 0) return;
const next = const wrappedIndex = index % items.length;
((index % items.length) + items.length) % items.length; const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex;
ctx.state.sessionHelpSelectedIndex = next; ctx.state.sessionHelpSelectedIndex = next;
items.forEach((item, idx) => { items.forEach((item, idx) => {
@@ -423,27 +428,39 @@ export function createSessionHelpModal(
); );
} }
function focusFallbackTarget(): void { function focusFallbackTarget(): boolean {
void window.electronAPI.focusMainWindow(); void window.electronAPI.focusMainWindow();
const items = getItems(); const items = getItems();
const firstItem = items.find((item) => item.offsetParent !== null); const firstItem = items.find((item) => item.offsetParent !== null);
if (firstItem) { if (firstItem) {
firstItem.focus({ preventScroll: true }); firstItem.focus({ preventScroll: true });
return; return document.activeElement === firstItem;
} }
if (ctx.dom.sessionHelpClose instanceof HTMLElement) { if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
ctx.dom.sessionHelpClose.focus({ preventScroll: true }); ctx.dom.sessionHelpClose.focus({ preventScroll: true });
return; return document.activeElement === ctx.dom.sessionHelpClose;
} }
window.focus(); window.focus();
return false;
} }
function enforceModalFocus(): void { function enforceModalFocus(): void {
if (!ctx.state.sessionHelpModalOpen) return; if (!ctx.state.sessionHelpModalOpen) return;
if (!isSessionHelpModalFocusTarget(document.activeElement)) { if (!isSessionHelpModalFocusTarget(document.activeElement)) {
if (isRecoveringModalFocus) return;
const now = Date.now();
if (now - lastFocusRecoveryAt < 120) return;
isRecoveringModalFocus = true;
lastFocusRecoveryAt = now;
focusFallbackTarget(); focusFallbackTarget();
window.setTimeout(() => {
isRecoveringModalFocus = false;
}, 120);
} }
} }
@@ -470,9 +487,6 @@ export function createSessionHelpModal(
const sectionNode = createSectionNode( const sectionNode = createSectionNode(
section, section,
sectionIndex, sectionIndex,
(selectionIndex) => {
setSelected(selectionIndex);
},
indexOffsets, indexOffsets,
); );
ctx.dom.sessionHelpContent.appendChild(sectionNode); ctx.dom.sessionHelpContent.appendChild(sectionNode);
@@ -506,14 +520,12 @@ export function createSessionHelpModal(
enforceModalFocus(); enforceModalFocus();
}; };
ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard); ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener("mousedown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard); ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
} }
function removePointerFocusListener(): void { function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return; if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard); ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener("mousedown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard); ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard);
modalPointerFocusGuard = null; modalPointerFocusGuard = null;
} }
@@ -536,37 +548,56 @@ export function createSessionHelpModal(
windowFocusGuard = null; windowFocusGuard = null;
} }
async function render(): Promise<void> { function showRenderError(message: string): void {
const [keybindings, styleConfig, shortcuts] = await Promise.all([ helpSections = [];
window.electronAPI.getKeybindings(), helpFilterValue = "";
window.electronAPI.getSubtitleStyle(), ctx.dom.sessionHelpFilter.value = "";
window.electronAPI.getConfiguredShortcuts(), ctx.dom.sessionHelpContent.classList.add("session-help-content-no-results");
]); ctx.dom.sessionHelpContent.textContent = message;
ctx.state.sessionHelpSelectedIndex = 0;
}
const bindingSections = buildBindingSections(keybindings); async function render(): Promise<boolean> {
if (bindingSections.length > 0) { try {
const playback = bindingSections.find( const [keybindings, styleConfig, shortcuts] = await Promise.all([
(section) => section.title === "Playback and navigation", window.electronAPI.getKeybindings(),
); window.electronAPI.getSubtitleStyle(),
if (playback) { window.electronAPI.getConfiguredShortcuts(),
playback.title = "MPV shortcuts"; ]);
const bindingSections = buildBindingSections(keybindings);
if (bindingSections.length > 0) {
const playback = bindingSections.find(
(section) => section.title === "Playback and navigation",
);
if (playback) {
playback.title = "MPV shortcuts";
}
} }
}
const shortcutSections = buildOverlayShortcutSections(shortcuts); const shortcutSections = buildOverlayShortcutSections(shortcuts);
if (shortcutSections.length > 0) { if (shortcutSections.length > 0) {
shortcutSections[0].title = "Overlay shortcuts (configurable)"; shortcutSections[0].title = "Overlay shortcuts (configurable)";
}
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];
applyFilterAndRender();
return true;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Unable to load session help data.";
showRenderError(`Session help failed to load: ${message}`);
return false;
} }
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];
applyFilterAndRender();
} }
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> { async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
openBinding = opening; openBinding = opening;
priorFocus = document.activeElement; priorFocus = document.activeElement;
await render(); const dataLoaded = await render();
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`; ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) { if (openBinding.fallbackUnavailable) {
@@ -577,8 +608,15 @@ export function createSessionHelpModal(
} else { } else {
ctx.dom.sessionHelpWarning.textContent = ""; ctx.dom.sessionHelpWarning.textContent = "";
} }
ctx.dom.sessionHelpStatus.textContent = if (dataLoaded) {
"Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes."; ctx.dom.sessionHelpStatus.textContent =
"Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.";
} else {
ctx.dom.sessionHelpStatus.textContent =
"Session help data is unavailable right now. Press Esc to close.";
ctx.dom.sessionHelpWarning.textContent =
"Unable to load latest shortcut settings from the runtime.";
}
ctx.state.sessionHelpModalOpen = true; ctx.state.sessionHelpModalOpen = true;
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
@@ -634,6 +672,7 @@ export function createSessionHelpModal(
} }
if (ctx.dom.overlay instanceof HTMLElement) { if (ctx.dom.overlay instanceof HTMLElement) {
// Overlay remains `tabindex="-1"` to allow programmatic focus for fallback.
ctx.dom.overlay.focus({ preventScroll: true }); ctx.dom.overlay.focus({ preventScroll: true });
} }
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
@@ -727,6 +766,16 @@ export function createSessionHelpModal(
} }
}); });
ctx.dom.sessionHelpContent.addEventListener("click", (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest(".session-help-item") as HTMLElement | null;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? "", 10);
if (!Number.isFinite(index)) return;
setSelected(index);
});
ctx.dom.sessionHelpClose.addEventListener("click", () => { ctx.dom.sessionHelpClose.addEventListener("click", () => {
closeSessionHelpModal(); closeSessionHelpModal();
}); });