Files
healer-man/src/ui/UiLayoutEditor.tsx
T
2026-09-12 21:14:41 -04:00

186 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 { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
} from "../game/uiLayout";
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
import { profileInputBindings } from "../game/keybindPreferences";
import { useUiLayoutStore } from "../game/uiLayoutStore";
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 [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().begin();
setTab(useUiLayoutStore.getState().device); setMessage("");
}
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open;
}, [open]);
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 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>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them 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><summary>Elements · 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" : ""}`}>
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
<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 }))}>Reset all positions and visibility</button>
</details>
<details open 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 } },
}))} />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, 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((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</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>;
}