Normalize shortcut spaces before fil

This commit is contained in:
2026-02-15 23:41:57 -08:00
parent dae1f817e0
commit 1ab7e6e1da
14 changed files with 1114 additions and 15 deletions

View File

@@ -8,6 +8,12 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: "KeyH" | "KeyK";
fallbackUsed: boolean;
fallbackUnavailable: boolean;
}) => void;
saveInvisiblePositionEdit: () => void;
cancelInvisiblePositionEdit: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void;
@@ -62,6 +68,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 {
if (!ctx.platform.isInvisibleLayer) return false;
@@ -163,6 +210,10 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e);
return;
}
if (ctx.state.sessionHelpModalOpen) {
options.handleSessionHelpKeydown(e);
return;
}
if (ctx.state.chordPending) {
const modifierKeys = [
@@ -202,6 +253,7 @@ export function createKeyboardHandlers(
!e.repeat
) {
e.preventDefault();
applySessionHelpChordBinding();
ctx.state.chordPending = true;
ctx.state.chordTimeout = setTimeout(() => {
resetChord();

View File

@@ -28,7 +28,7 @@
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="overlay">
<div id="overlay" tabindex="-1">
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>
@@ -259,6 +259,30 @@
</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>
<script type="module" src="renderer.js"></script>
</body>

View File

@@ -0,0 +1,741 @@
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})$/;
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): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "session-help-item";
button.tabIndex = -1;
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,
onSelect: (index: number) => void,
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 button = createShortcutRow(row);
const globalIndex = globalIndexMap[sectionIndex] + rowIndex;
button.addEventListener("click", () => onSelect(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;
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 next =
((index % items.length) + items.length) % items.length;
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(): void {
void window.electronAPI.focusMainWindow();
const items = getItems();
const firstItem = items.find((item) => item.offsetParent !== null);
if (firstItem) {
firstItem.focus({ preventScroll: true });
return;
}
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
return;
}
window.focus();
}
function enforceModalFocus(): void {
if (!ctx.state.sessionHelpModalOpen) return;
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
focusFallbackTarget();
}
}
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,
(selectionIndex) => {
setSelected(selectionIndex);
},
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("mousedown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener("mousedown", 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;
}
async function render(): Promise<void> {
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();
}
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
openBinding = opening;
priorFocus = document.activeElement;
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 = "";
}
ctx.dom.sessionHelpStatus.textContent =
"Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.";
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) {
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.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 { createJimakuModal } from "./modals/jimaku.js";
import { createKikuModal } from "./modals/kiku.js";
import { createSessionHelpModal } from "./modals/session-help.js";
import { createRuntimeOptionsModal } from "./modals/runtime-options.js";
import { createSubsyncModal } from "./modals/subsync.js";
import { createPositioningController } from "./positioning.js";
@@ -49,7 +50,8 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen
ctx.state.jimakuModalOpen ||
ctx.state.sessionHelpModalOpen
);
}
@@ -58,7 +60,8 @@ function isAnyModalOpen(): boolean {
ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen
ctx.state.subsyncModalOpen ||
ctx.state.sessionHelpModalOpen
);
}
@@ -84,6 +87,10 @@ const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -97,6 +104,8 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
@@ -178,6 +187,7 @@ async function init(): Promise<void> {
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runtimeOptionsModal.updateRuntimeOptions(options);

View File

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

View File

@@ -807,8 +807,198 @@ iframe[id^="yomitan-popup"] {
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) {
.kiku-cards-container {
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;
subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement;
sessionHelpWarning: HTMLDivElement;
sessionHelpStatus: HTMLDivElement;
sessionHelpFilter: HTMLInputElement;
sessionHelpContent: HTMLDivElement;
};
function getRequiredElement<T extends HTMLElement>(id: string): T {
@@ -127,5 +135,13 @@ export function resolveRendererDom(): RendererDom {
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"),
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
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"),
};
}