Release Healer Man 0.1.28
This commit is contained in:
1 parent
ed8b9e3719
commit
8c9809d3e0
23 files changed
+975
-55
No files matched your search
@@ -40,7 +40,7 @@ function cooldownPercent(remaining = 0, duration = 0): number {
|
||||
return Math.max(0, Math.min(100, (remaining / duration) * 100));
|
||||
}
|
||||
|
||||
function ActionIcon({ item }: { item: ActionBarItem | null }) {
|
||||
export function ActionIcon({ item }: { item: ActionBarItem | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [item?.iconUrl]);
|
||||
if (!item) return <span className="action-slot__icon" aria-hidden="true" />;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { ActionIcon, type ActionBarItem } from "./ActionBar";
|
||||
|
||||
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
|
||||
const ui = useUiLayoutStore();
|
||||
const editing = useGameStore((state) => state.overlay === "ui-layout");
|
||||
const blocked = useGameStore((state) => state.overlay !== null || state.companionOpen);
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (currentUiDevice() !== "pc" || !layout.wowBars) return null;
|
||||
return <>{layout.bars.slice(0, layout.barCount).map((bar, b) => {
|
||||
const offset = 30 + layout.bars.slice(0, b).reduce((total, previous) => total + (12 / previous.columns) * 46 + 10 + (editing ? 24 : 0), 0);
|
||||
return <EditableHudElement id={`bar-${b + 1}` as HudElementId} key={b} intrinsicWidth={bar.columns * 46 + 8} bottomAnchor={offset + (12 / bar.columns) * 23 + (editing ? 16 : 4)}>
|
||||
<section className="desktop-action-bar" aria-label={`Action bar ${b + 1}`} style={{ "--bar-columns": bar.columns, "--bar-offset": `${offset}px` } as CSSProperties}>
|
||||
{bar.slots.map((button, s) => {
|
||||
const item = button.abilityId ? items[button.abilityId] : null;
|
||||
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
|
||||
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
|
||||
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
|
||||
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
|
||||
onClick={() => {
|
||||
if (editing) { useUiLayoutStore.setState({ selectedButton: { bar: b, slot: s } }); return; }
|
||||
if (!blocked && item && !item.disabled) useCombatStore.getState().castAbility(item.id, useGameStore.getState().playerPosition, Date.now());
|
||||
}}>
|
||||
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
|
||||
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
|
||||
{editing && !item && <small>{s + 1}</small>}
|
||||
</button>;
|
||||
})}
|
||||
</section>
|
||||
</EditableHudElement>; })}</>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
|
||||
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
||||
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
||||
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
||||
objective: [.5, .1, 400], messages: [.5, .22, 350], cache: [.78, .42, 200],
|
||||
};
|
||||
|
||||
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
|
||||
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const editing = overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = layout.elements[id];
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
|
||||
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
|
||||
const positioned = editing || element.position !== null;
|
||||
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
|
||||
const position = element.position ?? (bottomAnchor !== undefined
|
||||
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
const node = ref.current;
|
||||
const measure = () => {
|
||||
const child = node.firstElementChild?.getBoundingClientRect();
|
||||
const host = node.closest(".game-shell")?.getBoundingClientRect();
|
||||
if (child && host && child.width > 0 && child.height > 0) {
|
||||
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
||||
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
||||
}, [positioned, children]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
const host = node?.closest(".game-shell");
|
||||
if (!positioned || !node || !host) return;
|
||||
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
|
||||
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
|
||||
} }));
|
||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||
const content = <div ref={ref} data-hud-element={id} style={style}
|
||||
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
|
||||
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
|
||||
{children}
|
||||
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const rect = ref.current!.getBoundingClientRect();
|
||||
drag.current = { pointerId: event.pointerId, dx: event.clientX - rect.left - rect.width / 2, dy: event.clientY - rect.top - rect.height / 2 };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
useUiLayoutStore.setState({ selectedElement: id });
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drag.current?.pointerId !== event.pointerId) return;
|
||||
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
|
||||
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
||||
}}
|
||||
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
||||
onKeyDown={(event) => {
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
||||
if (!delta) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
|
||||
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
|
||||
</div>;
|
||||
return positioned && host ? createPortal(content, host) : content;
|
||||
}
|
||||
+45
-32
@@ -32,6 +32,9 @@ import { AuraStrip } from "./AuraStrip";
|
||||
import { ManastormStatus } from "./ManastormStatus";
|
||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
@@ -139,6 +142,8 @@ export function DungeonCompletePrompt({
|
||||
}
|
||||
|
||||
export function Hud() {
|
||||
const ui = useUiLayoutStore();
|
||||
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const areaName = useGameStore((state) => state.areaName);
|
||||
@@ -216,10 +221,12 @@ export function Hud() {
|
||||
|
||||
const classColor = classById(classId).color;
|
||||
const resourceTone = resourceProfileForClass(classId).type;
|
||||
const actionItems = useMemo<ActionBarItems>(() => Object.fromEntries(ACTION_CONTROLS.map((control) => {
|
||||
const abilityId = actionBindings[actionBindingId(actionLayer, control)];
|
||||
const catalogAbility = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
if (!catalogAbility) return [control, null];
|
||||
const assignedAbilities = useMemo(() => {
|
||||
const used = new Set(Object.values(actionBindings));
|
||||
if (desktopBars) for (const bar of (ui.draft ?? ui.preferences.current.pc).bars) for (const slot of bar.slots) used.add(slot.abilityId);
|
||||
return abilities.filter((ability) => used.has(ability.id));
|
||||
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
|
||||
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
|
||||
const learnedTalentRank = catalogAbility.talentEntryId === undefined
|
||||
? undefined
|
||||
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
||||
@@ -254,8 +261,11 @@ export function Hud() {
|
||||
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
|
||||
target: ability.target,
|
||||
};
|
||||
return [control, item];
|
||||
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
return [catalogAbility.id, item];
|
||||
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
const actionItems = Object.fromEntries(ACTION_CONTROLS.map((control) => [control,
|
||||
abilityItems[actionBindings[actionBindingId(actionLayer, control)] ?? ""] ?? null,
|
||||
])) as ActionBarItems;
|
||||
|
||||
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
|
||||
useGameStore.getState().actionLayer,
|
||||
@@ -362,17 +372,17 @@ export function Hud() {
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ManastormStatus />
|
||||
<BossLootWindow />
|
||||
{dungeonComplete && health > 0 ? (
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
) : null}
|
||||
{feedback?.kind === "loot" ? (
|
||||
) : null}</EditableHudElement>
|
||||
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
|
||||
<section className="loot-reward" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">◆</span>
|
||||
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
|
||||
</section>
|
||||
) : null}
|
||||
<PartyFrames />
|
||||
<SummonFrames now={clock} />
|
||||
) : null}</EditableHudElement>
|
||||
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
|
||||
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
|
||||
|
||||
{pendingGroundTarget ? (
|
||||
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||
@@ -391,19 +401,19 @@ export function Hud() {
|
||||
) : null}
|
||||
|
||||
<div className="hud-top-right">
|
||||
<Minimap
|
||||
<EditableHudElement id="minimap"><Minimap
|
||||
position={playerPosition}
|
||||
yaw={cameraYaw}
|
||||
areaName={stagePresentation.areaName}
|
||||
distanceTravelled={distance}
|
||||
rotationMode={settings.minimapRotation}
|
||||
onOpenMap={toggleMap}
|
||||
/>
|
||||
<PlayerStatusPanel now={clock} />
|
||||
<EncounterTimeline now={clock} />
|
||||
/></EditableHudElement>
|
||||
<EditableHudElement id="player"><PlayerStatusPanel now={clock} /></EditableHudElement>
|
||||
<EditableHudElement id="timeline"><EncounterTimeline now={clock} /></EditableHudElement>
|
||||
</div>
|
||||
|
||||
{target && settings.showTargetFrame && (
|
||||
<EditableHudElement id="target">{target && settings.showTargetFrame && (
|
||||
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
|
||||
<header><strong>{target.name}</strong><span>{target.boss ? "Boss" : `Level ${target.level}`}</span></header>
|
||||
<div className="target-frame__track"><span style={{ width: `${targetPercent}%` }} /></div>
|
||||
@@ -420,18 +430,18 @@ export function Hud() {
|
||||
<AuraStrip targetId={target.id} className="aura-strip--target" />
|
||||
<small>{Math.ceil(target.health)} / {target.maxHealth}</small>
|
||||
</section>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
{target && settings.showThreatMeter && threatRows.length > 0 && (
|
||||
<EditableHudElement id="threat">{target && settings.showThreatMeter && threatRows.length > 0 && (
|
||||
<ThreatMeterWidget
|
||||
targetName={target.name}
|
||||
rows={threatRows}
|
||||
position={settings.threatMeterPosition}
|
||||
onPositionChange={updateThreatMeterPosition}
|
||||
/>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
{selectedFriendly && settings.showTargetFrame && (
|
||||
<EditableHudElement id="friendly">{selectedFriendly && settings.showTargetFrame && (
|
||||
<section className="target-frame target-frame--friendly" aria-label={`Friendly target ${selectedFriendly.name}${selectedFriendly.self ? ", self" : ""}`}>
|
||||
<header>
|
||||
<strong>{selectedFriendly.name}</strong>
|
||||
@@ -448,10 +458,10 @@ export function Hud() {
|
||||
/>
|
||||
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
|
||||
</section>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
<div className="hud-bottom-stack">
|
||||
{activeCast && castingAbility && (
|
||||
<EditableHudElement id="cast">{activeCast && castingAbility && (
|
||||
<section className={`cast-bar cast-bar--${activeCast.mode}`} aria-label={`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${castingAbility.name}`}>
|
||||
<header><strong>{castingAbility.name}</strong><span>{castRemaining.toFixed(1)}s</span></header>
|
||||
<div className="cast-bar__track">
|
||||
@@ -459,9 +469,9 @@ export function Hud() {
|
||||
</div>
|
||||
<small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small>
|
||||
</section>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
{!activeCast && (
|
||||
<EditableHudElement id="hints">{!activeCast && (
|
||||
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
|
||||
{inputMode === "gamepad"
|
||||
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause"
|
||||
@@ -469,21 +479,24 @@ export function Hud() {
|
||||
? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers"
|
||||
: "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
|
||||
</div>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
|
||||
<ActionBar
|
||||
{!desktopBars && <EditableHudElement id="actions"><ActionBar
|
||||
items={actionItems}
|
||||
activeLayer={actionLayer}
|
||||
onActivate={castBinding}
|
||||
inputMode={inputMode}
|
||||
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
|
||||
/>
|
||||
/></EditableHudElement>}
|
||||
</div>
|
||||
<ExperienceBar level={level} current={xp} required={xpToNext} />
|
||||
<DesktopActionBars items={abilityItems} />
|
||||
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
|
||||
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
|
||||
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
|
||||
|
||||
{cameraLookActive && <span className="reticle" aria-hidden="true" />}
|
||||
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
|
||||
|
||||
{assetStatus === "proxy" && (
|
||||
<EditableHudElement id="assets">{assetStatus === "proxy" && (
|
||||
<div className="asset-notice" role="status">
|
||||
<strong>Cave proxy active</strong>
|
||||
<span>{assetMessage ?? `Add ${[
|
||||
@@ -491,7 +504,7 @@ export function Hud() {
|
||||
...dungeon.assets.collision,
|
||||
].map((asset) => asset.fileName).join(" and ")}.`}</span>
|
||||
</div>
|
||||
)}
|
||||
)}</EditableHudElement>
|
||||
<LoadingOverlay />
|
||||
{health <= 0 ? (
|
||||
<DefeatedPrompt
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { ManastormClientOverlay } from "./ManastormClientOverlay";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
|
||||
export function ManastormStatus() {
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
@@ -56,7 +57,9 @@ export function ManastormStatus() {
|
||||
return () => window.clearInterval(interval);
|
||||
}, [encounter?.id, level, waveActive]);
|
||||
|
||||
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return null;
|
||||
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return <>
|
||||
<EditableHudElement id="manastorm" /><EditableHudElement id="objective" /><EditableHudElement id="messages" /><EditableHudElement id="cache" />
|
||||
</>;
|
||||
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const stageLocation = encounter.location ?? dungeon.title;
|
||||
@@ -85,7 +88,7 @@ export function ManastormStatus() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
|
||||
<EditableHudElement id="manastorm"><section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
|
||||
<div>
|
||||
<span>{milestone ? "Milestone" : modeLabel}</span>
|
||||
<strong>{level}</strong>
|
||||
@@ -120,9 +123,9 @@ export function ManastormStatus() {
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</section>
|
||||
<ManastormClientOverlay />
|
||||
{phase === "loot" ? (
|
||||
</section></EditableHudElement>
|
||||
<EditableHudElement id="messages"><ManastormClientOverlay /></EditableHudElement>
|
||||
<EditableHudElement id="objective">{phase === "loot" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`manastorm-objective manastorm-objective--${status}`}
|
||||
@@ -137,8 +140,8 @@ export function ManastormStatus() {
|
||||
<span aria-hidden="true">{phase === "portal" ? "✦" : "⚔"}</span>
|
||||
<strong>{objective}</strong>
|
||||
</div>
|
||||
)}
|
||||
{rewardCache[0] ? (
|
||||
)}</EditableHudElement>
|
||||
<EditableHudElement id="cache">{rewardCache[0] ? (
|
||||
<button
|
||||
type="button"
|
||||
className="manastorm-cache-open"
|
||||
@@ -147,7 +150,7 @@ export function ManastormStatus() {
|
||||
>
|
||||
Open {rewardCache[0].cacheId.replace(/-/g, " ")}
|
||||
</button>
|
||||
) : null}
|
||||
) : null}</EditableHudElement>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ function PauseDialog() {
|
||||
{ id: "pause-bindings", run: () => openOverlay("bindings") },
|
||||
{ id: "pause-talents", run: () => openOverlay("talents") },
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
@@ -77,6 +78,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
|
||||
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
|
||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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 { 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;
|
||||
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 and both saved layouts 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>
|
||||
{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. Bound keys take priority over game shortcuts." : "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>;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.editable-hud--default { display: contents; }
|
||||
.editable-hud--hidden { display: none !important; }
|
||||
.editable-hud--positioned {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
max-height: calc(100% - 12px);
|
||||
}
|
||||
.editable-hud--positioned > :not(.editable-hud__handle) {
|
||||
position: relative !important;
|
||||
inset: auto !important;
|
||||
transform: none !important;
|
||||
scale: none !important;
|
||||
margin: 0 !important;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.editable-hud--positioned:empty { display: none; }
|
||||
.editable-hud--editing {
|
||||
min-height: 38px;
|
||||
outline: 1px dashed var(--gold);
|
||||
border-radius: 5px;
|
||||
background: rgba(9, 25, 18, .6);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.editable-hud--editing > :not(.editable-hud__handle) { pointer-events: none; }
|
||||
.editable-hud--editing > .desktop-action-bar { pointer-events: auto; }
|
||||
.editable-hud--ghost > :not(.editable-hud__handle) { opacity: .22; }
|
||||
.editable-hud__handle {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--gold);
|
||||
border-radius: 4px;
|
||||
background: #16251c;
|
||||
color: #ffe3a0;
|
||||
font-size: 11px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.editable-hud__handle:active { cursor: grabbing; }
|
||||
.editable-hud--editing:has(.desktop-action-bar) { padding-top: 24px; }
|
||||
.hud-bottom-stack > .editable-hud--default > .action-bar {
|
||||
position: relative; bottom: auto; left: auto; width: min(420px, calc(100vw - 24px)); margin-top: 20px; transform: none; scale: none;
|
||||
}
|
||||
.hud-bottom-stack > .editable-hud--default > :is(.cast-bar, .control-hint) {
|
||||
position: relative; inset: auto; max-width: min(560px, calc(100vw - 24px)); transform: none; scale: none;
|
||||
}
|
||||
.desktop-action-bar {
|
||||
position: absolute;
|
||||
bottom: var(--bar-offset, 30px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--bar-columns), minmax(0, 1fr));
|
||||
width: min(calc(var(--bar-columns) * 46px + 8px), calc(100vw - 16px));
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 5px;
|
||||
background: rgba(4, 12, 8, .82);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.desktop-action-button {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid #807044;
|
||||
border-radius: 3px;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(145deg, #293c30, #070e0a);
|
||||
box-shadow: inset 0 0 5px #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
.desktop-action-button .action-slot__icon { position: absolute; inset: 0; width: 100%; height: 100%; display: grid; place-items: center; font-size: 16px; }
|
||||
.desktop-action-button .action-slot__icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.desktop-action-button > kbd { position: absolute; z-index: 2; top: 1px; right: 2px; max-width: 100%; font: bold 10px "Segoe UI", sans-serif; text-shadow: 0 1px 2px #000, 1px 0 2px #000; }
|
||||
.desktop-action-button > small { position: absolute; bottom: 2px; left: 3px; color: var(--muted); }
|
||||
.desktop-action-button.is-unavailable .action-slot__icon { opacity: .4; }
|
||||
.desktop-action-button:is(:hover, :focus-visible, .is-selected) { border-color: #ffe3a0; outline: 2px solid var(--gold); outline-offset: -2px; }
|
||||
.desktop-action-button__cooldown { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; font-size: 15px; font-weight: bold; text-shadow: 0 1px 2px #000; }
|
||||
.desktop-action-feedback { position: absolute; top: 65%; left: 50%; transform: translateX(-50%); max-width: 80%; color: #f3dfaa; text-align: center; text-shadow: 0 1px 3px #000; font-size: 12px; }
|
||||
.ui-layout-editor {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
left: max(10px, env(safe-area-inset-left));
|
||||
width: min(360px, calc(100% - 20px));
|
||||
max-height: calc(100% - 20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gold);
|
||||
border-radius: 10px;
|
||||
background: #0b1a14f5;
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ui-layout-editor > header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--edge); }
|
||||
.ui-layout-editor--right { left: auto; right: max(10px, env(safe-area-inset-right)); }
|
||||
.single-display-frame:has(.ui-layout-editor) > .single-context-toggle { display: none; }
|
||||
.ui-layout-editor h2 { margin: 3px 0 0; color: #f3dfaa; font: 22px Georgia, serif; }
|
||||
.ui-layout-editor small { color: var(--muted); }
|
||||
.ui-layout-editor p { line-height: 1.5; margin: 8px 0; }
|
||||
.ui-layout-editor__body { padding: 0 12px 12px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.ui-layout-editor details { padding: 10px 0; border-bottom: 1px solid var(--edge); }
|
||||
.ui-layout-editor summary { margin-bottom: 10px; color: #eed7a0; font-weight: bold; cursor: pointer; }
|
||||
.ui-layout-editor button, .ui-layout-editor select, .ui-layout-editor input:not([type="checkbox"]) { border: 1px solid var(--edge); border-radius: 4px; padding: 7px; background: #172b20; color: var(--ink); font: inherit; }
|
||||
.ui-layout-editor button { cursor: pointer; }
|
||||
.ui-layout-editor button:disabled { opacity: .4; cursor: default; }
|
||||
.ui-layout-editor button[aria-pressed="true"] { border-color: var(--gold); background: #3d3a22; }
|
||||
.ui-layout-editor button:focus-visible, .ui-layout-editor input:focus-visible, .ui-layout-editor select:focus-visible { outline: 2px solid var(--gold); }
|
||||
.ui-layout-editor label { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
|
||||
.ui-layout-editor input:not([type="checkbox"]) { min-width: 0; flex: 1; width: 100%; }
|
||||
.ui-layout-editor textarea { width: 100%; min-width: 0; min-height: 100px; resize: vertical; color: var(--ink); background: var(--cave); border: 1px solid var(--edge); }
|
||||
.ui-layout-editor__tabs, .ui-layout-editor__row, .ui-layout-editor > footer { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
.ui-layout-editor__slots { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
|
||||
.ui-layout-editor__slots button { min-width: 0; }
|
||||
.ui-layout-editor__slots :is(strong, small) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ui-layout-editor__element { display: flex; justify-content: space-between; align-items: center; gap: 5px; }
|
||||
.ui-layout-editor__element.is-selected { background: #a2853426; }
|
||||
.ui-layout-editor__element button { padding: 3px 6px; }
|
||||
.ui-layout-editor__abilities { display: grid; max-height: 160px; overflow-y: auto; gap: 3px; }
|
||||
.ui-layout-editor__assignment { margin-top: 10px; padding: 8px; border: 1px solid var(--edge); }
|
||||
.ui-layout-editor > footer { margin: 0; padding: 10px 12px; justify-content: flex-end; border-top: 1px solid var(--edge); }
|
||||
.ui-layout-editor > footer .button--primary { color: #0d170e; background: #c3d890; }
|
||||
@media (max-width: 650px), (max-height: 500px) {
|
||||
.ui-layout-editor { width: min(310px, calc(100% - 16px)); top: 8px; left: 8px; max-height: calc(100% - 16px); }
|
||||
.ui-layout-editor--right { left: auto; right: 8px; }
|
||||
.ui-layout-editor button { min-height: 36px; }
|
||||
.editable-hud__handle { min-height: 30px; font-size: 10px; }
|
||||
}
|
||||
Reference in new issue
Block a user