mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Prevent session help modal focus thr
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user