forked from phenom/healer-man
218 lines
17 KiB
TypeScript
218 lines
17 KiB
TypeScript
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;
|
||
}
|