BUG-108 — closeContextMenu() retirait le nœud du menu AVANT de déférencer _menu : le retrait émet focusout en synchrone (le menu tient le focus), qui rappelle closeContextMenu() ; le second remove() sur un nœud démonté lève NotFoundError et avorte le handler de l'action « Coller » du viewer — le coller par menu contextuel était silencieusement mort (régression de la fermeture au focus #179, reproduite par l'E2E « coller une plage », CI #850→#854). Déférencement avant retrait. Sécurité CI (job security, run #853) : - plancher multidict>=6.9.1 (CVE-2026-104874 ; 6.7.x préinstallée dans la toolcache de l'image du runner, même piège « already satisfied » que pypdf BUG-093) + entrée dans le garde-fou TestDependencySecurityFloors ; - exception documentée CVE-2026-85394 (python-jose, AUCUN correctif upstream) : non atteignable ici, jwt.decode passe toujours algorithms=["HS256"] avec un secret symétrique serveur — jamais de clé publique comme clé HMAC. Vérifié : E2E -g « coller une plage » 1/1 ; xlsx-menus 11/11, xlsx-formula 14/14, ai 100/100, ai-quick-actions 13/13 ; pytest 1586 passés ; ruff/mypy 0 erreur ; ISSUES_TODOLIST + CHANGELOG à jour.
210 lines
7.9 KiB
JavaScript
210 lines
7.9 KiB
JavaScript
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
|
|
*
|
|
* Builds a small menu anchored at a screen position. Labels, icons and
|
|
* callbacks come from the caller, so no i18n lives here.
|
|
*
|
|
* #179 — every menu entry carries a representative Lucide icon, the menu
|
|
* closes as soon as the focus leaves it (outside click, focusout, Escape,
|
|
* window blur, scroll, resize) and arrow keys walk through the entries.
|
|
* trackDismissable() exposes the same dismissal contract to the other
|
|
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
|
|
* own markup.
|
|
*/
|
|
import { safeCreateIcons } from '../utils.js';
|
|
|
|
let _menu = null;
|
|
let _openedAt = 0;
|
|
let _dismissBound = false;
|
|
|
|
export function closeContextMenu() {
|
|
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
|
|
// that holds the focus fires blur/focusout synchronously, which re-enters
|
|
// this very function; the second remove() on the mid-flight node throws
|
|
// NotFoundError and aborts the caller's click handler before its action
|
|
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
|
|
const menu = _menu;
|
|
_menu = null;
|
|
if (menu) menu.remove();
|
|
}
|
|
|
|
export function isContextMenuOpen() {
|
|
return _menu !== null;
|
|
}
|
|
|
|
function bindDismiss() {
|
|
if (_dismissBound) return;
|
|
_dismissBound = true;
|
|
document.addEventListener("click", (e) => {
|
|
if (!_menu) return;
|
|
// Ignore the very gesture that opened the menu (touch long-press can emit
|
|
// a click right after).
|
|
if (Date.now() - _openedAt < 250) return;
|
|
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
|
closeContextMenu();
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") closeContextMenu();
|
|
});
|
|
// #179 — never linger once the focus moves outside the menu (toolbar
|
|
// button, editor, another panel…). A null relatedTarget (click on a
|
|
// non-focusable cell) is already covered by the click-outside handler.
|
|
document.addEventListener("focusout", (e) => {
|
|
if (!_menu) return;
|
|
const next = e.relatedTarget;
|
|
if (next && _menu.contains(next)) return;
|
|
closeContextMenu();
|
|
});
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("blur", closeContextMenu);
|
|
// A scroll or resize detaches the viewport-anchored menu from its cell.
|
|
window.addEventListener("scroll", closeContextMenu, true);
|
|
window.addEventListener("resize", closeContextMenu);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walk through a menu's buttons with the keyboard (#179).
|
|
* @param {HTMLElement} menuEl - menu container
|
|
* @param {string} itemSelector - CSS selector of the navigable entries
|
|
*/
|
|
export function enableArrowNav(menuEl, itemSelector) {
|
|
menuEl.addEventListener("keydown", (e) => {
|
|
const items = [...menuEl.querySelectorAll(itemSelector)].filter((b) => !b.disabled);
|
|
if (!items.length) return;
|
|
const idx = items.indexOf(document.activeElement);
|
|
let next = -1;
|
|
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % items.length;
|
|
else if (e.key === "ArrowUp") next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
|
|
else if (e.key === "Home") next = 0;
|
|
else if (e.key === "End") next = items.length - 1;
|
|
else return;
|
|
e.preventDefault();
|
|
items[next].focus();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dismissal contract shared by the spreadsheet's other menus (#179).
|
|
* Closes on outside click (the toggle button owns its own clicks), Escape,
|
|
* focus leaving both the menu and its toggle, scroll and resize.
|
|
* @param {HTMLElement} menuEl - menu element to watch
|
|
* @param {object} opts
|
|
* @param {HTMLElement|null} opts.toggle - button opening the menu (exempt)
|
|
* @param {Function|null} opts.onClose - called once on dismissal (default: remove the element)
|
|
* @returns {Function} dismiss() — idempotent manual close (unbinds + onClose)
|
|
*/
|
|
export function trackDismissable(menuEl, { toggle = null, onClose = null } = {}) {
|
|
let done = false;
|
|
const unbind = () => {
|
|
document.removeEventListener("click", onDocClick);
|
|
document.removeEventListener("keydown", onKey);
|
|
document.removeEventListener("focusout", onFocusOut);
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", onScroll);
|
|
}
|
|
};
|
|
const dismiss = () => {
|
|
if (done) return;
|
|
done = true;
|
|
unbind();
|
|
if (onClose) onClose();
|
|
else menuEl.remove();
|
|
};
|
|
const onDocClick = (e) => {
|
|
if (done) return;
|
|
if (!menuEl.isConnected) { done = true; unbind(); return; }
|
|
const t = e.target;
|
|
if (menuEl.contains(t)) return;
|
|
if (toggle && (toggle === t || toggle.contains(t))) return;
|
|
dismiss();
|
|
};
|
|
const onKey = (e) => {
|
|
if (e.key === "Escape" && menuEl.isConnected) dismiss();
|
|
};
|
|
const onFocusOut = (e) => {
|
|
if (done || !menuEl.isConnected) return;
|
|
const next = e.relatedTarget;
|
|
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
|
dismiss();
|
|
};
|
|
const onScroll = () => {
|
|
if (menuEl.isConnected) dismiss();
|
|
};
|
|
document.addEventListener("click", onDocClick);
|
|
document.addEventListener("keydown", onKey);
|
|
document.addEventListener("focusout", onFocusOut);
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", onScroll);
|
|
}
|
|
return dismiss;
|
|
}
|
|
|
|
/**
|
|
* @param {number} x - viewport X of the pointer
|
|
* @param {number} y - viewport Y of the pointer
|
|
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
|
* @returns {HTMLElement} the menu element
|
|
*/
|
|
export function openContextMenu(x, y, items) {
|
|
bindDismiss();
|
|
closeContextMenu();
|
|
const menu = document.createElement("div");
|
|
menu.className = "xlsx-context-menu";
|
|
menu.setAttribute("role", "menu");
|
|
for (const item of items) {
|
|
if (item.separator) {
|
|
const sep = document.createElement("div");
|
|
sep.className = "xlsx-context-sep";
|
|
sep.setAttribute("aria-hidden", "true");
|
|
menu.appendChild(sep);
|
|
continue;
|
|
}
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
|
btn.setAttribute("role", "menuitem");
|
|
if (item.icon) {
|
|
const ic = document.createElement("i");
|
|
ic.setAttribute("data-lucide", item.icon);
|
|
ic.className = "xlsx-menu-icon";
|
|
ic.setAttribute("aria-hidden", "true");
|
|
btn.appendChild(ic);
|
|
}
|
|
const lab = document.createElement("span");
|
|
lab.className = "xlsx-menu-label";
|
|
lab.textContent = item.label;
|
|
btn.appendChild(lab);
|
|
btn.disabled = Boolean(item.disabled);
|
|
btn.addEventListener("click", () => {
|
|
closeContextMenu();
|
|
if (!item.disabled && item.onClick) item.onClick();
|
|
});
|
|
menu.appendChild(btn);
|
|
}
|
|
enableArrowNav(menu, ".xlsx-context-item");
|
|
document.body.appendChild(menu);
|
|
|
|
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
|
// which simply leaves the requested position).
|
|
const rect = menu.getBoundingClientRect();
|
|
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
|
|
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
|
|
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
|
|
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
|
|
menu.style.left = `${left}px`;
|
|
menu.style.top = `${top}px`;
|
|
|
|
_menu = menu;
|
|
_openedAt = Date.now();
|
|
// Keyboard users land on the first entry right away; mouse users are
|
|
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
|
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
|
|
if (first && typeof first.focus === "function") {
|
|
try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); }
|
|
}
|
|
if (typeof safeCreateIcons === "function") safeCreateIcons();
|
|
return menu;
|
|
}
|