zcbot/web/static/js/dom.js

115 lines
4.6 KiB
JavaScript

// DOM 小工具 + 单例浮层菜单。
import { escapeHtml } from "./format.js";
export const $ = (id) => document.getElementById(id);
// 对话区右缘的「导航 / 作业」共用一只容器。两个业务模块各自渲染内容,
// 这里仅负责可见性与互斥固定展开,避免 chat ↔ software_jobs 形成新的循环依赖。
export function syncConversationSideRail() {
const wrap = $("conversation-side-rail");
if (!wrap) return;
const sections = Array.from(wrap.querySelectorAll(".conversation-rail-section"));
const visible = sections.filter((section) => !section.hidden);
wrap.hidden = visible.length === 0;
const divider = $("conversation-side-divider");
if (divider) divider.hidden = visible.length < 2;
sections.filter((section) => section.hidden).forEach((section) => section.classList.remove("pinned"));
wrap.classList.toggle("has-pinned", visible.some((section) => section.classList.contains("pinned")));
}
export function toggleConversationRailPinned(section) {
const wrap = $("conversation-side-rail");
if (!wrap || !section) return false;
const wasPinned = section.classList.contains("pinned");
wrap.querySelectorAll(".conversation-rail-section").forEach((item) => {
item.classList.remove("pinned");
item.querySelector(".conversation-rail-caption")?.setAttribute("aria-expanded", "false");
});
if (!wasPinned) {
section.classList.add("pinned");
section.querySelector(".conversation-rail-caption")?.setAttribute("aria-expanded", "true");
}
wrap.classList.toggle("has-pinned", !wasPinned);
return !wasPinned;
}
// ───── floating dropdown menu (single instance) ─────
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
let _menuItems = null;
function renderMenu(items) {
_menuItems = items;
const menu = $("floating-menu");
menu.innerHTML = items.map((it) => {
const cls = "dd-item " + (it.cls || "");
const dis = it.disabled ? " disabled" : "";
return `<button class="${cls}" data-act="${escapeHtml(it.act)}"${dis}>${escapeHtml(it.label)}</button>`;
}).join("");
menu.querySelectorAll(".dd-item").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const act = btn.dataset.act;
const item = _menuItems && _menuItems.find((i) => i.act === act);
hideMenu();
if (item && item.onclick) item.onclick();
};
});
return menu;
}
function clampMenuPosition(menu, left, top) {
const mh = menu.offsetHeight || 120;
const mw = menu.offsetWidth || 132;
menu.style.left = Math.max(4, Math.min(left, window.innerWidth - mw - 4)) + "px";
menu.style.right = "auto";
menu.style.top = Math.max(4, Math.min(top, window.innerHeight - mh - 4)) + "px";
menu.style.visibility = "";
}
export function showMenu(triggerEl, items) {
const menu = renderMenu(items);
// 默认右下展开;若空间不足则改向上
const rect = triggerEl.getBoundingClientRect();
menu.style.visibility = "hidden";
menu.classList.add("show");
const mh = menu.offsetHeight || 120;
const mw = menu.offsetWidth || 132;
// 水平:默认右缘对齐触发按钮;触发按钮靠左(手机上 ⋯ 在顶栏左侧)会把菜单推出
// 左边缘 → 改成左缘对齐,并双向钳在视口内
if (rect.right - mw < 4) {
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - mw - 4)) + "px";
menu.style.right = "auto";
} else {
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
menu.style.left = "auto";
}
if (rect.bottom + mh + 8 > window.innerHeight) {
menu.style.top = Math.max(4, rect.top - mh - 4) + "px";
} else {
menu.style.top = (rect.bottom + 4) + "px";
}
menu.style.visibility = "";
}
// 右键菜单按指针位置展开;靠近视口边缘时向内收,避免操作项被裁掉。
export function showMenuAtPoint(clientX, clientY, items) {
const menu = renderMenu(items);
menu.style.visibility = "hidden";
menu.classList.add("show");
clampMenuPosition(menu, clientX, clientY);
}
export function hideMenu() {
_menuItems = null;
$("floating-menu").classList.remove("show");
}
document.addEventListener("click", (e) => {
if (e.target.closest(".dd-toggle")) return;
if (e.target.closest("#floating-menu")) return;
hideMenu();
}, true);
// 在非自定义菜单区域右键时,先收掉可能残留的浮层;列表行的冒泡处理随后会重新打开。
document.addEventListener("contextmenu", hideMenu, true);
window.addEventListener("resize", hideMenu);
// 滚动 pane 时菜单位置失效,直接关
document.addEventListener("scroll", hideMenu, true);