Files
ObsiGate/docs/features/xlsx-sheet-input-181.md
T
bruno 634ba8a272
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
feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
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.
2026-10-08 12:00:15 -04:00

17 KiB
Raw Blame History

#181 — Éditeur tableur : modèle de saisie Google Sheets & inventaire des écarts

Statut : ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | Impact : 🟡 Références : Roadmap · Changelog · #154 · #155 · #179 · #180

1. Constat

L'éditeur ne distinguait pas sélectionner une cellule et l'éditer :

  • le curseur de texte clignotait en permanence dans la cellule focalisée (tout est contenteditable, caret-color par défaut) ;
  • Entrée validait puis faisait blur() — le focus quittait la grille ;
  • l'indicateur de plage était un simple lavis, sans contour, et les en-têtes de la plage sélectionnée n'étaient pas reflétés ;
  • la première frappe ajoutait au contenu existant au lieu de le remplacer.

Un utilisateur habitué à Google Sheets ou Excel perd le repère : il ne sait plus s'il est en train de naviguer ou de saisir.

2. Livré (A1-A3)

A1 — Deux états distincts : sélection et édition

Toutes les cellules portent la classe xlsx-cell et démarrent en sélection (data-editing="") : contour visible, aucun curseur. Le curseur est masqué via caret-color: transparent — donc sans laisser contenteditable, ce qui préserve le collage, le clic et la frappe native.

L'édition s'atteint par trois voies, comme dans Google Sheets :

