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(() => 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(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([]); const [controllerModifier, setControllerModifier] = useState(""); const dialog = useRef(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 = "") => {className.includes("keybinds-change") ? "Change" : label}; useEffect(() => { const selected = [...(dialog.current?.querySelectorAll("[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

Your controls

Keybinds

{(["pc", "controller"] as const).map((tab) => setDevice(tab)}>{tab === "pc" ? "PC" : "Controller"})}

Both input types work on every device. Changes apply when saved.

{button("movement", "Restore movement", restoreMovement)} {button("conflicts", `${conflictsOnly ? "Show all actions" : "Show conflicts"} (${conflicts.length})`, () => setConflictsOnly(!conflictsOnly))} {shown.length} actions shown
{conflicts.length > 0 &&

Conflicting controls need attention. Movement takes priority over old conflicting ability bindings.

} {shown.map((entry, index) =>
{(index === 0 || shown[index - 1].group !== entry.group) &&

{entry.group}

}
conflict.id === entry.id) ? "has-conflict" : ""}`}> {entry.label}{detail(entry.id) && {detail(entry.id)}} {entry.group === "WoW action bars" && {draft.current.pc.wowBars && Number(entry.id.split(":")[1]) < draft.current.pc.barCount ? "PC WoW layout" : "Inactive until this PC bar is enabled"}} {conflicts.some((conflict) => conflict.id === entry.id) && Also used by {conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}} {inputBindingLabel(device, bindings[entry.id])} {button(`edit-${entry.id}`, `Change ${entry.label}`, () => editBinding(entry.id), "keybinds-change")}
)} {!shown.length &&

No matching actions.

}

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.

{(editing || resetPending) &&
{pending ? <>

Replace conflicting bindings?

{inputBindingLabel(device, pending.key)} is already assigned to {pending.conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}.

Replacing will unbind those actions and assign it to {INPUT_ACTION_BY_ID[pending.id].label}.

{button("keep", "Keep existing bindings", cancelEdit)}{button("replace", "Replace bindings", () => apply(pending.id, pending.key))}
: resetPending ? <>

Restore {device === "pc" ? "PC" : "Controller"} defaults?

This resets this input type, including its action-bar keys. Assigned skills stay in place.

{button("keep", "Keep my bindings", cancelEdit)}{button("reset-confirm", "Restore defaults", restoreDefaults)} : editing && <>

{INPUT_ACTION_BY_ID[editing].label}

Current: {inputBindingLabel(device, bindings[editing])}

{listening ? <>

{device === "pc" ? "Press a key or modifier + key…" : "Press a controller button, hold L1/L2 and press a button, or move a stick…"}

Escape cancels capture. Use the selectors below to bind Escape or any controller button.

: button("capture", device === "pc" ? "Listen for key" : "Listen for controller", startCapture)}

Or choose a binding using touch or mouse:

{device === "pc" ?
{["Ctrl", "Alt", "Shift", "Meta"].map((modifier) => )}
: }
{button("clear", "Clear binding", () => apply(editing, null))}{button("keep", "Cancel binding change", cancelEdit)}
}
}

{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.")}

{online &&

{cloud.message}{cloud.state === "error" && }

}
{button("reset", `Reset ${device === "pc" ? "PC" : "Controller"} defaults`, () => setResetPending(true))} {button("close", "Done", close)}{button("save", "Save changes", save, "button--primary")}
; } export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) { const storeOpen = useGameStore((state) => state.overlay === "keybinds"); return (open ?? storeOpen) ? : null; }