const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed"; const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden"; function getHudScale() { const rootStyle = getComputedStyle(document.documentElement); const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale")); return Number.isFinite(scale) && scale > 0 ? scale : 1; } function getEdgeFlipThresholdPx() { const rootStyle = getComputedStyle(document.documentElement); const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset")); if (Number.isFinite(hudOffset) && hudOffset > 0) { return hudOffset; } return 20 * getHudScale(); } function clampExpandDirection(direction) { return direction === "up" ? "up" : "down"; } function resolveElement(target, root = document) { if (!target) return null; if (target instanceof HTMLElement) return target; if (typeof target === "string") { return root.querySelector(target); } return null; } function pickExpandDirection({ panelRect, preferredDirection, }) { const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom); const edgeFlipThresholdPx = getEdgeFlipThresholdPx(); const preferred = clampExpandDirection(preferredDirection); // Pure edge-threshold contract: // - d < threshold => "up" family // - d >= threshold => "down" family // Do not pre-flip early based on expanded height. if (spaceBelow <= edgeFlipThresholdPx) return "up"; if (spaceBelow > edgeFlipThresholdPx) return "down"; return preferred; } function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) { // The arrow always describes the next action and must stay aligned with the // real expansion direction chosen by the controller. Panels should not add // their own extra CSS rotation on top of this mapping. if (collapsed) { return { title: expandLabel, icon: direction === "up" ? "expand_less" : "expand_more", }; } return { title: collapseLabel, icon: direction === "up" ? "expand_more" : "expand_less", }; } export function createHUDPanel({ panel, header, body, collapseBtn, bodyCollapsedClass = "", preferredDirection = "down", collapsedClass = DEFAULT_COLLAPSED_CLASS, hiddenClass = DEFAULT_HIDDEN_CLASS, expandLabel = "展开", collapseLabel = "折叠", }) { const panelEl = resolveElement(panel); const headerEl = resolveElement(header, panelEl ?? document); const bodyEl = resolveElement(body, panelEl ?? document); const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document); if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) { return { panel: panelEl, header: headerEl, body: bodyEl, collapseBtn: collapseBtnEl, setCollapsed() {}, setVisible() {}, syncLayout() {}, destroy() {}, isCollapsed() { return false; }, isVisible() { return false; }, getExpandDirection() { return clampExpandDirection(preferredDirection); }, }; } let currentDirection = clampExpandDirection(preferredDirection); const shouldAnchorBottomDuringToggle = () => panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== ""; const compensateTopForBottomAnchor = (beforeBottom) => { if (!shouldAnchorBottomDuringToggle()) return; const afterBottom = panelEl.getBoundingClientRect().bottom; const delta = afterBottom - beforeBottom; if (delta === 0) return; panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`; }; const syncDirection = () => { const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height); const nextDirection = pickExpandDirection({ panelRect: panelEl.getBoundingClientRect(), preferredDirection, }); currentDirection = nextDirection; panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up"); panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up"); panelEl.dataset.expandDirection = nextDirection; }; const syncButton = () => { if (!(collapseBtnEl instanceof HTMLElement)) return; const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded"); const { title, icon } = getCollapseButtonState({ collapsed: panelEl.classList.contains(collapsedClass), direction: currentDirection, expandLabel, collapseLabel, }); collapseBtnEl.title = title; collapseBtnEl.setAttribute("aria-label", title); collapseBtnEl.dataset.expandDirection = currentDirection; if (iconEl) { iconEl.textContent = icon; } }; const syncLayout = () => { syncDirection(); syncButton(); }; const setCollapsed = (collapsed) => { syncDirection(); const nextCollapsed = Boolean(collapsed); const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle(); const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0; if (shouldCompensate) { bodyEl.style.transition = "none"; } panelEl.classList.toggle(collapsedClass, nextCollapsed); if (bodyCollapsedClass) { bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed); } if (shouldCompensate) { void panelEl.offsetHeight; compensateTopForBottomAnchor(bottomBefore); requestAnimationFrame(() => { bodyEl.style.transition = ""; }); } syncButton(); }; const setVisible = (visible) => { panelEl.classList.toggle(hiddenClass, !visible); if (visible) { syncLayout(); } }; const handleViewportChange = () => { if (!panelEl.classList.contains(hiddenClass)) { syncLayout(); } }; const handlePointerMove = () => { if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) { syncLayout(); } }; window.addEventListener("resize", handleViewportChange); document.addEventListener("pointerup", handleViewportChange); document.addEventListener("pointermove", handlePointerMove); syncLayout(); requestAnimationFrame(syncLayout); return { panel: panelEl, header: headerEl, body: bodyEl, collapseBtn: collapseBtnEl, setCollapsed, setVisible, syncLayout, destroy() { window.removeEventListener("resize", handleViewportChange); document.removeEventListener("pointerup", handleViewportChange); document.removeEventListener("pointermove", handlePointerMove); }, isCollapsed() { return panelEl.classList.contains(collapsedClass); }, isVisible() { return !panelEl.classList.contains(hiddenClass); }, getExpandDirection() { return currentDirection; }, }; } export function setupCollapsibleHudPanel(options) { return createHUDPanel(options); }