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
+96
View File
@@ -0,0 +1,96 @@
# #186 — Création d'un fichier Excel depuis « Créer un fichier »
> **Statut :** ✅ livré le 2026-10-05 | **Effort :** 0,5 jour | **Impact :** 🟢
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [Guide Recherche, PDF, Excel & Excalidraw](../GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) ·
> [#153 — Visionneuse XLSX](./xlsx-viewer.md)
## 1. Constat
ObsiGate sait **lire et éditer** les classeurs depuis #152/#153, et l'assistant IA sait
en **produire** un (`create_xlsx`, #92). Mais la modale « Créer un fichier » — celle
du menu contextuel d'un vault / d'un dossier et de la palette de commandes —
ne proposait que des formats **texte** :
`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`, `.ps1` (+ Excalidraw).
Démarrer un classeur depuis l'UI imposait donc de passer par l'IA (avec
confirmation) ou de déposer un fichier existant dans le vault. Le point de départ
naturel manquait alors que l'éditeur complet était disponible.
## 2. Livré (A1-A3)
### A1 — Option Excel dans la liste des types
`frontend/js/ui.js`, `FileOperations.showCreateFileModal()` : ajout de
`<option value=".xlsx">` dans le `<select id="file-ext-select">`, après `.json`
(ordre : formats texte, puis formats enrichis, comme `.excalidraw`).
Au passage, les **dix libellés** du sélecteur passent en i18n
(`modal.ext_md`, `modal.ext_txt`, …, `modal.ext_xlsx`) : ils étaient des chaînes
françaises codées en dur, ce qui exposait un texte non traduit en mode EN et
violait la règle « tout texte d'interface existe en FR et en EN ». Les deux
locales sont alignées (`tests/test_i18n_parity.py`).
Aucun changement pour les autres formats : le nom saisi sans extension reçoit
celle choisie (`name.endsWith(ext) ? name : name + ext`), donc un nom déjà
suffiché n'est jamais doublé.
### A2 — Un vrai classeur côté backend
Le point non évident : `POST /api/file/{vault}` **écrit du texte**
(`file_path.write_text(content)`), et la modale poste `content: ""`. Un `.xlsx`
n'est pas un fichier texte mais une **archive OPC** (un ZIP) : écrire la charge
utile vide produisait un fichier de 0 octet que ni `openpyxl`, ni la visionneuse,
ni Excel n'ouvrent — l'utilisateur aurait vu une erreur à l'ouverture, ou pire,
un fichier corrompu.
`backend/services/mutations.py` :
- `_empty_xlsx_bytes()` construit l'archive avec openpyxl (une feuille `Feuille1`,
le titre par défaut déjà utilisé par l'outil IA `create_xlsx`) — import paresseux,
comme le reste du module qui garde openpyxl optionnel à l'import ;
- `create_file()` écrit ces octets (`write_bytes`) pour cette extension et ignore
`content`, qui n'a pas de sens pour un classeur ; `size` renvoie le nombre
d'octets réellement écrits (et non `len(content)`, qui aurait valu 0) ;
- même garde-fou que `.excalidraw` : le squelette est injecté par le **service**,
donc la palette, le glisser-déposer, l'IA et l'API en bénéficient, pas seulement
la modale.
`openpyxl` reste importé à la demande : un vault sans tableur ne paie pas l'import.
### A3 — Tests
- `tests/test_api_main.py::TestFileCRUD` — `test_create_xlsx_empty_workbook` : le
fichier créé est relu par `openpyxl` (`sheetnames == ["Feuille1"]`) **et** servi
par `GET /api/file/{vault}` avec `is_xlsx: true` (donc lisible par la visionneuse) ;
`test_create_xlsx_in_new_subdir` couvre la création dans un dossier inexistant
(auto-créé). Non-régression : sans A2, l'archive serait vide et les deux tests
échoueraient.
- `tests/e2e/xlsx-viewer.spec.js` — bloc « création depuis l'UI » : l'option
`.xlsx` existe et son libellé vient des locales, la création passe par le menu
contextuel d'un dossier, et le classeur s'ouvre dans la grille (`.xlsx-table` +
onglet `Feuille1`) ; l'API est interrogée pour confirmer `is_xlsx`. Le fichier est
supprimé en fin de test (le run ne doit pas salir `test_vault/`).
## 3. Limites assumées
- **Le nom de feuille est `Feuille1`**, en dur. Un vrai *nouveau classeur* de
Google Sheets le demande ; ici le classeur s'ouvre sur sa seule feuille et
l'utilisateur la renomme via le menu Structure. La modale reste celle d'un
éditeur de texte, volontairement simple.
- **`.csv` reste absent de la liste**, alors que le backend l'accepte
(`SUPPORTED_EXTENSIONS`) et que la visionneuse sait l'éditer comme tableur
(#156-A14). L'écart est réel mais hors périmètre de #186 ; le même correctif
s'appliquerait (une ligne dans le `<select>`).
- **`frontend/app.js` (monolithe obsolète, non chargé par `index.html`)**
duplique l'ancienne liste sans `.xlsx`. Le code vivant est dans `frontend/js/`;
ce fichier n'a pas été touché.
- L'outil IA `create_xlsx` reste le chemin pour un classeur **rempli** : la modale
crée une grille vierge, comme un `.md` vierge.
## 4. Historique
| Date | Événement |
|---|---|
| 2026-10-05 | #186 ouvert puis livré dans la foulée : option `.xlsx` + squelette openpyxl dans `create_file()` |
+119
View File
@@ -0,0 +1,119 @@
# #185 — Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#179](./xlsx-menus-179.md) ·
> [#180](./xlsx-sheetbar-180.md) · [#183](./xlsx-sheet-input-181.md))
## 1. Constat
L'éditeur tableur avait un ruban de boutons et trois menus (Structure, Mise en
forme, Export) mais **aucune barre de menus** : ni Fichier, ni Modifier, ni
Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni Gemini, ni
Extensions, ni Aide. Beaucoup de fonctions existentes n'étaient atteignables que
par un clic droit ou par un bouton à connaître. Le ruban lui-même gardait un
chromer bordersé qui ne ressemblait à rien, et la grille n'était bordée qu'en
bas avec une couleur (`--border`) quasi identique au fond des cellules : le
quadillage se lisait comme une tache, pas comme une grille.
## 2. Livré
### A1 — Barre de menus (les 10 menus de Google Sheets)
`frontend/js/xlsx/menus.js` (nouveau) décrit **les dix menus, dans l'ordre et avec
les libellés exacts de Google Sheets** (Fichier · Modifier · Affichage ·
Insertion · Format · Données · Outils · Gemini · Extensions · Aide), puis
`buildMenuBar()` produit la barre et ses dix menus déroulants. 161 entrées, dont
**125 wirables** (67 actions distinctes) et **36 honestement déclarées
indisponibles** (elles s'affichent mais répondent « *« X » n'est pas disponible
dans ObsiGate* »** — jamais un clic muet). Les sections imbriquées (Nombre,
Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons** comme dans Sheets :
une seule ouverte à la fois par niveau.
Le menu **Fichier** a quitté la barre d'onglets pour la barre de menus, en
conservant ses identifiants (`#xlsx-file-btn`, `#xlsx-file-menu`,
`#xlsx-download-btn`, `#xlsx-csv-btn`, `[data-xlsx-export]`). Seul
**Enregistrer** reste épinglé à droite de la barre d'onglets.
### A2 — Fonctions réellement câblées
- **Fichier** : Partager, Télécharger (original · CSV · Markdown · HTML),
Imprimer, **Renommer** (`PATCH /api/file/{vault}`), **Corbeille**
(`DELETE /api/file/{vault}`), **Historique des versions** (visionneuse de
sauvegardes), Détails (vault, chemin, feuilles, cellules, révision, ·
modifications en attente via `/api/health` pour le numéro de version).
- **Modifier** : Annuler, Rétablir, Couper, Copier, Coller, **Collage spécial →
valeurs uniquement** (les formules sont évaluées puis collées comme texte),
**Supprimer → lignes / colonnes / contenu**, Tout sélectionner,
**Rechercher et remplacer** (toutes les feuilles).
- **Affichage** : Figer (ici / ligne / colonne / libérer), **Afficher → barre de
formule · grille · en-têtes · tableau de bord**, Grouper (déclaré), Feuilles
masquées (déclaré), **Zoom** (50 → 200 %, même état que le ruban), **Plein
écran**.
- **Insertion** : Lignes (au-dessus / en dessous), Colonnes (à gauche / à
droite), Cellules, Lien, Commentaire, **Fonction** (insère l'appel et place le
curseur entre les parenthèses).
- **Format** : Nombre (6 formats), **Thème** (liste des 15 thèmes construite à la
demande), Gras / Italique / Souligné / Barré, Taille de police, Alignement
(horizontal + vertical), Retour à la ligne, Rotation, **Couleurs alternées**
(une écriture par ligne, lots de 50 — plafond du backend), Effacer la mise en
forme.
- **Données** : Trier la plage (croissant / décroissant / réinitialiser),
**Créer un filtre** (sur la valeur de la cellule active), **Plages nommées**
(panneau du tableau de bord), **Nettoyage → supprimer les doublons ·trimmer**,
**Diviser le texte en colonnes**.
- **Outils** : **Saisie semi-automatique** (bascule la liste de fonctions de la
barre de formule), **Accessibilité** (cellules plus grandes, grille plus
contrastée).
- **Gemini** : les six entrées ouvrent le panneau de l'assistant IA.
- **Aide** : aide intégrée, **Mises à jour** (version), **Raccourcis clavier**
(liste), **Signaler un problème** (diagnostic copié dans le presse-papiers).
### A3 — Ruban façon Google Sheets
Boutons **plats** : 30 × 30, sans bordure ni fond au repos, survol discret, état
actif teinté, groupes séparés par des filets verticaux. Les sélecteurs (zoom,
format de nombre, taille de police) suivent le même traitement. **Enregistrer**
reste le seul pilier plein. Nouveau bouton **grille** (`#xs-grid`), piloté par le
menu Affichage et inversement.
### A4 — Grille unie et visible
- Chaque cellule est encadrée sur **les quatre côtés** par **la même** ligne
1 px (les en-têtes compris : ils se distinguent par leur fond, plus par une
bordure plus lourde).
- `--grid-border` n'est plus `--border` (1-2 % d'écart avec le fond sur la plupart
des thèmes) mais un `color-mix` **du texte vers la surface** calculé par
`themes.js` : une seule valeur pour toute la grille, lisible en sombre comme
en clair et dans les modes générés (contraste élevé, sépia).
- Les valeurs de repli des 4 blocs `:root` ont été remontées en contraste.
## 3. Tests
- `tests/frontend/xlsx-menus.test.mjs` : i18n amorcé, barre de menus (10 libellés,
un déroulant par bouton, accordéon), ** exhaustivité des actions déclarées**,
disparition des entrées d'édition en lecture seule, absence de HTML dans les
libellés, ids historiques du menu Fichier ; ruban (save, « + », grille).
- `tests/frontend/xlsx-viewer.test.mjs` : barre présente au-dessus du ruban,
entrée indisponible qui répond (et n'envoie rien), bascules Affichage, Zoom,
écriture de structure (Insertion), écriture de style (Format ▸ Nombre),
**Couleurs alternées** (3 lignes → 3 ops), Accessibilité, Raccourcis clavier,
classeur en lecture seule, jeton `--grid-border`.
- `tests/e2e/xlsx-viewer.spec.js` : les 10 menus dans le bon ordre et **au-dessus**
du ruban, accordéon Format ▸ Nombre, option indisponible, **grille 1 px sur
les 4 côtés** puis masquée à 0 px.
## 4. Limites assumées
- Les 36 options Google Sheets qu'ObsiGate n'implémente pas (macros, Apps
Script, TCD, graphiques, formulaires,/modules, AppSheet, Looker Studio,
training…) sont **listées pour rester fidèle** à la demande, et **annoncées**
comme indisponibles au clic. Elles ne sont pas masquées : la parité visuelle
prime, l'honnêteté prime sur le silence.
- **Déplacer** lignes/colonnes est déclaré indisponible : leivinguer
(supprimer + insérer + réécrire) sur un classeur avec fusions, styles et
formules n'est pas sûr en un seul geste.
- Le menu **Thème** est construit à la demande (import dynamique de
`themes.js`) : 15 entrées, pas de coût au rendu.
- Les couleurs de la grille sont dérivées via `color-mix` : un moteur qui ne le
supporte pas retombe sur `--grid-border` (feuille de style).
+356
View File
@@ -0,0 +1,356 @@
# #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](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
> [#179](./xlsx-menus-179.md) · [#180](./xlsx-sheetbar-180.md)
## 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** :
```js
// ✘ 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.
+107
View File
@@ -0,0 +1,107 @@
# #180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
> [#179](./xlsx-menus-179.md) · [#156](./xlsx-editor-completeness.md)
## 1. Constat
Retours utilisateur après #179, quatre défauts d'ergonomie et un défaut de thème :
1. **Le curseur de redimensionnement n'apparaît pas.** Le redimensionnement
par les marges existe depuis #156-A8 (`wireResize`), mais l'indicateur est
invisible : `.xlsx-table td[contenteditable]` impose `cursor: cell` et les
en-têtes reçoivent `cursor: pointer` **en ligne** (`paintHeaders()`). Les
deux beat la règle `table.style.cursor` posée par le `mousemove`, y compris
pendant le glissement. L'utilisateur n'a aucun retour sur la zone cliquable.
2. **La grille ne suit pas le thème.** `--grid-*` n'est déclaré que pour le
thème par défaut (dark + light) dans `style.css`. Les 13 autres thèmes et
les modes générés *contraste élevé* / *sépia* n'en fournissent pas : la
grille y reste en couleurs GitHub. `themes.js` applique pourtant bien les
variables via `setProperty`, sans les jetons de grille.
3. **Les sorties de fichier sont dispersées.** Télécharger, CSV et Exporter
occupent trois commandes du ruban, à gauche du bouton Enregistrer.
4. **La barre n'est pas épinglée** et l'éditeur ne prend pas toute la largeur
du contenu quand la sidebar est masquée (contrairement aux viewers
PDF / images / Excalidraw, cf. la règle `:has(...)` de `.content-area`).
5. La pastille « Formules calculées localement » occupe une ligne entière
pour annoncer un comportement normal, pas une limite.
## 2. Livré (A1-A4)
### A1 — Curseur de redimensionnement sur les marges
`wireResize()` (viewer.js) remplace le `table.style.cursor = …` isolé par
`paintResizeCursor(kind, th)` :
- la table reçoit `.xlsx-resize-col` / `.xlsx-resize-row`, repint par le CSS
sur la table **et** ses cellules (surplus des deux curseurs concurrents) ;
- l'en-tête de bord est **miroité en ligne** (`th.style.cursor`), seul moyen
de passer devant le `cursor: pointer` inline — l'ancienne valeur est
mémorisée puis restaurée quand le pointeur quitte le bord (`mouseleave`) ;
- l'état est remis à zéro au `mouseup` global : le curseur ne reste pas
« coincé » après un glissement.
### A2 — Grille compatible tous les thèmes
- `themes.js` : `applyTheme()` dérive les jetons `--grid-bg`,
`--grid-header-bg`, `--grid-header-text`, `--grid-border` et `--grid-zebra`
des surfaces du thème actif. Le zébré passe par `color-mix(… 4%, transparent)`
pour rester subtil sur fond clair **et** sombre. Un thème importé sans ces
jetons est donc couvert automatiquement.
- `style.css` : jetons de repli (`var(--grid-bg, var(--surface))`, …) pour que
la grille reste lisible même sans `applyTheme()`, plus les deux modes
*contraste élevé* et *sépia* déclarés explicitement.
- Le fond du menu Export passe de `var(--bg, #fff)` (repli clair cassé en
thème sombre) à `var(--surface)`.
### A3 — Menu Fichier + Enregistrer au niveau des onglets
- `buildCommandBar()` renvoie un fragment `fileHtml` supplémentaire : menu
**Fichier** (`download`, `file-spreadsheet`, `file-text`, `code`,
`printer`) + bouton **Enregistrer** primary. Les trois commandes de sortie
sortent du ruban, qui ne garde que l'édition.
- La coquille (`viewer.js`) rend une barre de feuilles `.xlsx-sheetbar` :
onglets à gauche, `.xlsx-sheetbar-end` (menu + Enregistrer) à droite.
- Câblage : `trackDismissable()` (même contrat que #179), navigation clavier,
fermeture au clic sur Télécharger / CSV, `aria-haspopup` / `aria-expanded`.
- **Identifiants stables** : `#xlsx-download-btn`, `#xlsx-csv-btn`,
`#xlsx-save-btn` conservés — seuls le conteneur (`#xlsx-file-btn` /
`#xlsx-file-menu`) et la classe (`.xlsx-file-menu`) sont nouveaux.
### A4 — Retrait de la pastille + barre épinglée pleine largeur
- La pastille « Formules calculées localement » est retirée ; la barre de
statut ne subsiste que pour la pastille *lecture seule* (format `.xls` /
`.ods`), et disparaît entièrement quand elle n'a rien à afficher.
- `.xlsx-toolbar` devient `position: sticky; top: 0` avec des marges négatives
qui neutralizing le padding de `.content-area` : la barre se colle au bord
haut de la page et l'éditeur occupe la largeur totale du contenu.
- `.content-area:has(.xlsx-viewer)` rejoint les viewers plein cadre.
## 3. Tests
- `tests/frontend/xlsx-viewer.test.mjs` — 136/136 :
- barre de feuilles (onglets / menu / Enregistrer à droite, onglets sortis
du ruban), contenu exact du menu Fichier (5 icônes dans l'ordre), toggle ;
- curseur `col-resize` / `row-resize` sur les deux types de bord, classes de
repaint, restauration du curseur d'en-tête à la sortie ;
- jetons `--grid-*` pour 4 thèmes (Spotify dark, Notion light, GitHub
contrasté élevé, GitHub sépia) + zébré `color-mix` ;
- absence de pastille formules (xlsx **et** csv).
- `tests/frontend/xlsx-menus.test.mjs` — 12/12 : icônes de `fileHtml`.
- `tests/e2e/xlsx-viewer.spec.js` : le menu Fichier s'ouvre, expose les 5
entrées, se ferme au clic extérieur ; Enregistrer présent dans
`.xlsx-sheetbar-end`.
- 8 tests préexistants réalignés sur la sémantique « écriture verrouillée
puis application locale » (le viewer n'est plus re-lu après une écriture
structure / mise en forme) : le second appel PUT n'a plus lieu, les
assertions portent sur la grille peinte en place.
## 4. Points d'attention
- La copie des cellules vers le presse-papiers système et l'export sont
inchangés : seul le **point d'entrée** des sorties de fichier a bougé.
- `viewer.js` reste le propriétaire du câblage ; `command-bar.js` reste un
constructeur de chaînes pur, donc testable sans DOM.