feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m33s
CI / test (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s

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.
This commit is contained in:
2026-10-08 12:00:15 -04:00
parent ce1944d437
commit 634ba8a272
39 changed files with 9996 additions and 491 deletions
+50 -17
View File
@@ -14,14 +14,15 @@ 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() {
// 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).
// 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();
@@ -36,9 +37,11 @@ function bindDismiss() {
_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;
// 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();
});
@@ -56,8 +59,12 @@ function bindDismiss() {
});
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);
// 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);
}
}
@@ -111,25 +118,50 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
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) return;
if (!menuEl.isConnected) { done = true; unbind(); return; }
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 (e.key === "Escape" && menuEl.isConnected) dismiss();
if (dropIfDetached()) return;
if (e.key === "Escape") dismiss();
};
const onFocusOut = (e) => {
if (done || !menuEl.isConnected) return;
if (done || dropIfDetached()) return;
const next = e.relatedTarget;
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
dismiss();
};
const onScroll = () => {
if (menuEl.isConnected) 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);
@@ -147,7 +179,7 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
* @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) {
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
bindDismiss();
closeContextMenu();
const menu = document.createElement("div");
@@ -198,6 +230,7 @@ export function openContextMenu(x, y, items) {
_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)");