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.
This commit is contained in:
@@ -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()` |
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user