Geste Effet
Clic / flèches sélectionne (pas de curseur)
Double-clic édite sur place — caretRangeFromPoint place le curseur sous le pointeur
Première frappe édite et remplace le contenu (jamais d'ajout)
F2 édite, curseur en fin de texte
Alt+flèche saut de ligne dans la cellule
Échap quitte l'édition et restaure la valeur

enterEditing() / exitEditing() centralisent la bascule. Un garde editingRequested empêche le handler focus de la cellule d'annuler l'état d'édition que enterEditing() vient d'installer (il se déclenche au moment du focus).

A2 — Sélection multiple et indicateur visuel

  • Maj+flèches étend depuis l'ancre, glisser avec la souris aussi, plus Maj+clic — le mécanisme existait déjà, il est désormais complet et lisible.
  • Les cellules portent .xlsx-selected et quatre classes de bord (.xlsx-sel-top/bottom/left/right) : le bloc est contouré exactement comme dans Excel/Sheets, au lieu d'un lavis uniforme. Chaque cellule ne reçoit que les bords qu'elle porte réellement (les coins n'héritent pas d'un bord intérieur).
  • Les en-têtes couverts (lettres A, B… et numéros 1, 2…) reçoivent .xlsx-hdr-selected : la plage se lit aussi dans les marges.

A3 — Entrée contextuelle

État Entrée
édition valide, puis le focus passe à la cellule du dessous
sélection bascule la cellule courante en mode édition

Tab suit la même logique (validation + déplacement latéral en édition, déplacement simple en sélection), et un clic simple après une édition abandonne l'état d'édition sans laisser de curseur.

3. A4 — Inventaire des écarts vs Google Sheets / Excel

Rapprochement exhaustif impossible en une tranche. L'éditeur couvre déjà l'essentiel ; l'inventaire ci-dessous est l'état réel vérifié dans le code au 2026-10-05.

3.1 Déjà couvert

Édition de cellules · types et formats · recherche et remplacement · trier / filtrer · gel des volets · fusions · annuler / rétablir · presse-papiers de plage · insertion / suppression de lignes, colonnes et feuilles · graphiques KPI du tableau de bord · commentaires · liens hypertexte (formule =LIEN) · export (CSV, Markdown, HTML, impression) · impression · collaboration temps réel · import de formats · lecture seule .xls / .ods.

3.2 Manquants, par priorité

P1 — usage quotidien

  • Poignée de recopie (fill handle) : le petit carré en bas à droite de la plage, glisser pour remplir séries et formules. Très attendu, absent.
  • Remplacer (Ctrl+H) : seule la recherche existe.
  • Validation de données : listes déroulantes, bornes numériques.
  • Filtre automatique : l filtrage texte existe, pas les entêtes de filtre par colonne.
  • Tri multi-critères et options de tri (couleur, taille, valeurs personnalisées).
  • Déplacer lignes / colonnes par glisser-déposer (l'insertion existe).

P2 — présentation et analyse

  • Mise en forme conditionnelle (couleurs, barres de données, jeu d'icônes).
  • Graphiques dans le tableur : KPI et relevé de graphiques existants sont lus, mais on ne crée pas de graphique.
  • Tableaux croisés : lus et affichés, non modifiables.
  • Groupement / dégroupement de lignes et colonnes.
  • Images et formes dans les cellules (ObsiGate a Excalidraw séparé).
  • Protection d'une feuille ou de cellules (lecture seule).
  • Masquer des lignes / colonnes.

P3 — avancé

  • Analyse de données : supprimer les doublons, convertir du texte en colonnes, supprimer les espaces superflus.
  • Fusion des cellules à l'identique, annotations, suivi des modifications, historique des révisions.
  • Macros (les .xlsm sont lus et réécrits avec keep_vba).
  • Simulation de scénarios, recherche de cible (goal seek).
  • Options de tri avancées (insensible à la casse, séparateur décimal personnalisé).
  • Peintures de cellules, texte en art.

4. Tests

tests/frontend/xlsx-viewer.test.mjs — 146/146 (10 nouveaux cas #181) : sélection sans curseur au clic et aux flèches · double-clic qui édite en place · première frappe qui remplace · deuxième frappe qui n'efface pas · Entrée qui passe en édition · Entrée qui valide et descend sous la cellule · extension Maj+flèches avec les quatre bords du contour et les en-têtes reflétés · glisser qui peint le même contour · clic simple qui fait tomber l'état d'édition · Échap qui restaure la valeur.

Suites frontend également vertes : xlsx-menus 12/12, unit 13, editor-inline 44, toolbar-order, mobile-editor 35, ai 100, pane-manager 9, validate-imports 41 modules / 355 exports.

5. Point d'attention

Le caret-color: transparent repose sur la prise en charge navigateur (caret-color est largement disponible). Sur un très ancien moteur, le curseur resterait visible en sélection — l'état reste correct, seul l'indicateur disparaît.

6. #182 — Désélection & couleurs sur une plage

6.1 La sélection « collée »

Symptôme : après un Maj+clic ou un glissement, la plage restait affichée et un clic simple ne la supprimait pas.

Cause racine — la désélection reposait entièrement sur l'événement focus de la cellule, qui appelle setActiveCell() et donc réduit la plage à la cellule focalisée. Or le handler Maj+clic appelle e.preventDefault(), ce qui supprime le déplacement de focus natif du navigateur. Résultat : après un Maj+clic, activeTd valait la cellule cliquée mais document.activeElement restait sur l'ancienne. Le clic suivant sur la cellule déjà focalisée ne déclenchait donc aucun focus : la plage ne pouvait plus s'effacer.

Correctif

  • le clic simple appelle setActiveCell(td) dans mousedown : la plage est effacée de façon déterministe, sans dépendre du focus ;
  • le Maj+clic déplace réellement le focus, sous suppressSelectionReset pour que le handler focus ne rétrécisse pas la plage qui vient d'être étendue ;
  • le glissement ne vole pas le focus (comme dans un navigateur) — c'est précisément ce vol qui aurait fait disparaître la plage tracée ;
  • endDrag restaure la zone Nom sur la plage (A1:B2) au lieu de la cellule d'origine.

Le même correctif a guéri le symptôme connexe « la mini-barre de sélection ne se cachait plus après un glissement ».

6.2 Couleurs de texte et de fond

Les trois chemins ont été vérifiés (charge utile PUT …/xlsx/style puis peinture locale) sur une plage A1:B2 obtenue par Maj+flèches et par glissement :

Chemin Charge utile Résultat
Menu Mise en forme → couleur range: "A1:B2" 4 cellules peintes
Nuancier du ruban (police) range: "A1:B2" 4 cellules peintes
Nuancier du ruban (fond) range: "A1:B2" 4 cellules peintes

Le mécanisme d'écriture était donc correct ; ce qui manquait réellement, c'était le retour visuel : les deux nuanciers du ruban n'affichaient rien, d'où l'impression que la couleur n'était pas appliquée. Ils portent désormais une pastille (.xlsx-color-swatch) qui prend la couleur choisie.

6.3 Tests

tests/frontend/xlsx-viewer.test.mjs — 149/149 (+3 cas #182) :

  • un clic simple réduit toujours une plage « collée » (y compris sur la cellule qui porte déjà le focus) ;
  • la zone Nom affiche la plage après un glissement ;
  • couleurs de texte et de fond appliquées aux 4 cellules d'une plage, charge utile vérifiée, pastilles synchronisées.

Note d'implémentation : la peinture locale est asynchrone (elle suit l'écriture PUT), un test qui lit les styles inline doit donc attendre le tour de boucle d'événements.

6.4 Les contours qui s'empilaient (le point manquant)

Symptôme : en déplaçant le focus de cellule en cellule, les encadrés de l'ancienne cellule restaient et s'accumulaient — la grille finit quadrillée.

Cause racine — le sélecteur de nettoyage était écrit sans le point :

// ✘ avant : en CSS, un nom nu est un sélecteur de BALISE (<xlsx-sel-top>)
panel.querySelectorAll("xlsx-sel-top,xlsx-sel-bottom,...")
// ✔ après
panel.querySelectorAll(".xlsx-sel-top,.xlsx-sel-bottom,...")

La requête ne renvoyait aucun élément, donc classList.remove(...) n'était jamais appelé et aucun contour n'était effacé. La même famille de faute touchait classList.remove(".xlsx-hdr-selected") (un point passé comme jeton de classe ne correspond à rien) : les en-têtes reflétés n'étaient jamais effacés non plus.

Le test de non-régression compte les contours dans toute la grille après une randonnée au clavier et exige qu'une seule cellule soit encadrée.

6.5 Flèches pendant la saisie

Dans Google Sheets comme dans Excel, une flèche pendant la saisie valide la cellule et déplace la sélection ; le curseur n'est pas parcouru dans le texte (Alt+flèche sert à insérer un saut de ligne). Notre implémentation laissait le curseur se déplacer et la cellule restait figée sous l'encadré — ce qui amplifiait la confusion. Corrigé.

7. #183 — Panneau de couleurs façon Google Sheets

7.1 Ce qui remplace quoi

Le <input type="color"> natif (ruban A / seau, et les deux lignes du menu Mise en forme) est remplacé par un bouton ouvrant un panneau construit par frontend/js/xlsx/color-picker.js :

Section Contenu
En-tête icône + Réinitialiser (gomme pour le fond, pinceau pour le texte)
Palette 8 × 10 = 80 pastilles — ligne 1 : 8 gris du noir au blanc ; lignes 2-10 : 8 teintes (rouge, orange, jaune, vert clair, cyan, bleu, violet, magenta) × 9 nuances, du clair au foncé
STANDARD 8 couleurs prédéfinies + crayon
PERSONNALISÉ pastille de la couleur personnalisée, bouton « + », crayon
Pied Mise en forme conditionnelle, et Couleurs en alternance (n'apparaît qu'une fois une couleur choisie, comme sur la maquette)

Un clic sur une pastille referme le panneau et applique la couleur à la cellule ou à la plage sélectionnée. La coche de sélection est blanche ou noire selon la luminance de la pastille (luminanceOf).

7.2 Le piège d'encodage

La palette est calculée en HSL (lisible) mais doit sortir en #rrggbb : normHex() côté viewer n'accepte que l'hexadécimal à 6 chiffres, et une valeur hsl(0, 0%, 1429%) était donc rejetée sans erreur — la pastille se peignait à l'écran mais la couleur n'était jamais écrite sur la cellule. hslToHex() est doncexporté et testé par le contrôle de fumée.

7.3 Choix assumés

  • « + » et les crayons ouvrent le sélecteur natif : le panneau propose 88 couleurs curées, le natif reste le seul moyen d'atteindre une teinte arbitraire.
  • Les deux entrées de pied de page sont annoncées, pas fonctionnelles : « Mise en forme conditionnelle » et « Couleurs en alternance » ne sont pas livrées (cf. §3.2). Elles appellent onUnavailable et affichent un message explicite plutôt que d'être des boutons inertes.
  • Chrome thémé : uniquement des variables CSS, donc le panneau suit le thème actif ; seules les pastilles portent une couleur en ligne, ce sont des données.

8. #184 — Peinture de format, multi-lignes/colonnes, grille A → Z × 1000

8.1 Peindre le format

Symptôme : l'outil refusait toute cellule source sans mise en forme explicite (« Veuillez sélectionner une cellule formatée »). Peindre une cellule simple sur une cellule colorée et en grand corps ne produisait donc rien — c'est le cas le plus courant en pratique.

Deux défauts distincts

  1. Copie partielle : seuls gras/italique/souligné/barré, couleurs, taille, alignement et format de nombre étaient capturés. valign, retour à la ligne, rotation et bordures ne l'étaient pas — une cellule bordée perdait ses bordures en « peignant » la même mise en forme ailleurs.
  2. Source « neutre » rejetée : sans propriété capturée, l'arme était annulée. Dans Sheets/Excel, une cellule sans mise en forme est une source valide : elle réinitialise la cible.

Correctif — captureFormat() renvoie toujours un jeu complet de propriétés (y compris border: "none", font_color: "", fill_color: "", number_format: "General"), donc la cible finit toujours identique à la source, et une source neutre vide la cible.

Fuite d'écouteur : Échap ou un second clic sur le bouton désarmaient l'arme mais laissaient le gestionnaire « appliquer au prochain clic » en place — le clic suivant dans la grille repeignait une vieille mise en forme. Le gestionnaire est maintenant retiré par stopPaint().

8.2 Sélection de plusieurs lignes / colonnes

Cliquer un numéro de ligne sélectionnait déjà la ligne entière ; il manquait l'extension à plusieurs lignes. Le mécanisme :

  • mousedown sur un en-tête sélectionne la ligne / colonne entière ;
  • glisser au-dessus d'autres en-tête étend (A1:Z5), Maj+clic aussi (A1:Z5) — l'ancre est mémorisée après le glisser ;
  • la sélection réutilise le modèle {anchor, focus} d'une plage, donc copier, effacer, insérer/supprimer, le menu contextuel et la suppression de contenu fonctionnent sans code supplémentaire ;
  • le click sur en-tête a été retiré : il écrasait la multi-sélection juste après le glisser ;
  • une pression près du bord de l'en-tête reste le geste de redimensionnement (partage explicite avec wireResize).

8.3 Grille A → Z, et 1000 lignes à la demande

La grille de la feuille affichée est complétée jusqu'à 26 colonnes (A → Z) d'emblée ; les lignes s'ajoutent par blocs de 100 quand le lecteur approche du bas, jusqu'à 1000. Les cellules vides sont de vraies cellules éditables (contentEditable, tabIndex, écouteurs, data-cell). L'extension a lieu à l'activation d'un onglet, pas au montage : la faire pour toutes les feuilles multiplierait les cellules par le nombre d'onglets.

Une feuille tronquée garde son chargement serveur (« Charger la suite », fenêtre par fenêtre) : ses lignes manquantes sont des données, pas du remplissage. Étendre la grille par-dessus aurait dupliqué les lignes et supprimait le pied de page — régression de #153-A9bis, corrigée en gardant les deux mécanismes séparés (les colonnes et les blocs vides pour une feuille complète, la fenêtre serveur pour une feuille tronquée).

Pourquoi pas les 1000 lignes d'un coup. Matérialiser A → Z × 1000 revient à ~26 000 cellules câblées pour la seule feuille visible. Mesuré : ~0,3 Go retenus par rendu, non récupérables même après innerHTML = "" et GC forcé — la suite JSDOM xlsx-viewer (163 cas) épuisait le tas vers le 45ᵉ montage. La croissance par blocs offre la même étendue sans payer le coût d'un coup.

Trois goulots levés — toujours utiles sur la grille atteinte :

Goulot Avant Après
findTd un querySelector par appel → 26 000 requêtes pour une colonne index ref → td avec auto-réparation
refreshPanelFormulas cloneNode sur chaque cellule, à chaque frappe court-circuit sur cellule vide sans formule
applySheetMeta toutes les cellules touchées court-circuit sur celles que le classeur ne style pas

Sorties. L'export (CSV / Markdown / HTML / impression) écarte les lignes et colonnes vides de queue : sans cela un classeur de deux colonnes exportait les 26 colonnes du quadrillage.

Reste à faire. La virtualisation par fenêtre (ne garder que les lignes visibles et recycler au défilement) remplacerait la croissance par blocs si la grille de 1000 lignes redevenait lourde dans le navigateur.