2026-09-19 18:26:25 +03:00
|
|
|
"use client";
|
|
|
|
|
/**
|
|
|
|
|
* The Keyboard section (thin settings wave): view / rebind / reset over
|
|
|
|
|
* core/keyboard.ts's registry. The overrides persist in localStorage
|
|
|
|
|
* (lh:keymap, overrides-only, merged over DEFAULT_BINDINGS), so rebinding
|
|
|
|
|
* is INSTANT — the dispatcher reads the map per keydown, no reload.
|
|
|
|
|
*
|
|
|
|
|
* Rebind flow: click Rebind → the row listens for the next keydown
|
|
|
|
|
* (capture phase, preventDefault — nothing types anywhere), eventBinding
|
|
|
|
|
* normalizes it, saveOverrides persists. Escape cancels. displayOnly
|
|
|
|
|
* actions (a surface owns those keys — the question dock's row machine)
|
|
|
|
|
* are listed for reading but never rebindable.
|
|
|
|
|
*
|
|
|
|
|
* Thin: no conflict detection — if a new binding collides with another
|
|
|
|
|
* action's, the first match in registry order wins; the cheat sheet shows
|
|
|
|
|
* both. Detection belongs to the designed wave.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
KEYBOARD_ACTIONS,
|
|
|
|
|
eventBinding,
|
|
|
|
|
formatBinding,
|
|
|
|
|
loadOverrides,
|
|
|
|
|
saveOverrides,
|
|
|
|
|
type KeyAction,
|
|
|
|
|
type KeyScope,
|
|
|
|
|
} from "@/src/core";
|
|
|
|
|
import { SettingsRow } from "./controls.tsx";
|
|
|
|
|
|
|
|
|
|
const SCOPES: { scope: KeyScope; label: string }[] = [
|
|
|
|
|
{ scope: "global", label: "Global" },
|
|
|
|
|
{ scope: "chat", label: "Chat" },
|
|
|
|
|
{ scope: "graph", label: "Graph" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/** The overrides map after ONE change (pure — exported for tests):
|
|
|
|
|
* binding null = reset the action to its default (key removed). */
|
|
|
|
|
export function nextOverrides(
|
|
|
|
|
current: Partial<Record<KeyAction, string>>,
|
|
|
|
|
id: KeyAction,
|
|
|
|
|
binding: string | null,
|
|
|
|
|
): Partial<Record<KeyAction, string>> {
|
|
|
|
|
const next = { ...current };
|
|
|
|
|
if (binding === null) delete next[id];
|
|
|
|
|
else next[id] = binding;
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function KeyboardSection() {
|
|
|
|
|
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
|
|
|
|
|
const [rebinding, setRebinding] = useState<KeyAction | null>(null);
|
|
|
|
|
const [nonce, setNonce] = useState(0);
|
|
|
|
|
|
|
|
|
|
// localStorage is read post-mount (SSR-safe); `nonce` re-reads after writes.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setOverrides(loadOverrides());
|
|
|
|
|
}, [nonce]);
|
|
|
|
|
|
|
|
|
|
// The capture listener — only while a rebind is armed.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!rebinding) return;
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
setRebinding(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Modifier keydowns arrive one by one (⌘ down, then ⇧, then the
|
|
|
|
|
// key) — each is a keydown this capture listener sees. They are
|
|
|
|
|
// not bindings; keep listening for the real key.
|
|
|
|
|
if (e.key === "Control" || e.key === "Meta" || e.key === "Alt" || e.key === "Shift") return;
|
|
|
|
|
const binding = eventBinding(e);
|
|
|
|
|
if (!binding) return;
|
|
|
|
|
const next = nextOverrides(loadOverrides(), rebinding, binding);
|
|
|
|
|
saveOverrides(next);
|
|
|
|
|
setRebinding(null);
|
|
|
|
|
setNonce((n) => n + 1);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", onKey, true);
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey, true);
|
|
|
|
|
}, [rebinding]);
|
|
|
|
|
|
|
|
|
|
function resetOne(id: KeyAction) {
|
|
|
|
|
saveOverrides(nextOverrides(loadOverrides(), id, null));
|
|
|
|
|
setNonce((n) => n + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetAll() {
|
|
|
|
|
saveOverrides({});
|
|
|
|
|
setNonce((n) => n + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const overridden = (id: KeyAction) => overrides[id] !== undefined;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{SCOPES.map(({ scope, label }) => {
|
|
|
|
|
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
|
|
|
|
|
if (!actions.length) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div key={scope} data-testid={`kbd-scope-${scope}`}>
|
|
|
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{label}</p>
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{actions.map((def) => {
|
|
|
|
|
const effective = overrides[def.id] ?? def.binding;
|
|
|
|
|
return (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
key={def.id}
|
|
|
|
|
label={def.label}
|
2026-09-25 01:52:20 +03:00
|
|
|
hint={def.displayOnly ? "Fixed — part of this screen" : undefined}
|
2026-09-19 18:26:25 +03:00
|
|
|
testid={`kbd-row-${def.id}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
<kbd className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-ui text-xs">
|
|
|
|
|
{rebinding === def.id ? "Press keys…" : formatBinding(effective)}
|
|
|
|
|
</kbd>
|
|
|
|
|
{def.displayOnly ? null : rebinding === def.id ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setRebinding(null)}
|
|
|
|
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setRebinding(def.id)}
|
|
|
|
|
data-testid={`kbd-rebind-${def.id}`}
|
|
|
|
|
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Rebind
|
|
|
|
|
</button>
|
|
|
|
|
{overridden(def.id) && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => resetOne(def.id)}
|
|
|
|
|
data-testid={`kbd-reset-${def.id}`}
|
|
|
|
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Reset
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<div className="mt-1 flex items-center justify-between">
|
2026-09-19 21:59:30 +03:00
|
|
|
<span className="text-xs text-muted-foreground/70">Stored on this device (lh:keymap) — overrides only.</span>
|
2026-09-19 18:26:25 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={resetAll}
|
|
|
|
|
data-testid="kbd-reset-all"
|
|
|
|
|
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Reset all to defaults
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|