Merge pull request #6 from ksyasuda/feature/session-help-modal

Add help modal
This commit is contained in:
2026-02-16 00:32:27 -08:00
committed by GitHub
14 changed files with 1168 additions and 16 deletions

View File

@@ -3,7 +3,7 @@ id: TASK-26
title: >- title: >-
Add session help modal with dynamic keybinding/color legend and keyboard/mouse Add session help modal with dynamic keybinding/color legend and keyboard/mouse
navigation navigation
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-02-13 16:49' created_date: '2026-02-13 16:49'
labels: [] labels: []
@@ -19,19 +19,19 @@ Create a help modal that auto-generates its content from the project/app layout
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Help modal content is generated automatically from current keybinding config and project/app layout rather than hardcoded static text. - [x] #1 Help modal content is generated automatically from current keybinding config and project/app layout rather than hardcoded static text.
- [ ] #2 Modal displays current session keybindings and active color-key mappings in a clear, grouped layout with section separation for readability. - [x] #2 Modal displays current session keybindings and active color-key mappings in a clear, grouped layout with section separation for readability.
- [ ] #3 Modal can be opened with `y-h` when available; if `y-h` is already bound, modal opens with `y-k` instead. - [x] #3 Modal can be opened with `y-h` when available; if `y-h` is already bound, modal opens with `y-k` instead.
- [ ] #4 Close behavior: `Escape` exits the modal and returns to previous focus/state. - [x] #4 Close behavior: `Escape` exits the modal and returns to previous focus/state.
- [ ] #5 Modal supports mouse-based interaction for standard focus/selection actions. - [x] #5 Modal supports mouse-based interaction for standard focus/selection actions.
- [ ] #6 Navigation inside modal supports arrow keys for movement between focusable items. - [x] #6 Navigation inside modal supports arrow keys for movement between focusable items.
- [ ] #7 Modal supports internal navigation semantics equivalent to hjkl directional movement for users, while UI text/labels do not mention hjkl keys. - [x] #7 Modal supports internal navigation semantics equivalent to hjkl directional movement for users, while UI text/labels do not mention hjkl keys.
- [ ] #8 No visible UI mention of Vim key names is shown in modal labels/help copy. - [x] #8 No visible UI mention of Vim key names is shown in modal labels/help copy.
- [ ] #9 Modal opens from current session without requiring a restart and reflects updated config changes without code changes. - [x] #9 Modal opens from current session without requiring a restart and reflects updated config changes without code changes.
- [ ] #10 If the shortcut is unavailable due to conflicts, user-visible fallback behavior/error is deterministic and documented. - [x] #10 If the shortcut is unavailable due to conflicts, user-visible fallback behavior/error is deterministic and documented.
<!-- AC:END --> <!-- AC:END -->
## Definition of Done ## Definition of Done
<!-- DOD:BEGIN --> <!-- DOD:BEGIN -->
- [ ] #1 Auto-generated help modal displays up-to-date keybinding + color mapping data and supports both keyboard (arrow/fallback path) and mouse navigation with Escape-to-close. - [x] #1 Auto-generated help modal displays up-to-date keybinding + color mapping data and supports both keyboard (arrow/fallback path) and mouse navigation with Escape-to-close.
<!-- DOD:END --> <!-- DOD:END -->

View File

@@ -270,6 +270,28 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden. These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
### Session help modal
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
You can filter the modal quickly with `/`:
- Type any part of the action name or shortcut in the search bar.
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
While the modal is open:
- `Esc`: close the modal (or clear the filter when text is entered)
- `↑/↓`, `j/k`: move selection
- Mouse/trackpad: click to select and activate rows
The list is generated at runtime from:
- Your active mpv keybindings (`keybindings`).
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
- Current subtitle color settings from `subtitleStyle`.
### Auto-Start Overlay ### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv: Control whether the overlay automatically becomes visible when it connects to mpv:

View File

