234 lines
6.8 KiB
JavaScript
234 lines
6.8 KiB
JavaScript
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);
|
|
}
|