import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout"; import { currentDisplayLayout } from "../platform/displayLayout"; import { useHudSurface } from "./HudSurface"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { useGameStore } from "../game/store"; import "./uiLayout.css"; const FALLBACKS: Partial> = { 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], dps: [.13, .8, 230], 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], 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, elementLayout, editing: editingOverride, hostSelector = ".game-shell", onChange }: { id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number; elementLayout?: HudElementLayout; editing?: boolean; hostSelector?: string; onChange?: (update: Partial) => void; }) { const overlay = useGameStore((state) => state.overlay); const surface = useHudSurface(); const dualScreen = currentDisplayLayout() !== "single"; const editing = editingOverride ?? overlay === "ui-layout"; const state = useUiLayoutStore(); const layout = state.draft ?? state.preferences.current[currentUiDevice()]; const element = elementLayout ?? layout.elements[id]; const screen = resolveHudScreen(id, layout, dualScreen); const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null); const ref = useRef(null); const [host, setHost] = useState(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 || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id)); 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(hostSelector)); }, [hostSelector, screen]); useLayoutEffect(() => { if (positioned || !ref.current) return; const node = ref.current; const measure = () => { const child = node.firstElementChild?.getBoundingClientRect(); const host = node.closest(hostSelector)?.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, hostSelector]); useLayoutEffect(() => { const node = ref.current; const host = node?.closest(hostSelector); 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, hostSelector, screen]); const updatePosition = (x: number, y: number) => { const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width }; if (onChange) onChange(update); else if (surface.onChange) surface.onChange(id, update); else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } })); }; 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 =
{ if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}> {children} {editing && }
; if (dualScreen && screen !== surface.surface) return null; return positioned && host ? createPortal(content, host) : content; }