@@ -21,8 +21,10 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string; getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
runSubsyncManual: (request: unknown) => Promise<unknown>; runSubsyncManual: (request: unknown) => Promise<unknown>;
getAnkiConnectStatus: () => boolean; getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
@@ -33,6 +35,7 @@ export interface IpcServiceDeps {
interface WindowLike { interface WindowLike {
isDestroyed: () => boolean; isDestroyed: () => boolean;
focus: () => void;
setIgnoreMouseEvents: ( setIgnoreMouseEvents: (
ignore: boolean, ignore: boolean,
options?: { forward?: boolean }, options?: { forward?: boolean },
@@ -69,8 +72,10 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null; getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
runSubsyncManual: (request: unknown) => Promise<unknown>; runSubsyncManual: (request: unknown) => Promise<unknown>;
getAnkiConnectStatus: () => boolean; getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
@@ -120,9 +125,15 @@ export function createIpcDepsRuntimeService(
}, },
handleMpvCommand: options.handleMpvCommand, handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings, getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts,
getSecondarySubMode: options.getSecondarySubMode, getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => getCurrentSecondarySub: () =>
options.getMpvClient()?.currentSecondarySubText || "", options.getMpvClient()?.currentSecondarySubText || "",
focusMainWindow: () => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.focus();
},
runSubsyncManual: options.runSubsyncManual, runSubsyncManual: options.runSubsyncManual,
getAnkiConnectStatus: options.getAnkiConnectStatus, getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions, getRuntimeOptions: options.getRuntimeOptions,
@@ -229,6 +240,10 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void {
return deps.getKeybindings(); return deps.getKeybindings();
}); });
ipcMain.handle("get-config-shortcuts", () => {
return deps.getConfiguredShortcuts();
});
ipcMain.handle("get-secondary-sub-mode", () => { ipcMain.handle("get-secondary-sub-mode", () => {
return deps.getSecondarySubMode(); return deps.getSecondarySubMode();
}); });
@@ -237,6 +252,10 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void {
return deps.getCurrentSecondarySub(); return deps.getCurrentSecondarySub();
}); });
ipcMain.handle("focus-main-window", () => {
deps.focusMainWindow();
});
ipcMain.handle("subsync:run-manual", async (_event, request: unknown) => { ipcMain.handle("subsync:run-manual", async (_event, request: unknown) => {
return await deps.runSubsyncManual(request); return await deps.runSubsyncManual(request);
}); });

View File

