Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
243 lines
9.4 KiB
JavaScript
243 lines
9.4 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;
|
|
// Only a touch long-press can emit a click right after the menu opened.
|
|
let _openedByTouch = false;
|
|
let _dismissBound = false;
|
|
|
|
export function closeContextMenu() {
|
|
// Drop the reference BEFORE touching the DOM: removing a menu that holds
|
|
// the focus fires blur/focusout synchronously, which re-enters this very
|
|
// function — a second remove() on the mid-flight node throws NotFoundError
|
|
// and aborts the caller's click handler before its action runs.
|
|
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 — but only for a touch
|
|
// long-press, which is the one that emits a trailing click. A mouse
|
|
// right-click does not, so a deliberate outside click made right after
|
|
// must close the menu instead of being swallowed.
|
|
if (_openedByTouch && 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 detaches the viewport-anchored menu from its cell — except the
|
|
// tail of the smooth scroll that revealed the right-clicked cell (global
|
|
// `scroll-behavior: smooth` keeps firing after the menu opened).
|
|
window.addEventListener("scroll", () => {
|
|
if (Date.now() - _openedAt > 400) 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();
|
|
};
|
|
// A re-render can detach the menu without dismissing it: unbind silently
|
|
// instead of leaking listeners on a dead node.
|
|
const dropIfDetached = () => {
|
|
if (!menuEl.isConnected) {
|
|
if (!done) { done = true; unbind(); }
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
const onDocClick = (e) => {
|
|
if (done || dropIfDetached()) return;
|
|
const t = e.target;
|
|
if (menuEl.contains(t)) return;
|
|
if (toggle && (toggle === t || toggle.contains(t))) return;
|
|
dismiss();
|
|
};
|
|
const onKey = (e) => {
|
|
if (dropIfDetached()) return;
|
|
if (e.key === "Escape") dismiss();
|
|
};
|
|
const onFocusOut = (e) => {
|
|
if (done || dropIfDetached()) return;
|
|
const next = e.relatedTarget;
|
|
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
|
dismiss();
|
|
};
|
|
// Toolbar-anchored menus travel WITH their button: a scroll inside the
|
|
// hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's
|
|
// own overflow) must not dismiss them — only an outside scroll detaches.
|
|
// Same grace as the grid menu for the smooth-scroll tail of the opening.
|
|
// A body-level menu has no travelling anchor: every scroll detaches it.
|
|
const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar"))
|
|
|| menuEl.parentElement
|
|
|| null;
|
|
const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body
|
|
? rawAnchor
|
|
: null;
|
|
const openedAt = Date.now();
|
|
const onScroll = (e) => {
|
|
if (dropIfDetached()) return;
|
|
if (Date.now() - openedAt < 400) return;
|
|
const t = e && e.target;
|
|
if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return;
|
|
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, { byTouch = false } = {}) {
|
|
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();
|
|
_openedByTouch = byTouch;
|
|
// 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;
|
|
}
|