115 lines
4.6 KiB
JavaScript
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);
|