@@ -1365,6 +1365,13 @@ registerIpcRuntimeServices({
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!mainWindow.isFocused()) {
mainWindow.focus();
}
},
onOverlayModalClosed: (modal: string) => { onOverlayModalClosed: (modal: string) => {
handleOverlayModalClosed(modal as OverlayHostedModal); handleOverlayModalClosed(modal as OverlayHostedModal);
}, },
@@ -1396,6 +1403,7 @@ registerIpcRuntimeServices({
handleMpvCommand: (command: (string | number)[]) => handleMpvCommand: (command: (string | number)[]) =>
handleMpvCommandFromIpc(command), handleMpvCommandFromIpc(command),
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getSecondarySubMode: () => appState.secondarySubMode, getSecondarySubMode: () => appState.secondarySubMode,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
runSubsyncManual: (request: unknown) => runSubsyncManual: (request: unknown) =>

View File

@@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams {
toggleVisibleOverlay: IpcDepsRuntimeOptions["toggleVisibleOverlay"]; toggleVisibleOverlay: IpcDepsRuntimeOptions["toggleVisibleOverlay"];
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions["tokenizeCurrentSubtitle"]; tokenizeCurrentSubtitle: IpcDepsRuntimeOptions["tokenizeCurrentSubtitle"];
getCurrentSubtitleAss: IpcDepsRuntimeOptions["getCurrentSubtitleAss"]; getCurrentSubtitleAss: IpcDepsRuntimeOptions["getCurrentSubtitleAss"];
focusMainWindow?: IpcDepsRuntimeOptions["focusMainWindow"];
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions["getMpvSubtitleRenderMetrics"]; getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions["getMpvSubtitleRenderMetrics"];
getSubtitlePosition: IpcDepsRuntimeOptions["getSubtitlePosition"]; getSubtitlePosition: IpcDepsRuntimeOptions["getSubtitlePosition"];
getSubtitleStyle: IpcDepsRuntimeOptions["getSubtitleStyle"]; getSubtitleStyle: IpcDepsRuntimeOptions["getSubtitleStyle"];
@@ -80,6 +81,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions["getMecabTokenizer"]; getMecabTokenizer: IpcDepsRuntimeOptions["getMecabTokenizer"];
handleMpvCommand: IpcDepsRuntimeOptions["handleMpvCommand"]; handleMpvCommand: IpcDepsRuntimeOptions["handleMpvCommand"];
getKeybindings: IpcDepsRuntimeOptions["getKeybindings"]; getKeybindings: IpcDepsRuntimeOptions["getKeybindings"];
getConfiguredShortcuts: IpcDepsRuntimeOptions["getConfiguredShortcuts"];
getSecondarySubMode: IpcDepsRuntimeOptions["getSecondarySubMode"]; getSecondarySubMode: IpcDepsRuntimeOptions["getSecondarySubMode"];
getMpvClient: IpcDepsRuntimeOptions["getMpvClient"]; getMpvClient: IpcDepsRuntimeOptions["getMpvClient"];
runSubsyncManual: IpcDepsRuntimeOptions["runSubsyncManual"]; runSubsyncManual: IpcDepsRuntimeOptions["runSubsyncManual"];
@@ -204,6 +206,8 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer, getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand, handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts,
focusMainWindow: params.focusMainWindow ?? (() => {}),
getSecondarySubMode: params.getSecondarySubMode, getSecondarySubMode: params.getSecondarySubMode,
getMpvClient: params.getMpvClient, getMpvClient: params.getMpvClient,
runSubsyncManual: params.runSubsyncManual, runSubsyncManual: params.runSubsyncManual,

View File

@@ -46,6 +46,7 @@ import type {
RuntimeOptionValue, RuntimeOptionValue,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
OverlayContentMeasurement, OverlayContentMeasurement,
ShortcutsConfig,
} from "./types"; } from "./types";
const overlayLayerArg = process.argv.find((arg) => const overlayLayerArg = process.argv.find((arg) =>
@@ -145,6 +146,8 @@ const electronAPI: ElectronAPI = {
getKeybindings: (): Promise<Keybinding[]> => getKeybindings: (): Promise<Keybinding[]> =>
ipcRenderer.invoke("get-keybindings"), ipcRenderer.invoke("get-keybindings"),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke("get-config-shortcuts"),
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> => getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
ipcRenderer.invoke("jimaku:get-media-info"), ipcRenderer.invoke("jimaku:get-media-info"),
@@ -200,6 +203,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke("get-secondary-sub-mode"), ipcRenderer.invoke("get-secondary-sub-mode"),
getCurrentSecondarySub: (): Promise<string> => getCurrentSecondarySub: (): Promise<string> =>
ipcRenderer.invoke("get-current-secondary-sub"), ipcRenderer.invoke("get-current-secondary-sub"),
focusMainWindow: () =>
ipcRenderer.invoke("focus-main-window") as Promise<void>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> => getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke("get-subtitle-style"), ipcRenderer.invoke("get-subtitle-style"),
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => { onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => {

View File

@@ -8,6 +8,12 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: "KeyH" | "KeyK";
fallbackUsed: boolean;
fallbackUnavailable: boolean;
}) => void;
saveInvisiblePositionEdit: () => void; saveInvisiblePositionEdit: () => void;
cancelInvisiblePositionEdit: () => void; cancelInvisiblePositionEdit: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void; setInvisiblePositionEditMode: (enabled: boolean) => void;
@@ -15,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"] }],
@@ -62,6 +71,47 @@ export function createKeyboardHandlers(
); );
} }
function resolveSessionHelpChordBinding(): {
bindingKey: "KeyH" | "KeyK";
fallbackUsed: boolean;
fallbackUnavailable: boolean;
} {
const firstChoice = "KeyH";
if (!ctx.state.keybindingsMap.has("KeyH")) {
return {
bindingKey: firstChoice,
fallbackUsed: false,
fallbackUnavailable: false,
};
}
if (ctx.state.keybindingsMap.has("KeyK")) {
return {
bindingKey: "KeyK",
fallbackUsed: true,
fallbackUnavailable: true,
};
}
return {
bindingKey: "KeyK",
fallbackUsed: true,
fallbackUnavailable: false,
};
}
function applySessionHelpChordBinding(): void {
CHORD_MAP.delete("KeyH");
CHORD_MAP.delete("KeyK");
const info = resolveSessionHelpChordBinding();
CHORD_MAP.set(info.bindingKey, {
type: "electron",
action: () => {
options.openSessionHelpModal(info);
},
});
}
function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean { function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean {
if (!ctx.platform.isInvisibleLayer) return false; if (!ctx.platform.isInvisibleLayer) return false;
@@ -163,6 +213,10 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e); options.handleJimakuKeydown(e);
return; return;
} }
if (ctx.state.sessionHelpModalOpen) {
options.handleSessionHelpKeydown(e);
return;
}
if (ctx.state.chordPending) { if (ctx.state.chordPending) {
const modifierKeys = [ const modifierKeys = [
@@ -202,10 +256,11 @@ export function createKeyboardHandlers(
!e.repeat !e.repeat
) { ) {
e.preventDefault(); e.preventDefault();
applySessionHelpChordBinding();
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,7 +28,8 @@
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<div id="overlay"> <!-- Programmatic focus fallback target for Electron/window focus management. -->
<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>
</div> </div>
@@ -259,6 +260,30 @@
</div> </div>
</div> </div>
</div> </div>
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
<div class="modal-content session-help-content">
<div class="modal-header">
<div class="modal-title">Session Help</div>
<button id="sessionHelpClose" class="modal-close" type="button">
Close
</button>
</div>
<div class="modal-body">
<div id="sessionHelpShortcut" class="session-help-shortcut"></div>
<div id="sessionHelpWarning" class="session-help-warning"></div>
<div id="sessionHelpStatus" class="session-help-status"></div>
<input
id="sessionHelpFilter"
class="session-help-filter"
type="text"
placeholder="Type / to search shortcuts"
autocomplete="off"
spellcheck="false"
/>
<div id="sessionHelpContent" class="session-help-content-list"></div>
</div>
</div>
</div>
</div> </div>
<script type="module" src="renderer.js"></script> <script type="module" src="renderer.js"></script>
</body> </body>

View File

@@ -0,0 +1,790 @@
import type { Keybinding } from "../../types";
import type { ShortcutsConfig } from "../../types";
import { SPECIAL_COMMANDS } from "../../config/definitions";
import type { ModalStateReader, RendererContext } from "../context";
type SessionHelpBindingInfo = {
bindingKey: "KeyH" | "KeyK";
fallbackUsed: boolean;
fallbackUnavailable: boolean;
};
type SessionHelpItem = {
shortcut: string;
action: string;
color?: string;
};
type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, "multiCopyTimeoutMs">;
const HEX_COLOR_RE =
/^#(?:[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 = {
knownWordColor: "#a6da95",
nPlusOneColor: "#c6a0f6",
jlptN1Color: "#ed8796",
jlptN2Color: "#f5a97f",
jlptN3Color: "#f9e2af",
jlptN4Color: "#a6e3a1",
jlptN5Color: "#8aadf4",
};
const KEY_NAME_MAP: Record<string, string> = {
Space: "Space",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Escape: "Esc",
Tab: "Tab",
Enter: "Enter",
CommandOrControl: "Cmd/Ctrl",
Ctrl: "Ctrl",
Control: "Ctrl",
Command: "Cmd",
Cmd: "Cmd",
Shift: "Shift",
Alt: "Alt",
Super: "Meta",
Meta: "Meta",
Backspace: "Backspace",
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== "string") return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith("Key")) return token.slice(3);
if (token.startsWith("Digit")) return token.slice(5);
if (token.startsWith("Numpad")) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split("+");
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
return normalized.join(" + ");
}
const OVERLAY_SHORTCUTS: Array<{
key: keyof RuntimeShortcutConfig;
label: string;
}> = [
{ key: "copySubtitle", label: "Copy subtitle" },
{ key: "copySubtitleMultiple", label: "Copy subtitle (multi)" },
{ key: "updateLastCardFromClipboard", label: "Update last card from clipboard" },
{ key: "triggerFieldGrouping", label: "Trigger field grouping" },
{ key: "triggerSubsync", label: "Open subtitle sync controls" },
{ key: "mineSentence", label: "Mine sentence" },
{ key: "mineSentenceMultiple", label: "Mine sentence (multi)" },
{ key: "toggleSecondarySub", label: "Toggle secondary subtitle mode" },
{ key: "markAudioCard", label: "Mark audio card" },
{ key: "openRuntimeOptions", label: "Open runtime options" },
{ key: "openJimaku", label: "Open jimaku" },
{ key: "toggleVisibleOverlayGlobal", label: "Show/hide visible overlay" },
{ key: "toggleInvisibleOverlayGlobal", label: "Show/hide invisible overlay" },
];
function buildOverlayShortcutSections(
shortcuts: RuntimeShortcutConfig,
): SessionHelpSection[] {
const rows: SessionHelpItem[] = [];
for (const shortcut of OVERLAY_SHORTCUTS) {
const keybind = shortcuts[shortcut.key];
if (typeof keybind !== "string") continue;
if (keybind.trim().length === 0) continue;
rows.push({
shortcut: formatKeybinding(keybind),
action: shortcut.label,
});
}
if (rows.length === 0) return [];
return [{ title: "Overlay shortcuts", rows }];
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== "string") return "Unknown action";
if (first === "cycle" && command[1] === "pause") return "Toggle playback";
if (first === "seek" && typeof command[1] === "number") {
return `Seek ${command[1] > 0 ? "+" : ""}${command[1]} second(s)`;
}
if (first === "sub-seek" && typeof command[1] === "number") {
return `Shift subtitle by ${command[1]} ms`;
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return "Open subtitle sync controls";
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return "Open runtime options";
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return "Replay current subtitle";
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return "Play next subtitle";
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(":");
return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(" ")}`;
}
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== "string") return "Other shortcuts";
if (
first === "cycle" ||
first === "seek" ||
first === "sub-seek" ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return "Playback and navigation";
}
if (first === "show-text" || first === "show-progress" || first.startsWith("osd")) {
return "Visual feedback";
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return "Subtitle sync";
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return "Runtime settings";
}
if (first === "quit") return "System actions";
return "Other shortcuts";
}
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
const grouped = new Map<string, SessionHelpItem[]>();
for (const binding of keybindings) {
const section = sectionForCommand(binding.command ?? []);
const row: SessionHelpItem = {
shortcut: formatKeybinding(binding.key),
action: describeCommand(binding.command ?? []),
};
grouped.set(section, [...(grouped.get(section) ?? []), row]);
}
const sectionOrder = [
"Playback and navigation",
"Visual feedback",
"Subtitle sync",
"Runtime settings",
"System actions",
"Other shortcuts",
];
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
const bIdx = sectionOrder.indexOf(b[0]);
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
return sectionEntries.map(([title, rows]) => ({ title, rows }));
}
function buildColorSection(style: {
knownWordColor?: unknown;
nPlusOneColor?: unknown;
jlptColors?: {
N1?: unknown;
N2?: unknown;
N3?: unknown;
N4?: unknown;
N5?: unknown;
};
}): SessionHelpSection {
return {
title: "Color legend",
rows: [
{
shortcut: "Known words",
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: "N+1 words",
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: "JLPT N1",
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: "JLPT N2",
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: "JLPT N3",
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: "JLPT N4",
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: "JLPT N5",
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
function filterSections(
sections: SessionHelpSection[],
query: string,
): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, "ctrl")
.replace(/cmd\/ctrl/gu, "ctrl")
.replace(/[\s+\-_/]/gu, "");
const normalized = normalize(query);
if (!normalized) return sections;
return sections
.map((section) => {
if (normalize(section.title).includes(normalized)) {
return section;
}
const rows = section.rows.filter(
(row) =>
normalize(row.shortcut).includes(normalized) ||
normalize(row.action).includes(normalized),
);
if (rows.length === 0) return null;
return { ...section, rows };
})
.filter((section): section is SessionHelpSection => section !== null)
.filter((section) => section.rows.length > 0);
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === "KeyK" && info.fallbackUsed) {
return info.fallbackUnavailable
? "Y-K (fallback and conflict noted)"
: "Y-K (fallback)";
}
return "Y-H";
}
function createShortcutRow(
row: SessionHelpItem,
globalIndex: number,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "session-help-item";
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement("div");
left.className = "session-help-item-left";
const shortcut = document.createElement("span");
shortcut.className = "session-help-key";
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement("div");
right.className = "session-help-item-right";
const action = document.createElement("span");
action.className = "session-help-action";
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement("span");
dot.className = "session-help-color-dot";
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
button.appendChild(left);
button.appendChild(right);
return button;
}
const SECTION_ICON: Record<string, string> = {
"MPV shortcuts": "⚙",
"Playback and navigation": "▶",
"Visual feedback": "◉",
"Subtitle sync": "⟲",
"Runtime settings": "⚙",
"System actions": "◆",
"Other shortcuts": "…",
"Overlay shortcuts (configurable)": "✦",
"Overlay shortcuts": "✦",
"Color legend": "◈",
};
function createSectionNode(
section: SessionHelpSection,
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement("section");
sectionNode.className = "session-help-section";
const title = document.createElement("h3");
title.className = "session-help-section-title";
const icon = SECTION_ICON[section.title] ?? "•";
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement("div");
list.className = "session-help-item-list";
section.rows.forEach((row, rowIndex) => {
const globalIndex = globalIndexMap[sectionIndex] + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
sectionNode.appendChild(list);
return sectionNode;
}
export function createSessionHelpModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let priorFocus: Element | null = null;
let openBinding: SessionHelpBindingInfo = {
bindingKey: "KeyH",
fallbackUsed: false,
fallbackUnavailable: false,
};
let helpFilterValue = "";
let helpSections: SessionHelpSection[] = [];
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
let isRecoveringModalFocus = false;
let lastFocusRecoveryAt = 0;
function getItems(): HTMLButtonElement[] {
return Array.from(
ctx.dom.sessionHelpContent.querySelectorAll(".session-help-item"),
) as HTMLButtonElement[];
}
function setSelected(index: number): void {
const items = getItems();
if (items.length === 0) return;
const wrappedIndex = index % items.length;
const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex;
ctx.state.sessionHelpSelectedIndex = next;
items.forEach((item, idx) => {
item.classList.toggle("active", idx === next);
item.tabIndex = idx === next ? 0 : -1;
});
const activeItem = items[next];
activeItem.focus({ preventScroll: true });
activeItem.scrollIntoView({
block: "nearest",
inline: "nearest",
});
}
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
return (
target instanceof Element &&
ctx.dom.sessionHelpModal.contains(target)
);
}
function focusFallbackTarget(): boolean {
void window.electronAPI.focusMainWindow();
const items = getItems();
const firstItem = items.find((item) => item.offsetParent !== null);
if (firstItem) {
firstItem.focus({ preventScroll: true });
return document.activeElement === firstItem;
}
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
return document.activeElement === ctx.dom.sessionHelpClose;
}
window.focus();
return false;
}
function enforceModalFocus(): void {
if (!ctx.state.sessionHelpModalOpen) return;
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
if (isRecoveringModalFocus) return;
const now = Date.now();
if (now - lastFocusRecoveryAt < 120) return;
isRecoveringModalFocus = true;
lastFocusRecoveryAt = now;
focusFallbackTarget();
window.setTimeout(() => {
isRecoveringModalFocus = false;
}, 120);
}
}
function isFilterInputFocused(): boolean {
return document.activeElement === ctx.dom.sessionHelpFilter;
}
function focusFilterInput(): void {
ctx.dom.sessionHelpFilter.focus({ preventScroll: true });
ctx.dom.sessionHelpFilter.select();
}
function applyFilterAndRender(): void {
const sections = filterSections(helpSections, helpFilterValue);
const indexOffsets: number[] = [];
let running = 0;
for (const section of sections) {
indexOffsets.push(running);
running += section.rows.length;
}
ctx.dom.sessionHelpContent.innerHTML = "";
sections.forEach((section, sectionIndex) => {
const sectionNode = createSectionNode(
section,
sectionIndex,
indexOffsets,
);
ctx.dom.sessionHelpContent.appendChild(sectionNode);
});
if (getItems().length === 0) {
ctx.dom.sessionHelpContent.classList.add("session-help-content-no-results");
ctx.dom.sessionHelpContent.textContent = helpFilterValue
? "No matching shortcuts found."
: "No active session shortcuts found.";
ctx.state.sessionHelpSelectedIndex = 0;
return;
}
ctx.dom.sessionHelpContent.classList.remove("session-help-content-no-results");
if (isFilterInputFocused()) return;
setSelected(0);
}
function requestOverlayFocus(): void {
void window.electronAPI.focusMainWindow();
}
function addPointerFocusListener(): void {
if (modalPointerFocusGuard) return;
modalPointerFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard);
modalPointerFocusGuard = null;
}
function startFocusRecoveryGuards(): void {
if (windowFocusGuard) return;
windowFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
window.addEventListener("blur", windowFocusGuard);
window.addEventListener("focus", windowFocusGuard);
}
function stopFocusRecoveryGuards(): void {
if (!windowFocusGuard) return;
window.removeEventListener("blur", windowFocusGuard);
window.removeEventListener("focus", windowFocusGuard);
windowFocusGuard = null;
}
function showRenderError(message: string): void {
helpSections = [];
helpFilterValue = "";
ctx.dom.sessionHelpFilter.value = "";
ctx.dom.sessionHelpContent.classList.add("session-help-content-no-results");
ctx.dom.sessionHelpContent.textContent = message;
ctx.state.sessionHelpSelectedIndex = 0;
}
async function render(): Promise<boolean> {
try {
const [keybindings, styleConfig, shortcuts] = await Promise.all([
window.electronAPI.getKeybindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getConfiguredShortcuts(),
]);
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);
if (shortcutSections.length > 0) {
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;
}
}
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
openBinding = opening;
priorFocus = document.activeElement;
const dataLoaded = await render();
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) {
ctx.dom.sessionHelpWarning.textContent =
"Both Y-H and Y-K are bound; Y-K remains the fallback for this session.";
} else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent = "Y-H is already bound; using Y-K as fallback.";
} else {
ctx.dom.sessionHelpWarning.textContent = "";
}
if (dataLoaded) {
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;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.sessionHelpModal.classList.remove("hidden");
ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "false");
ctx.dom.sessionHelpModal.setAttribute("tabindex", "-1");
ctx.dom.sessionHelpFilter.value = "";
helpFilterValue = "";
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
if (focusGuard === null) {
focusGuard = (event: FocusEvent) => {
if (!ctx.state.sessionHelpModalOpen) return;
if (!isSessionHelpModalFocusTarget(event.target)) {
event.preventDefault();
enforceModalFocus();
}
};
document.addEventListener("focusin", focusGuard);
}
addPointerFocusListener();
startFocusRecoveryGuards();
requestOverlayFocus();
window.focus();
enforceModalFocus();
}
function closeSessionHelpModal(): void {
if (!ctx.state.sessionHelpModalOpen) return;
ctx.state.sessionHelpModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add("hidden");
ctx.dom.sessionHelpModal.setAttribute("aria-hidden", "true");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove("interactive");
}
if (focusGuard) {
document.removeEventListener("focusin", focusGuard);
focusGuard = null;
}
removePointerFocusListener();
stopFocusRecoveryGuards();
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
priorFocus.focus({ preventScroll: true });
return;
}
if (ctx.dom.overlay instanceof HTMLElement) {
// Overlay remains `tabindex="-1"` to allow programmatic focus for fallback.
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
ctx.dom.sessionHelpFilter.value = "";
helpFilterValue = "";
window.focus();
}
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
if (!ctx.state.sessionHelpModalOpen) return false;
if (isFilterInputFocused()) {
if (e.key === "Escape") {
e.preventDefault();
if (!helpFilterValue) {
closeSessionHelpModal();
return true;
}
helpFilterValue = "";
ctx.dom.sessionHelpFilter.value = "";
applyFilterAndRender();
focusFallbackTarget();
return true;
}
return false;
}
if (e.key === "Escape") {
e.preventDefault();
closeSessionHelpModal();
return true;
}
const items = getItems();
if (items.length === 0) return true;
if (
e.key === "/" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault();
focusFilterInput();
return true;
}
const key = e.key.toLowerCase();
if (
key === "arrowdown" ||
key === "j" ||
key === "l"
) {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
return true;
}
if (
key === "arrowup" ||
key === "k" ||
key === "h"
) {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.sessionHelpFilter.addEventListener("input", () => {
helpFilterValue = ctx.dom.sessionHelpFilter.value;
applyFilterAndRender();
});
ctx.dom.sessionHelpFilter.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
focusFallbackTarget();
}
});
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", () => {
closeSessionHelpModal();
});
}
return {
closeSessionHelpModal,
handleSessionHelpKeydown,
openSessionHelpModal,
wireDomEvents,
};
}

View File

@@ -29,6 +29,7 @@ import { createKeyboardHandlers } from "./handlers/keyboard.js";
import { createMouseHandlers } from "./handlers/mouse.js"; import { createMouseHandlers } from "./handlers/mouse.js";
import { createJimakuModal } from "./modals/jimaku.js"; import { createJimakuModal } from "./modals/jimaku.js";
import { createKikuModal } from "./modals/kiku.js"; import { createKikuModal } from "./modals/kiku.js";
import { createSessionHelpModal } from "./modals/session-help.js";
import { createRuntimeOptionsModal } from "./modals/runtime-options.js"; import { createRuntimeOptionsModal } from "./modals/runtime-options.js";
import { createSubsyncModal } from "./modals/subsync.js"; import { createSubsyncModal } from "./modals/subsync.js";
import { createPositioningController } from "./positioning.js"; import { createPositioningController } from "./positioning.js";
@@ -49,7 +50,8 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.runtimeOptionsModalOpen || ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen || ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen ctx.state.jimakuModalOpen ||
ctx.state.sessionHelpModalOpen
); );
} }
@@ -58,7 +60,8 @@ function isAnyModalOpen(): boolean {
ctx.state.jimakuModalOpen || ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen || ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ctx.state.subsyncModalOpen ||
ctx.state.sessionHelpModalOpen
); );
} }
@@ -84,6 +87,10 @@ const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const kikuModal = createKikuModal(ctx, { const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
@@ -97,6 +104,8 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit, saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit, cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode, setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
@@ -178,6 +187,7 @@ async function init(): Promise<void> {
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents(); subsyncModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runtimeOptionsModal.updateRuntimeOptions(options); runtimeOptionsModal.updateRuntimeOptions(options);

View File

@@ -54,6 +54,9 @@ export type RendererState = {
subsyncSourceTracks: SubsyncSourceTrack[]; subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubmitting: boolean; subsyncSubmitting: boolean;
sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number;
mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics | null; mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics | null;
invisiblePositionEditMode: boolean; invisiblePositionEditMode: boolean;
invisiblePositionEditStartX: number; invisiblePositionEditStartX: number;
@@ -127,6 +130,9 @@ export function createRendererState(): RendererState {
subsyncSourceTracks: [], subsyncSourceTracks: [],
subsyncSubmitting: false, subsyncSubmitting: false,
sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0,
mpvSubtitleRenderMetrics: null, mpvSubtitleRenderMetrics: null,
invisiblePositionEditMode: false, invisiblePositionEditMode: false,
invisiblePositionEditStartX: 0, invisiblePositionEditStartX: 0,

View File

@@ -846,8 +846,198 @@ iframe[id^="yomitan-popup"] {
color: #ff8f8f; color: #ff8f8f;
} }
.session-help-content {
width: min(760px, 92%);
max-height: 84%;
color: rgba(255, 255, 255, 0.95);
}
.session-help-shortcut,
.session-help-warning,
.session-help-status {
min-height: 18px;
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.45;
}
.session-help-shortcut {
font-weight: 600;
color: rgba(255, 255, 255, 0.97);
}
.session-help-warning {
color: #f8a100;
}
.session-help-content-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: calc(84vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.session-help-filter {
width: 100%;
min-height: 32px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 13px;
line-height: 1.2;
}
.session-help-filter::placeholder {
color: rgba(255, 255, 255, 0.45);
}
.session-help-filter:focus {
outline: none;
border-color: rgba(137, 180, 255, 0.6);
box-shadow: 0 0 0 2px rgba(137, 180, 255, 0.2);
}
.session-help-content-no-results {
color: rgba(255, 255, 255, 0.75);
padding: 12px;
font-size: 13px;
}
.session-help-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
backdrop-filter: blur(1px);
}
.session-help-section-title {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 800;
color: rgba(255, 255, 255, 0.55);
display: flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 0 4px;
}
.session-help-item-list {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
}
.session-help-item {
width: 100%;
min-height: 42px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: transparent;
color: #fff;
cursor: pointer;
}
.session-help-item:last-child {
border-bottom: none;
}
.session-help-item:hover,
.session-help-item:focus-visible,
.session-help-item.active {
background: rgba(137, 180, 255, 0.2);
outline: none;
}
.session-help-item.active {
box-shadow: inset 3px 0 0 0 rgba(137, 180, 255, 0.9);
}
.session-help-item-left {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.session-help-item-right {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.session-help-key {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
padding: 4px 9px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
border-radius: 999px;
background: rgba(137, 180, 255, 0.16);
border: 1px solid rgba(137, 180, 255, 0.35);
letter-spacing: 0.01em;
}
.session-help-action {
font-size: 13px;
color: rgba(255, 255, 255, 0.84);
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.35;
}
.session-help-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
flex: 0 0 auto;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.kiku-cards-container { .kiku-cards-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.session-help-content-list {
max-height: calc(84vh - 190px);
}
.session-help-item {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.session-help-item-right {
justify-content: flex-start;
width: 100%;
}
.session-help-key {
width: 100%;
justify-content: center;
text-align: center;
}
} }

View File

@@ -54,6 +54,14 @@ export type RendererDom = {
subsyncSourceSelect: HTMLSelectElement; subsyncSourceSelect: HTMLSelectElement;
subsyncRunButton: HTMLButtonElement; subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement; subsyncStatus: HTMLDivElement;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement;
sessionHelpWarning: HTMLDivElement;
sessionHelpStatus: HTMLDivElement;
sessionHelpFilter: HTMLInputElement;
sessionHelpContent: HTMLDivElement;
}; };
function getRequiredElement<T extends HTMLElement>(id: string): T { function getRequiredElement<T extends HTMLElement>(id: string): T {
@@ -127,5 +135,13 @@ export function resolveRendererDom(): RendererDom {
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"), subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"),
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"), subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"), subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"),
sessionHelpModal: getRequiredElement<HTMLDivElement>("sessionHelpModal"),
sessionHelpClose: getRequiredElement<HTMLButtonElement>("sessionHelpClose"),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>("sessionHelpShortcut"),
sessionHelpWarning: getRequiredElement<HTMLDivElement>("sessionHelpWarning"),
sessionHelpStatus: getRequiredElement<HTMLDivElement>("sessionHelpStatus"),
sessionHelpFilter: getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
sessionHelpContent: getRequiredElement<HTMLDivElement>("sessionHelpContent"),
}; };
} }

View File

@@ -649,6 +649,7 @@ export interface ElectronAPI {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>; getKeybindings: () => Promise<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>; getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: ( jimakuSearchEntries: (
query: JimakuSearchQuery, query: JimakuSearchQuery,
@@ -669,6 +670,7 @@ export interface ElectronAPI {
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void; onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void;
getSecondarySubMode: () => Promise<SecondarySubMode>; getSecondarySubMode: () => Promise<SecondarySubMode>;
getCurrentSecondarySub: () => Promise<string>; getCurrentSecondarySub: () => Promise<string>;
focusMainWindow: () => Promise<void>;
getSubtitleStyle: () => Promise<SubtitleStyleConfig | null>; getSubtitleStyle: () => Promise<SubtitleStyleConfig | null>;
onSubsyncManualOpen: ( onSubsyncManualOpen: (
callback: (payload: SubsyncManualPayload) => void, callback: (payload: SubsyncManualPayload) => void,