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
+297 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.53.3**.
> [Unreleased](#unreleased). La dernière version livrée est **2.54.0**.
---
@@ -14,6 +14,302 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.54.0] — 2026-10-08
### Ajouté
- **#186 - Création d'un fichier Excel depuis « Créer un fichier »**
- **Excel (`.xlsx`) fait partie des types proposés** dans la modale « Créer un
fichier » (menu contextuel d'un vault ou d'un dossier, palette de commandes) :
le classeur s'ouvre directement dans la visionneuse, prêt à être saisi. Les 10
libellés du sélecteur passent en i18n FR/EN (`modal.ext_*`) — ils étaient
codés en dur en français.
- **Un vrai classeur, pas un fichier vide.** Un `.xlsx` est une archive OPC, pas
du texte : `create_file()` construit désormais l'archive avec openpyxl (une
feuille `Feuille1`) au lieu d'écrire la charge utile vide de la modale, qui
aurait produit un fichier que ni la visionneuse ni Excel n'ouvrent. Le champ
`content` est ignoré pour cette extension.
- **#183 — Panneau de sélection de couleurs façon Google Sheets (texte et fond)**
- Le ruban et le menu **Mise en forme** n'utilisent plus le sélecteur natif :
ils ouvrent un **panneau** conforme à la description fournie —
*Réinitialiser* en en-tête, palette **8 × 10** (ligne 1 = 8 nuances de gris
du noir au blanc, lignes 2 à 10 = 8 teintes × 9 nuances du clair au foncé),
section **STANDARD** (8 couleurs prédéfinies) avec crayon, section
**PERSONNALISÉ** (pastille + bouton « + » + crayon), pied de page
**Mise en forme conditionnelle** / **Couleurs en alternance**.
- Un **clic sur une pastille referme le panneau** et applique la couleur à la
cellule ou à la plage sélectionnée ; l'indicateur de sélection est une
coche **blanche ou noire selon la luminance** de la pastille.
- **Correction d'un piège d'encodage** : la palette est calculée en HSL pour
la lisibilité mais sort en **`#rrggbb`**. Une valeur `hsl(...)` était
silencieusement rejetée par `normHex()` — la couleur n'était donc jamais
écrite sur la cellule.
- Le chrome du panneau suit le **thème actif** (variables CSS uniquement) ;
« + » et les crayons ouvrent le sélecteur natif pour une couleur
arbitraire.
- Les deux entrées de pied de page sont **annoncées mais pas encore
disponibles** : elles affichent un message explicite plutôt qu'un bouton
inerte.
### Modifié
- **#185 - Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie**
- **Les dix menus de Google Sheets sont là** : Fichier, Modifier, Affichage,
Insertion, Format, Données, Outils, Gemini, Extensions, Aide — dans l'ordre
et avec les libellés exacts de Sheets (161 entrées). Le nouveau module
`frontend/js/xlsx/menus.js` décrit l'arborescence, `viewer.js` ne fait que
brancher chaque entrée sur un flux existant. Les sections imbriquées
(Nombre, Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons**, une
seule ouverte à la fois, comme dans Sheets.
- **La majorité des fonctions sont opérationnelles** : Partager, Télécharger
(original / CSV / Markdown / HTML), Imprimer, **Renommer**, **Déplacer vers
la corbeille**, **Historique des versions**, Détails ; Annuler, Rétablir,
Couper, Copier, Coller, **Collage spécial (valeurs uniquement)**,
**Supprimer (lignes / colonnes / contenu)**, Tout sélectionner,
**Rechercher et remplacer** ; **Figer** (ici / ligne / colonne), **Afficher
→ barre de formule · grille · en-têtes · tableau de bord**, **Zoom**,
**Plein écran** ; Lignes / Colonnes / Cellules, Lien, Commentaire,
**Fonction** (curseur placé entre les parenthèses) ; Nombre, **Thème**,
Gras / Italique / Souligné / Barré, Taille de police, Alignement, Retour à la
ligne, Rotation, **Couleurs alternées**, Effacer la mise en forme ; Trier,
**Créer un filtre**, **Plages nommées**, **Nettoyage des données
(doublons / espaces superflus)**, **Diviser le texte en colonnes** ;
**Saisie semi-automatique**, **Accessibilité** ; Gemini (6 entrées vers
l'assistant IA) ; Aide, **Mises à jour**, **Raccourcis clavier**,
**Signaler un problème** (diagnostic copié dans le presse-papiers).
- **Les options qu'ObsiGate n'a pas le disent** : les 36 entrées absentes
(macros, Apps Script, tableaux croisés, graphiques, formulaires, modules,
AppSheet, Looker Studio, formation…) restent **listées** pour tenir la
parité demandée, mais un clic y répond « *« X » n'est pas disponible dans
ObsiGate* » au lieu de ne rien faire.
- **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`) : seuls les raccourcis internes et les
tests bougent. Seul **Enregistrer** reste épinglé à droite des onglets.
- **Ruban refait façon Google Sheets** : boutons plats de 30 × 30, sans bordure
ni fond au repos, survol discret, état actif teinté, groupes séparés par des
filets verticaux. **Enregistrer** reste le seul pilier plein. Nouveau bouton
**grille**, synchronisé avec Affichage ▸ Afficher ▸ Grille.
- **Grille unie et visible** : chaque cellule est encadrée sur **les quatre
côtés** par **la même** ligne de 1 px, en-têtes compris (ils se distinguent
par leur fond, plus par une bordure plus lourde). `--grid-border` n'est plus
`--border`, quasi identique au fond des cellules sur la plupart des thèmes :
c'est désormais un `color-mix` du texte vers la surface, calculé par
`themes.js`, donc une seule valeur pour toute la grille et lisible sur les 15
thèmes comme sur les modes contraste élevé et sépia.
- **#184 - Tableur : peinture de format complète, sélection de lignes/colonnes
multiples, grille A → Z × 1000 lignes**
- **Peindre le format ne ratait plus aucun cas.** L'outil copiait un
sous-ensemble de propriétés et **refusait** une cellule source sans mise en
forme explicite — donc une cellule simple vers une cellule colorée et en
grande taille ne produisait rien. La copie porte maintenant sur **toute** la
mise en forme (gras, italique, souligné, barré, couleur du texte, couleur de
fond, taille, alignement, valign, retour ligne, rotation, format de nombre,
bordures) ; une source **sans** format **réinitialise** la cible au lieu de
ne rien faire.
- **Fuite corrigée** : annuler la peinture (`Échap`, second clic) laissait le
gestionnaire « appliquer au prochain clic » en place — le clic suivant dans la
grille repeignait une vieille mise en forme. Il est maintenant désinstallé.
- **Sélection de plusieurs lignes / colonnes depuis les marges**, comme dans
Sheets et Excel : glisser sur les numéros de ligne (ou `Maj`+clic) sélectionne
une plage de lignes entières ; idem sur les lettres de colonne. Le
redimensionnement par les bords d'en-tête reste prioritaire sur ce geste.
- **Grille étendue à A → Z, et jusqu'à 1000 lignes**, les cellules vides
étant de **vraies cellules éditables**. Les colonnes sont posées d'emblée ;
les lignes s'ajoutent **par blocs de 100 au défilement**, et non d'un seul
coup : matérialiser 1000 lignes revient à ~26 000 cellules câblées par
feuille, ce qui épuisait le tas de la suite JSDOM. Une feuille tronquée
garde son chargement serveur « Charger la suite » (les lignes manquantes
sont des données, pas du remplissage).
- **Trois goulots d'étranglement levés**, sinon la grille étendue rendait
l'éditeur inutilisable : `findTd` faisait un `querySelector` par appel
(sélectionner une colonne = 26 000 requêtes) → **index de cellules** avec
auto-réparation ; le rafraîchissement des formules clonait 26 000 nœuds à
chaque frappe → **court-circuit** sur les cellules vides ; l'application des
styles touchait 26 000 cellules → **court-circuit** sur celles que le
classeur ne style pas.
- Marqueur `data-wired` explicite : `data-orig` valait `""` pour une cellule
vide, ce qui recâblait les cellules déjà câblées (double écouteur).
- **#181 — Éditeur tableur : modèle de saisie Google Sheets (sélection ≠ édition)**
- **Deux états distincts.** Une cellule est *sélectionnée* (clic, flèches) ou
*en cours d'édition*, et plus les deux en même temps. Le curseur de texte
n'apparaît plus en sélection (`caret-color: transparent`, sans quitter
`contenteditable`) : on ne voit plus un curseur clignotant au clic ou aux
flèches.
- **Entrées en édition**, comme Google Sheets / Excel : **double-clic** (le
curseur est placé *sous le pointeur*, l'édition se fait en place), **première
frappe** — qui **remplace** le contenu au lieu de s'y ajouter —, ou `F2`.
`Alt`+flèche insère un saut de ligne, `Échap` quitte l'édition et restaure
la valeur.
- **`Entrée` contextuelle** : en édition, elle valide et déplace le focus sur
**la cellule du dessous** (au lieu de perdre le focus) ; en sélection, elle
bascule la cellule courante en mode édition. `Tab` suit la même logique.
- **Indicateur de sélection renforcé** : la plage est **contourée** comme dans
Excel / Sheets (quatre classes de bord sur les côtés réels du bloc, pas un
simple lavis), et les en-têtes couverts — lettres `A`, `B`… et numéros
`1`, `2`… — sont reflétés.
- Un clic simple après une édition abandonne l'état d'édition : plus de
curseur résiduel.
- **Inventaire des écarts** vs Google Sheets / Excel dressé et priorisé
(poignée de recopie, *remplacer*, validation de données, filtre
automatique, tri multi-critères, mise en forme conditionnelle, création de
graphiques, protection…) : [fiche #181](docs/features/xlsx-sheet-input-181.md).
- Tests JSDOM : `xlsx-viewer` passe de 136 à **146/146** (10 cas #181).
- **#180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu
Fichier & barre épinglée**
- **Curseur de redimensionnement.** Survoler le bord d'un en-tête de colonne
(`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…) affiche désormais l'icône
`col-resize` / `row-resize`. Le curseur par défaut des cellules
(`cell`) et des en-têtes (`pointer`) masquait auparavant l'indicateur, y
compris pendant le glissement : les en-têtes concerned sont désormais
miroirés en ligne et la table porte une classe de repaint
(`.xlsx-resize-col` / `.xlsx-resize-row`).
- **Grille compatible tous les thèmes.** Les jetons de grille
(`--grid-*`) sont dérivés du thème actif (`--surface`, `--bg-hover`,
`--text-primary`, `--border`, bandeau zébré en `color-mix`) : les
15 thèmes × 4 modes de l'application, y compris les modes générés
contrasté élevé / sépia et les thèmes importés, sont désormais
respectés, plus les couleurs figées du thème par défaut.
- **Menu Fichier.** Télécharger, CSV, Markdown, HTML et Imprimer sont
réunis dans un seul menu **Fichier**, placé à droite de la barre des
onglets de feuilles, avec le bouton **Enregistrer** juste à côté. Le
ruban conserve les commandes d'édition. Les identifiants des contrôles
(`#xlsx-download-btn`, `#xlsx-csv-btn`, `#xlsx-save-btn`) sont
inchangés.
- **Barre épinglée pleine largeur.** La barre de boutons est épinglée en
haut, au bord de la marge de la page, et l'éditeur occupe toute la
largeur du contenu (comme les visionneuses PDF / images / Excalidraw).
- **Indicateur retiré.** La pastille « Formules calculées localement »
disparaît : le calcul local est le comportement normal, pas une limite
worthy of a badge.
### Corrigé
- **#182 — Éditeur tableur : les encadrés de la sélection s'empilaient et ne
suivaient plus le focus**
- **Cause racine.** Le sélecteur CSS de nettoyage des contours de plage était
écrit **sans le point** (`querySelectorAll("xlsx-sel-top,…")`). En CSS, un
nom nu désigne une **balise** (`<xlsx-sel-top>`), pas une classe : la requête
ne renvoyait **rien** et aucun contour n'était jamais effacé. Les encadrés
restaient donc en place et s'accumulaient à chaque changement de cellule.
Même famille de faute sur les en-têtes reflétés (`classList.remove` appelé
avec un point).
- **Comportement corrigé** : le contour suit désormais exactement la sélection —
une cellule seule est encadrée, une plage est encadrée sur ses quatre bords
réels, et **rien ne reste** sur les cellules quittées (navigation clavier,
clic, changement de cellule, changement de feuille).
- **Écart Sheets corrigé au passage** : pendant la saisie, les flèches
**valident et déplacent** la sélection (auparavant elles déplaçaient le
curseur dans le texte et la cellule semblait figée). `Alt`+flèche conserve
l'insertion d'un saut de ligne.
- Non-régression : `xlsx-viewer` passe de 149 à **152/152** (3 cas #182).
- **#182 — Éditeur tableur : la sélection multiple ne se désélectionnait plus, et
les couleurs paraissaient inertes sur une plage**
- **Cause racine de la sélection « collée ».** La désélection reposait sur
l'événement `focus` de la cellule. Or `Maj`+clic appelle `preventDefault()`,
ce qui **empêche le navigateur de déplacer le focus** : `activeTd` et le
focus réel divergeaient. Un clic suivant sur la cellule déjà focalisée ne
déclenchait donc **aucun** `focus` et la plage restait affichée. Le clic
simple efface désormais la plage explicitement dans `mousedown`, sans
dépendre du focus ; après un `Maj`+clic le focus suit enfin la cellule
cliquée (sous `suppressSelectionReset`, pour ne pas écraser la plage).
- **Zone Nom** : après un glissement, elle affiche de nouveau la **plage**
(`A1:B2`) au lieu de la seule cellule d'origine (`A1`).
- **Couleurs sur une plage** : vérifié sur les trois chemins (menu Mise en
forme, nuanciers du ruban, sélection par `Maj`+flèches ou par glissement) —
la charge utile porte bien la plage (`A1:B2`) et les 4 cellules reçoivent
`color` **et** `background`.
- **Retour visuel manquant** : les deux nuanciers du ruban affichent désormais
une **pastille** qui prend la couleur choisie, ce qui donnait l'impression
que la couleur n'était pas appliquée.
- Non-régression : `xlsx-viewer` passe de 146 à **149/149** (3 cas #182).
- **Visionneuse tableur / CSV : plus de ligne teintée au survol, la teinte de
fond est réservée aux sélections de 2 cellules et plus (Google Sheets)**
- Le survol ne colore **plus rien** : les règles `tr:hover` de la grille
XLSX (ligne entière en `--bg-hover`, y compris sur une cellule déjà
sélectionnée) et du tableau CSV sont retirées.
- Un clic simple ne change **plus le fond de la cellule** : elle ne garde que
son cadre accent (les quatre bords `xlsx-sel-*` + le contour
`xlsx-active`), comme dans Google Sheets.
- La teinte `.xlsx-selected` est désormais conditionnée par la classe
`.xlsx-range-multi`, posée/retirée sur `.xlsx-viewer` par
`applySelection()` selon l'étendue de la sélection — elle n'apparaît donc
qu'à partir de deux cellules. Les en-têtes de ligne / de colonne couverts
restent reflétés, comme dans Sheets.
- **Visionneuse tableur : le clic droit écrasait la sélection multiple avant
l'ouverture du menu contextuel**
- **Cause.** Le `mousedown` du bouton droit laissait le navigateur déplacer
le focus sur la cellule ; le handler `focus` appelait `setActiveCell()` et
réduisait la plage à cette cellule **avant** que le menu ne s'ouvre. Les
entrées Copier / Coller / Effacer / Insérer / Supprimer ne s'appliquaient
donc plus qu'à une cellule (`Delete 1 row(s)` au lieu de
`Delete 2 row(s)`), et la plage disparaissait à l'écran.
- **Correctif.** `preventDefault()` sur le `mousedown` de bouton droit dans
la grille : le focus ne bouge plus, la sélection multiple survit jusqu'au
choix d'une option. Un clic droit **hors** plage déplace toujours la
sélection sur la cellule visée (comportement Sheets), via
`selectForTarget()` ; le clic gauche, lui, écrase toujours la plage (#182).
- Vérifié en conditions réelles sur l'instance Docker : la plage `A2:C3` est
conservée à l'ouverture du menu, le menu annonce 2 lignes / 3 colonnes, et
Copier place bien les 2 lignes × 3 colonnes dans le presse-papiers.
- **#180 — 8 tests JSDOM réalignés** sur la sémantique « écriture verrouillée
puis application locale » des flux structure / mise en forme (le viewer
n'est plus re-lu après chaque écriture) : le second appel PUT est absent,
les assertions portent sur la grille peinte en place.
- **Tableur — les sorties du menu Fichier étaient inertes.** Les entrées
Markdown, HTML et Imprimer portaient `data-xlsx-export` mais aucun
`data-xlsx-action`, et le répartiteur générique ne lit que ce dernier : le
clic ne produisait rien (les identifiants `#xlsx-download-btn` et
`#xlsx-csv-btn`, eux, fonctionnaient). Elles sont câblées sur
`export-md` / `export-html` / `print`, donc sur les fonctions déjà en place.
- **Tableur — collision de classe sur `.xlsx-structure-menu`.** Les menus
déroulants de la nouvelle barre de menus portaient la même classe que le
menu Structure de la barre d'outils : toute requête `.xlsx-structure-menu`
tombait sur le premier nœud rencontré, donc sur un élément masqué. Les menus
de la barre prennent `.xlsx-menudrop` ; l'aspect reste partagé par le CSS.
- **Tableur — curseur de redimensionnement absent sur la table.** La sonde
géométrique était conditionnée à la présence d'un en-tête sous le pointeur ;
sur la table elle-même aucun en-tête n'est nommé et le curseur ne
s'affichait pas. La table retombe désormais sur la sonde géométrique.
- **Tableur — l'export emportait le quadrillage vide.** `sheetMatrix()`
renvoyait la grille telle que mise en page : avec les colonnes étendues à
A → Z, un classeur de deux colonnes exportait 26 colonnes de vide (CSV,
Markdown, HTML, impression). Les lignes et colonnes vides de queue sont
écartées — l'export porte le bloc utilisé, comme l'export d'un tableur.
- **Tableur — fuite mémoire à chaque rendu.** `renderXlsxViewer()` ajoutait un
écouteur `click` **anonyme** sur `#content-area` : l'élément survit au
re-rendu et la fermeture de l'écouteur retenait la grille précédente en
entier. Ouvrir ou enregistrer un classeur plusieurs fois faisait croître le
tas sans borne. Gestionnaire nommé, démonté en tête de rendu.
- **Tableur — le clic extérieur laissait le menu contextuel ouvert.** La fenêtre
de grâce de 250 ms, destinée au `click` résiduel d'un appui long tactile,
s'appliquait aussi au clic droit souris — qui n'en émet aucun : un clic
délibéré fait juste après l'ouverture était avalé par la grâce. Elle ne
couvre plus que l'ouverture tactile.
- **Tableur — une entrée indisponible ne refermait pas la barre de menus.** Le
répartiteur annonçait « *X* n'est pas disponible dans ObsiGate » puis sortait
avant la fermeture : le menu restait ouvert. Tout choix referme désormais le
menu, y compris une entrée qui ne fait que l'annoncer.
---
## [2.53.3] — 2026-10-07
### Corrigé
+1
View File
@@ -41,6 +41,7 @@ COPY VERSION ./VERSION
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
&& mkdir -p /app/data \
&& chmod 777 /app/data \
&& chown -R obsigate:obsigate /app
USER obsigate
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.53.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.3).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.54.0).
---
*Projet : ObsiGate | Version : 2.53.3 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.54.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.53.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.3).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.54.0).
---
*Project: ObsiGate | Version: 2.53.3 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.54.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.53.3
2.54.0
+11 -3
View File
@@ -323,9 +323,13 @@ def api_file_xlsx_style(
``ops`` is an ordered list applied in one locked, atomic rewrite:
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
``font_color``/``fill_color`` as ``#rrggbb``, ``align``
(left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``,
``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` /
``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``),
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``),
``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``)
and ``freeze`` (``cell``, empty to release).
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
@@ -548,6 +552,10 @@ async def api_file_create(
Returns:
FileCreateResponse confirming creation.
Note:
A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built
with openpyxl: the payload is binary, so ``content`` is ignored (#186).
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
+220 -15
View File
@@ -41,6 +41,28 @@ _EXCALIDRAW_SKELETON = (
'"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
)
# Title of the single sheet of a workbook created from scratch (#186).
_XLSX_DEFAULT_SHEET = "Feuille1"
def _empty_xlsx_bytes() -> bytes:
"""Return the bytes of a valid, empty single-sheet workbook.
An ``.xlsx`` is a ZIP archive (OPC), not a text file: writing ``""`` to it
produces a file no reader (and no spreadsheet application) can open. The
"Create a file" modal posts an empty text payload, so the archive itself is
built here with openpyxl.
"""
import io
from openpyxl import Workbook
wb = Workbook()
wb.active.title = _XLSX_DEFAULT_SHEET
buffer = io.BytesIO()
wb.save(buffer)
return buffer.getvalue()
def _rel(root: Path, path: Path) -> str:
"""Return *path* relative to *root* as a POSIX-style string."""
@@ -98,13 +120,14 @@ def create_file(
*,
overwrite: bool = False,
) -> dict[str, Any]:
"""Create a text file in a vault.
"""Create a file in a vault.
Args:
vault_name: Name of the vault.
path: Vault-relative path of the new file.
content: Initial content (an Excalidraw skeleton is injected for empty
``.excalidraw`` files).
``.excalidraw`` files). ``.xlsx`` is binary: the workbook is always
built from scratch and *content* is ignored for that extension.
overwrite: When True, replace an existing file (with a backup) instead
of raising ``already_exists``.
@@ -130,17 +153,25 @@ def create_file(
)
create_backup(file_path, vault_name, _rel(root, file_path))
suffix = file_path.suffix.lower()
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
content = _EXCALIDRAW_SKELETON
file_path.write_text(content, encoding="utf-8")
if suffix == ".xlsx":
# Binary OPC archive: a text write would leave an unopenable file
# (#186). Written as bytes, so the size below is the byte count.
payload = _empty_xlsx_bytes()
file_path.write_bytes(payload)
else:
if suffix == ".excalidraw" and not content.strip():
content = _EXCALIDRAW_SKELETON
payload = content.encode("utf-8")
file_path.write_text(content, encoding="utf-8")
except PermissionError as e:
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
rel_path = _rel(root, file_path)
logger.info(f"File created: {vault_name}/{rel_path}")
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(payload)}
def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]:
@@ -673,11 +704,42 @@ def mutate_xlsx_structure(
"Position 'at' / 'count' invalides", code="invalid", status=400
)
if op == "row_insert":
ws.insert_rows(at, count)
# Le modèle sparse d'openpyxl ignore les inserts
# au-delà de la dernière ligne utilisée (rien à
# décaler), et l'écrivain abandonne les cellules
# vides (None) au cycle suivant : on matérialise
# avec " " (un blanc qui persiste, relu tel quel,
# affiché vide, ignoré par la recherche) pour
# qu'une insertion « sous la dernière ligne »
# crée bien des lignes blanches, comme Excel.
if at > ws.max_row:
if at - ws.max_row > _INSERT_GAP_LIMIT:
raise ServiceError(
"Position trop éloignée de la fin de la feuille",
code="invalid", status=400,
)
for r in range(at, at + count):
cell = ws.cell(row=r, column=1)
if cell.value is None:
cell.value = " "
else:
ws.insert_rows(at, count)
elif op == "row_delete":
ws.delete_rows(at, count)
elif op == "col_insert":
ws.insert_cols(at, count)
# Idem côté colonnes : « à droite de la dernière ».
if at > ws.max_column:
if at - ws.max_column > _INSERT_GAP_LIMIT:
raise ServiceError(
"Position trop éloignée de la fin de la feuille",
code="invalid", status=400,
)
for c in range(at, at + count):
cell = ws.cell(row=1, column=c)
if cell.value is None:
cell.value = " "
else:
ws.insert_cols(at, count)
else:
ws.delete_cols(at, count)
applied.append(f"{op}:{sheet}@{at}x{count}")
@@ -725,16 +787,34 @@ def mutate_xlsx_structure(
# #156-A8 — write-side formatting. Every value is data-driven (the colours
# come from the caller, never from a hardcoded palette) and the whole batch
# rides the same lock, backup and atomic swap as the cell edits.
# Cap anti-DoS : une insertion au-delà de la fin matérialise des cellules,
# jamais un gouffre de lignes/colonnes.
_INSERT_GAP_LIMIT = 1000
_STYLE_KEYS = {
"bold",
"italic",
"underline",
"strike",
"font_size",
"font_color",
"fill_color",
"align",
"valign",
"wrap",
"rotation",
"border",
"border_style",
"border_color",
"number_format",
}
_STYLE_ALIGNS = {"left", "center", "right"}
_STYLE_ALIGNS = {"left", "center", "right", "justify"}
_STYLE_VALIGNS = {"top": "top", "middle": "center", "bottom": "bottom"}
_STYLE_BORDERS = {"all", "outer", "none"}
_STYLE_BORDER_STYLES = {
"thin", "medium", "thick", "dashed", "dotted", "double", "hair",
"mediumDashed", "dashDot", "mediumDashDot", "dashDotDot",
"mediumDashDotDot", "slantDashDot",
}
MAX_STYLE_CELLS = 10_000
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
@@ -760,14 +840,14 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
"""
import copy as copy_mod
from openpyxl.styles import Alignment, Color, PatternFill
from openpyxl.styles import Alignment, Border, Color, PatternFill, Side
unknown = set(style) - _STYLE_KEYS
if unknown:
raise ServiceError(
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
)
if {"bold", "italic", "underline", "font_color"} & set(style):
if {"bold", "italic", "underline", "strike", "font_size", "font_color"} & set(style):
font = copy_mod.copy(cell.font)
if "bold" in style:
font.bold = bool(style["bold"])
@@ -775,6 +855,22 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
font.italic = bool(style["italic"])
if "underline" in style:
font.underline = "single" if style["underline"] else None
if "strike" in style:
font.strike = bool(style["strike"])
if "font_size" in style:
try:
size = float(style["font_size"])
except (TypeError, ValueError):
raise ServiceError(
f"Taille de police invalide: {style['font_size']!r}",
code="invalid", status=400,
) from None
if not 6 <= size <= 72:
raise ServiceError(
f"Taille de police hors bornes (6-72): {style['font_size']!r}",
code="invalid", status=400,
)
font.size = size
if "font_color" in style:
rgb = _style_color(style["font_color"])
font.color = Color(rgb=rgb) if rgb else None
@@ -797,7 +893,69 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
horizontal=align,
vertical=getattr(current, "vertical", None),
wrap_text=getattr(current, "wrap_text", None),
text_rotation=getattr(current, "text_rotation", None),
)
if "valign" in style or "wrap" in style or "rotation" in style:
current = cell.alignment
vertical = getattr(current, "vertical", None)
wrap_text = getattr(current, "wrap_text", None)
text_rotation = getattr(current, "text_rotation", None) or 0
if "valign" in style:
valign = style["valign"]
if valign not in _STYLE_VALIGNS:
raise ServiceError(
f"Alignement vertical invalide: {valign!r}", code="invalid", status=400
)
vertical = _STYLE_VALIGNS[valign]
if "wrap" in style:
wrap_text = bool(style["wrap"])
if "rotation" in style:
try:
rotation = int(style["rotation"])
except (TypeError, ValueError):
raise ServiceError(
f"Rotation invalide: {style['rotation']!r}", code="invalid", status=400
) from None
if not (-90 <= rotation <= 90 or rotation == 180):
raise ServiceError(
f"Rotation hors bornes (-90..90, 180): {rotation!r}", code="invalid", status=400
)
# OOXML stocke les angles horaires sur 91..180 (-45° → 135).
# 180 = texte vertical retourné (le lecteur le relit tel quel).
text_rotation = rotation if rotation >= 0 else 180 + rotation
cell.alignment = Alignment(
horizontal=getattr(current, "horizontal", None),
vertical=vertical,
wrap_text=wrap_text,
text_rotation=text_rotation,
)
if {"border", "border_style", "border_color"} & set(style):
which = style.get("border", "all")
if which not in _STYLE_BORDERS:
raise ServiceError(
f"Bordure invalide: {which!r}", code="invalid", status=400
)
bstyle = style.get("border_style", "thin")
if bstyle not in _STYLE_BORDER_STYLES:
raise ServiceError(
f"Style de bordure invalide: {bstyle!r}", code="invalid", status=400
)
raw_color = style.get("border_color", "#000000")
if raw_color in (None, ""):
raw_color = "FF000000"
rgb = _style_color(raw_color)
if rgb is None:
raise ServiceError(
f"Couleur de bordure invalide: {style.get('border_color')!r}",
code="invalid", status=400,
)
if which == "none":
cell.border = Border()
else:
# `all` et `outer` se rendent pareil cellule par cellule : les
# bords partagés coïncident, le contour de la plage reste net.
side = Side(style=bstyle, color=rgb)
cell.border = Border(left=side, right=side, top=side, bottom=side)
if "number_format" in style:
fmt = style["number_format"] or "General"
if not isinstance(fmt, str) or len(fmt) > 120:
@@ -821,12 +979,17 @@ def mutate_xlsx_style(
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
``font_color`` / ``fill_color`` (``#rrggbb``, ``""`` clears),
``align`` (left/center/right/justify), ``valign`` (top/middle/bottom),
``wrap`` (bool), ``rotation`` (-90..90), ``border``
(all/outer/none) + ``border_style`` / ``border_color`` and
``number_format`` ;
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
* ``{"op": "comment_set", "sheet": "X", "ref": "B2", "text": "…"}``
(``author`` optional) / ``{"op": "comment_clear", ...}`` ;
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
Same guards as the cell edits: per-file lock, ``.bak`` backup,
@@ -960,7 +1123,17 @@ def mutate_xlsx_style(
raise ServiceError(
"Largeur invalide (0 à 255)", code="invalid", status=400
)
ws.column_dimensions[col.strip().upper()].width = float(width)
width = float(width)
if width == 0:
# 0 = largeur par défaut (équivalent « Default
# Width » d'Excel / « Reset » de Sheets) : on
# retire la mesure explicite au lieu d'écrire une
# largeur de 0, qu'Excel lirait comme une colonne
# quasi masquée pendant que l'affichage reste en
# largeur automatique.
ws.column_dimensions.pop(col.strip().upper(), None)
else:
ws.column_dimensions[col.strip().upper()].width = width
applied.append(f"col_width:{ws.title}!{col}={width}")
elif op == "row_height":
ws = _sheet(wb, action)
@@ -972,7 +1145,14 @@ def mutate_xlsx_style(
raise ServiceError(
"Hauteur invalide (0 à 409)", code="invalid", status=400
)
ws.row_dimensions[row].height = float(height)
height = float(height)
if height == 0:
# 0 = hauteur par défaut (même logique que la
# largeur ci-dessus) : la mesure explicite est
# retirée, la ligne redevient automatique.
ws.row_dimensions.pop(row, None)
else:
ws.row_dimensions[row].height = height
applied.append(f"row_height:{ws.title}!{row}={height}")
elif op == "freeze":
ws = _sheet(wb, action)
@@ -981,6 +1161,31 @@ def mutate_xlsx_style(
_range(cell_ref)
ws.freeze_panes = cell_ref.upper() or None
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
elif op in ("comment_set", "comment_clear"):
from openpyxl.comments import Comment
ws = _sheet(wb, action)
ref = str(action.get("ref") or action.get("cell") or "")
min_col, min_row, max_col, max_row = _range(ref)
if (min_col, min_row) != (max_col, max_row):
raise ServiceError(
"Un commentaire vise une cellule (A1), pas une plage",
code="invalid", status=400,
)
if op == "comment_clear":
ws.cell(row=min_row, column=min_col).comment = None
else:
text = str(action.get("text") or "")
if not text.strip() or len(text) > 2000:
raise ServiceError(
"Texte de commentaire invalide (1 à 2000 caractères)",
code="invalid", status=400,
)
author = str(action.get("author") or "ObsiGate")[:64]
ws.cell(row=min_row, column=min_col).comment = Comment(
text, author
)
applied.append(f"{op}:{ws.title}!{ref}")
else:
raise ServiceError(
f"Action inconnue: {op!r}", code="invalid", status=400
+139 -10
View File
@@ -129,8 +129,16 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
fragments.append("font-style:italic")
# #156-A8 — underline is written from the viewer too, so it is read back
# (the toggle in the formatting menu needs to see its own effect).
decorations = []
if font and font.underline:
fragments.append("text-decoration:underline")
decorations.append("underline")
if font and getattr(font, "strike", False):
decorations.append("line-through")
if decorations:
fragments.append("text-decoration:" + " ".join(decorations))
size = getattr(font, "size", None) if font else None
if isinstance(size, (int, float)) and size != 11:
fragments.append(f"font-size:{size:g}pt")
fmt = cell.number_format
if fmt and fmt not in ("General", "@"):
# A custom number format is signalled typographically (mono font)
@@ -140,29 +148,129 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
fragments.append("font-family:'JetBrains Mono',monospace")
alignment = cell.alignment
align = alignment.horizontal if alignment else None
return fragments, (align if align in ("left", "right", "center") else None)
if alignment is not None:
vertical = getattr(alignment, "vertical", None)
if vertical == "center":
fragments.append("vertical-align:middle")
elif vertical in ("top", "bottom"):
fragments.append(f"vertical-align:{vertical}")
if getattr(alignment, "wrap_text", None) is True:
fragments.append("white-space:normal")
elif getattr(alignment, "wrap_text", None) is False:
fragments.append("white-space:nowrap")
rotation = getattr(alignment, "text_rotation", None) or 0
if rotation == 90 or rotation == 255:
fragments.append("writing-mode:vertical-rl")
elif rotation == 180:
fragments.append("writing-mode:vertical-rl;transform:scale(-1)")
else:
# Angles diagonaux (ex. ±45° du menu Format › Rotation) : OOXML
# stocke l'anti-horaire sur 0..90 et l'horaire sur 91..180 (-45°
# → 135, cf. mutations). Le CSS tourne en sens horaire : signe
# inversé pour un rendu fidèle.
excel = rotation if rotation <= 90 else -(180 - rotation)
if excel:
fragments.append(f"transform:rotate({-excel}deg)")
border = getattr(cell, "border", None)
if border is not None:
sides = []
for side_name in ("left", "right", "top", "bottom"):
side = getattr(border, side_name, None)
style = getattr(side, "style", None)
if not style:
continue
color = getattr(getattr(side, "color", None), "rgb", None)
hexpart = ""
if isinstance(color, str) and len(color) == 8:
hexpart = f" #{color[2:].lower()}"
width = {"medium": "2px", "thick": "3px"}.get(style, "1px")
css_style = {"dashed": "dashed", "dotted": "dotted", "double": "double"}.get(
style, "solid"
)
sides.append(f"border-{side_name}:{width} {css_style}{hexpart}")
fragments.extend(sides)
return fragments, (align if align in ("left", "right", "center", "justify") else None)
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
def _sheet_style_maps(
ws: Any,
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
"""Flat maps of one worksheet: css, align, comment text, number format.
The flat string is what the API serves and what the viewer applies
verbatim to ``td.style``; a plain cell is simply absent from the map.
``left`` is the table default and never included. Bounded by
``MAX_ROWS x MAX_COLS`` like the render itself.
``MAX_ROWS x MAX_COLS`` like the render itself. Comment texts are capped
(tooltip use) and formats only list non-General number formats.
"""
styles: dict[str, str] = {}
aligns: dict[str, str] = {}
comments: dict[str, str] = {}
formats: dict[str, str] = {}
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
for cell in row:
if cell.value is None and cell.number_format == "General":
comment = getattr(cell, "comment", None)
comment_text = getattr(comment, "text", None) if comment is not None else None
# Une cellule vide mais stylée (gras, diagonale, commentaire…)
# doit survivre à la relecture : `has_style` (et le commentaire)
# sont les seuls tests rapides qui la distinguent d'une cellule
# vraiment vierge — sinon le style semble « ne pas fonctionner »
# dès qu'on recharge le classeur.
if cell.value is None and cell.number_format == "General" and not cell.has_style:
if comment_text:
comments[cell.coordinate] = str(comment_text)[:500]
continue
fragments, align = _cell_fragments(cell)
if fragments:
styles[cell.coordinate] = ";".join(fragments)
if align and align != "left":
aligns[cell.coordinate] = align
return styles, aligns
if comment_text:
comments[cell.coordinate] = str(comment_text)[:500]
fmt = getattr(cell, "number_format", None)
if fmt and fmt not in ("General", "@", None):
formats[cell.coordinate] = str(fmt)
return styles, aligns, comments, formats
def _sheet_dim_maps(ws: Any) -> tuple[dict[str, float], dict[str, float]]:
"""Explicit column widths (units) and row heights (pt), by letter/number.
Only dimensions the file sets explicitly are listed (defaults are a
rendering concern of the client). Bounded like the render itself.
"""
from openpyxl.utils import column_index_from_string
colwidths: dict[str, float] = {}
try:
for key, dim in (getattr(ws, "column_dimensions", {}) or {}).items():
width = getattr(dim, "width", None)
if not isinstance(width, (int, float)):
continue
letters = str(key).split(":")
try:
cols = [column_index_from_string(a.strip()) for a in letters]
except ValueError:
continue
lo, hi = min(cols), max(cols)
for c in range(lo, min(hi, MAX_COLS) + 1):
if c >= 1:
from openpyxl.utils import get_column_letter
colwidths[get_column_letter(c)] = float(width)
except Exception:
logger.debug("xlsx column widths unavailable", exc_info=True)
rowheights: dict[str, float] = {}
try:
for r, dim in (getattr(ws, "row_dimensions", {}) or {}).items():
if not isinstance(r, int) or not 1 <= r <= MAX_ROWS:
continue
height = getattr(dim, "height", None)
if isinstance(height, (int, float)):
rowheights[str(r)] = float(height)
except Exception:
logger.debug("xlsx row heights unavailable", exc_info=True)
return colwidths, rowheights
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
@@ -445,7 +553,8 @@ def invalidate_workbook_meta(file_path: Path | str) -> None:
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
"""Return ``{sheet: {styles, aligns, comments, formats, colwidths,
rowheights, merges, freeze}}``.
One normal (non-streaming) load serves the three A15 metadata maps: the
fragments are the workbook's own values, a failure yields ``{}`` per sheet
@@ -483,12 +592,17 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
try:
for ws in wb.worksheets:
styles, aligns = _sheet_style_maps(ws)
styles, aligns, comments, formats = _sheet_style_maps(ws)
colwidths, rowheights = _sheet_dim_maps(ws)
merged = getattr(ws, "merged_cells", None)
ranges = getattr(merged, "ranges", None) or []
out[ws.title] = {
"styles": styles,
"aligns": aligns,
"comments": comments,
"formats": formats,
"colwidths": colwidths,
"rowheights": rowheights,
"merges": [str(r) for r in ranges],
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
}
@@ -496,7 +610,14 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
except Exception:
logger.debug("xlsx meta unavailable", exc_info=True)
for t in wb.sheetnames:
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
out.setdefault(
t,
{
"styles": {}, "aligns": {}, "comments": {}, "formats": {},
"colwidths": {}, "rowheights": {},
"merges": [], "freeze": "",
},
)
return out
finally:
wb.close()
@@ -565,6 +686,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
"styles": sheet_meta.get("styles", {}),
"aligns": sheet_meta.get("aligns", {}),
"comments": sheet_meta.get("comments", {}),
"formats": sheet_meta.get("formats", {}),
"colwidths": sheet_meta.get("colwidths", {}),
"rowheights": sheet_meta.get("rowheights", {}),
"merges": sheet_meta.get("merges", []),
"freeze": sheet_meta.get("freeze", ""),
}
@@ -629,6 +754,10 @@ def read_sheet_window(
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
"styles": meta.get("styles", {}),
"aligns": meta.get("aligns", {}),
"comments": meta.get("comments", {}),
"formats": meta.get("formats", {}),
"colwidths": meta.get("colwidths", {}),
"rowheights": meta.get("rowheights", {}),
"merges": meta.get("merges", []),
"freeze": meta.get("freeze", ""),
}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.53.3"
version = "2.54.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.53.3"
version = "2.54.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.53.3",
"version": "2.54.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5 -4
View File
@@ -13,14 +13,14 @@ services:
context: .
image: obsigate:latest
container_name: obsigate-test
user: "1000:1000"
user: "197609:197609"
restart: unless-stopped
ports:
- "2021:8080"
volumes:
- /tmp/obsigate-test-vault:/vaults/TestVault
- /tmp/obsigate-test-dir:/vaults/TestDir
- /tmp/obsigate-test-data:/app/data
- ./test_vault:/vaults/TestVault
- ./test_dir:/vaults/TestDir
- obsigate_data:/app/data
environment:
- VAULT_1_NAME=TestVault
- VAULT_1_PATH=/vaults/TestVault
@@ -51,3 +51,4 @@ services:
volumes:
ollama_data:
obsigate_data:
+107 -31
View File
@@ -134,24 +134,79 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
## 6. Tableurs Excel (XLSX)
### Créer un classeur
Pour démarrer un tableur, clic droit sur un vault ou un dossier →
**Nouveau fichier** (ou **Nouveau fichier ici**), choisissez le type **Excel
(.xlsx)** et saisissez un nom : le classeur est créé avec une feuille
**Feuille1** et s'ouvre directement dans l'éditeur, prêt à être saisi. La même
liste de types est accessible depuis la palette de commandes (`Ctrl+Alt+Space`).
### Affichage et édition
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
**Enregistrer** envoie les cellules modifiées à
commandes reprend l'ordre de Google Sheets (recherche, annuler/rétablir,
impression, peinture, zoom, formats de nombre, police, couleurs, bordures,
fusion, alignements, retour ligne, rotation, options) et le bouton **⋮**
regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
**Mise en forme** / **Structure** restent disponibles. Elle est **épinglée en
haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que
les commandes restent atteignables pendant le défilement d'un grand tableau.
Les largeurs de colonnes et hauteurs de lignes s'ajustent en **glissant le
bord des en-têtes** (souris et tactile) : en plaçant le pointeur sur le bord
d'un en-tête de colonne (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…), le
curseur change pour indiquer l'ajustement.
### Sélectionner et saisir
Comme dans Google Sheets et Excel, une cellule est **sélectionnée** ou **en cours
de saisie** — jamais les deux à la fois.
- **Sélectionner** — un clic ou les flèches : la cellule s'entoure, sans curseur
clignotant.
- **Saisir** — un **double-clic** (le curseur se place sous le pointeur, la saisie
se fait en place), la **première frappe** (qui *remplace* le contenu au lieu
de s'y ajouter), ou `F2`.
- **Valider et descendre** — `Entrée` pendant la saisie enregistre et place le
focus **sur la cellule du dessous**. `Entrée` sur une cellule simplement
sélectionnée la bascule en saisie. `Tab` fait de même vers la droite. Les
**flèches** valident aussi et déplacent la sélection.
- **Quitter la saisie** — `Échap` restaure la valeur d'origine ; un clic ailleurs
conserve ce qui a été tapé.
- `Alt`+flèche insère un saut de ligne **dans** la cellule.
**Sélection multiple** — `Maj`+clic ou `Maj`+flèches étendent la plage depuis la
cellule d'origine ; glisser à la souris fait de même. Un **clic simple** réduit
toujours la sélection à la cellule cliquée. La plage est **contourée** comme dans
Excel, et les en-têtes couverts (lettres et numéros de ligne) sont mis en
évidence. Depuis les **marges**, un clic sur un numéro de ligne ou une lettre de
colonne sélectionne la **ligne ou la colonne entière**, et glisser — ou
`Maj`+clic — étend à **plusieurs lignes ou colonnes** (`A1:Z5`). Une sélection
de plusieurs cellules affiche une mini-barre (lien, commentaire,
réinitialiser le filtre, somme). **Enregistrer**, placé à droite des onglets
de feuilles, envoie les cellules modifiées à
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
**backup automatique** du fichier avant écriture, et une écriture
**atomique** (le classeur n'est jamais laissé à moitié écrit).
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Une
pastille **« Lecture seule »** rappelle la seule limite de la vue : les
formats `.xls`/`.ods`. ObsiGate calcule par ailleurs les formules courantes
à l'écran (arithmétique, `SOMME`, `MOYENNE`, `SI`…) ; les fonctions non
prises en charge affichent la formule telle qu'enregistrée, Excel la
recalcule à l'ouverture.
Le tableau suit le **thème de l'application** : fond, en-têtes, bordures et
lignes alternées sont dérivés du thème actif (tous les thèmes, y compris
contrasté élevé et sépia).
La grille montre toujours les colonnes **A à Z** et **1000 lignes**, comme un
tableur : les lignes et colonnes inutilisées restent affichées et leurs
cellules vides sont **éditables** comme les autres. Seule la feuille affichée
est étendue, à son ouverture.
### Avertissement avant enregistrement
@@ -262,18 +317,24 @@ la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteu
indique le nombre de feuilles concernées et passer sur une correspondance
**active l'onglet** qui la contient.
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
sur les valeurs calculées en cache) :
La sortie est rassemblée dans un seul menu **Fichier**, placé à droite des
onglets de feuilles, juste avant le bouton **Enregistrer**. Il propose cinq
actions, toujours sur le **contenu affiché** (et jamais sur les valeurs
calculées en cache) :
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
cellules est active (le nom du fichier reprend la plage, ex.
`Fruits-A1B2.csv`), sinon la feuille entière ;
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
HTML autonome, mêmes règles de sélection ;
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
sans le ruban ni les panneaux de l'application.
- **Télécharger le fichier** — le classeur d'origine, tel qu'il est sur le
disque ;
- **CSV** — exporte la **sélection** quand une plage de plusieurs cellules est
active (le nom du fichier reprend la plage, ex. `Fruits-A1B2.csv`), sinon la
feuille entière ;
- **Markdown** et **HTML** — tableau markdown ou document HTML autonome,
mêmes règles de sélection ;
- **Imprimer** — imprime la feuille ou la sélection seule, sans le ruban ni
les panneaux de l'application.
Rien de tout cela ne modifie le classeur.
Le menu se ferme au clic extérieur, à la perte de focus, avec `Échap` ou au
défilement, et se parcourt aux flèches. Rien de tout cela ne modifie le
classeur.
**Structure** — le menu **Structure** de la barre d'outils ajoute,
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
@@ -281,12 +342,23 @@ colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
automatique et confirmation, comme pour l'édition des cellules).
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
**sélection courante** (une cellule ou une plage) :
**sélection courante** (une cellule ou une plage), repris aussi dans la barre
d'outils (gras, italique, barré, souligné, taille, couleurs, bordures,
alignements, retour ligne, rotation) :
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
- **alignement** — gauche, centré, droite ;
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
palette imposée) ;
- **caractère** — gras, italique, souligné, barré, taille de police,
effacer la mise en forme ;
- **alignement** — gauche, centré, droite, justifié (+ vertical, retour à la
ligne, rotation) ;
- **bordures** — toutes, extérieures, aucune ;
- **couleurs** — couleur de police et couleur de fond, via un **panneau façon
Google Sheets** : *Réinitialiser*, une palette de 80 pastilles (une ligne de
gris du noir au blanc, puis huit teintes du clair au foncé), une section
**Standard** de huit couleurs prédéfinies et une section **Personnalisé**.
Un clic sur une pastille referme le panneau et applique la couleur à la
cellule ou à la plage sélectionnée. Le bouton **+** ouvre le sélecteur du
système pour une teinte libre. « Mise en forme conditionnelle » et
« Couleurs en alternance » sont annoncées mais pas encore disponibles ;
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
- **structure** — fusionner / défusionner les cellules, figer / libérer les
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
@@ -300,8 +372,10 @@ message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
n'y est pas proposé (il est également absent d'une grille en lecture seule).
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
Les **commentaires**, liens hypertexte, validation de données, mise en forme
conditionnelle et bordures restent hors périmètre.
Les **commentaires** se lisent (pastille) et s'éditent (mini-barre ou menu ⋮) ;
les liens s'insèrent comme formules `=LIEN(url;libellé)` (ouvr. `Ctrl+clic`).
Restent hors périmètre : validation de données, mise en forme conditionnelle,
graphiques et tableaux croisés dynamiques.
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
**macros sont préservées** à l'enregistrement (y compris le chargement des
@@ -354,11 +428,13 @@ une feuille, insérer/supprimer des lignes ou des colonnes).
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
plafond.
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
signale par un message. Excel reste la référence pour les valeurs calculées.
- **Moteur de formule local** : ObsiGate calcule à l'écran l'arithmétique, les
références, les plages et une trentaine de fonctions en français et en
anglais (`SOMME`, `MOYENNE`, `SI`, `LIEN`…). Une saisie commençant par `=`
ou `@` reste stockée comme **texte** (garde anti-DDE) tant que le bouton
`f(x)` n'est pas activé ; l'enregistrement vous le signale par un message.
Les fonctions non prises en charge affichent la valeur mise en cache par
Excel quand elle existe. Excel reste la référence pour les valeurs calculées.
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
actions de structure ; en revanche une **suppression** (feuille, ligne,
colonne) n'est pas annulable, faute d'inverse.
+227 -16
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.53.3 | **Dernière mise à jour :** 2026-10-07
> **Version :** 2.54.0 | **Dernière mise à jour :** 2026-10-08
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -62,7 +62,7 @@
---
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
@@ -112,7 +112,7 @@
---
## 🔵 En cours — Refonte UI/UX tableur (P2)
## ✅ Terminé — Refonte UI/UX tableur (P2)
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
@@ -139,7 +139,7 @@
---
## 🔵 En cours — Ergonomie tableur
## ✅ Terminé — Ergonomie tableur
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
@@ -157,12 +157,12 @@
---
## 🔵 En cours — Polish menus tableur
## ✅ Terminé — Polish menus tableur
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** 🔵 en cours depuis 2026-10-04
- **Statut :** ✅ **livré le 2026-10-04** (v2.53.0) — ouvert le 2026-10-04
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
@@ -181,33 +181,236 @@
---
## ✅ Terminé — Ergonomie & grille du tableur
### 186. Création d'un fichier Excel depuis « Créer un fichier »
- **Effort :** 0,5 jour | **Impact :** 🟢
- **Statut :** ✅ **livré le 2026-10-05** - ouvert le 2026-10-05
- **Fiche :** [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md)
- **Description :** l'éditeur Excel étant disponible, la modale « Créer un fichier » ne
proposait que des formats texte (`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`,
`.ps1`) + Excalidraw : impossible de démarrer un classeur depuis l'UI. Il fallait
passer par l'assistant IA (`create_xlsx`) ou déposer un fichier existant.
- **Sous-tâches :**
- [x] **A1** Option **Excel (.xlsx)** dans la liste des types, et les 10 libellés du
`<select>` passés en i18n FR/EN (`modal.ext_*`) — ils étaient codés en dur
- [x] **A2** `create_file()` backend : un `.xlsx` est une archive OPC, pas du texte —
le classeur est **construit avec openpyxl** (une feuille `Feuille1`) au lieu
d'écrire la charge utile vide, sinon le fichier est illisible par la visionneuse
- [x] **A3** Tests : non-régression pytest (`test_api_main.py` — classeur relu par
openpyxl et servi en `is_xlsx`), E2E `xlsx-viewer.spec.js` (option présente,
création depuis le menu contextuel, ouverture dans la grille)
---
## ✅ Terminé — Assistant IA
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
- **Effort :** 0,5 jour | **Impact :** 🟢
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
- **Statut :** ✅ **livré le 2026-10-07** (v2.53.2) — ouvert le 2026-10-06
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
profit d'un agent toujours actif : toute requête texte part sur
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
gardent la confirmation two-step) ; les images seules restent sur
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
quick actions `agent: true` basculaient déjà le mode en silence. Un
utilisateur pouvait donc voir l'assistant agir sans jamais avoir activé le
bouton. Retiré au profit d'un agent toujours actif : toute requête texte
part sur `/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les
mutations gardent la confirmation two-step) ; les images seules restent sur
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant
textuelle).
- **Sous-tâches :**
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
`/chat` (avant : `req.provider` brut transmis au provider adapter →
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
pydantic économisés par requête agent/MCP)
- [x] **A2** Optimisations requêtes AI : `/agent` résout désormais le
provider comme `/chat` (avant : `req.provider` brut transmis au
provider adapter → moteur réel ≠ étiquette SSE) ; schémas d'outils
mis en cache (~27 ms de pydantic économisés par requête agent/MCP)
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
ruff/mypy 0 erreur ; build + instance Docker locale à jour
---
## ✅ Terminé — Barre de menus & grille du tableur
### 185. Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
- **Fiche :** [features/xlsx-menus-185.md](./features/xlsx-menus-185.md)
- **Description :** l'éditeur n'avait **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 n'étaient atteignables
que par clic droit. Le ruban gardait un chromer bordersé qui ne ressemblait à
rien et la grille n'était bordée qu'en bas avec une couleur quasi identique au
fond des cellules — le quadrillage se lisait comme une tache.
- **Sous-tâches :**
- [x] **A1** `frontend/js/xlsx/menus.js` : les **dix menus de Google Sheets**,
dans l'ordre et avec les libellés exacts (161 entrées) ; sections en
accordéon ; le menu **Fichier** quitte la barre d'onglets pour la barre de
menus en **conservant ses identifiants**, seul *Enregistrer* reste
épinglé
- [x] **A2** **Majorité des fonctions câblées** : Partager, Renommer,
Corbeille, Historique, Détails ; Annuler/Rétablir/Couper/Copier/Coller,
**Collage spécial (valeurs)**, Supprimer, Tout sélectionner,
Rechercher-et-remplacer ; Figer, Afficher (barre de formule / grille /
en-têtes / tableau de bord), Zoom, Plein écran ; Lignes / Colonnes /
Cellules, Lien, Commentaire, Fonction ; Nombre, Thème, B/I/U/S, Taille,
Alignement, Retour ligne, Rotation, **Couleurs alternées**, Effacer ;
Trier, **Filtre**, Plages nommées, **Nettoyage (doublons / espaces)**,
**Diviser le texte en colonnes** ; Autocomplétion, Accessibilité ;
Gemini ; Aide, Mises à jour, Raccourcis, Signalement. Les 36 options
absentes d'ObsiGate **sont listées et le disent** au clic
- [x] **A3** **Ruban façon Sheets** : boutons plats 30 × 30 sans chromer,
survol discret, groupes par filets ; nouveau bouton grille piloté par le
menu Affichage
- [x] **A4** **Grille unie et visible** : 1 px **sur les quatre côtés** de
chaque cellule (en-têtes compris) et un seul `--grid-border` dérivé du
thème (`color-mix` texte → surface) au lieu de `--border` invisible
- [x] Tests JSDOM (`xlsx-menus` + `xlsx-viewer`), E2E `xlsx-viewer.spec.js`,
i18n FR/EN (134 clés `xlsx.m*` / `xlsx.mi_*`)
---
### 184. Tableur — peinture de format, multi-lignes/colonnes, grille A→Z × 1000
- **Effort :** 1 jour | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§8)
- **Description :** « Peindre le format » échouait quand la source n'avait pas de
format propre ; il manquait la sélection de plusieurs lignes/colonnes depuis
les marges ; la grille s'arrêtait aux dimensions réelles du fichier au lieu
d'aller jusqu'à A → Z et 1000 lignes.
- **Sous-tâches :**
- [x] **A1** Peinture de format **complète** (toutes propriétés) + source sans
format ⇒ **réinitialisation** de la cible ; fuite du handler annulé
- [x] **A2** Sélection multi-lignes / multi-colonnes par glissement sur les
marges et `Maj`+clic ; le geste de redimensionnement garde la priorité
- [x] **A3** Grille étendue à **A → Z × 1000 lignes**, cellules vides
éditables, padding à l'activation de l'onglet, pas de doublons
- [x] **A4** Performance : index de cellules `findTd`, court-circuits
formule/styles, marqueur `data-wired`
---
## ✅ Terminé — Panneau de couleurs du tableur
### 183. Panneau de sélection de couleurs façon Google Sheets (texte et fond)
- **Effort :** 0,5-1 jour | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§7)
- **Description :** le sélecteur natif `<input type="color">` du ruban et du menu
Mise en forme est remplacé par un panneau conforme aux maquettes : *Réinitialiser*,
palette 8 × 10, STANDARD, PERSONNALISÉ, pied de page. Un clic sur une pastille
referme le panneau et applique la couleur.
- **Sous-tâches :**
- [x] **A1** Module `frontend/js/xlsx/color-picker.js` : palette 8 × 10
(8 gris puis 8 teintes × 9 nuances), **sortie en `#rrggbb`**
- [x] **A2** Coche de sélection blanche/noire selon la luminance
- [x] **A3** Câblage ruban + menu Mise en forme, fermeture au clic, application
sur cellule **ou plage**, réinitialisation, « + » / crayons vers le
sélecteur natif
- [x] **A4** Chrome du panneau thémé (variables CSS), i18n FR/EN (21 clés),
pied de page honnête (message explicite pour les fonctions absentes)
---
## ✅ Terminé — Correctifs éditeur tableur
### 182. Tableur — désélection fiable & couleurs sur une plage
- **Effort :** 0,5 jour | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§6)
- **Description :** suite de #181 — la sélection multiple se dessinait mais ne
s'effaçait plus, et l'application d'une couleur de texte / de fond sur une
plage paraissait inerte.
- **Sous-tâches :**
- [x] **A1** Le clic simple efface toujours la plage, sans dépendre de
l'événement `focus` (cause : `Maj`+clic + `preventDefault()` figeait le
focus, donc aucun `focus` ne partait au clic suivant)
- [x] **A2** Après `Maj`+clic, le focus suit la cellule cliquée ; la zone Nom
affiche la plage après un glissement
- [x] **A3** Couleurs de texte et de fond vérifiées sur les 3 chemins
(menu Mise en forme, nuanciers du ruban, `Maj`+flèches et glissement)
- [x] **A4** Pastille de couleur sur les nuanciers du ruban (retour visuel)
- [x] **A5** **Contours de plage qui s'empilaient** : le sélecteur de
nettoyage oubliait le `.` des classes (`querySelectorAll("xlsx-sel-top")`
cherche une balise) — rien n'était effacé, les encadrés restaient et
s'ajoutaient à chaque changement de cellule. Même famille de faute sur
les en-têtes reflétés
- [x] **A6** Pendant la saisie, les flèches **valident et déplacent** la
sélection (au lieu de déplacer le curseur), comme Google Sheets ;
`Alt`+flèche garde le saut de ligne
- [x] Tests JSDOM `xlsx-viewer` **152/152** (+6), suites frontend vertes
---
## ✅ Terminé — Polish éditeur tableur (mise en page & thèmes)
### 181. Éditeur tableur — modèle de saisie Google Sheets (sélection ≠ édition) & écarts fonctionnels
- **Effort :** 1 jour (A1-A3) + inventaire des écarts (A4) | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md)
- **Description :** l'éditeur ne distingue pas **sélectionner** une cellule et
**l'éditer** : le curseur de texte est visible en permanence, `Entrée` valide
puis perd le focus, et l'indicateur de plage est discret. Aligner le modèle de
saisie sur Google Sheets / Excel (A1-A3) et **inventorier** précisément ce qui
manque encore face à un tableur de référence (A4) — le rapprochement exhaustif
n'est pas réalisable en une tranche.
- **Sous-tâches :**
- [x] **A1** Deux états distincts : *sélection* (clic / flèches, pas de
curseur via `caret-color: transparent`, contour visible) et *édition*
(double-clic en place, première frappe qui **remplace**, `F2`,
`Alt`+flèche pour un saut de ligne, `Échap` qui restaure)
- [x] **A2** Sélection multiple : glisser, `Maj`+clic, `Maj`+flèches, avec
contour de plage (`.xlsx-sel-*` sur les 4 bords réels) + en-têtes
`A`/`B`… et `1`/`2`… reflétés
- [x] **A3** `Entrée` contextuelle : en édition → valide et passe **à la
cellule du dessous** ; en sélection → bascule en **mode édition**
- [x] **A4** Inventaire des écarts vs Google Sheets / Excel et priorisation
(P1 poignée de recopie / remplacer / validation de données / filtre
auto / tri multi-critères ; P2 mise en forme conditionnelle / création
de graphiques / TCD éditables / protection ; P3 analyse de données,
historique, macros) — voir [fiche](./features/xlsx-sheet-input-181.md)
- [x] Tests JSDOM (`xlsx-viewer` **146/146**, +10 cas #181), suites frontend
vertes, build + déploiement de l'instance Docker locale
---
### 180. Éditeur tableur — redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
- **Fiche :** [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md)
- **Description :** retours utilisateur après #179 — (1) le redimensionnement des
colonnes/lignes par les marges (`A, B, C…` / `1, 2, 3…`) doit afficher un
curseur iconique (`col-resize` / `row-resize`) quand le pointeur est sur le
bord ; (2) la grille doit suivre le thème de l'utilisateur (15 thèmes ×
4 modes + importés : tokens dérivés, plus de fond figé) ; (3) regrouper
Télécharger / CSV / Exporter dans un **menu Fichier** en haut à droite, au
niveau des onglets de feuilles, avec **Enregistrer** juste à côté ;
(4) retirer l'indicateur « Formules calculées localement » ; (5) épingler la
barre de boutons en haut, au bord de la marge, et étendre l'éditeur à la
largeur de la page.
- **Sous-tâches :**
- [x] **A1** Curseur de redimensionnement sur les marges d'en-têtes
(en-tête de bord miroité en ligne + classe de repaint `.xlsx-resize-col/row`)
- [x] **A2** Grille compatible tous thèmes (`--grid-*` dérivés dans
`themes.js`, replis CSS, modes contraste élevé / sépia, menu Export sur `--surface`)
- [x] **A3** Menu Fichier + Enregistrer au niveau des onglets (`.xlsx-sheetbar`,
identifiants des contrôles conservés)
- [x] **A4** Retrait de l'indicateur de formules + barre épinglée pleine largeur
(`position: sticky`, `:has(.xlsx-viewer)` en plein cadre)
- [x] Tests JSDOM (`xlsx-viewer` 136/136, `xlsx-menus` 12/12), E2E `xlsx-viewer.spec.js`,
i18n FR/EN, 8 tests réalignés sur l'application locale des écritures
---
## ✅ Terminé — Navigation
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
@@ -475,6 +678,14 @@
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
| 179 | Tableur — icônes des menus & boutons, fermeture au focus, polish mobile | 2.53.0 | [features/xlsx-menus-179.md](./features/xlsx-menus-179.md) |
| 180 | Tableur — redimensionnement au curseur, grille thémée, menu Fichier, barre épinglée | 2.54.0 | [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md) |
| 181 | Tableur — modèle de saisie Google Sheets (sélection ≠ édition) & inventaire des écarts | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
| 182 | Tableur — désélection fiable, contours non empilés, couleurs sur une plage | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
| 183 | Tableur — panneau de couleurs façon Google Sheets (texte et fond) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
| 184 | Tableur — peinture de format complète, multi-lignes/colonnes, grille A → Z × 1000 (croissance par blocs) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) |
| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) |
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
+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.
+20
View File
@@ -587,6 +587,26 @@ function applyTheme(themeKey, mode) {
Object.keys(vars).forEach(function(key) {
root.style.setProperty(key, vars[key]);
});
// #180 — the spreadsheet grid follows the active theme: derive its tokens
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
// imports) gets a matching grid instead of the hardcoded dark/light pair.
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
// cell background on most themes, so the grid was invisible). The line is now
// a blend of the text colour into the sheet surface: the SAME single value
// for every line, and legible on both dark and light grounds.
var _gridSurface = vars['--surface'] || vars['--bg-secondary'] || '#000000';
var _gridInk = vars['--text-primary'] || vars['--text'] || '#888888';
var _grid = {
'--grid-bg': _gridSurface,
'--grid-header-bg': vars['--bg-hover'] || _gridSurface,
'--grid-header-text': _gridInk,
'--grid-border': 'color-mix(in srgb, ' + _gridInk + ' 22%, ' + _gridSurface + ')',
'--grid-zebra': 'color-mix(in srgb, ' + _gridInk + ' 4%, transparent)'
};
Object.keys(_grid).forEach(function(key) {
if (_grid[key]) root.style.setProperty(key, _grid[key]);
});
// Store resolved vars on the theme for export/preview
if (!theme.modes[mode]) theme.modes[mode] = vars;
+10 -9
View File
@@ -795,15 +795,16 @@ const FileOperations = {
<div class="modal-form-group">
<label class="modal-label">${t('modal.file_type')}</label>
<select class="modal-select" id="file-ext-select">
<option value=".md">Markdown (.md)</option>
<option value=".txt">Texte (.txt)</option>
<option value=".py">Python (.py)</option>
<option value=".js">JavaScript (.js)</option>
<option value=".json">JSON (.json)</option>
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
<option value=".yaml">YAML (.yaml)</option>
<option value=".sh">Shell (.sh)</option>
<option value=".ps1">PowerShell (.ps1)</option>
<option value=".md">${t('modal.ext_md')}</option>
<option value=".txt">${t('modal.ext_txt')}</option>
<option value=".py">${t('modal.ext_py')}</option>
<option value=".js">${t('modal.ext_js')}</option>
<option value=".json">${t('modal.ext_json')}</option>
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
<option value=".yaml">${t('modal.ext_yaml')}</option>
<option value=".sh">${t('modal.ext_sh')}</option>
<option value=".ps1">${t('modal.ext_ps1')}</option>
</select>
</div>
</div>
+15
View File
@@ -695,6 +695,21 @@ async function reloadExternalWrite(vault, path, force = false) {
}
// Not editing: refresh the read view when it shows the written document.
if (state.currentVault === vault && state.currentPath === path) {
// The spreadsheet viewer applies its own writes dynamically (values,
// styles and structure stay in the DOM): reloading it here would wipe the
// grid behind a loading spinner on every save. Skip the reload when the
// event is the watcher echo of our own write (recent stamp) or when
// unsaved edits are still on screen (they would be clobbered). Forced
// reloads (assistant writes) still go through.
const xlsxOpen = !force
&& document.querySelector("#content-area .xlsx-viewer");
if (xlsxOpen) {
const own = window.__xlsxLastOwnWrite;
const recentOwn = own && own.vault === vault && own.path === path
&& (Date.now() - (own.at || 0)) < 15000;
const hasDirty = Boolean(document.querySelector("#content-area td.xlsx-dirty"));
if (recentOwn || hasDirty) return;
}
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
// refresh) and discard the in-editor scene. Its own save already persisted
// the file, so there is nothing to reload here.
+3629 -185
View File
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
/* ObsiGate — XLSX colour picker panel (#183).
*
* Replaces the native `<input type="color">` of the spreadsheet ribbon and of
* the Mise en forme menu with a Google-Sheets-like panel:
*
* ┌ header ─ « Réinitialiser » ───────────────────┐
* │ palette 8 x 10 : ligne 1 = échelles de gris, │
* │ lignes 2-10 = 8 teintes x 9 │
* │ nuances (clair -> foncé) │
* │ STANDARD 8 pastilles prédéfinies │
* │ PERSONNALISÉ pastille + bouton « + » + crayon │
* │ pied Mise en forme conditionnelle, │
* │ Couleurs en alternance │
* └────────────────────────────────────────────────┘
*
* Picking a colour closes the panel and applies it (the caller owns the
* write). The panel markup is built here, positioning and dismissal are the
* viewer's (it already owns `anchorUnder` + `trackDismissable` for the other
* spreadsheet menus), so this module stays a pure builder.
*/
import { t } from '../i18n.js';
// 8 columns = 8 hues, in the Google Sheets order: red, orange, yellow, light
// green, cyan, blue, violet, magenta.
const HUES = [0, 30, 55, 100, 180, 215, 270, 320];
// 9 value rows, light (top) -> dark (bottom).
const ROWS = 9;
const GREYS = 8;
/**
* HSL → `#rrggbb`.
*
* The palette is generated in HSL for readability, but it MUST leave this
* module as hex: the write path (`normHex` in viewer.js) and the backend only
* accept `#rrggbb`, so an `hsl(...)` swatch would be silently dropped on save.
*/
export function hslToHex(h, s, l) {
const hue = ((h % 360) + 360) % 360;
const sat = Math.min(1, Math.max(0, s));
const lig = Math.min(1, Math.max(0, l));
const c = (1 - Math.abs(2 * lig - 1)) * sat;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = lig - c / 2;
const seg = Math.floor(hue / 60) % 6;
const rgb = [
[c, x, 0], [x, c, 0], [0, c, x],
[0, x, c], [x, 0, c], [c, 0, x],
][seg] || [0, 0, 0];
return `#${rgb.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, "0")).join("")}`;
}
/** Relative luminance (WCAG-ish) of a `#rrggbb` string, 0 (black) → 1 (white). */
export function luminanceOf(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
if (!m) return 0;
const n = parseInt(m[1], 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* The 80 swatches: a first row of greys then 9 x 8 hues.
* @returns {Array<Array<string>>} 10 rows of 8 CSS colours
*/
export function buildPalette() {
const rows = [];
const greys = [];
for (let i = 0; i < GREYS; i++) {
// black -> white in 8 steps (l stays a 0..1 ratio, converted by hslToHex)
greys.push(hslToHex(0, 0, i / (GREYS - 1)));
}
rows.push(greys);
for (let r = 0; r < ROWS; r++) {
const l = 0.90 - r * (0.60 / (ROWS - 1)); // 0.90 -> 0.30
const s = 0.60 + r * (0.25 / (ROWS - 1)); // 0.60 -> 0.85
rows.push(HUES.map((h) => hslToHex(h, s, l)));
}
return rows;
}
/** The 8 predefined colours of the STANDARD row. */
export const STANDARD_COLOURS = [
{ key: "black", hex: "#000000" },
{ key: "white", hex: "#ffffff" },
{ key: "blue", hex: "#1a73e8" },
{ key: "red", hex: "#d93025" },
{ key: "yellow", hex: "#f4b400" },
{ key: "green", hex: "#188038" },
{ key: "orange", hex: "#e8710a" },
{ key: "cyan", hex: "#00bcd4" },
];
const CHECK_SVG =
'<svg class="xlsx-colour-check" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true" focusable="false">'
+ '<path fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/></svg>';
const icon = (name) =>
`<i data-lucide="${name}" class="xlsx-colour-icon" aria-hidden="true"></i>`;
const button = (cls, label, ariaLabel) => {
const b = document.createElement("button");
b.type = "button";
b.className = `btn-action ${cls}`;
b.title = label;
b.setAttribute("aria-label", ariaLabel || label);
return b;
};
/**
* A single round swatch.
* @param {string} colour - CSS colour
* @param {string} label - tooltip / aria-label
* @param {boolean} selected - draw the check mark
*/
const swatchButton = (colour, label, selected) => {
const b = document.createElement("button");
b.type = "button";
b.className = "xlsx-colour-swatch-btn";
b.title = label;
b.setAttribute("aria-label", label);
b.style.background = colour;
b.dataset.colour = colour;
if (selected) {
b.classList.add("selected");
b.setAttribute("aria-pressed", "true");
// White check on dark grounds, black on light ones — otherwise the mark
// disappears (the same contrast rule as the design).
b.style.color = luminanceOf(colour) > 0.55 ? "#000000" : "#ffffff";
b.innerHTML = CHECK_SVG;
}
return b;
};
/**
* Build the panel.
*
* @param {object} opts
* @param {"font"|"fill"} opts.kind - drives the panel label and the footer
* @param {string} [opts.value] - current colour (`""` = none)
* @param {string} [opts.custom] - colour shown in the PERSONNALISÉ slot
* @param {Function} opts.onPick - (cssColour) => void; the panel is closed by
* the caller right after
* @param {Function} [opts.onReset] - clear the colour entirely
* @param {Function} [opts.onCustom] - open the native picker for a custom
* colour, called with no argument
* @param {Function} [opts.onUnavailable] - (i18nLabel) => void for the footer
* entries that ObsiGate does not implement yet
* @returns {{ el: HTMLElement, sync: Function }}
*/
export function createColourPicker({
kind = "font",
value = "",
custom = "",
onPick,
onReset,
onCustom,
onUnavailable,
} = {}) {
const panel = document.createElement("div");
panel.className = "xlsx-colour-panel";
panel.setAttribute("role", "dialog");
panel.setAttribute(
"aria-label",
kind === "fill" ? t("xlsx.colour_panel_fill") : t("xlsx.colour_panel_font")
);
const isFill = kind === "fill";
const norm = (c) => String(c || "").trim().toLowerCase();
// ── Header : Réinitialiser ────────────────────────────────────────────────
const head = document.createElement("div");
head.className = "xlsx-colour-head";
const reset = button(
"xlsx-colour-reset",
t("xlsx.colour_reset"),
isFill ? t("xlsx.colour_reset_fill") : t("xlsx.colour_reset_font")
);
reset.innerHTML = `${icon(isFill ? "eraser" : "paintbrush")}<span>${t("xlsx.colour_reset")}</span>`;
reset.addEventListener("click", () => {
if (onReset) onReset();
});
head.appendChild(reset);
panel.appendChild(head);
// ── Palette 8 x 10 ───────────────────────────────────────────────────────
const grid = document.createElement("div");
grid.className = "xlsx-colour-grid";
grid.setAttribute("role", "group");
grid.setAttribute("aria-label", t("xlsx.colour_palette"));
const current = norm(value);
buildPalette().forEach((row) => {
row.forEach((colour) => {
const b = swatchButton(colour, colour, norm(colour) === current);
b.addEventListener("click", () => {
if (onPick) onPick(colour);
});
grid.appendChild(b);
});
});
panel.appendChild(grid);
// ── STANDARD ─────────────────────────────────────────────────────────────
const standard = document.createElement("div");
standard.className = "xlsx-colour-section";
const stdHead = document.createElement("div");
stdHead.className = "xlsx-colour-title";
stdHead.appendChild(document.createTextNode(t("xlsx.colour_standard")));
const stdEdit = button(
"xlsx-colour-edit",
t("xlsx.colour_edit_standard"),
t("xlsx.colour_edit_standard")
);
stdEdit.innerHTML = icon("pencil");
stdEdit.addEventListener("click", () => {
if (onCustom) onCustom();
});
stdHead.appendChild(stdEdit);
standard.appendChild(stdHead);
const stdRow = document.createElement("div");
stdRow.className = "xlsx-colour-row";
STANDARD_COLOURS.forEach(({ key, hex }) => {
const label = t(`xlsx.colour_${key}`);
const b = swatchButton(hex, label, norm(hex) === current);
b.addEventListener("click", () => {
if (onPick) onPick(hex);
});
stdRow.appendChild(b);
});
standard.appendChild(stdRow);
panel.appendChild(standard);
// ── PERSONNALISÉ ─────────────────────────────────────────────────────────
const customSection = document.createElement("div");
customSection.className = "xlsx-colour-section";
const cusHead = document.createElement("div");
cusHead.className = "xlsx-colour-title";
cusHead.appendChild(document.createTextNode(t("xlsx.colour_custom")));
customSection.appendChild(cusHead);
const cusRow = document.createElement("div");
cusRow.className = "xlsx-colour-row";
const customColour = custom || value;
const cusSwatch = swatchButton(
customColour || "transparent",
t("xlsx.colour_custom"),
Boolean(customColour) && norm(customColour) === current
);
cusSwatch.addEventListener("click", () => {
if (customColour && onPick) onPick(customColour);
});
cusRow.appendChild(cusSwatch);
const add = button("xlsx-colour-add", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
add.innerHTML = icon("plus");
add.addEventListener("click", () => {
if (onCustom) onCustom();
});
cusRow.appendChild(add);
const cusEdit = button("xlsx-colour-edit", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
cusEdit.innerHTML = icon("pencil");
cusEdit.addEventListener("click", () => {
if (onCustom) onCustom();
});
cusRow.appendChild(cusEdit);
customSection.appendChild(cusRow);
panel.appendChild(customSection);
// ── Footer ───────────────────────────────────────────────────────────────
// Both entries are announced by ObsiGate but not implemented yet: they call
// back with their label so the caller can tell the user instead of leaving a
// dead button. « Couleurs en alternance » only shows once a colour is set,
// exactly like the reference screenshots.
const foot = document.createElement("div");
foot.className = "xlsx-colour-foot";
const footEntry = (label, key) => {
const b = document.createElement("button");
b.type = "button";
b.className = "xlsx-colour-foot-item";
b.textContent = label;
b.dataset.colourAction = key;
b.addEventListener("click", () => {
if (onUnavailable) onUnavailable(label);
});
return b;
};
foot.appendChild(footEntry(t("xlsx.colour_conditional"), "conditional"));
if (value) foot.appendChild(footEntry(t("xlsx.colour_banded"), "banded"));
panel.appendChild(foot);
/** Repaint the check mark after the value changed (custom colour applied). */
const sync = (next, nextCustom) => {
const want = norm(next);
panel.querySelectorAll(".xlsx-colour-swatch-btn").forEach((b) => {
const on = norm(b.dataset.colour) === want;
b.classList.toggle("selected", on);
b.setAttribute("aria-pressed", String(on));
b.innerHTML = on
? (b.style.color = luminanceOf(b.dataset.colour) > 0.55 ? "#000000" : "#ffffff", CHECK_SVG)
: "";
});
if (nextCustom) {
const slot = panel.querySelector(".xlsx-colour-section:nth-of-type(3) .xlsx-colour-row .xlsx-colour-swatch-btn");
if (slot) slot.style.background = nextCustom;
}
};
return { el: panel, sync };
}
+128 -25
View File
@@ -1,19 +1,29 @@
/* ObsiGate — XLSX command bar builder (#154-A5).
/* ObsiGate — XLSX command bar builder (Sheets-like ribbon).
*
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
* is testable and the viewer only wires behaviour.
*
* Order follows Google Sheets: search, undo/redo, print, paint, zoom, number
* formats, font size, B/I/S/U, colours, borders, merge, alignments, wrap,
* rotation, overflow menu — then the ObsiGate-specific groups (structure,
* format, view, gridlines) kept with their stable ids. File outputs
* (Download / CSV / Markdown / HTML / Print) moved to the **Fichier** menu of
* the menu bar (#185, frontend/js/xlsx/menus.js); only Save stays on the
* sheet-tab strip, pinned at its right end.
*/
import { t } from '../i18n.js';
import { escapeHtml } from '../utils.js';
const IC = (name) => `<i data-lucide="${name}" style="width:14px;height:14px" aria-hidden="true"></i>`;
/**
* @param {object} ctx
* @param {Array<{name: string}>} ctx.sheets
* @param {boolean} ctx.isCsv
* @param {boolean} ctx.readOnly
* @param {boolean} ctx.editable - not a CSV and not read-only
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
* @returns {{ tabs: string, actionsHtml: string, statusBar: string, fileHtml: string }}
*/
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
@@ -26,19 +36,23 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
: ""}</div>`;
// Status pills make the viewer's limits visible up front.
// Status pills make the viewer's limits visible up front (#180: the
// "formulas computed locally" pill is gone — local computation is the
// normal behaviour, not a limit worth a badge).
const statusPills = [
readOnly
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
: "",
!isCsv
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
: "",
].filter(Boolean).join("");
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
// Command groups, separated by thin rules.
// Command groups, separated by thin rules (Sheets order).
const actionGroups = [];
actionGroups.push(`<span class="xlsx-cmd-group" data-group="search">
<button class="btn-action" id="xs-search" type="button" title="${escapeHtml(t("xlsx.tb_search"))}" aria-label="${escapeHtml(t("xlsx.tb_search"))}">
${IC("search")}
</button>
</span>`);
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
@@ -48,6 +62,97 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
</button>
</span>`);
}
actionGroups.push(`<span class="xlsx-cmd-group" data-group="print">
<button class="btn-action" id="xs-print" type="button" title="${escapeHtml(t("xlsx.print"))}" aria-label="${escapeHtml(t("xlsx.print"))}">
${IC("printer")}
</button>
</span>`);
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="paint">
<button class="btn-action" id="xs-paint" type="button" title="${escapeHtml(t("xlsx.tb_paint"))}" aria-label="${escapeHtml(t("xlsx.tb_paint"))}" aria-pressed="false">
${IC("paint-roller")}
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" data-group="zoom">
<select class="xlsx-select" id="xs-zoom" title="${escapeHtml(t("xlsx.tb_zoom"))}" aria-label="${escapeHtml(t("xlsx.tb_zoom"))}">
${[50, 75, 90, 100, 125, 150, 200].map((z) =>
`<option value="${z}"${z === 100 ? " selected" : ""}>${z} %</option>`).join("")}
</select>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" data-group="number">
<button class="btn-action" id="xs-cur" type="button" title="${escapeHtml(t("xlsx.tb_currency"))}" aria-label="${escapeHtml(t("xlsx.tb_currency"))}">
${IC("dollar-sign")}
</button>
<button class="btn-action" id="xs-pct" type="button" title="${escapeHtml(t("xlsx.tb_percent"))}" aria-label="${escapeHtml(t("xlsx.tb_percent"))}">
${IC("percent")}
</button>
<button class="btn-action xlsx-dec" id="xs-decminus" type="button" title="${escapeHtml(t("xlsx.tb_dec_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_minus"))}">.0&larr;</button>
<button class="btn-action xlsx-dec" id="xs-decplus" type="button" title="${escapeHtml(t("xlsx.tb_dec_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_plus"))}">.00&rarr;</button>
<button class="btn-action" id="xs-num123" type="button" title="${escapeHtml(t("xlsx.tb_number"))}" aria-label="${escapeHtml(t("xlsx.tb_number"))}">123</button>
<select class="xlsx-select" id="xs-numfmt" title="${escapeHtml(t("xlsx.tb_numfmt"))}" aria-label="${escapeHtml(t("xlsx.tb_numfmt"))}">
<option value="" selected>${escapeHtml(t("xlsx.tb_numfmt"))}</option>
<option value="General">${escapeHtml(t("xlsx.format_num_general"))}</option>
<option value="0.00">${escapeHtml(t("xlsx.format_num_number"))}</option>
<option value="0.0%">${escapeHtml(t("xlsx.format_num_percent"))}</option>
<option value="#,##0.00 &quot;€&quot;">${escapeHtml(t("xlsx.format_num_currency"))}</option>
<option value="DD/MM/YYYY">${escapeHtml(t("xlsx.format_num_date"))}</option>
<option value="@">${escapeHtml(t("xlsx.format_num_text"))}</option>
</select>
</span>`);
// #183 — colour commands open the Sheets-like panel built by
// xlsx/color-picker.js (the native <input type="color"> is kept hidden and
// only driven by the panel's « + » / crayon buttons).
const colourPick = (id, label, iconName, swatchId) =>
`<span class="xlsx-colorpick-wrap">`
+ `<button class="btn-action xlsx-colorpick" id="${id}" type="button"`
+ ` data-xlsx-colour="${swatchId === "xs-fgcolor" ? "font" : "fill"}"`
+ ` title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}"`
+ ` aria-haspopup="dialog" aria-expanded="false">`
+ (iconName === "fill" ? IC("paint-bucket") : '<span class="xlsx-colorletter" aria-hidden="true">A</span>')
+ `<span class="xlsx-color-swatch" data-swatch-for="${swatchId}" aria-hidden="true"></span>`
+ `</button>`
+ `<input type="color" class="xlsx-color-native" data-xlsx-native-for="${swatchId}" tabindex="-1" aria-hidden="true">`
+ `</span>`;
actionGroups.push(`<span class="xlsx-cmd-group" data-group="font">
<button class="btn-action" id="xs-fsminus" type="button" title="${escapeHtml(t("xlsx.tb_font_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_minus"))}">&minus;</button>
<select class="xlsx-select xlsx-fontsize" id="xs-fssize" title="${escapeHtml(t("xlsx.tb_font_size"))}" aria-label="${escapeHtml(t("xlsx.tb_font_size"))}">
${[6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) =>
`<option value="${s}"${s === 11 ? " selected" : ""}>${s}</option>`).join("")}
</select>
<button class="btn-action" id="xs-fsplus" type="button" title="${escapeHtml(t("xlsx.tb_font_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_plus"))}">+</button>
<button class="btn-action xlsx-fontstyle" id="xs-b" type="button" title="${escapeHtml(t("xlsx.format_bold"))}" aria-label="${escapeHtml(t("xlsx.format_bold"))}" aria-pressed="false"><b>B</b></button>
<button class="btn-action xlsx-fontstyle" id="xs-i" type="button" title="${escapeHtml(t("xlsx.format_italic"))}" aria-label="${escapeHtml(t("xlsx.format_italic"))}" aria-pressed="false"><i>I</i></button>
<button class="btn-action xlsx-fontstyle" id="xs-s" type="button" title="${escapeHtml(t("xlsx.format_strike"))}" aria-label="${escapeHtml(t("xlsx.format_strike"))}" aria-pressed="false"><s>S</s></button>
<button class="btn-action xlsx-fontstyle" id="xs-u" type="button" title="${escapeHtml(t("xlsx.format_underline"))}" aria-label="${escapeHtml(t("xlsx.format_underline"))}" aria-pressed="false"><u>U</u></button>
${colourPick("xs-fgcolor", t("xlsx.format_font_color"), "font", "xs-fgcolor")}
${colourPick("xs-bgcolor", t("xlsx.format_fill_color"), "fill", "xs-bgcolor")}
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" data-group="arrange">
<button class="btn-action" id="xs-borders" type="button" title="${escapeHtml(t("xlsx.tb_borders"))}" aria-label="${escapeHtml(t("xlsx.tb_borders"))}" aria-haspopup="menu" aria-expanded="false">
${IC("grid-2x2")}
</button>
<button class="btn-action" id="xs-merge" type="button" title="${escapeHtml(t("xlsx.tb_merge"))}" aria-label="${escapeHtml(t("xlsx.tb_merge"))}">
${IC("combine")}
</button>
<button class="btn-action" id="xs-alignh" type="button" title="${escapeHtml(t("xlsx.tb_alignh"))}" aria-label="${escapeHtml(t("xlsx.tb_alignh"))}" aria-haspopup="menu" aria-expanded="false">
${IC("align-left")}
</button>
<button class="btn-action" id="xs-alignv" type="button" title="${escapeHtml(t("xlsx.tb_alignv"))}" aria-label="${escapeHtml(t("xlsx.tb_alignv"))}" aria-haspopup="menu" aria-expanded="false">
${IC("align-center-vertical")}
</button>
<button class="btn-action" id="xs-wrap" type="button" title="${escapeHtml(t("xlsx.tb_wrap"))}" aria-label="${escapeHtml(t("xlsx.tb_wrap"))}" aria-pressed="false">
${IC("wrap-text")}
</button>
<button class="btn-action" id="xs-rot" type="button" title="${escapeHtml(t("xlsx.tb_rotation"))}" aria-label="${escapeHtml(t("xlsx.tb_rotation"))}" aria-haspopup="menu" aria-expanded="false">
${IC("rotate-cw")}
</button>
<button class="btn-action" id="xs-more" type="button" title="${escapeHtml(t("xlsx.tb_more"))}" aria-label="${escapeHtml(t("xlsx.tb_more"))}" aria-haspopup="menu" aria-expanded="false">
${IC("more-vertical")}
</button>
</span>`);
}
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
@@ -64,26 +169,24 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
}
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
// #185 — the ribbon now reads like Google Sheets': flat icon buttons, no
// chrome, grouped by thin vertical rules. The gridline switch belongs to it
// (the View ▸ Show menu toggles the very same state).
actionGroups.push(`<span class="xlsx-cmd-group" data-group="grid">
<button class="btn-action active" id="xs-grid" type="button" aria-pressed="true"
title="${escapeHtml(t("xlsx.mi_show_grid"))}" aria-label="${escapeHtml(t("xlsx.mi_show_grid"))}">
${IC("grid-3x3")}
</button>
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<span class="export-dropdown xlsx-export-dropdown">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
</div>
</span>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
return { tabs, actionsHtml, statusBar };
// #185 — file outputs moved to the **Fichier** menu of the menu bar
// (frontend/js/xlsx/menus.js), which keeps the `#xlsx-download-btn` /
// `#xlsx-csv-btn` ids and the `[data-xlsx-export]` markers. Only **Save**
// stays on the sheet-tab strip, pinned at its right end.
const fileHtml =
`<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>`
+ `<i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>`;
return { tabs, actionsHtml, statusBar, fileHtml };
}
+50 -17
View File
@@ -14,14 +14,15 @@ import { safeCreateIcons } from '../utils.js';
let _menu = null;
let _openedAt = 0;
// Only a touch long-press can emit a click right after the menu opened.
let _openedByTouch = false;
let _dismissBound = false;
export function closeContextMenu() {
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
// that holds the focus fires blur/focusout synchronously, which re-enters
// this very function; the second remove() on the mid-flight node throws
// NotFoundError and aborts the caller's click handler before its action
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
// Drop the reference BEFORE touching the DOM: removing a menu that holds
// the focus fires blur/focusout synchronously, which re-enters this very
// function — a second remove() on the mid-flight node throws NotFoundError
// and aborts the caller's click handler before its action runs.
const menu = _menu;
_menu = null;
if (menu) menu.remove();
@@ -36,9 +37,11 @@ function bindDismiss() {
_dismissBound = true;
document.addEventListener("click", (e) => {
if (!_menu) return;
// Ignore the very gesture that opened the menu (touch long-press can emit
// a click right after).
if (Date.now() - _openedAt < 250) return;
// Ignore the very gesture that opened the menu — but only for a touch
// long-press, which is the one that emits a trailing click. A mouse
// right-click does not, so a deliberate outside click made right after
// must close the menu instead of being swallowed.
if (_openedByTouch && Date.now() - _openedAt < 250) return;
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
closeContextMenu();
});
@@ -56,8 +59,12 @@ function bindDismiss() {
});
if (typeof window !== "undefined") {
window.addEventListener("blur", closeContextMenu);
// A scroll or resize detaches the viewport-anchored menu from its cell.
window.addEventListener("scroll", closeContextMenu, true);
// A scroll detaches the viewport-anchored menu from its cell — except the
// tail of the smooth scroll that revealed the right-clicked cell (global
// `scroll-behavior: smooth` keeps firing after the menu opened).
window.addEventListener("scroll", () => {
if (Date.now() - _openedAt > 400) closeContextMenu();
}, true);
window.addEventListener("resize", closeContextMenu);
}
}
@@ -111,25 +118,50 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
if (onClose) onClose();
else menuEl.remove();
};
// A re-render can detach the menu without dismissing it: unbind silently
// instead of leaking listeners on a dead node.
const dropIfDetached = () => {
if (!menuEl.isConnected) {
if (!done) { done = true; unbind(); }
return true;
}
return false;
};
const onDocClick = (e) => {
if (done) return;
if (!menuEl.isConnected) { done = true; unbind(); return; }
if (done || dropIfDetached()) return;
const t = e.target;
if (menuEl.contains(t)) return;
if (toggle && (toggle === t || toggle.contains(t))) return;
dismiss();
};
const onKey = (e) => {
if (e.key === "Escape" && menuEl.isConnected) dismiss();
if (dropIfDetached()) return;
if (e.key === "Escape") dismiss();
};
const onFocusOut = (e) => {
if (done || !menuEl.isConnected) return;
if (done || dropIfDetached()) return;
const next = e.relatedTarget;
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
dismiss();
};
const onScroll = () => {
if (menuEl.isConnected) dismiss();
// Toolbar-anchored menus travel WITH their button: a scroll inside the
// hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's
// own overflow) must not dismiss them — only an outside scroll detaches.
// Same grace as the grid menu for the smooth-scroll tail of the opening.
// A body-level menu has no travelling anchor: every scroll detaches it.
const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar"))
|| menuEl.parentElement
|| null;
const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body
? rawAnchor
: null;
const openedAt = Date.now();
const onScroll = (e) => {
if (dropIfDetached()) return;
if (Date.now() - openedAt < 400) return;
const t = e && e.target;
if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return;
dismiss();
};
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
@@ -147,7 +179,7 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
bindDismiss();
closeContextMenu();
const menu = document.createElement("div");
@@ -198,6 +230,7 @@ export function openContextMenu(x, y, items) {
_menu = menu;
_openedAt = Date.now();
_openedByTouch = byTouch;
// Keyboard users land on the first entry right away; mouse users are
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
+755
View File
@@ -0,0 +1,755 @@
/* ObsiGate — minimal client-side spreadsheet formula engine.
*
* Evaluates `=…` / `@…` cell content locally (same sheet, plus loaded
* cross-sheet panels) so functions work without a round-trip: arithmetic,
* comparisons, %, cell/range references and ~30 FR/EN functions.
*
* Safe by construction: own tokenizer + recursive-descent parser, no eval().
* Anything the parser rejects keeps its original rendering (Excel stays the
* source of truth on save); evaluation failures surface as Excel-style error
* codes instead.
*
* Limits (documented): same-workbook only, no whole-column (A:A) references,
* TODAY()/NOW() refresh on edit/render (no timer), array results show their
* top-left value.
*/
const MAX_COL = 18278; // XFD
const MAX_ROW = 1048576;
/** FR aliases accepted next to the EN canonical names. */
const ALIASES = {
SOMME: "SUM", MOYENNE: "AVERAGE", NB: "COUNT", NBVAL: "COUNTA",
PRODUIT: "PRODUCT", MEDIANE: "MEDIAN", ARRONDI: "ROUND", ENT: "INT",
RACINE: "SQRT", PUISSANCE: "POWER", SI: "IF", ET: "AND", OU: "OR",
NON: "NOT", SIERREUR: "IFERROR", CONCATENER: "CONCAT",
MAJUSCULES: "UPPER", MINUSCULES: "LOWER", NBCAR: "LEN",
SUPPRESPACE: "TRIM", GAUCHE: "LEFT", DROITE: "RIGHT", STXT: "MID",
AUJOURDHUI: "TODAY", MAINTENANT: "NOW", JOUR: "DAY", MOIS: "MONTH",
ANNEE: "YEAR", LIEN: "HYPERLINK",
};
const ERRORS = {
fr: { DIV: "#DIV/0!", VALUE: "#VALEUR!", NAME: "#NOM?", REF: "#REF!", CYCLE: "#CYCLE!" },
en: { DIV: "#DIV/0!", VALUE: "#VALUE!", NAME: "#NAME?", REF: "#REF!", CIRC: "#CIRC!" },
};
export function errorCodes(locale) {
return ERRORS[locale === "fr" ? "fr" : "en"];
}
export function isFormulaText(s) {
if (typeof s !== "string") return false;
const t = s.trim();
return t.length > 1 && (t[0] === "=" || t[0] === "@");
}
/** Column letters (no $) → 1-based index, or 0 when invalid. */
export function colToIndex(letters) {
let n = 0;
const up = String(letters || "").toUpperCase();
if (!/^[A-Z]{1,3}$/.test(up)) return 0;
for (const ch of up) n = n * 26 + (ch.charCodeAt(0) - 64);
return n > MAX_COL ? 0 : n;
}
export function indexToCol(n) {
let s = "";
while (n > 0) {
const m = (n - 1) % 26;
s = String.fromCharCode(65 + m) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
/** Parse "[$]A[$]1" (already stripped of any sheet prefix) → {col,row} | null. */
export function parseA1(ref) {
const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(String(ref || "").trim());
if (!m) return null;
const col = colToIndex(m[1]);
const row = Number(m[2]);
if (!col || !(row >= 1 && row <= MAX_ROW)) return null;
return { col, row };
}
// ── Tokenizer ─────────────────────────────────────────────────────────────
function tokenize(src, sep) {
const tokens = [];
let i = 0;
const n = src.length;
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
while (i < n) {
const c = src[i];
if (isSpace(c)) { i++; continue; }
if (c === '"') {
let j = i + 1;
let out = "";
while (j < n) {
if (src[j] === '"') {
if (src[j + 1] === '"') { out += '"'; j += 2; }
else break;
} else { out += src[j]; j++; }
}
if (j >= n) throw new Error("string");
tokens.push({ t: "str", v: out });
i = j + 1;
continue;
}
if (c === "'") {
// Quoted sheet name: 'Mon Sheet'!A1
let j = i + 1;
let out = "";
while (j < n && src[j] !== "'") { out += src[j]; j++; }
if (j >= n) throw new Error("sheet");
tokens.push({ t: "sheet", v: out });
i = j + 1;
continue;
}
const numMatch = /^[0-9]+(?:[.,][0-9]+)?/.exec(src.slice(i));
if (numMatch && (sep === ";" || !numMatch[0].includes(","))) {
tokens.push({ t: "num", v: numMatch[0].replace(",", ".") });
i += numMatch[0].length;
continue;
}
const absRef = /^\$?[A-Za-z]{1,3}\$?[0-9]+/.exec(src.slice(i));
if (absRef) {
tokens.push({ t: "ref", v: absRef[0] });
i += absRef[0].length;
continue;
}
const two = src.slice(i, i + 2);
if (["<=", ">=", "<>"].includes(two)) {
tokens.push({ t: "op", v: two === "<>" ? "!=" : two });
i += 2;
continue;
}
if ("+-*/^%=()<>,;:&!".includes(c)) {
tokens.push({ t: c === "," || c === ";" ? "sep" : "op", v: c });
i++;
continue;
}
const word = /^[A-Za-zÀ-ÿ_][A-Za-zÀ-ÿ0-9_.]*/.exec(src.slice(i));
if (word) {
const w = word[0];
// A bare cell reference (A1, $B$12) — but not TRUE/FALSE yet (bools).
if (/^\$?[A-Za-z]{1,3}\$?[0-9]+$/.test(w)) {
tokens.push({ t: "ref", v: w });
} else {
const up = w.toUpperCase();
if (up === "TRUE" || up === "VRAI") tokens.push({ t: "bool", v: true });
else if (up === "FALSE" || up === "FAUX") tokens.push({ t: "bool", v: false });
else tokens.push({ t: "word", v: w });
}
i += w.length;
continue;
}
throw new Error("char:" + c);
}
return tokens;
}
// ── Parser (recursive descent, builds a small AST) ─────────────────────────
function makeParser(tokens, sep) {
let pos = 0;
const peek = () => tokens[pos] || null;
const next = () => tokens[pos++] || null;
const expectOp = (v) => {
const t = next();
if (!t || t.t !== "op" || t.v !== v) throw new Error("expected " + v);
};
const expectSep = () => {
const t = next();
if (!t || t.t !== "sep") throw new Error("expected separator");
};
function parseExpr() { return parseComparison(); }
function parseComparison() {
let left = parseConcat();
for (;;) {
const t = peek();
if (!t || t.t !== "op" || !["=", "!=", "<", "<=", ">", ">="].includes(t.v)) return left;
next();
left = [t.v, left, parseConcat()];
}
}
function parseConcat() {
let left = parseAdd();
for (;;) {
const t = peek();
if (!t || t.t !== "op" || t.v !== "&") return left;
next();
left = ["&", left, parseAdd()];
}
}
function parseAdd() {
let left = parseMul();
for (;;) {
const t = peek();
if (!t || t.t !== "op" || (t.v !== "+" && t.v !== "-")) return left;
next();
left = [t.v, left, parseMul()];
}
}
function parseMul() {
let left = parsePower();
for (;;) {
const t = peek();
if (!t || t.t !== "op" || (t.v !== "*" && t.v !== "/")) return left;
next();
left = [t.v, left, parsePower()];
}
}
// Excel-compatible: a leading sign binds to the base (-2^2 = (-2)^2 = 4),
// and to the exponent on the right (2^-2).
function parseSigned() {
let neg = false;
for (;;) {
const t = peek();
if (t && t.t === "op" && (t.v === "+" || t.v === "-")) {
if (t.v === "-") neg = !neg;
next();
} else break;
}
const node = parsePostfix();
return neg ? ["neg", node] : node;
}
function parsePower() {
const base = parseSigned();
const t = peek();
if (t && t.t === "op" && t.v === "^") {
next();
return ["^", base, parseSigned()];
}
return base;
}
function parsePostfix() {
let node = parsePrimary();
for (;;) {
const t = peek();
if (t && t.t === "op" && t.v === "%") { next(); node = ["%", node]; }
else return node;
}
}
function parsePrimary() {
const t = next();
if (!t) throw new Error("empty");
if (t.t === "num") return ["n", Number(t.v)];
if (t.t === "str") return ["s", t.v];
if (t.t === "bool") return ["b", t.v];
if (t.t === "ref") {
const after = peek();
if (after && after.t === "op" && after.v === ":") {
next();
const t2 = next();
if (!t2 || t2.t !== "ref") throw new Error("range");
return ["range", normRef(t.v), normRef(t2.v)];
}
return ["ref", normRef(t.v)];
}
if (t.t === "word" || t.t === "sheet") {
const isSheet = t.t === "sheet";
let name = t.v;
if (!isSheet) {
// Unquoted sheet name: Name!A1
const bang = peek();
if (bang && bang.t === "op" && bang.v === "!") {
next();
} else {
// Function call?
const paren = peek();
if (paren && paren.t === "op" && paren.v === "(") {
next();
const args = [];
const closing = peek();
if (!(closing && closing.t === "op" && closing.v === ")")) {
for (;;) {
args.push(parseExpr());
const s = peek();
if (s && s.t === "sep") { next(); continue; }
break;
}
}
expectOp(")");
return ["call", canonName(name), args];
}
throw new Error("name:" + name);
}
} else {
const bang = peek();
if (!bang || bang.t !== "op" || bang.v !== "!") throw new Error("sheetref");
next();
}
const r1 = next();
if (!r1 || r1.t !== "ref") throw new Error("sheetref");
const colon = peek();
if (colon && colon.t === "op" && colon.v === ":") {
next();
const r2 = next();
if (!r2 || r2.t !== "ref") throw new Error("sheetrange");
return ["sheetrange", name, normRef(r1.v), normRef(r2.v)];
}
return ["sheetref", name, normRef(r1.v)];
}
if (t.t === "op" && t.v === "(") {
const e = parseExpr();
expectOp(")");
return e;
}
throw new Error("primary");
}
function normRef(r) {
return r.replace(/\$/g, "").toUpperCase();
}
function canonName(name) {
const up = String(name).toUpperCase();
return ALIASES[up] || up;
}
return { parseExpr, atEnd: () => pos >= tokens.length };
}
export function parseFormula(text) {
const body = String(text || "").trim().replace(/^[=@]/, "");
const sep = body.includes(";") ? ";" : ",";
// A comma is a decimal mark (FR style) when ';' is the separator, or when
// the expression holds no letters at all ("=1,5+1" can only mean 1.5 + 1).
const commaDecimal = sep === ";" || !/[A-Za-zÀ-ÿ]/.test(body);
const { parseExpr, atEnd } = makeParser(tokenize(body, commaDecimal ? ";" : sep), sep);
const ast = parseExpr();
if (!atEnd()) throw new Error("trailing");
return ast;
}
// ── Evaluator ───────────────────────────────────────────────────────────────
/** Engine scope: resolve raw cell values + locale + cycle guard. */
export function createScope({ readRaw, sheet, locale = "en", seen = null } = {}) {
const scope = {
sheet: sheet || "",
locale: locale === "fr" ? "fr" : "en",
seen: seen || new Set(),
memo: new Map(),
/** Engine value of `sheet!ref` (formulas recurse, cycles guarded). */
getCell(targetSheet, ref) {
const key = `${targetSheet || scope.sheet}!${String(ref).toUpperCase()}`;
const raw = readRaw ? readRaw(targetSheet || scope.sheet, ref) : null;
return evalRefValue(raw, scope, key);
},
};
return scope;
}
const err = (code) => ({ t: "e", code });
const isErr = (v) => v && v.t === "e";
const isBlank = (v) => !v || v.t === "blank";
function toNumber(v) {
if (!v || v.t === "blank") return 0;
if (v.t === "n") return v.v;
if (v.t === "b") return v.v ? 1 : 0;
if (v.t === "s") {
const s = v.v.trim().replace(",", ".");
if (s === "") return 0;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
return null;
}
function toText(v, locale) {
if (!v || v.t === "blank") return "";
if (v.t === "s") return v.v;
if (v.t === "n") return numText(v.v);
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
if (v.t === "link") return v.label;
return "";
}
export function numText(n) {
if (!Number.isFinite(n)) return String(n);
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
return String(parseFloat(n.toPrecision(10)));
}
function flattenArgs(vals) {
const out = [];
const walk = (v) => {
if (v && v.t === "a") v.rows.forEach((r) => r.forEach(walk));
else out.push(v);
};
vals.forEach(walk);
return out;
}
function evalRefValue(raw, scope, key) {
// Raw storage text → engine value (formulas recurse, cycles guarded).
if (raw === null || raw === undefined) return { t: "blank" };
if (typeof raw === "number") return { t: "n", v: raw };
if (typeof raw === "boolean") return { t: "b", v: raw };
const s = String(raw);
if (scope.memo.has(key)) return scope.memo.get(key);
let out;
if (isFormulaText(s)) {
if (scope.seen.has(key)) {
out = err("CYCLE");
} else {
scope.seen.add(key);
try {
out = evaluate(parseFormula(s), scope);
} catch (_) {
out = err("NAME");
}
scope.seen.delete(key);
}
} else if (s.trim() === "") {
out = { t: "blank" };
} else {
const n = Number(s.trim().replace(",", "."));
out = s.trim() !== "" && Number.isFinite(n) && /^[-+0-9.,\s]+$/.test(s.trim())
? { t: "n", v: n }
: { t: "s", v: s };
}
scope.memo.set(key, out);
return out;
}
function rangeValues(r1, r2, scope) {
const a = parseA1(r1);
const b = parseA1(r2);
if (!a || !b) return err("REF");
const rStart = Math.min(a.row, b.row);
const rEnd = Math.max(a.row, b.row);
const cStart = Math.min(a.col, b.col);
const cEnd = Math.max(a.col, b.col);
if ((rEnd - rStart + 1) * (cEnd - cStart + 1) > 100000) return err("VALUE");
const rows = [];
for (let r = rStart; r <= rEnd; r++) {
const line = [];
for (let c = cStart; c <= cEnd; c++) {
const ref = indexToCol(c) + r;
line.push(scope.getCell(scope.sheet, ref));
}
rows.push(line);
}
return { t: "a", rows };
}
function firstOf(v) {
if (v && v.t === "a") {
for (const row of v.rows) for (const cell of row) {
if (cell && cell.t !== "blank") return cell;
}
return { t: "blank" };
}
return v;
}
function cmpValues(a, b) {
// Excel-ish: numbers < text; blank sorts with text "".
const rank = (v) => (!v || v.t === "blank" ? 0 : v.t === "n" ? 1 : v.t === "b" ? 2 : 3);
const ra = rank(a);
const rb = rank(b);
if (ra !== rb) return ra - rb;
if (ra === 1) return a.v - b.v;
if (ra === 2) return (a.v ? 1 : 0) - (b.v ? 1 : 0);
const sa = toText(a);
const sb = toText(b);
return sa < sb ? -1 : sa > sb ? 1 : 0;
}
const FUNCTIONS = {
SUM: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
if (isBlank(v)) return acc;
const n = toNumber(v);
return n === null ? acc : acc + n;
}, 0)),
AVERAGE: (vals, scope) => {
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
if (!nums.length) return err("DIV");
return num(nums.reduce((a, b) => a + b, 0) / nums.length);
},
MIN: (vals) => {
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
return nums.length ? num(Math.min(...nums)) : num(0);
},
MAX: (vals) => {
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
return nums.length ? num(Math.max(...nums)) : num(0);
},
COUNT: (vals) => num(flattenArgs(vals).filter((v) => v && v.t === "n").length),
COUNTA: (vals) => num(flattenArgs(vals).filter((v) => !isBlank(v)).length),
PRODUCT: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
if (isBlank(v)) return acc;
const n = toNumber(v);
return n === null ? acc : acc * n;
}, 1)),
MEDIAN: (vals) => {
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null).sort((a, b) => a - b);
if (!nums.length) return err("DIV");
const m = Math.floor(nums.length / 2);
return num(nums.length % 2 ? nums[m] : (nums[m - 1] + nums[m]) / 2);
},
ABS: ([v]) => {
const n = toNumber(firstOf(v));
return n === null ? err("VALUE") : num(Math.abs(n));
},
ROUND: ([v, d]) => {
const n = toNumber(firstOf(v));
const digits = d === undefined ? 0 : toNumber(firstOf(d));
if (n === null || digits === null) return err("VALUE");
const f = 10 ** Math.trunc(digits);
return num(Math.round(n * f) / f);
},
INT: ([v]) => {
const n = toNumber(firstOf(v));
return n === null ? err("VALUE") : num(Math.floor(n));
},
SQRT: ([v]) => {
const n = toNumber(firstOf(v));
if (n === null) return err("VALUE");
if (n < 0) return err("VALUE");
return num(Math.sqrt(n));
},
POWER: ([a, b]) => {
const x = toNumber(firstOf(a));
const y = toNumber(firstOf(b));
if (x === null || y === null) return err("VALUE");
const r = x ** y;
return Number.isFinite(r) ? num(r) : err("VALUE");
},
MOD: ([a, b]) => {
const x = toNumber(firstOf(a));
const y = toNumber(firstOf(b));
if (x === null || y === null || y === 0) return y === 0 ? err("DIV") : err("VALUE");
return num(((x % y) + y) % y);
},
CONCAT: (vals, scope) => ({ t: "s", v: flattenArgs(vals).map((v) => toText(v, scope.locale)).join("") }),
UPPER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toUpperCase() }),
LOWER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toLowerCase() }),
LEN: ([v], scope) => num([...toText(firstOf(v), scope.locale)].length),
TRIM: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).trim().replace(/\s+/g, " ") }),
LEFT: ([v, n]) => {
const s = toText(firstOf(v), "en");
const k = n === undefined ? 1 : toNumber(firstOf(n));
if (k === null) return err("VALUE");
return { t: "s", v: s.slice(0, Math.max(0, Math.trunc(k))) };
},
RIGHT: ([v, n]) => {
const s = toText(firstOf(v), "en");
const k = n === undefined ? 1 : toNumber(firstOf(n));
if (k === null) return err("VALUE");
return { t: "s", v: k <= 0 ? "" : s.slice(-Math.trunc(k)) };
},
MID: ([v, start, len]) => {
const s = toText(firstOf(v), "en");
const a = toNumber(firstOf(start));
const k = len === undefined ? s.length : toNumber(firstOf(len));
if (a === null || k === null || a < 1) return err("VALUE");
return { t: "s", v: s.slice(Math.trunc(a) - 1, Math.trunc(a) - 1 + Math.max(0, Math.trunc(k))) };
},
TODAY: () => ({ t: "s", v: new Date().toLocaleDateString() }),
NOW: () => ({ t: "s", v: new Date().toLocaleString() }),
DAY: ([v]) => datePart(v, "day"),
MONTH: ([v]) => datePart(v, "month"),
YEAR: ([v]) => datePart(v, "year"),
NOT: ([v]) => ({ t: "b", v: !toBool(firstOf(v)) }),
HYPERLINK: ([url, label], scope) => {
const u = toText(firstOf(url), scope.locale).trim();
if (!/^https?:\/\//i.test(u)) return err("VALUE");
const text = label === undefined ? u : toText(firstOf(label), scope.locale);
return { t: "link", url: u, label: text };
},
};
function num(n) {
return { t: "n", v: n };
}
function toBool(v) {
if (!v || v.t === "blank") return false;
if (v.t === "b") return v.v;
if (v.t === "n") return v.v !== 0;
const s = v.t === "s" ? v.v.trim().toUpperCase() : "";
if (["TRUE", "VRAI", "1", "YES", "OUI"].includes(s)) return true;
if (["FALSE", "FAUX", "0", "", "NO", "NON"].includes(s)) return false;
return s !== "";
}
function datePart(v, part) {
const first = firstOf(v);
let d = null;
if (first && first.t === "s") {
const parsed = new Date(first.v);
if (!Number.isNaN(parsed.getTime())) d = parsed;
} else if (first && first.t === "n") {
// Excel serial date (1900 system).
const base = Date.UTC(1899, 11, 30);
d = new Date(base + Math.trunc(first.v) * 86400000);
}
if (!d) return err("VALUE");
if (part === "day") return num(d.getDate());
if (part === "month") return num(d.getMonth() + 1);
return num(d.getFullYear());
}
function callFunction(name, argNodes, scope) {
if (name === "IF") {
if (argNodes.length < 2 || argNodes.length > 3) return err("NAME");
const cond = evaluate(argNodes[0], scope);
if (isErr(cond)) return cond;
const branch = toBool(cond) ? argNodes[1] : argNodes[2];
if (!branch) return { t: "b", v: false };
return evaluate(branch, scope);
}
if (name === "AND" || name === "OR") {
let out = name === "AND";
for (const node of argNodes) {
const v = evaluate(node, scope);
if (isErr(v)) return v;
const b = toBool(v);
if (name === "AND" && !b) return { t: "b", v: false };
if (name === "OR" && b) return { t: "b", v: true };
out = b;
}
return { t: "b", v: out };
}
if (name === "IFERROR") {
if (!argNodes.length || argNodes.length > 2) return err("NAME");
const v = evaluate(argNodes[0], scope);
if (!isErr(v)) return v;
if (v.code === "CYCLE" || v.code === "CIRC") return v;
return argNodes[1] ? evaluate(argNodes[1], scope) : { t: "blank" };
}
const fn = FUNCTIONS[name];
if (!fn) return err("NAME");
const vals = argNodes.map((node) => evaluate(node, scope));
for (const v of vals) {
if (isErr(v)) {
// Errors propagate, except through aggregations over ranges.
if (["SUM", "AVERAGE", "MIN", "MAX", "COUNT", "COUNTA", "PRODUCT", "MEDIAN", "CONCAT"].includes(name)) {
continue;
}
return v;
}
}
return fn(vals, scope);
}
export function evaluate(node, scope) {
if (Array.isArray(node)) {
const [kind, ...rest] = node;
switch (kind) {
case "n": return { t: "n", v: rest[0] };
case "s": return { t: "s", v: rest[0] };
case "b": return { t: "b", v: rest[0] };
case "neg": {
const v = firstOf(evaluate(rest[0], scope));
if (isErr(v)) return v;
const n = toNumber(v);
return n === null ? err("VALUE") : num(-n);
}
case "%": {
const v = firstOf(evaluate(rest[0], scope));
if (isErr(v)) return v;
const n = toNumber(v);
return n === null ? err("VALUE") : num(n / 100);
}
case "ref": {
const p = parseA1(rest[0]);
if (!p) return err("REF");
return scope.getCell(scope.sheet, indexToCol(p.col) + p.row);
}
case "range": return rangeValues(rest[0], rest[1], scope);
case "sheetref": {
const [, sheet, ref] = node;
const p = parseA1(ref);
if (!p) return err("REF");
return scope.getCell(sheet, indexToCol(p.col) + p.row);
}
case "sheetrange": {
const [, sheet, r1, r2] = node;
const a = parseA1(r1);
const b = parseA1(r2);
if (!a || !b) return err("REF");
const rows = [];
for (let r = Math.min(a.row, b.row); r <= Math.max(a.row, b.row); r++) {
const line = [];
for (let c = Math.min(a.col, b.col); c <= Math.max(a.col, b.col); c++) {
line.push(scope.getCell(sheet, indexToCol(c) + r));
}
rows.push(line);
}
return { t: "a", rows };
}
case "call": return callFunction(rest[0], rest[1], scope);
case "+":
case "-":
case "*":
case "/":
case "^":
case "&": {
const l = firstOf(evaluate(rest[0], scope));
const r = firstOf(evaluate(rest[1], scope));
if (isErr(l)) return l;
if (isErr(r)) return r;
if (kind === "&") return { t: "s", v: toText(l, scope.locale) + toText(r, scope.locale) };
const a = toNumber(l);
const b = toNumber(r);
if (a === null || b === null) return err("VALUE");
if (kind === "+") return num(a + b);
if (kind === "-") return num(a - b);
if (kind === "*") return num(a * b);
if (kind === "/") return b === 0 ? err("DIV") : num(a / b);
if (kind === "^") {
const v = a ** b;
return Number.isFinite(v) ? num(v) : err("VALUE");
}
return err("VALUE");
}
case "=":
case "!=":
case "<":
case "<=":
case ">":
case ">=": {
const l = firstOf(evaluate(rest[0], scope));
const r = firstOf(evaluate(rest[1], scope));
if (isErr(l)) return l;
if (isErr(r)) return r;
const c = cmpValues(l, r);
const out = kind === "=" ? c === 0 : kind === "!=" ? c !== 0
: kind === "<" ? c < 0 : kind === "<=" ? c <= 0
: kind === ">" ? c > 0 : c >= 0;
return { t: "b", v: out };
}
default:
return err("NAME");
}
}
return err("NAME");
}
/** Render an evaluated value for display (localized booleans/errors). */
export function displayValue(v, locale = "en") {
const codes = errorCodes(locale);
if (!v || v.t === "blank") return "";
if (v.t === "n") return numText(v.v);
if (v.t === "s") return v.v;
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
if (v.t === "link") return v.label;
if (v.t === "e") {
if (v.code === "CYCLE" || v.code === "CIRC") {
return locale === "fr" ? codes.CYCLE : codes.CIRC;
}
return codes[v.code] || codes.VALUE;
}
if (v.t === "a") {
for (const row of v.rows) for (const cell of row) {
if (cell && cell.t !== "blank") return displayValue(cell, locale);
}
return "";
}
return "";
}
+513
View File
@@ -0,0 +1,513 @@
/* ObsiGate — XLSX menu bar (the ten Google Sheets menus).
*
* Pure declarative data + a string builder, in the same spirit as
* `command-bar.js`: the menu tree lives here and stays testable on its own,
* while `viewer.js` only resolves each entry's symbolic `action` against its
* own flows. No DOM here beyond `innerHTML` of the shell.
*
* Entry shape
* -----------
* { labelKey, icon, action, children, unavailable, danger, needs, sep }
* - `labelKey` i18n key of the visible label (never raw text).
* - `icon` Lucide icon name, hydrated later by `safeCreateIcons()`.
* - `action` symbolic name resolved by the viewer. An entry with no
* action — or flagged `unavailable` — is rendered honestly and
* reports that ObsiGate does not implement it: a documented
* dead end, never a silent no-op.
* - `children` a Sheets-style accordion section (Nombre, Thème, Zoom, …):
* collapsed, expanded by a click on its parent row.
* - `needs` `"editable"` (hide unless the workbook can be written) or
* `"notReadOnly"` (hide only for .xls/.ods).
* - `sep` a thin rule instead of an entry.
*
* The Fichier menu keeps the historical `#xlsx-file-btn` / `#xlsx-file-menu` /
* `#xlsx-download-btn` / `#xlsx-csv-btn` ids and the `[data-xlsx-export]`
* markers: the File menu moved from the sheet strip to the menu bar, but every
* consumer (viewer wiring, tests, E2E) keeps working untouched.
*/
import { t } from '../i18n.js';
import { escapeHtml } from '../utils.js';
const IC = (name) => `<i data-lucide="${name}" class="xlsx-menu-icon" aria-hidden="true"></i>`;
/** Google Sheets' menu bar: same order, same entries, same French labels. */
export const SHEETS_MENUS = [
{
id: "file",
labelKey: "xlsx.mbar_file",
btnId: "xlsx-file-btn",
dropId: "xlsx-file-menu",
items: [
{ labelKey: "xlsx.mi_new", icon: "file-plus", unavailable: true },
{ labelKey: "xlsx.mi_open", icon: "folder-open", unavailable: true },
{ labelKey: "xlsx.mi_import", icon: "file-input", unavailable: true },
{ labelKey: "xlsx.mi_make_copy", icon: "copy", unavailable: true },
{ labelKey: "xlsx.mi_share", icon: "share-2", action: "share" },
{ labelKey: "xlsx.mi_email", icon: "mail", unavailable: true },
{ sep: true },
{
labelKey: "xlsx.mi_download",
icon: "download",
children: [
{ labelKey: "viewer.download", icon: "download", id: "xlsx-download-btn", action: "download" },
{ labelKey: "xlsx.export_csv", icon: "file-spreadsheet", id: "xlsx-csv-btn", action: "export-csv", titleKey: "xlsx.csv_export" },
{ sep: true },
{ labelKey: "xlsx.export_md", icon: "file-text", export: "md", action: "export-md" },
{ labelKey: "xlsx.export_html", icon: "code", export: "html", action: "export-html" },
{ labelKey: "xlsx.print", icon: "printer", export: "print", action: "print" },
],
},
{ labelKey: "xlsx.mi_rename", icon: "pencil", action: "rename" },
{ labelKey: "xlsx.mi_trash", icon: "trash-2", action: "trash", danger: true },
{ labelKey: "xlsx.mi_history", icon: "history", action: "history" },
{ labelKey: "xlsx.mi_offline", icon: "cloud-off", unavailable: true },
{ sep: true },
{ labelKey: "xlsx.mi_details", icon: "info", action: "details" },
{ labelKey: "xlsx.mi_settings", icon: "settings", action: "settings" },
{ labelKey: "xlsx.print", icon: "printer", action: "print" },
],
},
{
id: "edit",
labelKey: "xlsx.mbar_edit",
items: [
{ labelKey: "xlsx.undo", icon: "undo-2", action: "undo", needs: "notReadOnly" },
{ labelKey: "xlsx.redo", icon: "redo-2", action: "redo", needs: "notReadOnly" },
{ sep: true },
{ labelKey: "xlsx.cut", icon: "scissors", action: "cut", needs: "editable" },
{ labelKey: "xlsx.copy", icon: "copy", action: "copy" },
{ labelKey: "xlsx.paste", icon: "clipboard-paste", action: "paste", needs: "editable" },
{
labelKey: "xlsx.mi_paste_special",
icon: "clipboard-paste",
needs: "editable",
children: [
{ labelKey: "xlsx.mi_paste_all", icon: "clipboard-paste", action: "paste" },
{ labelKey: "xlsx.mi_paste_values", icon: "pilcrow", action: "paste-values" },
],
},
{ sep: true },
{
labelKey: "xlsx.mi_move",
icon: "move",
needs: "editable",
children: [
{ labelKey: "xlsx.mi_move_rows", icon: "rows", unavailable: true },
{ labelKey: "xlsx.mi_move_cols", icon: "columns", unavailable: true },
],
},
{
labelKey: "xlsx.mi_delete",
icon: "trash-2",
needs: "editable",
children: [
{ labelKey: "xlsx.mi_delete_rows", icon: "rows", action: "delete-rows", danger: true },
{ labelKey: "xlsx.mi_delete_cols", icon: "columns", action: "delete-cols", danger: true },
{ sep: true },
{ labelKey: "xlsx.clear_contents", icon: "eraser", action: "clear-contents" },
],
},
{ sep: true },
{ labelKey: "xlsx.mi_select_all", icon: "check-check", action: "select-all" },
{ labelKey: "xlsx.mi_replace", icon: "replace", action: "find-replace", needs: "editable" },
],
},
{
id: "view",
labelKey: "xlsx.mbar_view",
items: [
{
labelKey: "xlsx.mi_freeze",
icon: "snowflake",
children: [
{ labelKey: "xlsx.mi_freeze_here", icon: "snowflake", action: "freeze" },
{ labelKey: "xlsx.mi_freeze_row", icon: "rows", action: "freeze-row" },
{ labelKey: "xlsx.mi_freeze_col", icon: "columns", action: "freeze-col" },
{ sep: true },
{ labelKey: "xlsx.format_unfreeze", icon: "sun", action: "unfreeze" },
],
},
{
labelKey: "xlsx.mi_show",
icon: "eye",
children: [
{ labelKey: "xlsx.mi_show_formula", icon: "chevrons-right", action: "toggle-formula-bar" },
{ labelKey: "xlsx.mi_show_grid", icon: "grid-3x3", action: "toggle-grid" },
{ labelKey: "xlsx.mi_show_headers", icon: "rows-3", action: "toggle-headers" },
{ sep: true },
{ labelKey: "xlsx.mi_show_dashboard", icon: "layout-dashboard", action: "dashboard" },
],
},
{
labelKey: "xlsx.mi_group",
icon: "group",
children: [
{ labelKey: "xlsx.mi_group_rows", icon: "rows", unavailable: true },
{ labelKey: "xlsx.mi_group_cols", icon: "columns", unavailable: true },
{ sep: true },
{ labelKey: "xlsx.mi_ungroup_rows", icon: "ungroup", unavailable: true },
{ labelKey: "xlsx.mi_ungroup_cols", icon: "ungroup", unavailable: true },
],
},
{
labelKey: "xlsx.mi_hidden_sheets",
icon: "eye-off",
children: [
{ labelKey: "xlsx.mi_hide_sheet", icon: "eye-off", unavailable: true },
{ labelKey: "xlsx.mi_unhide_sheets", icon: "eye", unavailable: true },
],
},
{
labelKey: "xlsx.mi_zoom",
icon: "zoom-in",
children: [50, 75, 90, 100, 125, 150, 200].map((z) => ({
labelKey: null,
label: `${z} %`,
icon: z === 100 ? "crosshair" : null,
action: "zoom",
arg: z,
})),
},
{ labelKey: "xlsx.mi_fullscreen", icon: "fullscreen", action: "fullscreen" },
],
},
{
id: "insert",
labelKey: "xlsx.mbar_insert",
items: [
{
labelKey: "xlsx.mi_insert_rows",
icon: "rows",
needs: "editable",
children: [
{ labelKey: "xlsx.insert_row_above", icon: "arrow-up", action: "insert-rows-above" },
{ labelKey: "xlsx.insert_row_below", icon: "arrow-down", action: "insert-rows-below" },
],
},
{
labelKey: "xlsx.mi_insert_cols",
icon: "columns",
needs: "editable",
children: [
{ labelKey: "xlsx.insert_col_left", icon: "arrow-left", action: "insert-cols-left" },
{ labelKey: "xlsx.insert_col_right", icon: "arrow-right", action: "insert-cols-right" },
],
},
{ labelKey: "xlsx.mi_insert_cells", icon: "grid-2x2-plus", action: "insert-cells", needs: "editable" },
{ sep: true },
{ labelKey: "xlsx.mi_image", icon: "image", unavailable: true },
{ labelKey: "xlsx.mi_chart", icon: "bar-chart-3", unavailable: true },
{ labelKey: "xlsx.mi_drawing", icon: "pen-tool", unavailable: true },
{ labelKey: "xlsx.link_add", icon: "link", action: "link", needs: "editable" },
{ labelKey: "xlsx.comment_add", icon: "message-square", action: "comment", needs: "editable" },
{
labelKey: "xlsx.mi_function",
icon: "function-square",
needs: "editable",
titleKey: "xlsx.mi_function_hint",
action: "function",
},
{ sep: true },
{ labelKey: "xlsx.mi_pivot", icon: "table-2", unavailable: true },
{ labelKey: "xlsx.mi_smart_chips", icon: "tags", unavailable: true },
{ labelKey: "xlsx.mi_form", icon: "clipboard-list", unavailable: true },
],
},
{
id: "format",
labelKey: "xlsx.mbar_format",
items: [
{
labelKey: "xlsx.mi_number",
icon: "hash",
needs: "editable",
children: [
{ labelKey: "xlsx.format_num_general", icon: "type", action: "numfmt", arg: "General" },
{ labelKey: "xlsx.format_num_number", icon: "hash", action: "numfmt", arg: "0.00" },
{ labelKey: "xlsx.format_num_percent", icon: "percent", action: "numfmt", arg: "0.0%" },
{ labelKey: "xlsx.format_num_currency", icon: "euro", action: "numfmt", arg: '#,##0.00 "€"' },
{ labelKey: "xlsx.format_num_date", icon: "calendar", action: "numfmt", arg: "DD/MM/YYYY" },
{ labelKey: "xlsx.format_num_text", icon: "pilcrow", action: "numfmt", arg: "@" },
],
},
{
labelKey: "xlsx.mi_theme",
icon: "swatch-book",
// Filled on demand by the viewer (the theme list is built from
// themes.js, so it must not be hard-coded here).
children: [],
action: "theme-menu",
},
{ sep: true },
{ labelKey: "xlsx.format_bold", icon: "bold", action: "bold", needs: "editable" },
{ labelKey: "xlsx.format_italic", icon: "italic", action: "italic", needs: "editable" },
{ labelKey: "xlsx.format_underline", icon: "underline", action: "underline", needs: "editable" },
{ labelKey: "xlsx.format_strike", icon: "strikethrough", action: "strike", needs: "editable" },
{
labelKey: "xlsx.mi_font_size",
icon: "type",
needs: "editable",
children: [9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) => ({
labelKey: null,
label: String(s),
icon: null,
action: "fontsize",
arg: s,
})),
},
{
labelKey: "xlsx.mi_align",
icon: "align-justify",
needs: "editable",
children: [
{
labelKey: "xlsx.mi_align_h",
icon: "align-left",
children: [
{ labelKey: "xlsx.format_align_left", icon: "align-left", action: "align", arg: "left" },
{ labelKey: "xlsx.format_align_center", icon: "align-center", action: "align", arg: "center" },
{ labelKey: "xlsx.format_align_right", icon: "align-right", action: "align", arg: "right" },
{ labelKey: "xlsx.format_align_justify", icon: "align-justify", action: "align", arg: "justify" },
],
},
{
labelKey: "xlsx.mi_align_v",
icon: "align-center-vertical",
children: [
{ labelKey: "xlsx.valign_top", icon: "align-start-vertical", action: "valign", arg: "top" },
{ labelKey: "xlsx.valign_middle", icon: "align-center-vertical", action: "valign", arg: "middle" },
{ labelKey: "xlsx.valign_bottom", icon: "align-end-vertical", action: "valign", arg: "bottom" },
],
},
],
},
{
labelKey: "xlsx.mi_wrap",
icon: "wrap-text",
needs: "editable",
children: [
{ labelKey: "xlsx.mi_wrap_on", icon: "wrap-text", action: "wrap-on" },
{ labelKey: "xlsx.mi_wrap_off", icon: "remove-formatting", action: "wrap-off" },
],
},
{
labelKey: "xlsx.mi_rotation",
icon: "rotate-cw",
needs: "editable",
children: [
{ labelKey: "xlsx.rotation_0", icon: "minus", action: "rotate", arg: 0 },
{ labelKey: "xlsx.rotation_45", icon: "trending-up", action: "rotate", arg: 45 },
{ labelKey: "xlsx.rotation_315", icon: "trending-down", action: "rotate", arg: -45 },
{ labelKey: "xlsx.rotation_90", icon: "arrow-up", action: "rotate", arg: 90 },
],
},
{ sep: true },
// Tailles de colonnes / de lignes — même emplacement que Google Sheets
// (Format ▸ Redimensionner) et Excel (Format ▸ Colonne / Ligne) :
// saisie manuelle (unités Excel : caractères / points) + ajustement
// automatique au contenu (AutoFit / « Fit to data »).
{
labelKey: "xlsx.mi_resize",
icon: "move-horizontal",
needs: "editable",
children: [
{ labelKey: "xlsx.format_col_width", icon: "move-horizontal", action: "col-width" },
{ labelKey: "xlsx.format_col_fit", icon: "maximize-2", action: "col-fit" },
{ labelKey: "xlsx.format_row_height", icon: "move-vertical", action: "row-height" },
{ labelKey: "xlsx.format_row_fit", icon: "maximize-2", action: "row-fit" },
],
},
{ sep: true },
{ labelKey: "xlsx.mi_conditional", icon: "git-compare", unavailable: true },
{
labelKey: "xlsx.mi_banded",
icon: "rows-3",
needs: "editable",
titleKey: "xlsx.mi_banded_hint",
action: "banded",
},
{ labelKey: "xlsx.format_clear", icon: "remove-formatting", action: "clear-format", needs: "editable" },
],
},
{
id: "data",
labelKey: "xlsx.mbar_data",
items: [
{
labelKey: "xlsx.mi_sort",
icon: "arrow-up-down",
children: [
{ labelKey: "xlsx.sort_asc", icon: "arrow-up", action: "sort-asc" },
{ labelKey: "xlsx.sort_desc", icon: "arrow-down", action: "sort-desc" },
{ sep: true },
{ labelKey: "xlsx.sort_reset", icon: "rotate-ccw", action: "sort-reset" },
],
},
{ labelKey: "xlsx.mi_filter", icon: "filter", titleKey: "xlsx.mi_filter_hint", action: "filter-column" },
{ sep: true },
{ labelKey: "xlsx.mi_validation", icon: "list-checks", unavailable: true },
{ labelKey: "xlsx.mi_named", icon: "bookmark", action: "named-ranges" },
{ labelKey: "xlsx.mi_protect", icon: "lock", unavailable: true },
{ sep: true },
{
labelKey: "xlsx.mi_cleanup",
icon: "wand-2",
needs: "editable",
children: [
{ labelKey: "xlsx.mi_cleanup_dupes", icon: "copy-x", action: "cleanup-dupes" },
{ labelKey: "xlsx.mi_cleanup_trim", icon: "scissors", action: "cleanup-trim" },
],
},
{
labelKey: "xlsx.mi_split",
icon: "columns-2",
needs: "editable",
titleKey: "xlsx.mi_split_hint",
action: "split-text",
},
],
},
{
id: "tools",
labelKey: "xlsx.mbar_tools",
items: [
{ labelKey: "xlsx.mi_form_create", icon: "clipboard-list", unavailable: true },
{ labelKey: "xlsx.mi_spellcheck", icon: "spell-check", unavailable: true },
{
labelKey: "xlsx.mi_autocomplete",
icon: "text-cursor-input",
children: [
{ labelKey: "xlsx.mi_autocomplete_on", icon: "check", action: "autocomplete", arg: true },
{ labelKey: "xlsx.mi_autocomplete_off", icon: "x", action: "autocomplete", arg: false },
],
},
{ sep: true },
{ labelKey: "xlsx.mi_notifications", icon: "bell", unavailable: true },
{ labelKey: "xlsx.mi_macros", icon: "workflow", unavailable: true },
{ labelKey: "xlsx.mi_script_editor", icon: "code", unavailable: true },
{ sep: true },
{ labelKey: "xlsx.mi_a11y", icon: "accessibility", action: "a11y" },
],
},
{
id: "gemini",
labelKey: "xlsx.mbar_gemini",
items: [
{ labelKey: "xlsx.mi_ask_gemini", icon: "sparkles", action: "gemini" },
{ sep: true },
{ labelKey: "xlsx.mi_gem_table", icon: "table-2", action: "gemini" },
{ labelKey: "xlsx.mi_gem_formula", icon: "function-square", action: "gemini" },
{ labelKey: "xlsx.mi_gem_insights", icon: "lightbulb", action: "gemini" },
{ labelKey: "xlsx.mi_gem_chart", icon: "bar-chart-3", unavailable: true },
{ labelKey: "xlsx.mi_gem_summarize", icon: "file-text", unavailable: true },
{ sep: true },
{ labelKey: "xlsx.mi_gem_actions", icon: "wand-sparkles", action: "gemini" },
{ labelKey: "xlsx.mi_gem_more", icon: "ellipsis", action: "gemini" },
],
},
{
id: "extensions",
labelKey: "xlsx.mbar_extensions",
items: [
{ labelKey: "xlsx.mi_addons", icon: "blocks", unavailable: true },
{ labelKey: "xlsx.mi_apps_script", icon: "code", unavailable: true },
{ labelKey: "xlsx.mi_appsheet", icon: "app-window", unavailable: true },
{ labelKey: "xlsx.mi_looker", icon: "bar-chart", unavailable: true },
],
},
{
id: "help",
labelKey: "xlsx.mbar_help",
items: [
{ labelKey: "xlsx.mi_help_sheets", icon: "life-buoy", action: "help" },
{ labelKey: "xlsx.mi_training", icon: "graduation-cap", unavailable: true },
{ labelKey: "xlsx.mi_whatsnew", icon: "sparkles", action: "whatsnew" },
{ sep: true },
{ labelKey: "xlsx.mi_shortcuts", icon: "keyboard", action: "shortcuts" },
{ labelKey: "xlsx.mi_report", icon: "bug", action: "report" },
{ labelKey: "xlsx.mi_abuse", icon: "flag", unavailable: true },
],
},
];
/** Drop an entry when the workbook cannot honour it. */
const allowed = (entry, { editable, readOnly }) => {
if (entry.needs === "editable") return editable;
if (entry.needs === "notReadOnly") return !readOnly;
return true;
};
/** Label of one entry: an explicit label, else the i18n key, else nothing. */
const labelOf = (entry) => (entry.label !== undefined
? entry.label
: (entry.labelKey ? t(entry.labelKey) : ""));
/** One clickable row. `depth` > 0 = nested inside an accordion section. */
const rowHtml = (entry, depth, ctx) => {
if (entry.sep) return `<div class="xlsx-structure-sep" role="separator"></div>`;
const unavailable = entry.unavailable || (!entry.children && !entry.action && !entry.export);
const id = entry.id ? ` id="${entry.id}"` : "";
const exportAttr = entry.export ? ` data-xlsx-export="${entry.export}"` : "";
const argAttr = entry.arg !== undefined ? ` data-xlsx-arg="${escapeHtml(String(entry.arg))}"` : "";
const title = entry.titleKey ? ` title="${escapeHtml(t(entry.titleKey))}"` : "";
const cls = [
"btn-action",
"xlsx-structure-item",
"xlsx-menu-item",
unavailable ? "xlsx-menu-item-na" : "",
entry.danger ? "xlsx-context-danger" : "",
depth ? `xlsx-menu-item-d${depth}` : "",
].filter(Boolean).join(" ");
const caret = entry.children
? `<i data-lucide="chevron-right" class="xlsx-menu-caret" aria-hidden="true"></i>`
: "";
const body = entry.children
// A section may ALSO declare an action: the viewer runs it instead of
// toggling, which is how the empty « Thème » section gets filled.
? `<button type="button" class="${cls}" role="menuitem" aria-haspopup="menu" aria-expanded="false"`
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}${title}${id}>`
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span>${caret}</button>`
+ `<div class="xlsx-menu-sub" hidden>${entry.children
.filter((c) => allowed(c, ctx))
.map((c) => rowHtml(c, depth + 1, ctx))
.join("")}</div>`
: `<button type="button" class="${cls}" role="menuitem"`
+ `${unavailable ? ' data-xlsx-unavailable="1"' : ""}`
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}`
+ `${argAttr}${exportAttr}${title}${id}>`
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span></button>`;
return body;
};
/** One dropdown: the filtered rows of a menu. */
const menuItemsHtml = (menu, ctx) => menu.items
.filter((entry) => allowed(entry, ctx))
.map((entry) => rowHtml(entry, 0, ctx))
.join("");
/**
* Build the menu bar: one button per menu plus its (hidden) dropdown.
*
* @param {object} ctx
* @param {boolean} ctx.editable - .xlsx that can be written
* @param {boolean} ctx.readOnly - .xls / .ods
* @returns {string} the `.xlsx-menubar` markup
*/
export function buildMenuBar({ editable = true, readOnly = false } = {}) {
const ctx = { editable, readOnly };
const bars = SHEETS_MENUS.map((menu) => {
const btnId = menu.btnId || `xlsx-menu-btn-${menu.id}`;
const dropId = menu.dropId || `xlsx-menu-${menu.id}`;
const label = escapeHtml(t(menu.labelKey));
return `<span class="xlsx-menuwrap${menu.id === "gemini" ? " xlsx-menuwrap-gemini" : ""}">`
+ `<button type="button" class="xlsx-menubtn" id="${btnId}" role="menuitem"`
+ ` aria-haspopup="menu" aria-expanded="false" aria-controls="${dropId}"`
+ ` data-xlsx-menu="${menu.id}">${label}</button>`
+ `<div class="xlsx-menudrop" id="${dropId}" role="menu"`
+ ` aria-label="${label}" style="display:none">`
+ `${menuItemsHtml(menu, ctx)}`
+ `</div></span>`;
});
return `<div class="xlsx-menubar" role="menubar" aria-label="${escapeHtml(t("xlsx.mbar_label"))}">`
+ `${bars.join("")}</div>`;
}
+28 -1
View File
@@ -24,9 +24,36 @@ export function columnName(col) {
return name;
}
// #184 — cell lookup cache. `findTd` used to run a `querySelector` per call;
// painting the grid out to A → Z × 1000 rows means a single "select column"
// asks for 26 000 cells, which was quadratic on the DOM. The index is built
// once per panel and rebuilt on demand whenever an entry no longer matches its
// own `data-cell` (the signature of a renumbered grid after a structure op).
const _cellIndex = new WeakMap();
function buildCellIndex(panel) {
const map = new Map();
panel.querySelectorAll("td[data-cell]").forEach((td) => {
map.set(td.dataset.cell, td);
});
_cellIndex.set(panel, map);
return map;
}
/** Drop a panel's lookup cache (bulk DOM changes). */
export function invalidateCellIndex(panel) {
if (panel) _cellIndex.delete(panel);
}
/** Find a rendered cell inside a sheet panel by (row, col). */
export function findTd(panel, row, col) {
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
const ref = `${columnName(col)}${row}`;
const cached = _cellIndex.get(panel);
const hit = cached && cached.get(ref);
if (hit && hit.dataset.cell === ref) return hit;
// Miss, or a stale entry (the row was renumbered): rebuild once and retry.
const fresh = buildCellIndex(panel).get(ref);
return fresh && fresh.dataset.cell === ref ? fresh : null;
}
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
+242 -4
View File
@@ -1461,6 +1461,16 @@
"modal.delete_permanently": "Delete permanently",
"modal.delete_warning": "Warning!",
"modal.dir_to_delete": "Folder to delete:",
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
"modal.ext_js": "JavaScript (.js)",
"modal.ext_json": "JSON (.json)",
"modal.ext_md": "Markdown (.md)",
"modal.ext_ps1": "PowerShell (.ps1)",
"modal.ext_py": "Python (.py)",
"modal.ext_sh": "Shell (.sh)",
"modal.ext_txt": "Plain text (.txt)",
"modal.ext_xlsx": "Excel (.xlsx)",
"modal.ext_yaml": "YAML (.yaml)",
"modal.file_name": "File name",
"modal.file_to_delete": "File to delete:",
"modal.file_type": "File type",
@@ -1847,8 +1857,8 @@
"xlsx.tabs_add_sheet": "Add a sheet",
"xlsx.readonly_badge": "Read-only",
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
"xlsx.formulas_note": "Formulas not recalculated",
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
"xlsx.formulas_note": "Formulas computed locally",
"xlsx.formulas_note_title": "ObsiGate computes common formulas on screen (arithmetic, SUM, AVERAGE, IF…). Unsupported functions show the formula as stored: Excel recalculates it on open.",
"xlsx.truncated_title": "Truncated sheet",
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
@@ -1876,8 +1886,31 @@
"xlsx.find_count": "{index}/{count}",
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
"xlsx.csv_export": "Export the sheet or the selection as CSV",
"xlsx.colour_panel_font": "Text colour",
"xlsx.colour_panel_fill": "Fill colour",
"xlsx.colour_palette": "Colour palette",
"xlsx.colour_reset": "Reset",
"xlsx.colour_reset_font": "Reset the text colour",
"xlsx.colour_reset_fill": "Reset the fill colour",
"xlsx.colour_standard": "Standard",
"xlsx.colour_custom": "Custom",
"xlsx.colour_edit_standard": "Pick a custom colour",
"xlsx.colour_custom_pick": "Add a custom colour",
"xlsx.colour_conditional": "Conditional formatting",
"xlsx.colour_banded": "Banded colours",
"xlsx.colour_unavailable": "“{feature}” is not available in ObsiGate yet.",
"xlsx.colour_black": "Black",
"xlsx.colour_white": "White",
"xlsx.colour_blue": "Blue",
"xlsx.colour_red": "Red",
"xlsx.colour_yellow": "Yellow",
"xlsx.colour_green": "Green",
"xlsx.colour_orange": "Orange",
"xlsx.colour_cyan": "Cyan",
"xlsx.export_btn": "Export",
"xlsx.export_csv": "Export as CSV",
"xlsx.export_title": "Export the selection or the sheet",
"xlsx.file_menu": "File",
"xlsx.export_md": "Export as Markdown",
"xlsx.export_html": "Export as HTML",
"xlsx.print": "Print",
@@ -1885,6 +1918,47 @@
"xlsx.export_scope_selection": "the selection",
"xlsx.export_done": "Exported {scope}",
"xlsx.format_btn": "Formatting",
"xlsx.format_strike": "Strikethrough",
"xlsx.format_align_justify": "Justified",
"xlsx.tb_search": "Search in sheet",
"xlsx.tb_paint": "Paint format",
"xlsx.paint_pick_target": "Click the target cell or range",
"xlsx.tb_zoom": "Zoom",
"xlsx.tb_currency": "Currency format",
"xlsx.tb_percent": "Percent format",
"xlsx.tb_dec_minus": "Decrease decimals",
"xlsx.tb_dec_plus": "Increase decimals",
"xlsx.tb_number": "Number format",
"xlsx.tb_numfmt": "Number format",
"xlsx.tb_font_minus": "Decrease font size",
"xlsx.tb_font_plus": "Increase font size",
"xlsx.tb_font_size": "Font size",
"xlsx.tb_borders": "Borders",
"xlsx.border_all": "All borders",
"xlsx.border_outer": "Outer borders",
"xlsx.border_none": "No borders",
"xlsx.tb_merge": "Merge cells",
"xlsx.tb_alignh": "Horizontal alignment",
"xlsx.tb_alignv": "Vertical alignment",
"xlsx.valign_top": "Top",
"xlsx.valign_middle": "Middle",
"xlsx.valign_bottom": "Bottom",
"xlsx.tb_wrap": "Text wrapping",
"xlsx.tb_rotation": "Text rotation",
"xlsx.rotation_0": "Horizontal",
"xlsx.rotation_45": "Diagonal up",
"xlsx.rotation_315": "Diagonal down",
"xlsx.rotation_90": "Vertical",
"xlsx.tb_more": "More options",
"xlsx.comment_add": "Comment",
"xlsx.comment_prompt": "Comment text (empty to delete):",
"xlsx.link_add": "Insert link",
"xlsx.link_url": "Link address (https://…):",
"xlsx.link_label": "Label:",
"xlsx.link_invalid": "Invalid address (https://… expected)",
"xlsx.sum_insert": "Sum",
"xlsx.sum_empty": "Nothing to total (numbers above or a selection)",
"xlsx.paint_pick_source": "Select a formatted source cell first",
"xlsx.format_bold": "Bold",
"xlsx.format_italic": "Italic",
"xlsx.format_underline": "Underline",
@@ -1906,9 +1980,13 @@
"xlsx.format_freeze": "Freeze panes here",
"xlsx.format_unfreeze": "Unfreeze panes",
"xlsx.format_col_width": "Column width…",
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
"xlsx.format_col_prompt": "Width of column {col} — in characters (0 to 255, 0 = default width)",
"xlsx.format_row_height": "Row height…",
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
"xlsx.format_row_prompt": "Height of row {row} — in points (0 to 409, 0 = default height)",
"xlsx.format_col_fit": "Fit column width to data",
"xlsx.format_row_fit": "Fit row height to data",
"xlsx.mi_resize": "Resize",
"xlsx.fit_nothing": "Nothing to fit (empty content or already fitted)",
"xlsx.format_applied": "Formatting saved",
"xlsx.format_error": "Formatting failed",
"xlsx.sort_asc": "Sort column A→Z",
@@ -1969,6 +2047,166 @@
"xlsx.feature_signature": "digital signature",
"xlsx.feature_rich_comments": "rich comments",
"xlsx.feature_macros": "macros",
"xlsx.mbar_label": "Spreadsheet menus",
"xlsx.cmdbar_label": "Spreadsheet command bar",
"xlsx.mbar_file": "File",
"xlsx.mbar_edit": "Edit",
"xlsx.mbar_view": "View",
"xlsx.mbar_insert": "Insert",
"xlsx.mbar_format": "Format",
"xlsx.mbar_data": "Data",
"xlsx.mbar_tools": "Tools",
"xlsx.mbar_gemini": "Gemini",
"xlsx.mbar_extensions": "Extensions",
"xlsx.mbar_help": "Help",
"xlsx.menu_unavailable": "“{item}” is not available in ObsiGate.",
"xlsx.menu_unavailable_hint": "This option does not exist in ObsiGate's spreadsheet editor: it is listed to stay faithful to Google Sheets.",
"xlsx.mi_new": "New",
"xlsx.mi_open": "Open",
"xlsx.mi_import": "Import",
"xlsx.mi_make_copy": "Make a copy",
"xlsx.mi_share": "Share",
"xlsx.mi_email": "Email",
"xlsx.mi_download": "Download",
"xlsx.mi_rename": "Rename",
"xlsx.mi_trash": "Move to trash",
"xlsx.mi_history": "Version history",
"xlsx.mi_offline": "Make available offline",
"xlsx.mi_details": "Details",
"xlsx.mi_settings": "Settings",
"xlsx.mi_paste_special": "Paste special",
"xlsx.mi_paste_all": "Paste all",
"xlsx.mi_paste_values": "Paste values only",
"xlsx.mi_move": "Move",
"xlsx.mi_move_rows": "Selected rows down",
"xlsx.mi_move_cols": "Selected columns right",
"xlsx.mi_delete": "Delete",
"xlsx.mi_delete_rows": "Delete selected rows",
"xlsx.mi_delete_cols": "Delete selected columns",
"xlsx.mi_select_all": "Select all",
"xlsx.mi_replace": "Find and replace",
"xlsx.mi_replace_find": "Find",
"xlsx.mi_replace_with": "Replace with",
"xlsx.mi_replace_one": "Replace",
"xlsx.mi_replace_all": "Replace all",
"xlsx.mi_replace_none": "No match found",
"xlsx.mi_replace_done": "{n} cell(s) replaced",
"xlsx.mi_freeze": "Freeze",
"xlsx.mi_freeze_here": "Freeze up to active cell",
"xlsx.mi_freeze_row": "Freeze active row",
"xlsx.mi_freeze_col": "Freeze active column",
"xlsx.mi_show": "Show",
"xlsx.mi_show_formula": "Formula bar",
"xlsx.mi_show_grid": "Gridlines",
"xlsx.mi_show_headers": "Headers",
"xlsx.mi_show_dashboard": "Dashboard",
"xlsx.mi_group": "Group",
"xlsx.mi_group_rows": "Group selected rows",
"xlsx.mi_group_cols": "Group selected columns",
"xlsx.mi_ungroup_rows": "Ungroup rows",
"xlsx.mi_ungroup_cols": "Ungroup columns",
"xlsx.mi_hidden_sheets": "Hidden sheets",
"xlsx.mi_hide_sheet": "Hide active sheet",
"xlsx.mi_unhide_sheets": "Show hidden sheets",
"xlsx.mi_zoom": "Zoom",
"xlsx.mi_fullscreen": "Full screen",
"xlsx.mi_exit_fullscreen": "Exit full screen",
"xlsx.mi_insert_rows": "Rows",
"xlsx.mi_insert_cols": "Columns",
"xlsx.mi_insert_cells": "Cells",
"xlsx.mi_image": "Image",
"xlsx.mi_chart": "Chart",
"xlsx.mi_drawing": "Drawing",
"xlsx.mi_function": "Function",
"xlsx.mi_function_hint": "Inserts the function and puts the caret inside the parentheses",
"xlsx.mi_pivot": "Pivot table",
"xlsx.mi_smart_chips": "Smart chips",
"xlsx.mi_form": "Form",
"xlsx.mi_number": "Number",
"xlsx.mi_theme": "Theme",
"xlsx.mi_font_size": "Font size",
"xlsx.mi_align": "Alignment",
"xlsx.mi_align_h": "Horizontal alignment",
"xlsx.mi_align_v": "Vertical alignment",
"xlsx.mi_wrap": "Text wrapping",
"xlsx.mi_wrap_on": "On",
"xlsx.mi_wrap_off": "Off",
"xlsx.mi_rotation": "Text rotation",
"xlsx.mi_conditional": "Conditional formatting",
"xlsx.mi_banded": "Banded colours",
"xlsx.mi_banded_hint": "Alternates the fill colour every other row in the selection",
"xlsx.mi_banded_done": "Banded colours applied to {n} row(s)",
"xlsx.mi_banded_default": "#eef3fb",
"xlsx.mi_sort": "Sort range",
"xlsx.mi_filter": "Create filter",
"xlsx.mi_filter_hint": "Filters the rows on the active cell's value",
"xlsx.mi_validation": "Data validation",
"xlsx.mi_named": "Named ranges",
"xlsx.mi_protect": "Protect sheets and ranges",
"xlsx.mi_cleanup": "Data cleanup",
"xlsx.mi_cleanup_dupes": "Remove duplicates",
"xlsx.mi_cleanup_trim": "Trim extra spaces",
"xlsx.mi_cleanup_dupes_none": "No duplicate in the selection",
"xlsx.mi_cleanup_dupes_done": "{n} duplicate row(s) removed",
"xlsx.mi_cleanup_trim_done": "{n} cell(s) cleaned",
"xlsx.mi_split": "Split text to columns",
"xlsx.mi_split_hint": "Splits the active cell's content on a separator",
"xlsx.mi_split_sep": "Separator",
"xlsx.mi_split_done": "{n} column(s) filled from the active cell",
"xlsx.mi_form_create": "Create a form",
"xlsx.mi_spellcheck": "Spelling",
"xlsx.mi_autocomplete": "Autocomplete",
"xlsx.mi_autocomplete_on": "Autocomplete on",
"xlsx.mi_autocomplete_off": "Autocomplete off",
"xlsx.mi_notifications": "Notification rules",
"xlsx.mi_macros": "Macros",
"xlsx.mi_script_editor": "Script editor",
"xlsx.mi_a11y": "Accessibility",
"xlsx.mi_a11y_on": "Enhanced accessibility on (bigger cells, higher-contrast grid)",
"xlsx.mi_a11y_off": "Enhanced accessibility off",
"xlsx.mi_ask_gemini": "Ask Gemini",
"xlsx.mi_gem_table": "Create tables",
"xlsx.mi_gem_formula": "Create formulas",
"xlsx.mi_gem_insights": "Analysis and insights",
"xlsx.mi_gem_chart": "Create charts",
"xlsx.mi_gem_summarize": "Summarize emails and files",
"xlsx.mi_gem_actions": "Perform actions",
"xlsx.mi_gem_more": "More options",
"xlsx.mi_addons": "Add-ons",
"xlsx.mi_apps_script": "Apps Script",
"xlsx.mi_appsheet": "AppSheet",
"xlsx.mi_looker": "Looker Studio",
"xlsx.mi_help_sheets": "Spreadsheets help",
"xlsx.mi_training": "Training",
"xlsx.mi_whatsnew": "What's new",
"xlsx.mi_shortcuts": "Keyboard shortcuts",
"xlsx.mi_report": "Report a problem",
"xlsx.mi_abuse": "Report abuse",
"xlsx.details_title": "File details",
"xlsx.details_body": "Vault: {vault}\nFile: {path}\nSheets: {sheets}\nDisplayed cells: {cells}\nLast revision: {revision}\nUnsaved changes: {dirty}",
"xlsx.shortcuts_title": "Keyboard shortcuts",
"xlsx.shortcuts_body": "Ctrl/Cmd + Z — Undo\nCtrl/Cmd + Y — Redo\nCtrl/Cmd + C / X / V — Copy / Cut / Paste\nCtrl/Cmd + A — Select all\nCtrl/Cmd + F — Search in sheet\nEnter — Commit and move down\nF2 — Edit cell\nAlt + arrow — Line break inside the cell\nEscape — Leave edit mode\nDelete — Clear the selection\nDouble-click — Edit in place\nShift + click — Extend selection",
"xlsx.version_title": "What's new",
"xlsx.version_body": "ObsiGate {version} — {date}\n{status}",
"xlsx.report_copied": "Diagnostic copied to the clipboard.",
"xlsx.themes_title": "Sheet theme",
"xlsx.function_prompt": "Function to insert",
"xlsx.rename_prompt": "New file name",
"xlsx.trash_confirm": "Move “{name}” to trash? The workbook will close.",
"xlsx.rename_done": "File renamed to {name}",
"xlsx.trash_done": "File moved to trash",
"viewer.download_md": "Download as .md",
"viewer.download_file": "Download file",
"viewer.pretty": "Pretty",
+242 -4
View File
@@ -1461,6 +1461,16 @@
"modal.delete_permanently": "Supprimer définitivement",
"modal.delete_warning": "Attention !",
"modal.dir_to_delete": "Dossier à supprimer:",
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
"modal.ext_js": "JavaScript (.js)",
"modal.ext_json": "JSON (.json)",
"modal.ext_md": "Markdown (.md)",
"modal.ext_ps1": "PowerShell (.ps1)",
"modal.ext_py": "Python (.py)",
"modal.ext_sh": "Shell (.sh)",
"modal.ext_txt": "Texte (.txt)",
"modal.ext_xlsx": "Excel (.xlsx)",
"modal.ext_yaml": "YAML (.yaml)",
"modal.file_name": "Nom du fichier",
"modal.file_to_delete": "Fichier à supprimer:",
"modal.file_type": "Type de fichier",
@@ -1847,8 +1857,8 @@
"xlsx.tabs_add_sheet": "Ajouter une feuille",
"xlsx.readonly_badge": "Lecture seule",
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
"xlsx.formulas_note": "Formules non recalculées",
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
"xlsx.formulas_note": "Formules calculées localement",
"xlsx.formulas_note_title": "ObsiGate calcule les formules courantes à l'écran (arithmétique, SOMME, MOYENNE, SI…). Les fonctions non prises en charge affichent la formule telle qu'enregistrée : Excel la recalcule à l'ouverture.",
"xlsx.truncated_title": "Feuille tronquée",
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
@@ -1876,8 +1886,31 @@
"xlsx.find_count": "{index}/{count}",
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
"xlsx.colour_panel_font": "Couleur du texte",
"xlsx.colour_panel_fill": "Couleur de fond",
"xlsx.colour_palette": "Palette de couleurs",
"xlsx.colour_reset": "Réinitialiser",
"xlsx.colour_reset_font": "Réinitialiser la couleur du texte",
"xlsx.colour_reset_fill": "Réinitialiser la couleur de fond",
"xlsx.colour_standard": "Standard",
"xlsx.colour_custom": "Personnalisé",
"xlsx.colour_edit_standard": "Choisir une couleur personnalisée",
"xlsx.colour_custom_pick": "Ajouter une couleur personnalisée",
"xlsx.colour_conditional": "Mise en forme conditionnelle",
"xlsx.colour_banded": "Couleurs en alternance",
"xlsx.colour_unavailable": "« {feature} » n'est pas encore disponible dans ObsiGate.",
"xlsx.colour_black": "Noir",
"xlsx.colour_white": "Blanc",
"xlsx.colour_blue": "Bleu",
"xlsx.colour_red": "Rouge",
"xlsx.colour_yellow": "Jaune",
"xlsx.colour_green": "Vert",
"xlsx.colour_orange": "Orange",
"xlsx.colour_cyan": "Cyan",
"xlsx.export_btn": "Exporter",
"xlsx.export_csv": "Exporter en CSV",
"xlsx.export_title": "Exporter la sélection ou la feuille",
"xlsx.file_menu": "Fichier",
"xlsx.export_md": "Exporter en Markdown",
"xlsx.export_html": "Exporter en HTML",
"xlsx.print": "Imprimer",
@@ -1885,6 +1918,47 @@
"xlsx.export_scope_selection": "la sélection",
"xlsx.export_done": "Export de {scope} terminé",
"xlsx.format_btn": "Mise en forme",
"xlsx.format_strike": "Barré",
"xlsx.format_align_justify": "Justifié",
"xlsx.tb_search": "Rechercher dans la feuille",
"xlsx.tb_paint": "Peindre le format",
"xlsx.paint_pick_target": "Cliquez sur la cellule ou la plage cible du format",
"xlsx.tb_zoom": "Zoom",
"xlsx.tb_currency": "Format monétaire",
"xlsx.tb_percent": "Format pourcentage",
"xlsx.tb_dec_minus": "Diminuer les décimales",
"xlsx.tb_dec_plus": "Augmenter les décimales",
"xlsx.tb_number": "Format nombre",
"xlsx.tb_numfmt": "Format de nombre",
"xlsx.tb_font_minus": "Réduire la taille de police",
"xlsx.tb_font_plus": "Augmenter la taille de police",
"xlsx.tb_font_size": "Taille de police",
"xlsx.tb_borders": "Bordures",
"xlsx.border_all": "Toutes les bordures",
"xlsx.border_outer": "Bordures extérieures",
"xlsx.border_none": "Aucune bordure",
"xlsx.tb_merge": "Fusionner les cellules",
"xlsx.tb_alignh": "Alignement horizontal",
"xlsx.tb_alignv": "Alignement vertical",
"xlsx.valign_top": "En haut",
"xlsx.valign_middle": "Au milieu",
"xlsx.valign_bottom": "En bas",
"xlsx.tb_wrap": "Retour à la ligne automatique",
"xlsx.tb_rotation": "Rotation du texte",
"xlsx.rotation_0": "Horizontal",
"xlsx.rotation_45": "Diagonale vers le haut",
"xlsx.rotation_315": "Diagonale vers le bas",
"xlsx.rotation_90": "Vertical",
"xlsx.tb_more": "Plus d'options",
"xlsx.comment_add": "Commentaire",
"xlsx.comment_prompt": "Texte du commentaire (vide pour supprimer) :",
"xlsx.link_add": "Insérer un lien",
"xlsx.link_url": "Adresse du lien (https://…) :",
"xlsx.link_label": "Libellé :",
"xlsx.link_invalid": "Adresse invalide (https://… attendue)",
"xlsx.sum_insert": "Somme",
"xlsx.sum_empty": "Aucune plage à totaliser (nombres au-dessus ou sélection)",
"xlsx.paint_pick_source": "Sélectionnez d'abord une cellule source mise en forme",
"xlsx.format_bold": "Gras",
"xlsx.format_italic": "Italique",
"xlsx.format_underline": "Souligné",
@@ -1906,9 +1980,13 @@
"xlsx.format_freeze": "Figer les volets ici",
"xlsx.format_unfreeze": "Libérer les volets",
"xlsx.format_col_width": "Largeur de colonne…",
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
"xlsx.format_col_prompt": "Largeur de la colonne {col} — en caractères (0 à 255, 0 = largeur par défaut)",
"xlsx.format_row_height": "Hauteur de ligne…",
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
"xlsx.format_row_prompt": "Hauteur de la ligne {row} — en points (0 à 409, 0 = hauteur par défaut)",
"xlsx.format_col_fit": "Ajuster la largeur au contenu",
"xlsx.format_row_fit": "Ajuster la hauteur au contenu",
"xlsx.mi_resize": "Redimensionner",
"xlsx.fit_nothing": "Rien à ajuster (contenu vide ou déjà à la bonne taille)",
"xlsx.format_applied": "Mise en forme enregistrée",
"xlsx.format_error": "Échec de la mise en forme",
"xlsx.sort_asc": "Trier la colonne A→Z",
@@ -1969,6 +2047,166 @@
"xlsx.feature_signature": "signature numérique",
"xlsx.feature_rich_comments": "commentaires enrichis",
"xlsx.feature_macros": "macros",
"xlsx.mbar_label": "Menus du tableur",
"xlsx.cmdbar_label": "Barre de commandes du tableur",
"xlsx.mbar_file": "Fichier",
"xlsx.mbar_edit": "Modifier",
"xlsx.mbar_view": "Affichage",
"xlsx.mbar_insert": "Insertion",
"xlsx.mbar_format": "Format",
"xlsx.mbar_data": "Données",
"xlsx.mbar_tools": "Outils",
"xlsx.mbar_gemini": "Gemini",
"xlsx.mbar_extensions": "Extensions",
"xlsx.mbar_help": "Aide",
"xlsx.menu_unavailable": "« {item} » n'est pas disponible dans ObsiGate.",
"xlsx.menu_unavailable_hint": "Cette option n'existe pas dans l'éditeur tableur d'ObsiGate : elle est listée pour rester fidèle à Google Sheets.",
"xlsx.mi_new": "Nouveau",
"xlsx.mi_open": "Ouvrir",
"xlsx.mi_import": "Importer",
"xlsx.mi_make_copy": "Créer une copie",
"xlsx.mi_share": "Partager",
"xlsx.mi_email": "Envoyer par e-mail",
"xlsx.mi_download": "Télécharger",
"xlsx.mi_rename": "Renommer",
"xlsx.mi_trash": "Déplacer vers la corbeille",
"xlsx.mi_history": "Historique des versions",
"xlsx.mi_offline": "Rendre disponible hors connexion",
"xlsx.mi_details": "Détails",
"xlsx.mi_settings": "Paramètres",
"xlsx.mi_paste_special": "Collage spécial",
"xlsx.mi_paste_all": "Tout coller",
"xlsx.mi_paste_values": "Coller les valeurs uniquement",
"xlsx.mi_move": "Déplacer",
"xlsx.mi_move_rows": "Lignes sélectionnées vers le bas",
"xlsx.mi_move_cols": "Colonnes sélectionnées vers la droite",
"xlsx.mi_delete": "Supprimer",
"xlsx.mi_delete_rows": "Supprimer les lignes sélectionnées",
"xlsx.mi_delete_cols": "Supprimer les colonnes sélectionnées",
"xlsx.mi_select_all": "Tout sélectionner",
"xlsx.mi_replace": "Rechercher et remplacer",
"xlsx.mi_replace_find": "Rechercher",
"xlsx.mi_replace_with": "Remplacer par",
"xlsx.mi_replace_one": "Remplacer",
"xlsx.mi_replace_all": "Tout remplacer",
"xlsx.mi_replace_none": "Aucune occurrence trouvée",
"xlsx.mi_replace_done": "{n} cellule(s) remplacée(s)",
"xlsx.mi_freeze": "Figer",
"xlsx.mi_freeze_here": "Figer jusqu'à la cellule active",
"xlsx.mi_freeze_row": "Figer la ligne active",
"xlsx.mi_freeze_col": "Figer la colonne active",
"xlsx.mi_show": "Afficher",
"xlsx.mi_show_formula": "Barre de formule",
"xlsx.mi_show_grid": "Grille",
"xlsx.mi_show_headers": "En-têtes",
"xlsx.mi_show_dashboard": "Tableau de bord",
"xlsx.mi_group": "Grouper",
"xlsx.mi_group_rows": "Grouper les lignes sélectionnées",
"xlsx.mi_group_cols": "Grouper les colonnes sélectionnées",
"xlsx.mi_ungroup_rows": "Dégrouper les lignes",
"xlsx.mi_ungroup_cols": "Dégrouper les colonnes",
"xlsx.mi_hidden_sheets": "Feuilles masquées",
"xlsx.mi_hide_sheet": "Masquer la feuille active",
"xlsx.mi_unhide_sheets": "Afficher les feuilles masquées",
"xlsx.mi_zoom": "Zoom",
"xlsx.mi_fullscreen": "Plein écran",
"xlsx.mi_exit_fullscreen": "Quitter le plein écran",
"xlsx.mi_insert_rows": "Lignes",
"xlsx.mi_insert_cols": "Colonnes",
"xlsx.mi_insert_cells": "Cellules",
"xlsx.mi_image": "Image",
"xlsx.mi_chart": "Graphique",
"xlsx.mi_drawing": "Dessin",
"xlsx.mi_function": "Fonction",
"xlsx.mi_function_hint": "Insère la fonction et place le curseur dans les parenthèses",
"xlsx.mi_pivot": "Tableau croisé dynamique",
"xlsx.mi_smart_chips": "Chips intelligents",
"xlsx.mi_form": "Formulaire",
"xlsx.mi_number": "Nombre",
"xlsx.mi_theme": "Thème",
"xlsx.mi_font_size": "Taille de police",
"xlsx.mi_align": "Alignement",
"xlsx.mi_align_h": "Alignement horizontal",
"xlsx.mi_align_v": "Alignement vertical",
"xlsx.mi_wrap": "Retour à la ligne",
"xlsx.mi_wrap_on": "Activer",
"xlsx.mi_wrap_off": "Désactiver",
"xlsx.mi_rotation": "Rotation du texte",
"xlsx.mi_conditional": "Mise en forme conditionnelle",
"xlsx.mi_banded": "Couleurs alternées",
"xlsx.mi_banded_hint": "Alterne la couleur de fond une ligne sur deux dans la sélection",
"xlsx.mi_banded_done": "Couleurs alternées appliquées à {n} ligne(s)",
"xlsx.mi_banded_default": "#eef3fb",
"xlsx.mi_sort": "Trier la plage",
"xlsx.mi_filter": "Créer un filtre",
"xlsx.mi_filter_hint": "Filtre les lignes selon la valeur de la cellule active",
"xlsx.mi_validation": "Validation des données",
"xlsx.mi_named": "Plages nommées",
"xlsx.mi_protect": "Protéger les feuilles et les plages",
"xlsx.mi_cleanup": "Nettoyage des données",
"xlsx.mi_cleanup_dupes": "Supprimer les doublons",
"xlsx.mi_cleanup_trim": "Supprimer les espaces superflus",
"xlsx.mi_cleanup_dupes_none": "Aucun doublon dans la sélection",
"xlsx.mi_cleanup_dupes_done": "{n} ligne(s) en doublon supprimée(s)",
"xlsx.mi_cleanup_trim_done": "{n} cellule(s) nettoyée(s)",
"xlsx.mi_split": "Diviser le texte en colonnes",
"xlsx.mi_split_hint": "Sépare le contenu de la cellule active sur un séparateur",
"xlsx.mi_split_sep": "Séparateur",
"xlsx.mi_split_done": "{n} colonne(s) remplie(s) à partir de la cellule active",
"xlsx.mi_form_create": "Créer un formulaire",
"xlsx.mi_spellcheck": "Vérifier l'orthographe",
"xlsx.mi_autocomplete": "Saisie semi-automatique",
"xlsx.mi_autocomplete_on": "Saisie semi-automatique activée",
"xlsx.mi_autocomplete_off": "Saisie semi-automatique désactivée",
"xlsx.mi_notifications": "Règles de notification",
"xlsx.mi_macros": "Macros",
"xlsx.mi_script_editor": "Éditeur de script",
"xlsx.mi_a11y": "Accessibilité",
"xlsx.mi_a11y_on": "Accessibilité renforcée activée (cellules plus grandes, grille plus contrastée)",
"xlsx.mi_a11y_off": "Accessibilité renforcée désactivée",
"xlsx.mi_ask_gemini": "Demander à Gemini",
"xlsx.mi_gem_table": "Créer des tableaux",
"xlsx.mi_gem_formula": "Créer des formules",
"xlsx.mi_gem_insights": "Analyse et insights",
"xlsx.mi_gem_chart": "Créer des graphiques",
"xlsx.mi_gem_summarize": "Résumer des e-mails et des fichiers",
"xlsx.mi_gem_actions": "Effectuer des actions",
"xlsx.mi_gem_more": "Plus d'options",
"xlsx.mi_addons": "Modules complémentaires",
"xlsx.mi_apps_script": "Apps Script",
"xlsx.mi_appsheet": "AppSheet",
"xlsx.mi_looker": "Looker Studio",
"xlsx.mi_help_sheets": "Aide sur les feuilles de calcul",
"xlsx.mi_training": "Formation",
"xlsx.mi_whatsnew": "Mises à jour",
"xlsx.mi_shortcuts": "Raccourcis clavier",
"xlsx.mi_report": "Signaler un problème",
"xlsx.mi_abuse": "Signaler un abus",
"xlsx.details_title": "Détails du fichier",
"xlsx.details_body": "Vault : {vault}\nFichier : {path}\nFeuilles : {sheets}\nCellules affichées : {cells}\nDernière révision : {revision}\nModification en attente : {dirty}",
"xlsx.shortcuts_title": "Raccourcis clavier",
"xlsx.shortcuts_body": "Ctrl/Cmd + Z — Annuler\nCtrl/Cmd + Y — Rétablir\nCtrl/Cmd + C / X / V — Copier / Couper / Coller\nCtrl/Cmd + A — Tout sélectionner\nCtrl/Cmd + F — Rechercher dans la feuille\nEntrée — Valider et descendre d'une cellule\nF2 — Modifier la cellule\nAlt + flèche — Saut de ligne dans la cellule\nÉchap — Quitter la saisie\nSuppr — Effacer le contenu de la sélection\nDouble-clic — Modifier la cellule en place\nMaj + clic — Étendre la sélection",
"xlsx.version_title": "Mises à jour",
"xlsx.version_body": "ObsiGate {version} — {date}\n{status}",
"xlsx.report_copied": "Diagnostic copié dans le presse-papiers.",
"xlsx.themes_title": "Thème de la feuille",
"xlsx.function_prompt": "Fonction à insérer",
"xlsx.rename_prompt": "Nouveau nom du fichier",
"xlsx.trash_confirm": "Déplacer « {name} » vers la corbeille ? Le classeur sera fermé.",
"xlsx.rename_done": "Fichier renommé en {name}",
"xlsx.trash_done": "Fichier déplacé vers la corbeille",
"viewer.download_md": "Télécharger en .md",
"viewer.download_file": "Télécharger le fichier",
"viewer.pretty": "Pretty",
+732 -52
View File
@@ -62,7 +62,7 @@
--grid-bg: #161b22;
--grid-header-bg: #1f2430;
--grid-header-text: #e6edf3;
--grid-border: #30363d;
--grid-border: #3d444d;
--grid-zebra: rgba(255, 255, 255, 0.025);
}
@@ -111,7 +111,7 @@
--grid-bg: #161b22;
--grid-header-bg: #1f2430;
--grid-header-text: #e6edf3;
--grid-border: #30363d;
--grid-border: #3d444d;
--grid-zebra: rgba(255, 255, 255, 0.025);
}
@@ -160,10 +160,28 @@
--grid-bg: #ffffff;
--grid-header-bg: #eaeef2;
--grid-header-text: #1f2328;
--grid-border: #d0d7de;
--grid-border: #b6bec9;
--grid-zebra: rgba(0, 0, 0, 0.025);
}
/* ===== THEME — HIGH-CONTRAST (generated mode, see themes.js) ===== */
:root[data-theme="high-contrast"] {
--grid-bg: #000000;
--grid-header-bg: #141414;
--grid-header-text: #ffffff;
--grid-border: #6b6b6b;
--grid-zebra: rgba(255, 255, 255, 0.045);
}
/* ===== THEME — SEPIA (generated mode, see themes.js) ===== */
:root[data-theme="sepia"] {
--grid-bg: #f5ecd5;
--grid-header-bg: #e8dcc0;
--grid-header-text: #3d2e1a;
--grid-border: #b09a6d;
--grid-zebra: rgba(61, 46, 26, 0.04);
}
/* ===== BASE ===== */
html {
font-size: 16px;
@@ -1491,10 +1509,12 @@ select {
max-width: 1200px;
}
/* Full-bleed viewers (PDF, images, Excalidraw) must use the whole width when
the navigation sidebar is hidden instead of the centered reading column. */
/* Full-bleed viewers (PDF, images, Excalidraw, spreadsheets) must use the
whole width when the navigation sidebar is hidden instead of the centered
reading column. */
.sidebar.hidden ~ .content-wrapper .content-area:has(.pdf-viewer-container),
.sidebar.hidden ~ .content-wrapper .content-area:has(.image-viewer-container),
.sidebar.hidden ~ .content-wrapper .content-area:has(.xlsx-viewer),
.sidebar.hidden ~ .content-wrapper .content-area:has(iframe[src*="excalidraw-editor.html"]) {
margin: 0;
max-width: none;
@@ -10998,24 +11018,33 @@ body.desktop-mode .editor-container {
padding: 6px 12px;
border-bottom: 1px solid var(--border-light, var(--border));
}
.csv-table tr:hover td {
background: var(--surface);
}
/* Google Sheets : le survol d'une ligne ne teinte rien (jamais de highlight
de ligne entière au survol). */
/* ── XLSX Viewer ── */
/* #154-A1 — the viewer shell is a grouped command bar: sheet tabs on the left,
action groups (Formules · Insertion · Vue · Fichier) on the right, then a
status row stating the viewer's limits (read-only, formulas not recalculated). */
/* #154-A1 — the viewer shell is a grouped command bar (edition commands),
then a sheet strip (tabs on the left, File menu + Save on the right), then
a status row stating the viewer's limits (read-only). #180 pins the bar to
the top edge of the page, full content width. */
.xlsx-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
position: sticky; /* #180 — stays on the top edge while the grid scrolls */
/* La marge haute négative (pleine largeur) tirait la barre 40 px sous sa
position statique : `top: 0` la replaquait et elle recouvrait le contenu
(en-tête A–Z, ligne 1) de 32 px. La contrainte reprend la même expression
que la marge : aucun déplacement au repos, épinglage intact au scroll. */
top: calc(-1 * clamp(16px, 3vw, 40px));
z-index: 30;
margin: calc(-1 * clamp(16px, 3vw, 40px)) calc(-1 * clamp(16px, 4vw, 40px)) 8px;
padding: 8px clamp(16px, 4vw, 40px);
border: 1px solid var(--border);
border-radius: var(--radius);
border-left: none;
border-right: none;
border-top: none;
border-radius: 0;
background: var(--surface2);
position: relative; /* anchors the A14 structure menu */
}
.xlsx-cmdbar {
display: flex;
@@ -11023,6 +11052,277 @@ body.desktop-mode .editor-container {
gap: 10px;
flex-wrap: wrap;
}
/* #185 — the Google Sheets menu bar: a flat row of text buttons above the
ribbon. No chrome, no borders: only the open menu gets a filled pill. */
.xlsx-menubar {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
margin: -2px 0 -2px;
}
/* No `position: relative` here on purpose: the dropdown is positioned by
anchorUnder() (viewer.js) relative to the sticky `.xlsx-toolbar`, exactly like
the Structure / Mise en forme / ribbon menus. Making the wrapper a containing
block would shift every dropdown by the button's offset. */
.xlsx-menuwrap {
display: inline-flex;
}
.xlsx-menubtn {
appearance: none;
border: none;
background: transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.82rem;
line-height: 1.4;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xlsx-menubtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.xlsx-menubtn[aria-expanded="true"] {
background: var(--accent-bg, var(--bg-hover));
color: var(--accent-text, var(--accent, #4a90d9));
}
/* The Gemini entry keeps its own accent so it reads as the AI corner of the
bar, like the themed icon in Google Sheets. */
.xlsx-menuwrap-gemini .xlsx-menubtn {
color: var(--accent, #4a90d9);
font-weight: 600;
}
.xlsx-menudrop {
min-width: 260px;
max-height: min(70vh, 560px);
overflow-y: auto;
z-index: 40;
}
/* Accordion section (Nombre, Thème, Zoom, Alignement, …). */
.xlsx-menu-sub {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 18px;
border-left: 2px solid var(--border);
margin: 2px 0 4px 8px;
}
.xlsx-menu-sub[hidden] {
display: none;
}
.xlsx-menu-item {
width: 100%;
}
.xlsx-menu-item-d2 {
padding-left: 18px;
}
.xlsx-menu-caret {
width: 14px;
height: 14px;
flex: 0 0 auto;
margin-left: auto;
color: var(--text-muted);
transition: transform 0.12s ease;
}
.xlsx-menu-item[aria-expanded="true"] > .xlsx-menu-caret {
transform: rotate(90deg);
}
/* An entry ObsiGate does not implement: dimmed but still clickable, so it
answers instead of pretending. */
.xlsx-menu-item-na {
opacity: 0.55;
}
.xlsx-menu-item-na:hover {
opacity: 1;
}
.xlsx-menu-empty {
padding: 6px 10px;
font-size: 0.78rem;
color: var(--text-muted);
}
/* #185 — Google Sheets' ribbon look: flat square icon buttons, no border and
no resting background, grouped by thin vertical rules. The old rounded /
bordered chrome fought with the theme of every one of the 15 themes. */
.xlsx-toolbar .btn-action {
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
min-width: 30px;
height: 30px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.xlsx-toolbar .btn-action:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.xlsx-toolbar .btn-action.active {
background: var(--accent-bg, var(--bg-hover));
color: var(--accent-text, var(--accent, #4a90d9));
}
.xlsx-toolbar .btn-action:disabled {
opacity: 0.4;
cursor: default;
}
.xlsx-toolbar .xlsx-cmd-sep {
width: 1px;
height: 22px;
background: var(--border);
}
.xlsx-toolbar .xlsx-select {
height: 30px;
border-color: transparent;
background: transparent;
}
.xlsx-toolbar .xlsx-select:hover {
background: var(--bg-hover);
}
/* Save stays the one solid pill: it is the only command with a consequence
outside the browser. */
.xlsx-toolbar .xlsx-save-primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
font-size: 0.8rem;
}
.xlsx-toolbar .xlsx-save-primary:hover:not(:disabled) {
background: var(--accent);
color: #fff;
filter: brightness(1.08);
}
.xlsx-formula-bar[hidden] {
display: none;
}
/* Wrapped command groups: every control stays visible, no scrollbar. */
.xlsx-cmdbar > .xlsx-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
flex-wrap: wrap;
overflow: visible;
}
.xlsx-cmdbar .xlsx-cmd-group,
.xlsx-cmdbar .xlsx-cmd-sep {
flex: 0 0 auto;
}
/* Native selects (zoom, number format): no custom dropdown to dismiss. */
.xlsx-select {
font-family: inherit;
font-size: 0.75rem;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 6px;
cursor: pointer;
max-width: 130px;
}
.xlsx-select:hover {
color: var(--accent);
border-color: var(--accent);
}
/* B/I/S/U letter buttons + decimal shorthands. */
.xlsx-fontstyle {
font-family: Georgia, "Times New Roman", serif;
min-width: 28px;
}
.xlsx-dec {
font-family: "JetBrains Mono", monospace;
}
.xlsx-fontsize {
min-width: 26px;
text-align: center;
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Colour pickers: a letter/swatch label wrapping the native input. */
.xlsx-colorpick {
position: relative;
overflow: hidden;
}
.xlsx-colorpick input[type="color"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
padding: 0;
border: none;
}
/* #182 — colour swatch under the picker: mirrors the chosen colour so applying
one to a selection is visible on the button itself. Empty (transparent)
until a colour is actually picked, so the default ribbon stays clean. */
.xlsx-color-swatch {
width: 14px;
height: 4px;
border-radius: 2px;
border: 1px solid var(--border);
background: transparent;
flex: 0 0 auto;
}
.xlsx-colorletter {
font-family: Georgia, serif;
font-weight: 700;
border-bottom: 3px solid var(--accent);
line-height: 1;
padding: 0 2px;
}
/* Ribbon dropdown menus share the structure-menu look. */
.xlsx-ribbon-menu {
min-width: 220px;
}
/* Floating contextual mini-bar (multi-cell selections). */
.xlsx-minibar {
position: fixed;
z-index: 10060;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
}
.xlsx-minibar[hidden] {
display: none;
}
.xlsx-minibar button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.xlsx-minibar button:hover {
background: var(--bg-hover);
color: var(--accent);
}
.xlsx-minibar button svg,
.xlsx-minibar button i[data-lucide] {
width: 15px;
height: 15px;
}
@media (pointer: coarse) {
.xlsx-minibar button {
width: 44px;
height: 44px;
}
}
.xlsx-toolbar-actions {
margin-left: auto;
display: flex;
@@ -11082,6 +11382,31 @@ body.desktop-mode .editor-container {
border-color: var(--accent);
color: var(--accent);
}
/* #180 — sheet strip: tabs on the left, File menu + Save pinned right. */
.xlsx-sheetbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.xlsx-sheetbar .xlsx-tabs {
flex: 1 1 auto;
min-width: 0;
}
.xlsx-sheetbar-end {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.xlsx-file-dropdown {
position: relative;
display: inline-block;
}
.xlsx-file-item {
text-align: left;
}
.xlsx-status-bar {
display: flex;
align-items: center;
@@ -11162,28 +11487,45 @@ body.desktop-mode .editor-container {
white-space: pre-line;
}
.xlsx-table {
background: var(--grid-bg);
background: var(--grid-bg, var(--surface));
}
/* #185 — one uniform 1px grid line on EVERY side of every cell (header rows
included), the way Sheets draws it. Previously only the bottom edge existed
and its colour came from `--border`, which is far too close to the cell
background on most themes: the grid read as a smear instead of a grid.
`--grid-border` is now derived from the theme by themes.js (same token for
every line, so the mesh stays perfectly regular). */
.xlsx-table td,
.xlsx-table th {
border: 1px solid var(--grid-border, var(--border));
}
.xlsx-table td {
border-bottom: 1px solid var(--grid-border);
border-bottom: 1px solid var(--grid-border, var(--border));
/* Cellules de données : une seule et même couleur (fond de grille). Pas de
variante une-ligne-sur-deux ici : sa spécificité écraserait la teinte de
sélection (une ligne sur deux non teintée). */
background: var(--grid-bg, var(--surface));
}
/* #154-A1 — zebra + hover make rows scannable; headers use dedicated tokens so
they are visually distinct from the cells. */
.xlsx-table tbody tr:nth-child(even) td {
background: var(--grid-zebra);
}
.xlsx-table tbody tr:hover td {
background: var(--bg-hover);
/* Cellules fusionnées : couleur d'en-tête, sauf en sélection (la teinte de
sélection gagne, uniforme sur toutes les cellules). */
.xlsx-table tbody td[rowspan]:not([rowspan="1"]):not(.xlsx-selected),
.xlsx-table tbody td[colspan]:not([colspan="1"]):not(.xlsx-selected) {
background: var(--grid-header-bg, var(--bg-hover));
color: var(--grid-header-text, var(--text-primary));
}
/* Google Sheets : aucun survol teinté — ni la ligne entière, ni la cellule.
Rien ne se met en couleur tant que l'utilisateur n'a pas sélectionné au
moins 2 cellules (voir `.xlsx-range-multi`). */
.xlsx-table th.xlsx-corner,
.xlsx-table th.xlsx-rownum {
background: var(--grid-header-bg);
color: var(--grid-header-text);
background: var(--grid-header-bg, var(--bg-hover));
color: var(--grid-header-text, var(--text-primary));
font-weight: 500;
text-align: right;
padding: 6px 8px;
border-bottom: 2px solid var(--grid-border);
border-right: 1px solid var(--grid-border);
/* #185 — the same 1px line as the cells: the header is told apart by its
fill, not by a heavier rule that broke the grid's regularity. */
border: 1px solid var(--grid-border, var(--border));
position: sticky;
left: 0;
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
@@ -11194,7 +11536,9 @@ body.desktop-mode .editor-container {
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
/* Suit `--xlsx-stick` comme l'en-tête : mesuré en JS sur le recouvrement
réel de la toolbar sticky, sinon la ligne A–Z glisse dessous. */
top: var(--xlsx-stick, 0px);
z-index: 4;
}
/* #153 A8 — the column headers stay visible while the sheet scrolls down.
@@ -11202,11 +11546,43 @@ body.desktop-mode .editor-container {
order is intentional: thead (3) < row numbers (2) < corner (4). */
.xlsx-table thead th {
position: sticky;
top: 0;
top: var(--xlsx-stick, 0px);
z-index: 3;
background: var(--grid-header-bg);
color: var(--grid-header-text);
border-bottom: 2px solid var(--grid-border);
background: var(--grid-header-bg, var(--bg-hover));
color: var(--grid-header-text, var(--text-primary));
border-bottom: 1px solid var(--grid-border, var(--border));
}
/* #185 — Affichage ▸ Afficher ▸ Grille / En-têtes, and Outils ▸ Accessibilité:
display concerns only, never a change to the file. */
.xlsx-viewer.xlsx-no-grid .xlsx-table td,
.xlsx-viewer.xlsx-no-grid .xlsx-table th {
border-width: 0;
}
.xlsx-viewer.xlsx-no-headers thead,
.xlsx-viewer.xlsx-no-headers th.xlsx-rownum,
.xlsx-viewer.xlsx-no-headers th.xlsx-corner {
display: none;
}
.xlsx-viewer.xlsx-a11y .xlsx-table td {
padding: 8px 10px;
font-size: 0.95rem;
}
.xlsx-viewer.xlsx-a11y {
--grid-border: color-mix(in srgb, var(--text-primary, #888) 42%, var(--surface, #000));
}
/* #180 — while the pointer rides a header edge, the representative resize
icon must win over the cell (`cell`) and header (`pointer`) cursors. The
edge header itself is mirrored inline by the viewer (its inline
`cursor:pointer` beats any stylesheet rule). */
.xlsx-table.xlsx-resize-col,
.xlsx-table.xlsx-resize-col th,
.xlsx-table.xlsx-resize-col td {
cursor: col-resize;
}
.xlsx-table.xlsx-resize-row,
.xlsx-table.xlsx-resize-row th,
.xlsx-table.xlsx-resize-row td {
cursor: row-resize;
}
.xlsx-table td[contenteditable] {
/* #155 — Excel-style cross cursor over the cells. */
@@ -11218,6 +11594,57 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
/* #181-A1 — SELECTION vs EDITION, like Google Sheets / Excel.
A selected cell is outlined but must NOT show a text caret; the caret is
only revealed in edit mode (double-click, F2 or the first keystroke).
`caret-color: transparent` hides it without leaving `contenteditable`, so the
click-to-select / type-to-replace flow stays native. */
.xlsx-table td.xlsx-cell {
caret-color: transparent;
}
.xlsx-table td.xlsx-editing {
caret-color: auto;
cursor: text;
}
/* The editing cell also reads as "open": a solid accent frame plus a raised
shadow, so the state is visible even on a row that is part of a range. */
.xlsx-table td.xlsx-editing {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
z-index: 1;
position: relative;
}
/* #181-A2 — range indicator: the four edges of the selected block trace it
exactly (Excel/Sheets draw a rectangle, not a flat wash), and the covered
row/column headers are highlighted too. `outline` on inner edges would be
overdrawn by the next cell, so the four borders are declared separately and
win over `.xlsx-selected`'s background. */
.xlsx-table tbody td.xlsx-sel-top {
border-top: 2px solid var(--accent, #4a90d9);
}
.xlsx-table tbody td.xlsx-sel-bottom {
border-bottom: 2px solid var(--accent, #4a90d9);
}
.xlsx-table tbody td.xlsx-sel-left {
border-left: 2px solid var(--accent, #4a90d9);
}
.xlsx-table tbody td.xlsx-sel-right {
border-right: 2px solid var(--accent, #4a90d9);
}
/* A one-cell selection keeps its four accent edges (a framed box) but NO fill:
`.xlsx-range-multi` is toggled on the viewer by applySelection() and gates
the tint, which starts at 2 cells — Google Sheets behaviour. */
.xlsx-viewer.xlsx-range-multi .xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
}
.xlsx-table thead th.xlsx-hdr-selected,
.xlsx-table th.xlsx-rownum.xlsx-hdr-selected {
background: var(--accent-bg);
color: var(--accent-text, var(--accent, #4a90d9));
font-weight: 700;
}
/* #153 A7 — the active cell keeps its outline even when focus moves to the
formula bar, so the user never loses track of what the bar edits.
#154-A1 — solid (not dashed) for a stronger active-cell affordance. */
@@ -11226,25 +11653,47 @@ body.desktop-mode .editor-container {
outline-offset: -2px;
}
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
/* Barre d'édition de la cellule, juste au-dessus de la grille :
[ position | champ d'édition ] + recherche. Bandeau distinct du fond pour
rester identifiable dans tous les thèmes (le fond transparent la rendait
invisible). Elle vit DANS la barre d'outils sticky (dernier rang) : la
toolbar restait sinon peinte par-dessus au défilement. */
.xlsx-formula-bar {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
margin-bottom: 8px;
padding: 6px 8px;
background: var(--surface2, var(--surface));
border: 1px solid var(--border);
border-radius: 8px;
}
/* Dernier rang de la toolbar sticky : l'écart est déjà géré par son `gap`. */
.xlsx-toolbar .xlsx-formula-bar {
margin-bottom: 0;
}
/* Zone d'édition : position de la cellule | champ d'édition. */
.xlsx-editbar {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
}
/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like
the read-only badge it replaced. */
.xlsx-active-cell {
width: 84px;
flex: 0 0 auto;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid var(--border);
border: 1px solid var(--grid-border, var(--border));
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8rem;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
}
@@ -11252,17 +11701,22 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-formula-sep {
width: 14px;
height: 14px;
/* Pastille « fx » façon tableur : repère visuel du champ d'édition. */
.xlsx-edit-fx {
flex: 0 0 auto;
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
font-weight: 700;
font-size: 0.85rem;
color: var(--text-muted);
padding: 0 2px;
user-select: none;
}
.xlsx-formula-input {
flex: 1;
min-width: 0;
padding: 5px 10px;
border: 1px solid var(--border);
border: 1px solid var(--grid-border, var(--border));
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
@@ -11334,7 +11788,7 @@ body.desktop-mode .editor-container {
min-width: 200px;
max-width: 86vw;
padding: 4px;
background: var(--bg, #fff);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
@@ -11393,18 +11847,23 @@ body.desktop-mode .editor-container {
.xlsx-table tr.xlsx-frozen-row td,
.xlsx-table tr.xlsx-frozen-row th {
position: sticky;
top: 33px; /* thead height — keeps the frozen row below the header */
/* Hauteur de l'en-tête + décalage toolbar sticky (`--xlsx-stick`). */
top: calc(var(--xlsx-stick, 0px) + 33px); /* thead height — keeps the frozen row below the header */
z-index: 2;
background: var(--grid-header-bg);
background: var(--grid-header-bg, var(--bg-hover));
}
.xlsx-table td.xlsx-frozen-col {
position: sticky;
left: 44px; /* the row-number column width */
background: var(--grid-bg);
background: var(--grid-bg, var(--surface));
}
/* #153 A14 — structure menu (sheets / rows / columns) */
.xlsx-structure-menu {
/* #185 — the menu-bar dropdowns share the toolbar menus' look; the toolbar
menus keep the `.xlsx-structure-menu` class to themselves so `.xlsx-structure-menu`
queries (viewer, tests, E2E) only ever match the open toolbar menu. */
.xlsx-structure-menu,
.xlsx-menudrop {
position: absolute;
z-index: 30;
display: flex;
@@ -11412,7 +11871,8 @@ body.desktop-mode .editor-container {
gap: 2px;
min-width: 240px;
max-width: min(320px, 92vw);
margin-top: 4px;
/* Positioned under its toggle by anchorUnder() (viewer.js), which already
adds the 4px gap. */
padding: 6px;
border: 1px solid var(--border);
border-radius: 6px;
@@ -11610,6 +12070,29 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* Resize ghost: fixed guide line following the pointer while dragging a
header edge (column widths / row heights). */
.xlsx-resize-ghost {
position: fixed;
z-index: 10060;
margin: 0;
background: var(--accent, #4a90d9);
pointer-events: none;
}
.xlsx-resize-ghost[data-kind="col"] { width: 2px; }
.xlsx-resize-ghost[data-kind="row"] { height: 2px; }
/* Comment marker: content-free so copy/export/save text stays clean. */
.xlsx-comment-dot::after {
content: "\1F4AC";
font-size: 0.68em;
margin-left: 3px;
vertical-align: super;
cursor: help;
}
/* Computed formula links behave like spreadsheet links. */
.xlsx-formula-link { color: var(--accent, #4a90d9); text-decoration: underline; }
/* #179 — shared menu-entry icon (grid context menu, structure, format, export). */
.xlsx-menu-icon {
width: 14px;
@@ -11636,10 +12119,9 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
}
/* #155 — Excel-like range selection + right-click / long-press context menu.
Le remplissage de la sélection est déclaré plus haut et conditionné par
`.xlsx-range-multi` : pas de fond teinté pour une sélection d'une cellule. */
.xlsx-viewer.xlsx-selecting,
.xlsx-viewer.xlsx-selecting td {
user-select: none;
@@ -11673,7 +12155,8 @@ body.desktop-mode .editor-container {
.xlsx-context-menu {
min-width: min(260px, 86vw);
}
.xlsx-structure-menu {
.xlsx-structure-menu,
.xlsx-menudrop {
max-width: 92vw;
}
}
@@ -11703,6 +12186,203 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* #183 — Sheets-like colour panel (text + fill). The chrome follows the
active theme (variables only); the swatches carry their own colour inline,
which is data, not decoration. */
.xlsx-colorpick-wrap {
position: relative;
display: inline-flex;
}
.xlsx-color-native {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
border: none;
padding: 0;
}
.xlsx-colour-panel {
position: absolute;
z-index: 40;
display: flex;
flex-direction: column;
width: 236px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 6px 18px var(--shadow, rgba(0, 0, 0, 0.22));
font-size: 0.8rem;
}
.xlsx-colour-head {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.xlsx-colour-reset {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
}
.xlsx-colour-reset:hover {
color: var(--accent);
border-color: var(--accent);
}
.xlsx-colour-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
/* 8 columns x 10 rows = the 80 curated swatches. */
.xlsx-colour-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.xlsx-colour-swatch-btn {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
min-width: 20px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.xlsx-colour-swatch-btn:hover {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
}
.xlsx-colour-swatch-btn.selected {
box-shadow: 0 0 0 2px var(--accent);
}
.xlsx-colour-check {
width: 12px;
height: 12px;
pointer-events: none;
}
.xlsx-colour-section {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.xlsx-colour-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.xlsx-colour-row {
display: flex;
align-items: center;
gap: 6px;
}
.xlsx-colour-row .xlsx-colour-swatch-btn {
width: 22px;
height: 22px;
}
.xlsx-colour-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px dashed var(--border);
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.xlsx-colour-add:hover {
color: var(--accent);
border-color: var(--accent);
}
.xlsx-colour-edit {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.xlsx-colour-edit:hover {
color: var(--accent);
background: var(--bg-hover);
}
.xlsx-colour-foot {
margin-top: 10px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.xlsx-colour-foot-item {
display: block;
width: 100%;
padding: 6px 4px;
border: none;
border-radius: 4px;
background: none;
color: var(--text-primary);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.xlsx-colour-foot-item:hover {
background: var(--bg-hover);
}
/* Mise en forme menu: the colour rows now carry a trigger + a live dot. */
.xlsx-format-colour {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
}
.xlsx-format-colour-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
cursor: pointer;
}
.xlsx-format-colour-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.xlsx-format-colour-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--border);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-green, var(--accent)) 100%);
}
.xlsx-colour-panel {
max-height: 70vh;
overflow-y: auto;
}
/* JSDOM shims for the tests that click anchors */
mark {
font: inherit;
@@ -11785,7 +12465,7 @@ mark {
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
margin: 8px 0;
border: 1px solid var(--border);
border-left: 3px solid var(--accent, #4a90d9);
border-radius: 4px;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.53.3",
"version": "2.54.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+200 -15
View File
@@ -41,6 +41,8 @@ const FIXTURE = 'sample-xlsx-lossy.xlsx';
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
// committed next to the other fixture; nothing in the suite writes to it.
const LARGE = 'sample-xlsx-large.xlsx';
const LARGE_PATH = path.resolve(process.cwd(), 'test_vault', LARGE);
let originalLargeBytes = null;
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
@@ -85,19 +87,17 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
// formula results (BUG-085), so the shadow line only exists on a pristine
// fixture.
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
test('calcule les formules à l’écran, garde le texte en barre (#153 A12)', async ({ page }) => {
await login(page);
await openFixture(page);
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>): the
// local engine computes it, so the cell shows the value (not the shadow).
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
await expect(formulaCell).toContainText('=B1*2');
const cached = formulaCell.locator('.xlsx-cached');
await expect(cached).toHaveCount(1);
await expect(cached).toHaveText('200');
// The tooltip is translated client-side, never hardcoded by the backend.
await expect(cached).toHaveAttribute('title', /Excel/);
await expect(formulaCell).toHaveText('200');
await expect(formulaCell.locator('.xlsx-cached')).toHaveCount(0);
await formulaCell.click();
await expect(page.locator('#xlsx-formula-input')).toHaveValue('=B1*2');
// A plain value cell must not be duplicated with a shadow line.
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
@@ -286,11 +286,12 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
await cellOf('B12').click();
await page.keyboard.press('Control+a');
await expect(nameBox).toHaveValue('A1:C500');
// #184 — la feuille rendue va jusqu'à Z (la grille est posée A → Z).
await expect(nameBox).toHaveValue('A1:Z500');
await page.keyboard.press('Control+Home');
await expect(nameBox).toHaveValue('A1');
await page.keyboard.press('Control+End');
await expect(nameBox).toHaveValue('C500');
await expect(nameBox).toHaveValue('Z500');
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
// utilise le presse-papiers interne de la grille : aucune permission
@@ -358,27 +359,160 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
await login(page);
await openFixture(page);
await page.locator('#xlsx-structure-btn').click();
const toggle = page.locator('#xlsx-structure-btn');
await toggle.click();
const menu = page.locator('.xlsx-structure-menu');
await expect(menu).toBeVisible();
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
// The menu opens right under its button, inside the toolbar width.
const [btnBox, menuBox, toolbarBox] = await Promise.all([
toggle.boundingBox(), menu.boundingBox(),
page.locator('#content-area .xlsx-toolbar').boundingBox(),
]);
expect(menuBox.y).toBeGreaterThanOrEqual(btnBox.y + btnBox.height - 2);
expect(menuBox.y).toBeLessThanOrEqual(btnBox.y + btnBox.height + 24);
// Le menu reste DANS la barre : près du bord droit il est décalé vers la
// gauche (clamp de anchorUnder) au lieu de dépasser.
expect(menuBox.x).toBeGreaterThanOrEqual(toolbarBox.x - 2);
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 2);
// Clic dans la grille = le focus quitte le menu → fermeture.
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
});
test('le menu Export se ferme au clic extérieur', async ({ page }) => {
test('le menu Fichier (Télécharger/CSV/Export) se ferme au clic extérieur', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-export-btn').click();
const menu = page.locator('#xlsx-export-menu');
await page.locator('#xlsx-file-btn').click();
const menu = page.locator('#xlsx-file-menu');
await expect(menu).toBeVisible();
// Les 5 sorties sont réunies dans ce seul menu (#185 : il est passé dans
// la barre de menus, au-dessus du ruban), repliées dans la section
// « Télécharger » — les sections du menu sont des accordéons.
await menu.locator('.xlsx-menu-item', { hasText: 'Télécharger' }).first().click();
await expect(menu.locator('#xlsx-download-btn')).toBeVisible();
await expect(menu.locator('#xlsx-csv-btn')).toBeVisible();
await expect(page.locator('#content-area .xlsx-sheetbar-end #xlsx-save-btn')).toBeVisible();
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(menu).toBeHidden();
});
test('#185 : les 10 menus Google Sheets au-dessus du ruban, et leurs sections', async ({ page }) => {
await login(page);
await openFixture(page);
const bar = page.locator('#content-area .xlsx-menubar');
await expect(bar).toBeVisible();
await expect(bar.locator('.xlsx-menubtn')).toHaveText([
'Fichier', 'Modifier', 'Affichage', 'Insertion', 'Format',
'Données', 'Outils', 'Gemini', 'Extensions', 'Aide',
]);
// Le ruban est bien SOUS les menus.
const [barBox, ribbonBox] = await Promise.all([
bar.boundingBox(),
page.locator('#content-area .xlsx-cmdbar').boundingBox(),
]);
expect(ribbonBox.y).toBeGreaterThan(barBox.y);
// Une section s'ouvre en accordéon, comme dans Sheets.
await page.locator('#content-area [data-xlsx-menu="format"]').click();
const format = page.locator('#xlsx-menu-format');
await expect(format).toBeVisible();
const number = format.locator('.xlsx-menu-item', { hasText: 'Nombre' }).first();
await number.click();
await expect(number).toHaveAttribute('aria-expanded', 'true');
await expect(format.locator('[data-xlsx-action="numfmt"]').first()).toBeVisible();
// Une option non implémentée le dit au lieu de ne rien faire.
const ghost = format.locator('[data-xlsx-unavailable]').first();
await ghost.click();
await expect(page.locator('.toast').first()).toBeVisible();
await expect(format).toBeHidden();
});
test('#185 : la grille est unie (1px sur les quatre côtés) et se masque', async ({ page }) => {
await login(page);
await openFixture(page);
const cell = page.locator('#content-area td[data-cell="B2"]');
const sides = await cell.evaluate((td) => {
const cs = getComputedStyle(td);
return [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth];
});
expect(new Set(sides).size).toBe(1);
expect(sides[0]).toBe('1px');
await page.locator('#content-area [data-xlsx-menu="view"]').click();
const view = page.locator('#xlsx-menu-view');
await expect(view).toBeVisible();
// Les entrées d'affichage vivent dans la section « Afficher » (accordéon).
await view.locator('.xlsx-menu-item', { hasText: 'Afficher' }).first().click();
await page.locator('#xlsx-menu-view [data-xlsx-action="toggle-grid"]').click();
await expect(page.locator('#content-area .xlsx-viewer')).toHaveClass(/xlsx-no-grid/);
const hidden = await cell.evaluate((td) => getComputedStyle(td).borderTopWidth);
expect(hidden).toBe('0px');
});
// Mon seul test mutant : restaure la fixture comme le bloc lossy.
test.beforeAll(() => {
if (existsSync(LARGE_PATH)) originalLargeBytes = readFileSync(LARGE_PATH);
});
test.afterAll(() => {
if (originalLargeBytes) writeFileSync(LARGE_PATH, originalLargeBytes);
});
test('insère une colonne à droite et une ligne en fin de feuille', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
// #184 — la grille posée va jusqu'à Z : le compte d'en-têtes ne peut plus
// témoigner de l'insertion (un re-rendu re-complète à 26 colonnes). Le
// toast est le signal de l'opération, la fixture restaurée sa preuve.
const headers = () => page.locator('#content-area .xlsx-table thead th:not(.xlsx-corner)');
await expect(headers()).toHaveCount(26);
await headers().nth(2).click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'droite' }).click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// Insérée localement (pas de re-rendu complet) : la colonne s'ajoute à
// la grille posée, 26 → 27.
await expect(headers()).toHaveCount(27);
// La ligne 501 dépasse le plafond de rendu : l'insertion réussit
// (toast) même si la ligne reste hors champ.
await page.locator('#content-area th.xlsx-rownum').nth(499).click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'dessous' }).click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
test('la barre façon Sheets expose ses contrôles et agit', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
for (const id of ['#xs-search', '#xs-print', '#xs-paint', '#xs-zoom', '#xs-cur', '#xs-pct',
'#xs-numfmt', '#xs-b', '#xs-i', '#xs-s', '#xs-u', '#xs-borders', '#xs-merge',
'#xs-alignh', '#xs-alignv', '#xs-wrap', '#xs-rot', '#xs-more', '#xlsx-save-btn']) {
await expect(page.locator(id)).toBeVisible();
}
// Zoom : la grille suit le sélecteur.
await page.locator('#xs-zoom').selectOption('150');
await expect.poll(async () => page.locator('#content-area .xlsx-panels').evaluate(
(el) => el.style.zoom || '100%')).toBe('150%');
await page.locator('#xs-zoom').selectOption('100');
// Gras via la barre : PUT + toast, sans dialogue.
await page.locator('#content-area td[data-cell="A1"]').click();
await page.locator('#xs-b').click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// Mini-barre contextuelle sur une plage.
await page.locator('#content-area td[data-cell="A1"]').click();
await page.locator('#content-area td[data-cell="B2"]').click({ modifiers: ['Shift'] });
await expect(page.locator('#xlsx-minibar')).toBeVisible();
});
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
await login(page);
@@ -402,3 +536,54 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
expect(itemBox.height).toBeGreaterThanOrEqual(44);
});
});
// ── #186 — création d'un classeur depuis la modale « Créer un fichier » ──────
test.describe('Excel viewer — création depuis l\'UI (#186)', () => {
// Le nom saisi dans la modale ne porte que le nom court : le dossier parent
// (« IT ») est déjà choisi par le menu contextuel.
const stem = `e2e-cree-${Date.now()}`;
const CREATED = `IT/${stem}.xlsx`;
async function cleanup(request) {
await request.fetch(
`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`,
{ method: 'DELETE' },
).catch(() => {});
}
test('la liste des types propose Excel et le classeur créé s\'ouvre dans la visionneuse', async ({ page, request }) => {
await cleanup(request);
await login(page);
// Menu contextuel du dossier « IT » → « Nouveau fichier ici ».
await page.locator('.tree-item.vault-item[data-vault="TestVault"]').first().click();
await page.locator('.tree-item[data-vault="TestVault"][data-path="IT"]').first()
.click({ button: 'right' });
const menu = page.locator('#context-menu.active');
await expect(menu).toBeVisible({ timeout: 5000 });
await menu.locator('.context-menu-item', { hasText: 'Nouveau fichier ici' }).click();
await page.locator('.obsigate-modal-overlay.active').waitFor({ state: 'visible', timeout: 5000 });
const extSelect = page.locator('#file-ext-select');
// L'option Excel existe, et son libellé vient des locales (jamais du dur).
await expect(extSelect.locator('option[value=".xlsx"]')).toHaveText('Excel (.xlsx)');
await extSelect.selectOption('.xlsx');
await page.fill('#file-name-input', stem);
await page.click('#file-create-btn');
// Le classeur s'ouvre dans la visionneuse : une grille, pas une erreur.
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
await expect(page.locator('#content-area .xlsx-tab', { hasText: 'Feuille1' })).toBeVisible();
// Et c'est un vrai classeur : l'API le sert en tableur, avec sa feuille.
const body = await request
.get(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`)
.then((r) => r.json());
expect(body.is_xlsx).toBe(true);
expect(body.xlsx_sheets.map((s) => s.name)).toEqual(['Feuille1']);
await cleanup(request);
});
});
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* ObsiGate — unit tests for the spreadsheet formula engine
* (frontend/js/xlsx/formula.js). Pure logic, no DOM needed.
*
* Usage: node tests/frontend/xlsx-formula.test.mjs
*/
import { strict as assert } from "node:assert";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const modUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "formula.js")
).href;
const {
isFormulaText, parseFormula, evaluate, createScope, displayValue,
colToIndex, indexToCol, parseA1,
} = await import(modUrl);
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (e) {
console.error(` ✗ ${name}`);
console.error(e);
process.exitCode = 1;
}
}
/** Evaluate `text` against a fake sheet {ref: raw}. */
function run(text, cells = {}, locale = "en") {
const scope = createScope({
readRaw: (_sheet, ref) => {
const hit = Object.entries(cells).find(
([k]) => k.toUpperCase() === String(ref).toUpperCase()
);
return hit ? hit[1] : null;
},
sheet: "F",
locale,
});
return displayValue(evaluate(parseFormula(text), scope), locale);
}
await test("detects formula text", () => {
assert.ok(isFormulaText("=1+1"));
assert.ok(isFormulaText(" @A1 "));
assert.ok(!isFormulaText("hello"));
assert.ok(!isFormulaText("="));
});
await test("refs helpers", () => {
assert.equal(colToIndex("A"), 1);
assert.equal(colToIndex("Z"), 26);
assert.equal(colToIndex("AA"), 27);
assert.equal(indexToCol(28), "AB");
assert.deepEqual(parseA1("$B$12"), { col: 2, row: 12 });
assert.equal(parseA1("ZZZ"), null);
});
await test("arithmetic and Excel precedence (-2^2 = 4)", () => {
assert.equal(run("=1+2*3"), "7");
assert.equal(run("=(1+2)*3"), "9");
assert.equal(run("=2^3"), "8");
assert.equal(run("=-2^2"), "4");
assert.equal(run("=10/4"), "2.5");
assert.equal(run("=50%"), "0.5");
assert.equal(run("=5*10%"), "0.5");
});
await test("comparisons and concatenation", () => {
assert.equal(run("=1+1=2"), "TRUE");
assert.equal(run('="a"&"b"'), "ab");
assert.equal(run("=3>2"), "TRUE");
assert.equal(run("=1<>1"), "FALSE");
});
await test("cell refs and ranges", () => {
const cells = { A1: 2, A2: 4, B1: "x" };
assert.equal(run("=A1*3", cells), "6");
assert.equal(run("=SOMME(A1:A2)", cells), "6");
assert.equal(run("=SUM(A1:A2)", cells), "6");
assert.equal(run("=MOYENNE(A1:A2)", cells), "3");
assert.equal(run("=A1&B1", cells), "2x");
});
await test("French decimal comma with ; separator", () => {
assert.equal(run("=1,5+1", {}, "fr"), "2.5");
assert.equal(run("=SOMME(1;2;3)"), "6");
assert.equal(run("=SI(1>2;10;20)"), "20");
});
await test("functions FR/EN", () => {
assert.equal(run("=MAX(1;5;3)"), "5");
assert.equal(run("=MIN(1;5;3)"), "1");
assert.equal(run("=ABS(-4)"), "4");
assert.equal(run("=ARRONDI(2.456;2)"), "2.46");
assert.equal(run("=RACINE(9)"), "3");
assert.equal(run("=PUISSANCE(2;10)"), "1024");
assert.equal(run("=NB(1;\"x\";3)"), "2");
assert.equal(run("=NBVAL(1;\"x\";3)"), "3");
assert.equal(run("=NBCAR(\"héllo\")"), "5");
assert.equal(run("=MAJUSCULES(\"abc\")"), "ABC");
assert.equal(run("=GAUCHE(\"hello\";2)"), "he");
assert.equal(run("=DROITE(\"hello\";2)"), "lo");
assert.equal(run("=STXT(\"hello\";2;3)"), "ell");
assert.equal(run("=ET(1;1)"), "TRUE");
assert.equal(run("=OU(0;1)"), "TRUE");
assert.equal(run("=NON(1)"), "FALSE");
assert.equal(run('=SIERREUR(1/0;"raté")'), "raté");
assert.equal(run("=MEDIANE(3;1;2)"), "2");
assert.equal(run("=PRODUIT(2;3;4)"), "24");
assert.equal(run("=MOD(7;3)"), "1");
assert.equal(run("=ENT(2.9)"), "2");
});
await test("nested formulas and chained refs", () => {
const cells = { A1: "=B1*2", B1: 21 };
assert.equal(run("=A1+1", cells), "43");
});
await test("errors surface as localized codes", () => {
assert.equal(run("=1/0"), "#DIV/0!");
assert.equal(run("=1/0", {}, "fr"), "#DIV/0!");
assert.equal(run("=NOSUCHFN(1)"), "#NAME?");
assert.equal(run("=NOSUCHFN(1)", {}, "fr"), "#NOM?");
assert.equal(run('="a"+1'), "#VALUE!");
assert.equal(run('="a"+1', {}, "fr"), "#VALEUR!");
assert.equal(run("=A99999999"), "#REF!");
});
await test("cycles are detected", () => {
const cells = { A1: "=B1", B1: "=A1" };
assert.equal(run("=A1", cells), "#CIRC!");
assert.equal(run("=A1", cells, "fr"), "#CYCLE!");
});
await test("booleans localize", () => {
assert.equal(run("=VRAI"), "TRUE");
assert.equal(run("=VRAI", {}, "fr"), "VRAI");
assert.equal(run("=TRUE"), "TRUE");
assert.equal(run("=1=1", {}, "fr"), "VRAI");
});
await test("HYPERLINK yields a link value", async () => {
const scope = createScope({ readRaw: () => null, sheet: "F", locale: "en" });
const v = evaluate(parseFormula('=LIEN("https://x.test";"ici")'), scope);
assert.equal(v.t, "link");
assert.equal(v.url, "https://x.test");
assert.equal(v.label, "ici");
assert.equal(displayValue(v), "ici");
});
await test("garbage is rejected by the parser", () => {
assert.throws(() => parseFormula("=1+"), Error);
assert.throws(() => parseFormula("=SUM("), Error);
assert.throws(() => parseFormula("=(1"), Error);
});
await test("quoted and absolute refs", () => {
const cells = { A1: 5 };
assert.equal(run("=$A$1+1", cells), "6");
});
console.log(`\n${passCount}/${testCount} passed`);
process.exit(process.exitCode || 0);
+175 -14
View File
@@ -1,14 +1,19 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
* ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185).
*
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
* Covers frontend/js/xlsx/context-menu.js, command-bar.js and menus.js:
* - every grid-menu entry renders its representative Lucide icon + safe label;
* - the grid menu closes on Escape / outside click / focus loss / scroll /
* resize, and offers arrow-key navigation with autofocus on open;
* - trackDismissable() gives Structure / Mise en forme / Export the same
* dismissal contract (toggle exempt, custom onClose);
* - the ribbon carries the new icons (save, tab "+", export entries).
* - the ribbon carries its icons (save, tab "+", gridlines);
* - #185: the menu bar lists the ten Google Sheets menus, every entry is
* actionable or honestly flagged unavailable, labels come from the catalog,
* editing entries disappear on a read-only workbook, and the historical
* Fichier ids (#xlsx-file-btn / -menu / #xlsx-download-btn / #xlsx-csv-btn)
* survive the move from the sheet strip.
*
* Usage: node tests/frontend/xlsx-menus.test.mjs
*/
@@ -43,6 +48,18 @@ globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
// #185 — the menu bar renders localized labels, so the real FR catalog has to
// be reachable: serve /static/locales/*.json from disk and nothing else.
const { readFileSync } = await import("node:fs");
globalThis.fetch = async (url) => {
const href = String(url);
if (href.includes("/static/locales/")) {
const file = path.join(REPO_ROOT, "frontend", "locales", "fr.json");
return { ok: true, status: 200, json: async () => JSON.parse(readFileSync(file, "utf8")) };
}
return { ok: true, status: 200, json: async () => ({}), text: async () => "" };
};
// ── Module under test ───────────────────────────────────────────────────────
const ctxMenuUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
@@ -50,9 +67,17 @@ const ctxMenuUrl = pathToFileURL(
const cmdBarUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
).href;
const menusUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js")
).href;
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
await import(ctxMenuUrl);
const { buildCommandBar } = await import(cmdBarUrl);
const { buildMenuBar, SHEETS_MENUS } = await import(menusUrl);
const { initI18n } = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
);
await initI18n();
// ── Mini runner ─────────────────────────────────────────────────────────────
let testCount = 0;
@@ -163,13 +188,32 @@ await test("focus leaving the menu closes it, focus inside keeps it", () => {
outside.remove();
});
await test("scroll and resize close the menu", () => {
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("scroll"));
assert.ok(!isContextMenuOpen(), "scroll closes");
await test("double close never throws and a late click still runs its action", () => {
// Regression: in a real browser, remove() fires focusout synchronously,
// which re-entered closeContextMenu — the second remove() threw
// NotFoundError and aborted the item's onClick before it ran.
let runs = 0;
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "Copy", onClick: () => { runs++; } }]);
const btn = menu.querySelector(".xlsx-context-item");
closeContextMenu();
closeContextMenu(); // idempotent: no-op, no throw
assert.ok(!isContextMenuOpen());
btn.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(runs, 1, "click on the detached entry still runs its action");
});
await test("scroll and resize close the menu", async () => {
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("resize"));
assert.ok(!isContextMenuOpen(), "resize closes");
openContextMenu(10, 10, itemsOf());
// The smooth-scroll tail of the opening gesture is forgiven…
window.dispatchEvent(new w.Event("scroll"));
assert.ok(isContextMenuOpen(), "immediate scroll does not close");
// …but a later scroll detaches the menu.
await sleep(450);
window.dispatchEvent(new w.Event("scroll"));
assert.ok(!isContextMenuOpen(), "later scroll closes");
});
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
@@ -226,26 +270,143 @@ await test("trackDismissable: Escape and focus loss dismiss", () => {
toggle.remove();
});
// ── Ribbon icons ────────────────────────────────────────────────────────────
await test("the ribbon carries save, tab-plus and export icons", () => {
const { actionsHtml, tabs } = buildCommandBar({
// ── Ribbon icons (#185: the File menu moved to the menu bar) ────────────────
await test("the ribbon carries save, tab-plus and gridline icons", () => {
const { actionsHtml, tabs, fileHtml } = buildCommandBar({
sheets: [{ name: "F1" }],
isCsv: false,
readOnly: false,
editable: true,
});
const host = document.createElement("div");
host.innerHTML = tabs + actionsHtml;
host.innerHTML = tabs + actionsHtml + fileHtml;
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
assert.ok(saveIcon, "save button has an icon");
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
assert.ok(plusIcon, "tab + button has an icon");
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
const gridIcon = host.querySelector("#xs-grid i[data-lucide]");
assert.ok(gridIcon, "gridline switch has an icon");
assert.equal(gridIcon.getAttribute("data-lucide"), "grid-3x3");
// #185 — the sheet strip keeps Save only; the file outputs live in the menu bar.
assert.equal(host.querySelector("#xlsx-file-btn"), null, "no file dropdown left on the strip");
});
// ── #185 — the menu bar itself ──────────────────────────────────────────────
await test("buildMenuBar renders the ten Sheets menus with their labels", () => {
assert.deepEqual(
SHEETS_MENUS.map((m) => m.id),
["file", "edit", "view", "insert", "format", "data", "tools", "gemini", "extensions", "help"],
);
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
const html = buildMenuBar({ editable: true, readOnly: false });
const host = document.createElement("div");
host.innerHTML = html;
const menus = [...host.querySelectorAll(".xlsx-menubar > .xlsx-menuwrap")];
assert.deepEqual(
menus.map((w) => w.querySelector(".xlsx-menubtn").textContent.trim()),
["Fichier", "Modifier", "Affichage", "Insertion", "Format", "Données", "Outils", "Gemini", "Extensions", "Aide"],
);
// Every button controls its own dropdown, hidden until opened.
menus.forEach((w) => {
const btn = w.querySelector(".xlsx-menubtn");
const drop = host.querySelector(`#${btn.getAttribute("aria-controls")}`);
assert.ok(drop, `${btn.dataset.xlsxMenu} has a dropdown`);
assert.equal(drop.getAttribute("role"), "menu");
assert.equal(drop.style.display, "none", "dropdown starts closed");
assert.equal(btn.getAttribute("aria-expanded"), "false");
});
});
await test("the Fichier menu keeps the historical ids and export markers", () => {
const host = document.createElement("div");
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
assert.ok(host.querySelector("#xlsx-file-btn"), "file button id kept");
assert.ok(host.querySelector("#xlsx-file-menu"), "file menu id kept");
assert.ok(host.querySelector("#xlsx-download-btn"), "download entry kept its id");
assert.ok(host.querySelector("#xlsx-csv-btn"), "csv entry kept its id");
assert.deepEqual(
[...host.querySelectorAll("#xlsx-file-menu [data-xlsx-export]")].map((n) => n.dataset.xlsxExport),
["md", "html", "print"],
);
});
await test("every menu entry is either actionable or honestly flagged", () => {
const host = document.createElement("div");
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
const rows = [...host.querySelectorAll(".xlsx-menu-item")];
assert.ok(rows.length > 120, `all Sheets entries are listed (${rows.length})`);
rows.forEach((row) => {
const actionable = row.hasAttribute("data-xlsx-action") || row.hasAttribute("data-xlsx-export");
const flagged = row.hasAttribute("data-xlsx-unavailable");
assert.ok(actionable || flagged || row.hasAttribute("aria-haspopup"),
`"${row.textContent.trim()}" is actionable, a section, or flagged unavailable`);
});
// A declared action always resolves against the viewer's registry (#185).
const ACTIONS = [
"share", "download", "export-csv", "rename", "trash", "history", "details", "settings", "print",
"undo", "redo", "cut", "copy", "paste", "paste-values", "delete-rows", "delete-cols",
"clear-contents", "select-all", "find-replace", "freeze", "freeze-row", "freeze-col",
"unfreeze", "toggle-formula-bar", "toggle-grid", "toggle-headers", "dashboard", "zoom",
"fullscreen", "insert-rows-above", "insert-rows-below", "insert-cols-left",
"insert-cols-right", "insert-cells", "link", "comment", "function", "numfmt", "theme-menu",
"bold", "italic", "underline", "strike", "fontsize", "align", "valign",
"wrap-on", "wrap-off", "rotate", "clear-format", "banded", "sort-asc", "sort-desc",
"sort-reset", "filter-column", "named-ranges", "cleanup-dupes", "cleanup-trim",
"split-text", "autocomplete", "a11y", "gemini", "help", "whatsnew", "shortcuts", "report",
];
const declared = new Set(
[...host.querySelectorAll("[data-xlsx-action]")].map((n) => n.dataset.xlsxAction),
);
ACTIONS.forEach((name) => assert.ok(declared.has(name), `menu declares "${name}"`));
// "apply-theme" is deliberately absent: the theme list is built on demand.
assert.equal(declared.has("apply-theme"), false, "themes are not hard-coded in the markup");
});
await test("Format ▸ Thème is an empty section the viewer fills on demand (#185)", () => {
const host = document.createElement("div");
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
const row = host.querySelector('[data-xlsx-menu="format"]')
.closest(".xlsx-menuwrap")
.querySelector('[data-xlsx-action="theme-menu"]');
assert.ok(row, "the Thème section exists");
const sub = row.nextElementSibling;
assert.equal(sub.className, "xlsx-menu-sub", "it is a section, not a leaf");
assert.equal(sub.hidden, true, "collapsed");
assert.equal(sub.children.length, 0, "empty until the viewer fills it");
assert.equal(row.getAttribute("aria-expanded"), "false");
// The next entry is untouched by the empty section.
assert.ok(host.querySelector('[data-xlsx-action="bold"]'), "Bold still listed");
});
await test("editing entries disappear on a read-only workbook, view entries stay", () => {
const host = document.createElement("div");
host.innerHTML = buildMenuBar({ editable: false, readOnly: true });
assert.equal(host.querySelector('[data-xlsx-action="bold"]'), null, "no Bold");
assert.equal(host.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert cells");
assert.equal(host.querySelector('[data-xlsx-action="undo"]'), null, "no Undo");
assert.ok(host.querySelector('[data-xlsx-action="copy"]'), "Copy stays available");
assert.ok(host.querySelector('[data-xlsx-action="zoom"]'), "Zoom stays available");
assert.ok(host.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay available");
});
await test("menu labels come from the i18n catalog, never from raw text", () => {
const host = document.createElement("div");
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
const labels = [...host.querySelectorAll(".xlsx-menu-label")].map((n) => n.textContent);
assert.ok(labels.length > 100);
assert.equal(labels.some((l) => /xlsx\./.test(l)), false, "no unresolved key is rendered");
assert.ok(labels.includes("Nouveau"), "Google Sheets' own wording is kept");
assert.ok(labels.includes("Couleurs alternées"));
assert.ok(labels.includes("Diviser le texte en colonnes"));
});
await test("a sheet name or cell value cannot leak HTML into a menu label", () => {
const host = document.createElement("div");
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
host.querySelectorAll(".xlsx-menu-label").forEach((n) => {
assert.equal(n.querySelector("img, script"), null, "labels stay text nodes");
});
});
console.log(`\n${passCount}/${testCount} passed`);
File diff suppressed because it is too large Load Diff
+42
View File
@@ -1,6 +1,9 @@
# tests/test_api_main.py — Integration tests for main.py API endpoints
# Uses the existing app_with_vault / client fixtures from conftest.py
from pathlib import Path
import pytest
# ═══════════════════════════════════════════════════════════════════
# Health & Metadata
@@ -200,6 +203,45 @@ class TestFileCRUD:
data = resp.json()
assert data["success"] is True
def test_create_xlsx_empty_workbook(self, client, test_vault_dir):
"""#186 - "Create a file" > Excel must produce a readable workbook.
An .xlsx is a ZIP archive, not a text file: writing the empty payload
of the modal as text would leave a file the viewer cannot open.
"""
openpyxl = pytest.importorskip("openpyxl")
resp = client.post("/api/file/TestVault", json={
"path": "nouveau.xlsx",
"content": "",
})
assert resp.status_code == 200
assert resp.json()["success"] is True
# The archive is a real workbook: one empty sheet, loadable by openpyxl.
wb = openpyxl.load_workbook(Path(test_vault_dir) / "nouveau.xlsx")
assert wb.sheetnames == ["Feuille1"]
# And the viewer serves it as a spreadsheet.
resp2 = client.get("/api/file/TestVault", params={"path": "nouveau.xlsx"})
assert resp2.status_code == 200
body = resp2.json()
assert body["is_xlsx"] is True
assert [s["name"] for s in body["xlsx_sheets"]] == ["Feuille1"]
def test_create_xlsx_in_new_subdir(self, client):
"""#186 - same in a subdirectory that does not exist yet."""
pytest.importorskip("openpyxl")
resp = client.post("/api/file/TestVault", json={
"path": "Tableaux/annee.xlsx",
"content": "",
})
assert resp.status_code == 200
resp2 = client.get("/api/file/TestVault", params={"path": "Tableaux/annee.xlsx"})
assert resp2.status_code == 200
assert resp2.json()["is_xlsx"] is True
def test_save_file(self, client):
"""Save (update) an existing file"""
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
+30
View File
@@ -129,6 +129,36 @@ class TestRowColOps:
assert wb["Data"]["B1"].value == "Valeur"
wb.close()
def test_insert_past_the_last_row_and_column_takes_effect(self, client, book):
# openpyxl's sparse model silently ignores inserts past the used
# extent: the service materialises the missing rows/columns first so
# « below the last row » / « right of the last column » behave.
wb = openpyxl.load_workbook(book)
last_row, last_col = wb["Data"].max_row, wb["Data"].max_column
wb.close()
assert _put(client, actions=[
{"op": "col_insert", "sheet": "Data", "at": last_col + 1},
]).status_code == 200
assert _put(client, actions=[
{"op": "row_insert", "sheet": "Data", "at": last_row + 1},
]).status_code == 200
wb = openpyxl.load_workbook(book)
ws = wb["Data"]
assert ws.max_column == last_col + 1, "one blank column appended"
assert ws.max_row == last_row + 1, "one blank row appended"
wb.close()
def test_insert_far_past_the_extent_extends_the_sheet(self, client, book):
wb = openpyxl.load_workbook(book)
last_row = wb["Data"].max_row
wb.close()
assert _put(client, actions=[
{"op": "row_insert", "sheet": "Data", "at": last_row + 3},
]).status_code == 200
wb = openpyxl.load_workbook(book)
assert wb["Data"].max_row >= last_row + 3
wb.close()
class TestGuards:
def test_unknown_sheet_is_400(self, client, book):
+109
View File
@@ -250,6 +250,115 @@ class TestStyleWriting:
assert backups, "the rewrite is backed up first"
class TestExtendedStyles:
"""Nouvelles clés de style (barre façon Sheets) : taille, barré,
alignements verticaux, retour ligne, rotation, bordures — et commentaires."""
def _style(self, client, path, ops, **extra):
return client.put(
f"/api/file/{VAULT}/xlsx/style",
params={"path": path},
json={"ops": ops, **extra},
)
def test_font_size_strike_valign_wrap_rotation(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{
"op": "cell", "sheet": "Data", "range": "A1",
"style": {
"font_size": 14, "strike": True, "valign": "middle",
"wrap": True, "rotation": -45,
},
},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
cell = wb["Data"]["A1"]
assert cell.font.size == 14
assert cell.font.strike is True
assert cell.alignment.vertical == "center"
assert cell.alignment.wrap_text is True
assert cell.alignment.textRotation == 135
wb.close()
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert "font-size:14pt" in sheet["styles"]["A1"]
assert "line-through" in sheet["styles"]["A1"]
assert "vertical-align:middle" in sheet["styles"]["A1"]
assert "white-space:normal" in sheet["styles"]["A1"]
# La diagonale (-45° → 135 OOXML) est relue en `rotate()` CSS.
assert "transform:rotate(45deg)" in sheet["styles"]["A1"]
def test_borders_all_outer_none(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1:B2",
"style": {"border": "all", "border_color": "#112233"}},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
side = wb["Data"]["A1"].border.left
assert side.style == "thin" and side.color.rgb == "FF112233"
wb.close()
resp = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1:B2", "style": {"border": "none"}},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
cleared = wb["Data"]["A1"].border.left
assert cleared is None or cleared.style is None
wb.close()
def test_comment_set_clear_and_readback(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": "À vérifier"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
assert wb["Data"]["B2"].comment.text == "À vérifier"
wb.close()
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
assert resp.json()["xlsx_sheets"][0]["comments"]["B2"] == "À vérifier"
resp = self._style(client, "plain.xlsx", [
{"op": "comment_clear", "sheet": "Data", "ref": "B2"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
assert wb["Data"]["B2"].comment is None
wb.close()
def test_formats_map_lists_number_formats(self, client, plain_book):
self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "B2", "style": {"number_format": "0.00"}},
])
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
assert resp.json()["xlsx_sheets"][0]["formats"]["B2"] == "0.00"
def test_extended_invalid_inputs_are_refused(self, client, plain_book):
style = lambda s: self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": s},
]).status_code
assert style({"font_size": 200}) == 400
assert style({"font_size": "grand"}) == 400
assert style({"valign": "diagonal"}) == 400
# 180° (texte vertical retourné, relu tel quel) est accepté — seules
# les valeurs vraiment hors bornes sont refusées.
assert style({"rotation": 180}) == 200
assert style({"rotation": 181}) == 400
assert style({"rotation": -91}) == 400
assert style({"border": "dotted-circle"}) == 400
assert style({"border_style": "wavy"}) == 400
assert self._style(client, "plain.xlsx", [
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": ""},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "comment_set", "sheet": "Data", "ref": "B2:C3", "text": "x"},
]).status_code == 400
class TestEndpointWiring:
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})