Files
healer-man/src/ui/KeybindsPanel.tsx
T
2026-09-14 13:20:20 -04:00

218 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { Capacitor } from "@capacitor/core";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { defaultUiLayout } from "../game/uiLayout";
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "../game/keybindPreferences";
import {
CONTROLLER_BINDING_CHOICES, INPUT_ACTIONS, INPUT_ACTION_BY_ID, conflictingInputActions,
defaultInputDevice, defaultInputBindings, inputBindingLabel, inputKeyForEvent, normalizeInputBinding, replaceInputBinding, type InputDevice,
} from "../input/keybinds";
import { getControllerHeldTokens, interceptControllerInput, subscribeControllerMovement } from "../input/controller";
import { setCapturingInput } from "../input/keybindRuntime";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import "./keybinds.css";
const PC_KEYS = [
...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
"Space", "Tab", "Enter", "Escape", "Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Backquote", "Minus", "Equal", "BracketLeft", "BracketRight", "Backslash", "Semicolon", "Quote", "Comma", "Period", "Slash",
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
];
function KeybindsDialog({ onClose }: { onClose?: () => void }) {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState("");
const [group, setGroup] = useState("All actions");
const [conflictsOnly, setConflictsOnly] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
const [listening, setListening] = useState(false);
const [pending, setPending] = useState<{ id: string; key: string; conflicts: string[] } | null>(null);
const [resetPending, setResetPending] = useState(false);
const [message, setMessage] = useState("");
const [manualKey, setManualKey] = useState("KeyW");
const [modifiers, setModifiers] = useState<string[]>([]);
const [controllerModifier, setControllerModifier] = useState("");
const dialog = useRef<HTMLElement>(null);
const cloud = useCloudSaveStatus();
const online = useShellStore((state) => Boolean(state.session?.accessToken));
const abilities = useCombatStore((state) => state.abilities);
const assignments = useCombatStore((state) => state.actionBindings);
const bindings = profileInputBindings(draft, device);
const conflicts = INPUT_ACTIONS.filter((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).length > 0);
const detail = (id: string): string => {
const abilityId = id.startsWith("ability:") ? assignments[id.slice(8) as keyof typeof assignments]
: id.startsWith("bar:") ? draft.current.pc.bars[Number(id.split(":")[1])]?.slots[Number(id.split(":")[2])]?.abilityId : null;
if (id.startsWith("ability:") || id.startsWith("bar:")) return abilities.find((ability) => ability.id === abilityId)?.name ?? "Empty ability slot";
if (id === "loot" && device === "controller") return "When interaction is available, it takes priority over the base Cross ability.";
return "";
};
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
const close = onClose ?? (() => useGameStore.getState().openOverlay("options"));
const endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => {
setDraft((current) => withProfileInputBindings(current, device, replaceInputBinding(profileInputBindings(current, device), device, id, key)));
setMessage(`${INPUT_ACTION_BY_ID[id].label}: ${inputBindingLabel(device, key)}. Save changes to keep it.`);
cancelEdit();
};
const offer = (key: string) => {
if (!editing) return;
endCapture();
const collisions = conflictingInputActions(bindings, device, editing, key);
if (collisions.length) setPending({ id: editing, key, conflicts: collisions });
else apply(editing, key);
};
const startCapture = () => { setCapturingInput(true); setListening(true); };
const editBinding = (id: string) => {
setEditing(id); setManualKey(device === "pc" ? "KeyW" : "Button0"); setModifiers([]); setControllerModifier("");
};
const save = () => {
try { useUiLayoutStore.getState().persist(draft); setMessage("Keybinds saved to your profile."); }
catch (error) { setMessage(error instanceof Error ? error.message : "Could not save keybinds."); }
};
const restoreMovement = () => {
setDraft((current) => restoreMovementBindings(current, device));
setMessage("Movement and jump restored. Conflicting bindings were cleared. Save changes to keep this repair.");
};
const restoreDefaults = () => {
let defaults = defaultInputBindings(device);
if (device === "pc") defaultUiLayout().bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { defaults[`bar:${b}:${s}`] = slot.key; }));
setDraft((current) => withProfileInputBindings(current, device, defaults));
cancelEdit(); setMessage(`${device === "pc" ? "PC" : "Controller"} defaults restored. Save changes to keep them.`);
};
const actions: MenuAction[] = pending ? [
{ id: "keep", run: cancelEdit }, { id: "replace", run: () => apply(pending.id, pending.key) },
] : resetPending ? [{ id: "keep", run: cancelEdit }, { id: "reset-confirm", run: restoreDefaults }]
: editing ? [{ id: "capture", run: startCapture }, { id: "clear", run: () => apply(editing, null) }, { id: "keep", run: cancelEdit }]
: [
{ id: "tab-pc", run: () => setDevice("pc") }, { id: "tab-controller", run: () => setDevice("controller") },
{ id: "movement", run: restoreMovement }, { id: "conflicts", run: () => setConflictsOnly(!conflictsOnly) },
...shown.map((entry) => ({ id: `edit-${entry.id}`, run: () => editBinding(entry.id) })),
{ id: "reset", run: () => setResetPending(true) }, { id: "close", run: close }, { id: "save", run: save },
];
const controller = useMenuController(actions, { onBack: () => editing || resetPending ? cancelEdit() : close() });
const button = (id: string, label: string, run: () => void, className = "") => <ControllerButton
key={id} controlId={id} selectedId={controller.selectedId} select={controller.select} data-control-id={id}
type="button" aria-label={label} className={`button ${className}`} onClick={run}>{className.includes("keybinds-change") ? "Change" : label}</ControllerButton>;
useEffect(() => {
const selected = [...(dialog.current?.querySelectorAll<HTMLElement>("[data-control-id]") ?? [])].find((element) => element.dataset.controlId === controller.selectedId);
selected?.scrollIntoView({ block: "nearest" });
}, [controller.selectedId]);
useEffect(() => () => setCapturingInput(false), []);
useEffect(() => {
if (!listening || !editing) return;
let captured = false;
const ignored = new Set(getControllerHeldTokens());
const accept = (key: string | null) => { if (!key || captured) return; captured = true; offer(key); };
const onKey = (event: KeyboardEvent) => {
event.preventDefault(); event.stopImmediatePropagation();
if (event.repeat) return;
if (event.code === "Escape") { endCapture(); return; }
if (device === "pc") accept(inputKeyForEvent(event));
};
window.addEventListener("keydown", onKey, true);
const releaseController = interceptControllerInput(({ token, pressed, repeat }) => {
if (pressed === false) {
const wasIgnored = ignored.delete(token);
if (!wasIgnored && device === "controller" && !captured && ["Button4", "Button6"].includes(token)) accept(token);
return true;
}
if (repeat || ignored.has(token)) return true;
if (token === "SystemBack") { endCapture(); return true; }
if (device !== "controller" || ["Button4", "Button6"].includes(token)) return true;
const held = [...getControllerHeldTokens()].filter((part) => part !== token && !ignored.has(part) && part.startsWith("Button"));
accept(normalizeInputBinding("controller", [...held, token].join("&")));
return true;
});
const releaseMotion = subscribeControllerMovement((motion) => {
if (device !== "controller") return;
const axes = [motion.moveX, motion.moveY, motion.lookX, motion.lookY];
const index = axes.findIndex((axis) => Math.abs(axis) > .65);
if (index >= 0) accept(`Axis${index}${axes[index] > 0 ? "+" : "-"}`);
});
return () => { window.removeEventListener("keydown", onKey, true); releaseController(); releaseMotion(); };
}, [listening, editing, device]);
useEffect(() => {
if (listening) return;
const onKey = (event: KeyboardEvent) => {
if (event.code !== "Escape") return;
event.preventDefault(); event.stopImmediatePropagation();
if (editing || resetPending) cancelEdit(); else close();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [listening, editing, resetPending]);
const manualBinding = device === "pc" ? normalizeInputBinding(device, [...modifiers, manualKey].join("+"))
: normalizeInputBinding(device, [controllerModifier, manualKey].filter(Boolean).join("&"));
return <div className="modal-backdrop game-panel-backdrop keybinds-backdrop">
<section ref={dialog} className="game-panel keybinds-panel" role="dialog" aria-modal="true" aria-labelledby="keybinds-title">
<header className="game-panel__header"><div><p className="eyebrow">Your controls</p><h2 id="keybinds-title">Keybinds</h2></div>
<button type="button" className="icon-button" onClick={close} aria-label="Close keybinds">×</button></header>
<div className="keybinds-toolbar" inert={editing !== null || resetPending}>
<div className="keybinds-tabs" role="tablist" aria-label="Input type">{(["pc", "controller"] as const).map((tab) => <ControllerButton
key={tab} controlId={`tab-${tab}`} selectedId={controller.selectedId} select={controller.select} type="button" role="tab"
aria-selected={device === tab} onClick={() => setDevice(tab)}>{tab === "pc" ? "PC" : "Controller"}</ControllerButton>)}</div>
<p>Both input types work on every device. Changes apply when saved.</p>
<div className="keybinds-filters"><label>Search actions or keys<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Try W, jump, or a skill name" /></label>
<label>Category<select value={group} onChange={(event) => setGroup(event.target.value)}>{["All actions", ...new Set(INPUT_ACTIONS.map((entry) => entry.group))].map((value) => <option key={value}>{value}</option>)}</select></label></div>
<div className="keybinds-tools">{button("movement", "Restore movement", restoreMovement)}
{button("conflicts", `${conflictsOnly ? "Show all actions" : "Show conflicts"} (${conflicts.length})`, () => setConflictsOnly(!conflictsOnly))}
<small>{shown.length} actions shown</small></div>
</div>
<div className="keybinds-list" inert={editing !== null || resetPending}>
{conflicts.length > 0 && <p className="keybinds-warning">Conflicting controls need attention. Movement takes priority over old conflicting ability bindings.</p>}
{shown.map((entry, index) => <div key={entry.id}>
{(index === 0 || shown[index - 1].group !== entry.group) && <h3>{entry.group}</h3>}
<div className={`keybinds-row ${conflicts.some((conflict) => conflict.id === entry.id) ? "has-conflict" : ""}`}>
<span><strong>{entry.label}</strong>{detail(entry.id) && <small>{detail(entry.id)}</small>}
{entry.group === "WoW action bars" && <small>{draft.current.pc.wowBars && Number(entry.id.split(":")[1]) < draft.current.pc.barCount ? "PC WoW layout" : "Inactive until this PC bar is enabled"}</small>}
{conflicts.some((conflict) => conflict.id === entry.id) && <small className="keybinds-warning">Also used by {conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}</small>}</span>
<kbd>{inputBindingLabel(device, bindings[entry.id])}</kbd>
{button(`edit-${entry.id}`, `Change ${entry.label}`, () => editBinding(entry.id), "keybinds-change")}
</div>
</div>)}
{!shown.length && <p>No matching actions.</p>}
<p className="keybinds-note">Point and click / tap selects a target. Hold right mouse and drag to turn the camera. Android system Back remains available for navigation. Ability slots cast the skills assigned in Ability controls; WoW bar keys follow the loaded PC UI template.</p>
</div>
{(editing || resetPending) && <div className="keybinds-capture" role="group" aria-label="Change binding">
{pending ? <><h3>Replace conflicting bindings?</h3><p><kbd>{inputBindingLabel(device, pending.key)}</kbd> is already assigned to {pending.conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}.</p>
<p>Replacing will unbind those actions and assign it to {INPUT_ACTION_BY_ID[pending.id].label}.</p>
<div>{button("keep", "Keep existing bindings", cancelEdit)}{button("replace", "Replace bindings", () => apply(pending.id, pending.key))}</div></>
: resetPending ? <><h3>Restore {device === "pc" ? "PC" : "Controller"} defaults?</h3><p>This resets this input type, including its action-bar keys. Assigned skills stay in place.</p>
{button("keep", "Keep my bindings", cancelEdit)}{button("reset-confirm", "Restore defaults", restoreDefaults)}</>
: editing && <><h3>{INPUT_ACTION_BY_ID[editing].label}</h3><p>Current: {inputBindingLabel(device, bindings[editing])}</p>
{listening ? <><p role="status">{device === "pc" ? "Press a key or modifier + key…" : "Press a controller button, hold L1/L2 and press a button, or move a stick…"}</p><p>Escape cancels capture. Use the selectors below to bind Escape or any controller button.</p><button type="button" onClick={endCapture}>Stop listening</button></>
: button("capture", device === "pc" ? "Listen for key" : "Listen for controller", startCapture)}
<div className="keybinds-manual"><p>Or choose a binding using touch or mouse:</p>
{device === "pc" ? <div className="keybinds-modifiers">{["Ctrl", "Alt", "Shift", "Meta"].map((modifier) => <label key={modifier}><input type="checkbox" checked={modifiers.includes(modifier)} onChange={(event) => setModifiers((current) => event.target.checked ? [...current, modifier] : current.filter((part) => part !== modifier))} />{modifier}</label>)}</div>
: <label>Hold modifier<select value={controllerModifier} onChange={(event) => setControllerModifier(event.target.value)}><option value="">None</option>{CONTROLLER_BINDING_CHOICES.filter((key) => key.startsWith("Button")).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>}
<label>{device === "pc" ? "Key" : "Button or stick direction"}<select value={manualKey} onChange={(event) => setManualKey(event.target.value)}>{(device === "pc" ? PC_KEYS : CONTROLLER_BINDING_CHOICES).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>
<button type="button" disabled={!manualBinding} onClick={() => manualBinding && offer(manualBinding)}>Use selected binding</button>
</div><div>{button("clear", "Clear binding", () => apply(editing, null))}{button("keep", "Cancel binding change", cancelEdit)}</div></>}
</div>}
<footer className="keybinds-footer" inert={editing !== null || resetPending}>
<p role="status">{message || (online ? "Saved controls sync with your account and are included in UI exports." : "Offline controls save to this profile. UI export/import transfers them to another device.")}</p>
{online && <p role="status">{cloud.message}{cloud.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
<div>{button("reset", `Reset ${device === "pc" ? "PC" : "Controller"} defaults`, () => setResetPending(true))}
{button("close", "Done", close)}{button("save", "Save changes", save, "button--primary")}</div>
</footer>
</section>
</div>;
}
export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "keybinds");
return (open ?? storeOpen) ? <KeybindsDialog onClose={onClose} /> : null;
}