forked from phenom/healer-man
212 lines
19 KiB
TypeScript
212 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { useShellStore } from "../app/shellStore";
|
||
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
||
import { useCombatStore } from "../game/combatStore";
|
||
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||
import { useGameStore } from "../game/store";
|
||
import {
|
||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
|
||
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
||
resolveHudScreen, type HudScreen,
|
||
} from "../game/uiLayout";
|
||
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
|
||
import { profileInputBindings } from "../game/keybindPreferences";
|
||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||
|
||
export function UiLayoutEditor() {
|
||
const open = useGameStore((state) => state.overlay === "ui-layout");
|
||
const ui = useUiLayoutStore();
|
||
const session = useShellStore((state) => state.session);
|
||
const cloudSave = useCloudSaveStatus();
|
||
const abilities = useCombatStore((state) => state.abilities);
|
||
const level = useCombatStore((state) => state.level);
|
||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||
const [tab, setTab] = useState<UiDevice>(ui.device);
|
||
const [name, setName] = useState("My UI");
|
||
const [slot, setSlot] = useState(0);
|
||
const [message, setMessage] = useState("");
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [dockRight, setDockRight] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const [pendingImport, setPendingImport] = useState<ReturnType<typeof importUiPreferences> | null>(null);
|
||
const [transferText, setTransferText] = useState<string | null>(null);
|
||
const file = useRef<HTMLInputElement>(null);
|
||
const wasOpen = useRef(false);
|
||
useEffect(() => {
|
||
if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
|
||
useUiLayoutStore.getState().begin();
|
||
setTab(useUiLayoutStore.getState().device); setMessage("");
|
||
}
|
||
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
|
||
wasOpen.current = open;
|
||
}, [open, ui.draft !== null]);
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (event: KeyboardEvent) => {
|
||
const state = useUiLayoutStore.getState();
|
||
if (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']")) return;
|
||
if (event.code === "Escape") {
|
||
event.preventDefault(); event.stopImmediatePropagation();
|
||
if (state.selectedButton) useUiLayoutStore.setState({ selectedButton: null });
|
||
else { state.finish(false); useGameStore.getState().openOverlay("pause"); }
|
||
return;
|
||
}
|
||
if (state.mode !== "bindings" || !state.selectedButton) return;
|
||
event.preventDefault(); event.stopImmediatePropagation();
|
||
if (event.repeat) return;
|
||
const key = event.code === "Backspace" || event.code === "Delete" ? null : actionKeyForEvent(event);
|
||
if (!key && event.code !== "Backspace" && event.code !== "Delete") return;
|
||
const { bar, slot: button } = state.selectedButton;
|
||
const bindings = profileInputBindings(state.preferences, "pc");
|
||
const conflicts = conflictingInputActions(bindings, "pc", `bar:${bar}:${button}`, key).filter((id) => !id.startsWith("bar:"));
|
||
if (conflicts.length) {
|
||
setMessage(`That key belongs to ${conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}. Change it in Options → Keybinds first. Your existing bindings were kept.`);
|
||
useUiLayoutStore.setState({ selectedButton: null }); return;
|
||
}
|
||
state.updateDraft((draft) => bindDesktopSlot(draft, bar, button, key));
|
||
setMessage(key ? `Bound bar ${bar + 1}, button ${button + 1}. Any previous assignment was cleared.` : "Keybind cleared.");
|
||
useUiLayoutStore.setState({ selectedButton: null });
|
||
};
|
||
window.addEventListener("keydown", onKey, true);
|
||
return () => window.removeEventListener("keydown", onKey, true);
|
||
}, [open]);
|
||
if (!open || !ui.draft) return null;
|
||
const draft = ui.draft;
|
||
const dualScreen = currentDisplayLayout() !== "single";
|
||
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
|
||
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
|
||
const validName = name.trim().length > 0;
|
||
const chooseSlot = (device: UiDevice, index: number) => {
|
||
setTab(device); setSlot(index); setName(ui.preferences.slots[device][index]?.name ?? "My UI");
|
||
};
|
||
const selected = ui.selectedButton;
|
||
const setAbility = (abilityId: string | null) => {
|
||
if (!selected) return;
|
||
ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, index) => index !== selected.bar ? bar : {
|
||
...bar, slots: bar.slots.map((button, index) => index === selected.slot ? { ...button, abilityId } : button),
|
||
}) }));
|
||
};
|
||
return <aside className={`ui-layout-editor ${collapsed ? "ui-layout-editor--collapsed" : ""} ${dockRight ? "ui-layout-editor--right" : ""}`} aria-label="UI layout editor">
|
||
<header><div><small>PERSONAL INTERFACE · {ui.device === "pc" ? "PC" : "MOBILE"}</small><h2>Edit your UI</h2></div>
|
||
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
|
||
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
|
||
{!collapsed && <div className="ui-layout-editor__body">
|
||
<p>Choose a screen for each element below. Collapse this panel and drag a widget’s label to move it. Use the list to show hidden elements again.</p>
|
||
<details open><summary>UI templates · 6 slots</summary>
|
||
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
|
||
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
|
||
<label>Template name<input value={name} maxLength={40} onChange={(event) => setName(event.target.value)} /></label>
|
||
<div className="ui-layout-editor__row">
|
||
<button type="button" disabled={!validName || tab !== ui.device} onClick={() => attempt(() => {
|
||
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => index === slot ? { name: name.trim(), layout: draft } : template) } });
|
||
setMessage(`Saved ${name.trim()} to ${tab === "pc" ? "PC" : "Mobile"} slot ${slot + 1}.`);
|
||
})}>{ui.preferences.slots[tab][slot] ? "Overwrite slot" : "Save to slot"}</button>
|
||
<button type="button" disabled={!validName || !ui.preferences.slots[tab][slot]} onClick={() => attempt(() => {
|
||
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => template && index === slot ? { ...template, name: name.trim() } : template) } });
|
||
setMessage("Template renamed.");
|
||
})}>Rename</button>
|
||
<button type="button" disabled={!ui.preferences.slots[tab][slot] || tab !== ui.device} onClick={() => {
|
||
useUiLayoutStore.setState({ draft: ui.preferences.slots[tab][slot]!.layout, selectedButton: null }); setMessage("Template loaded into the editor. Save UI to apply.");
|
||
}}>Load</button>
|
||
</div>
|
||
{tab !== ui.device && <small>Load and save {tab === "pc" ? "PC" : "Mobile"} layouts on that device type. You can rename or export either set here.</small>}
|
||
<div className="ui-layout-editor__row">
|
||
<button type="button" onClick={() => {
|
||
const blob = new Blob([exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } })], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "healer-man-ui.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}}>Export all</button>
|
||
<button type="button" onClick={() => file.current?.click()}>Import file</button>
|
||
<button type="button" onClick={() => setTransferText(exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } }))}>Export text</button>
|
||
<button type="button" onClick={() => setTransferText("")}>Import text</button>
|
||
<input ref={file} type="file" accept=".json,application/json" hidden onChange={(event) => {
|
||
const chosen = event.target.files?.[0]; event.target.value = ""; if (!chosen) return;
|
||
if (chosen.size > 256_000) { setMessage("UI files must be smaller than 256 KB."); return; }
|
||
void chosen.text().then((text) => setPendingImport(importUiPreferences(text))).catch((error: Error) => setMessage(error.message));
|
||
}} />
|
||
</div>
|
||
{transferText !== null && <div>
|
||
<label>UI configuration JSON<textarea aria-label="UI configuration JSON" value={transferText} maxLength={256000} onChange={(event) => setTransferText(event.target.value)} /></label>
|
||
<small>Copy this text to transfer your UI, or paste an exported configuration to import it.</small>
|
||
<button type="button" onClick={() => attempt(() => { setPendingImport(importUiPreferences(transferText)); setTransferText(null); })}>Review import</button>
|
||
<button type="button" onClick={() => setTransferText(null)}>Close text</button>
|
||
</div>}
|
||
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots, both saved layouts, and profile keybinds with this file?</p><button type="button" onClick={() => attempt(() => {
|
||
ui.persist(pendingImport); useUiLayoutStore.setState({ draft: pendingImport.current[ui.device], selectedButton: null }); setPendingImport(null); setMessage("Imported all six slots.");
|
||
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
|
||
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
|
||
</details>
|
||
<details open={dualScreen}><summary>Elements · screen / move / hide</summary>
|
||
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}
|
||
onClick={() => useUiLayoutStore.setState({ selectedElement: id })} onFocus={() => useUiLayoutStore.setState({ selectedElement: id })}>
|
||
<label>{label}</label>
|
||
<select aria-label={`${label} screen`} value={dualScreen ? resolveHudScreen(id, draft, true) : draft.elements[id].visible ? draft.elements[id].screen ?? "auto" : "hidden"}
|
||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||
[id]: { ...layout.elements[id], screen: event.target.value as HudScreen, visible: event.target.value !== "hidden", position: null, width: null },
|
||
} }))}>
|
||
{!dualScreen && <option value="auto">Automatic</option>}
|
||
<option value="top">Top</option><option value="bottom">Bottom</option><option value="hidden">Hidden</option>
|
||
</select>
|
||
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
|
||
</div>)}
|
||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements, dpsMeterDisplay: "auto" }))}>Reset all elements</button>
|
||
{dualScreen && <p>Top: party health and the dungeon. Bottom: action bars, buffs and debuffs. Collapse this panel to drag labels on either display.</p>}
|
||
</details>
|
||
<details className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked,
|
||
screen: event.target.checked && layout.elements.dps.screen === "hidden" ? "auto" : layout.elements.dps.screen } },
|
||
}))} />Show DPS meter</label>
|
||
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
|
||
elements: { ...layout.elements, dps: { ...layout.elements.dps, screen: "auto", position: null } },
|
||
}))}>
|
||
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||
</select></label>
|
||
<label>Width<input type="range" aria-label="DPS meter width" min={160} max={600} step={10} value={draft.elements.dps.width ?? 230}
|
||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||
dps: { ...layout.elements.dps, width: Number(event.target.value) },
|
||
} }))} /><output>{draft.elements.dps.width ?? 230}px</output></label>
|
||
<p>Drag the DPS label on its screen to move it. Damage includes pets and summons. The last fight stays visible until the next pull.</p>
|
||
</details>
|
||
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
||
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
||
const combat = useCombatStore.getState();
|
||
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
|
||
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
|
||
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
|
||
})} />Use WoW action bars</label>
|
||
{draft.wowBars && <>
|
||
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
|
||
{draft.bars.slice(0, draft.barCount).map((bar, index) => <div className="ui-layout-editor__row" key={index}><span>Bar {index + 1}</span>
|
||
<label>Rows<select aria-label={`Bar ${index + 1} rows`} value={12 / bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: 12 / Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((rows) => <option key={rows}>{rows}</option>)}</select></label>
|
||
<label>Columns<select aria-label={`Bar ${index + 1} columns`} value={bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((cols) => <option key={cols}>{cols}</option>)}</select></label>
|
||
</div>)}
|
||
<button type="button" aria-pressed={ui.mode === "bindings"} onClick={() => useUiLayoutStore.setState({ mode: ui.mode === "bindings" ? "layout" : "bindings", selectedButton: null })}>{ui.mode === "bindings" ? "Finish keybind editing" : "Keybind edit mode"}</button>
|
||
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Keys already used by game controls are protected. Review all bindings in Options → Keybinds." : "Click a bar button to assign an ability."}</p>
|
||
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
|
||
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
|
||
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
|
||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
||
const ability = abilityAtLevel(definition, level, talentRank);
|
||
return <AbilityTooltipTarget key={ability.id} ability={{
|
||
name: ability.name, description: ability.description, target: ability.target,
|
||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
|
||
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
||
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
|
||
})}</div>
|
||
</div>}
|
||
</>}
|
||
</details>}
|
||
<p role="status">{message}</p>
|
||
{session?.accessToken && <p role="status">{cloudSave.message}{cloudSave.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
|
||
</div>}
|
||
<footer><button type="button" onClick={() => { ui.finish(false); useGameStore.getState().closeOverlay(); }}>Cancel edits</button><button type="button" className="button--primary" onClick={saveAndClose}>Save UI</button></footer>
|
||
</aside>;
|
||
}
|