mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Merge pull request #6 from ksyasuda/feature/session-help-modal
Add help modal
This commit is contained in:
@@ -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 -->
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
790
src/renderer/modals/session-help.ts
Normal file
790
src/renderer/modals/session-help.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user