Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72da123a51 | ||
|
|
e94af0369b | ||
|
|
8da65611cb |
+47
-1
@@ -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.40.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.43.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,52 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.43.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 4) : undo/redo, défilement et accessibilité.**
|
||||
Les éditions de cellules peuvent être **annulées / rétablies** (boutons dans le ruban,
|
||||
raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`). Les fenêtres de lignes d'une feuille tronquée se
|
||||
chargent via **`IntersectionObserver`** (repli sur l'écouteur de défilement quand l'API est
|
||||
absente). La grille expose désormais une sémantique **ARIA** (`role="grid"` / `row` /
|
||||
`gridcell` / `columnheader` / `rowheader`). Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
|
||||
(47) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts. Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.42.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 3) : inspecteur droit.**
|
||||
Le **Tableau de bord** quitte le flux de la grille pour un **panneau latéral droit repliable**
|
||||
(`.xlsx-inspector`) : la grille reste visible à côté. L'en-tête de l'inspecteur porte le titre,
|
||||
une entrée **Assistant IA** (ouvre le panneau latéral global existant) et un bouton de fermeture.
|
||||
Sous 900 px, l'inspecteur se place sous la grille. Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (44) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
|
||||
Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.41.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non
|
||||
bloquants.**
|
||||
Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème**
|
||||
(`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant
|
||||
`.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer /
|
||||
dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la
|
||||
confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`)
|
||||
n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en
|
||||
conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée
|
||||
signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
|
||||
(42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.40.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.40.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.40.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.43.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.40.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.40.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.43.0 | Last updated: September 2026*
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.40.0"
|
||||
version = "2.43.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.40.0"
|
||||
version = "2.43.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.40.0",
|
||||
"version": "2.43.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -159,10 +159,15 @@ réécrire : **valeurs calculées** mises en cache par Excel, segments
|
||||
(slicers), chronologies, contrôles de formulaire, connexions/requêtes,
|
||||
XML personnalisé, signature numérique, commentaires enrichis, macros.
|
||||
L'ouverture affiche alors un bandeau qui les liste, et la première
|
||||
sauvegarde demande confirmation. Si vous refusez, rien n'est écrit.
|
||||
sauvegarde demande confirmation dans une fenêtre intégrée au thème de
|
||||
l'application. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
|
||||
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
|
||||
l'enregistrement, vos modifications restant en place.
|
||||
|
||||
### Formules
|
||||
|
||||
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
|
||||
@@ -210,6 +215,9 @@ A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
|
||||
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
|
||||
plage se copie telle quelle vers un tableur.
|
||||
|
||||
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
|
||||
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
|
||||
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
||||
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
||||
@@ -231,13 +239,14 @@ en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
|
||||
conformément à la RFC 4180 (les guillemets et séparateurs sont
|
||||
échappés).
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** liste les plages
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
nommées du classeur (nom, référence, portée), signale les feuilles
|
||||
contenant des graphiques ou des tableaux croisés, et donne pour chaque
|
||||
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
|
||||
numériques) avec quelques chiffres clés. C'est une aide à la lecture :
|
||||
les plages nommées peuvent ensuite être exploitées avec l'assistant IA
|
||||
(outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
numériques) avec quelques chiffres clés. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
`append_xlsx_rows`).
|
||||
|
||||
### Limites
|
||||
|
||||
+12
-8
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.40.0 | **Dernière mise à jour :** 2026-09-29
|
||||
> **Version :** 2.43.0 | **Dernière mise à jour :** 2026-09-29
|
||||
> **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)**
|
||||
@@ -97,10 +97,13 @@
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
|
||||
- **Statut :** 🔵 **en cours** — **Lot 1 livré le 2026-09-29** (ruban de commandes groupé, onglets
|
||||
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
|
||||
recalculées, tokens de grille et affordances). Lots suivants : dialogues thémés, inspecteur
|
||||
droit (dashboard + IA), découpage modulaire + undo/redo.
|
||||
- **Statut :** 🔵 **en cours** — **Lots 1, 2, 3 & 4 livrés le 2026-09-29** (ruban de commandes groupé,
|
||||
onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
|
||||
recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`,
|
||||
bandeau de conflit 409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** pour le
|
||||
tableau de bord avec entrée Assistant IA ; **undo/redo**, chargement via `IntersectionObserver`,
|
||||
ARIA `role="grid"`). Reste **A5** : découpage modulaire (`frontend/js/xlsx/*`) et finitions
|
||||
(lien dashboard → grille, inspecteur redimensionnable, split).
|
||||
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
|
||||
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
|
||||
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
|
||||
@@ -110,9 +113,10 @@
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
|
||||
badges d'état, tokens de grille et affordances visuelles (Lot 1)
|
||||
- [ ] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
|
||||
- [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3)
|
||||
- [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4)
|
||||
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
|
||||
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
|
||||
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
|
||||
- [ ] **A5** Découpage `frontend/js/xlsx/*` + lien dashboard → grille, inspecteur redimensionnable, split
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
|
||||
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
||||
> **Statut :** 🔵 **en cours** — Lot 1 livré le 2026-09-29
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4)
|
||||
> **Statut :** 🔵 **en cours** — Lots 1, 2, 3 & 4 livrés le 2026-09-29 (reste A5, découpage modulaire)
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
|
||||
|
||||
@@ -68,25 +68,40 @@ la grille ni le backend.
|
||||
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
|
||||
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
|
||||
|
||||
### Lot 2 — Dialogues thémés & feedback (à venir)
|
||||
### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
|
||||
|
||||
- Remplacer `confirm()`/`prompt()` par des **modales thémées** (`.obsigate-modal-*`) et des
|
||||
**toasts** : structure (feuille/ligne/colonne), perte (409 `xlsx_lossy_content`), conflit
|
||||
(409 `conflict`) en bandeau non bloquant.
|
||||
- Indicateur *dirty* sur l'onglet de feuille et la barre.
|
||||
- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
|
||||
réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
|
||||
- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
|
||||
renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
|
||||
confirmation de perte (409 `xlsx_lossy_content`).
|
||||
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
|
||||
avec bouton **Réessayer** — les modifications sont conservées.
|
||||
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
|
||||
|
||||
### Lot 3 — Inspecteur droit (à venir)
|
||||
### Lot 3 — Inspecteur droit ✅ *(2026-09-29)*
|
||||
|
||||
- Panneau repliable hébergeant **Tableau de bord** et **Assistant IA** en onglets ; la grille
|
||||
reste visible. Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection).
|
||||
- Détachement en split via `PaneManager.splitRight()`.
|
||||
- **A3.1** Le **Tableau de bord** quitte le flux de la grille pour un **panneau droit repliable**
|
||||
(`.xlsx-inspector`) : la grille reste visible à côté (`.xlsx-body` = `.xlsx-main` + inspecteur).
|
||||
- **A3.2** En-tête d'inspecteur : titre, bouton **Assistant IA** (ouvre le panneau latéral global
|
||||
existant) et bouton de fermeture.
|
||||
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
|
||||
|
||||
### Lot 4 — Découpage & finitions (à venir)
|
||||
### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)*
|
||||
|
||||
- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons
|
||||
**Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`.
|
||||
- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de
|
||||
défilement pour les environnements sans IO).
|
||||
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
|
||||
|
||||
### Lot 5 — Découpage modulaire & finitions (à venir)
|
||||
|
||||
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
|
||||
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api`.
|
||||
- **Undo/redo** local (pile de commandes), défilement paresseux via `IntersectionObserver`,
|
||||
accessibilité ARIA (`role="grid"`, *roving tabindex*).
|
||||
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api` (refactor sans changement de
|
||||
comportement, sous couvert des tests JSDOM/E2E existants).
|
||||
- Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur
|
||||
**redimensionnable**, détachement en split via `PaneManager.splitRight()`.
|
||||
|
||||
## 5. Recommandations techniques (contrainte « zéro build »)
|
||||
|
||||
@@ -116,3 +131,6 @@ la grille ni le backend.
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
|
||||
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
|
||||
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
|
||||
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
|
||||
|
||||
@@ -1143,6 +1143,88 @@ const FileOperations = {
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic themed dialogs (#154-A2)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promise-based replacements for window.confirm() / window.prompt() so the
|
||||
// Excel viewer's structure actions and lossy-write confirmations stay inside
|
||||
// the app theme (and are keyboard accessible) instead of native dialogs.
|
||||
|
||||
function _closeDialog(overlay, resolve, value) {
|
||||
overlay.classList.remove("active");
|
||||
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
function _openDialog(innerHtml) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "obsigate-modal-overlay";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "obsigate-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.innerHTML = innerHtml;
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
setTimeout(() => overlay.classList.add("active"), 10);
|
||||
return { overlay, modal };
|
||||
}
|
||||
|
||||
/** Themed replacement for window.confirm(). Resolves to a boolean. */
|
||||
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { overlay, modal } = _openDialog(`
|
||||
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
|
||||
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
|
||||
<div class="obsigate-modal-footer">
|
||||
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
|
||||
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
|
||||
</div>`);
|
||||
const done = (v) => _closeDialog(overlay, resolve, v);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
|
||||
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
|
||||
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
|
||||
overlay._onKey = (e) => {
|
||||
if (e.key === "Escape") done(false);
|
||||
else if (e.key === "Enter") done(true);
|
||||
};
|
||||
document.addEventListener("keydown", overlay._onKey);
|
||||
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
|
||||
});
|
||||
}
|
||||
|
||||
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
|
||||
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { overlay, modal } = _openDialog(`
|
||||
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
|
||||
<div class="obsigate-modal-body">
|
||||
<div class="modal-form-group">
|
||||
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
|
||||
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
|
||||
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="obsigate-modal-footer">
|
||||
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
|
||||
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
|
||||
</div>`);
|
||||
const input = modal.querySelector('[data-dialog="input"]');
|
||||
const done = (v) => _closeDialog(overlay, resolve, v);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
|
||||
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
|
||||
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
|
||||
overlay._onKey = (e) => {
|
||||
if (e.key === "Escape") done(null);
|
||||
else if (e.key === "Enter") done(input.value);
|
||||
};
|
||||
document.addEventListener("keydown", overlay._onKey);
|
||||
setTimeout(() => { input.focus(); input.select(); }, 20);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find in Page Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+229
-25
@@ -3,7 +3,7 @@ import { api, AuthManager } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
|
||||
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
|
||||
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
|
||||
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
|
||||
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
||||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||||
import { initDashboardTabs } from './sync.js';
|
||||
@@ -15,6 +15,7 @@ import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||
import { t } from './i18n.js';
|
||||
import { onFileRender } from './plugins.js';
|
||||
import { NowPlaying } from './now-playing.js';
|
||||
import { openAssistant } from './ai-fab.js';
|
||||
|
||||
// ── Multi-format export ────────────────────────────────────────────────────
|
||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||||
@@ -1112,6 +1113,19 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
}
|
||||
};
|
||||
// #154-A4 — ARIA grid semantics (the backend renders a plain <table>).
|
||||
const applyGridA11y = (panel) => {
|
||||
const table = panel.querySelector(".xlsx-table");
|
||||
if (!table) return;
|
||||
table.setAttribute("role", "grid");
|
||||
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
|
||||
table.querySelectorAll("tbody tr").forEach((tr) => {
|
||||
tr.setAttribute("role", "row");
|
||||
tr.querySelectorAll("th, td").forEach((cell) => {
|
||||
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
|
||||
});
|
||||
});
|
||||
};
|
||||
const panels = isCsv
|
||||
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
||||
: sheets.map((s, i) =>
|
||||
@@ -1147,6 +1161,14 @@ export function renderXlsxViewer(area, data) {
|
||||
// later into the dedicated (empty) “view” group.
|
||||
const actionGroups = [];
|
||||
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>
|
||||
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
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>
|
||||
@@ -1198,7 +1220,23 @@ export function renderXlsxViewer(area, data) {
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
<div class="xlsx-body">
|
||||
<div class="xlsx-main">
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
</div>
|
||||
<aside class="xlsx-inspector" id="xlsx-inspector" aria-hidden="true">
|
||||
<div class="xlsx-inspector-head">
|
||||
<span class="xlsx-inspector-title">${escapeHtml(t("xlsx.inspector_title"))}</span>
|
||||
<span class="xlsx-inspector-actions">
|
||||
<button class="btn-action xlsx-inspector-ai" id="xlsx-inspector-ai" type="button" title="${escapeHtml(t("xlsx.inspector_ai"))}">
|
||||
<i data-lucide="bot" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.inspector_ai_short"))}
|
||||
</button>
|
||||
<button class="btn-action xlsx-inspector-close" id="xlsx-inspector-close" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xlsx-inspector-body" id="xlsx-inspector-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
|
||||
@@ -1212,14 +1250,26 @@ export function renderXlsxViewer(area, data) {
|
||||
title: t("xlsx.dashboard_btn"),
|
||||
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
|
||||
(area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn);
|
||||
const inspector = area.querySelector("#xlsx-inspector");
|
||||
const inspectorBody = area.querySelector("#xlsx-inspector-body");
|
||||
const closeInspector = () => {
|
||||
const panel = inspectorBody.querySelector(".xlsx-dashboard");
|
||||
if (panel) panel.remove();
|
||||
inspector.classList.remove("open");
|
||||
inspector.setAttribute("aria-hidden", "true");
|
||||
dashBtn.classList.remove("active");
|
||||
};
|
||||
dashBtn.addEventListener("click", async () => {
|
||||
const old = area.querySelector(".xlsx-dashboard");
|
||||
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
|
||||
// #154-A3 — the dashboard lives in a right-hand inspector so the grid
|
||||
// stays visible beside it (it used to be prepended into the grid flow).
|
||||
if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; }
|
||||
dashBtn.classList.add("active");
|
||||
inspector.classList.add("open");
|
||||
inspector.setAttribute("aria-hidden", "false");
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xlsx-dashboard";
|
||||
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
|
||||
area.querySelector(".xlsx-panels").prepend(panel);
|
||||
inspectorBody.prepend(panel);
|
||||
try {
|
||||
const dash = await api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
|
||||
@@ -1261,12 +1311,50 @@ export function renderXlsxViewer(area, data) {
|
||||
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
|
||||
}
|
||||
});
|
||||
area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector);
|
||||
// #154-A3 — the assistant is the existing global side panel; the inspector
|
||||
// just gives it a contextual entry point from the spreadsheet.
|
||||
area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); });
|
||||
}
|
||||
|
||||
const saveBtn = area.querySelector("#xlsx-save-btn");
|
||||
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
|
||||
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
||||
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
|
||||
const refreshSaveState = () => {
|
||||
const dirty = dirtyCount() > 0;
|
||||
saveBtn.disabled = !dirty;
|
||||
saveBtn.classList.toggle("is-dirty", dirty);
|
||||
// #154-A2 — star a sheet tab that holds unsaved edits.
|
||||
panelEls.forEach((panel) => {
|
||||
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
|
||||
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
|
||||
});
|
||||
};
|
||||
|
||||
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
|
||||
// and the user can retry the save without losing their work.
|
||||
const bannerHost = document.createElement("div");
|
||||
bannerHost.className = "xlsx-banner-host";
|
||||
area.querySelector(".xlsx-panels").before(bannerHost);
|
||||
const showConflict = (msg) => {
|
||||
bannerHost.innerHTML = "";
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "xlsx-banner xlsx-banner-conflict";
|
||||
banner.setAttribute("role", "alert");
|
||||
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
|
||||
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
|
||||
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
|
||||
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
|
||||
banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => {
|
||||
bannerHost.innerHTML = "";
|
||||
saveBtn.click();
|
||||
});
|
||||
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
|
||||
bannerHost.innerHTML = "";
|
||||
});
|
||||
bannerHost.appendChild(banner);
|
||||
safeCreateIcons();
|
||||
};
|
||||
|
||||
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
|
||||
// fetched window by window from GET …/xlsx/sheet when the user reaches the
|
||||
@@ -1325,6 +1413,7 @@ export function renderXlsxViewer(area, data) {
|
||||
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
||||
applySheetMeta(panel);
|
||||
}
|
||||
applyGridA11y(panel);
|
||||
safeCreateIcons();
|
||||
};
|
||||
|
||||
@@ -1348,11 +1437,20 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
|
||||
wrapper.addEventListener("scroll", () => {
|
||||
if (loading || done) return;
|
||||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||||
});
|
||||
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
|
||||
// scroll listener); the scroll listener stays as a fallback for
|
||||
// environments without IO (JSDOM).
|
||||
if (typeof IntersectionObserver === "function") {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) loadMore();
|
||||
}, { root: wrapper, rootMargin: "120px" });
|
||||
io.observe(foot);
|
||||
} else {
|
||||
wrapper.addEventListener("scroll", () => {
|
||||
if (loading || done) return;
|
||||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||||
@@ -1450,6 +1548,65 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── #154-A4 — local undo/redo for cell edits ──────────────────────────
|
||||
// One command per committed edit (a cell loses focus with a new value).
|
||||
// Structure changes re-render from the server, which rebuilds the whole
|
||||
// viewer and therefore clears the stacks (they are per-render).
|
||||
const undoStack = [];
|
||||
const redoStack = [];
|
||||
let pendingEdit = null;
|
||||
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
||||
const redoBtn = area.querySelector("#xlsx-redo-btn");
|
||||
const updateUndoButtons = () => {
|
||||
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
|
||||
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
|
||||
};
|
||||
const commitEdit = () => {
|
||||
if (!pendingEdit) return;
|
||||
const { td, before } = pendingEdit;
|
||||
pendingEdit = null;
|
||||
const after = td.textContent;
|
||||
if (before === after) return;
|
||||
undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after });
|
||||
redoStack.length = 0;
|
||||
updateUndoButtons();
|
||||
};
|
||||
const beginEdit = (td) => {
|
||||
if (pendingEdit && pendingEdit.td !== td) commitEdit();
|
||||
if (!pendingEdit) pendingEdit = { td, before: td.textContent };
|
||||
};
|
||||
const applyHistory = (entry, value) => {
|
||||
const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0];
|
||||
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
|
||||
if (!td) return;
|
||||
td.textContent = value;
|
||||
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
|
||||
if (td === activeTd) syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
};
|
||||
const undo = () => {
|
||||
const entry = undoStack.pop();
|
||||
if (!entry) return;
|
||||
applyHistory(entry, entry.before);
|
||||
redoStack.push(entry);
|
||||
updateUndoButtons();
|
||||
};
|
||||
const redo = () => {
|
||||
const entry = redoStack.pop();
|
||||
if (!entry) return;
|
||||
applyHistory(entry, entry.after);
|
||||
undoStack.push(entry);
|
||||
updateUndoButtons();
|
||||
};
|
||||
if (undoBtn) undoBtn.addEventListener("click", undo);
|
||||
if (redoBtn) redoBtn.addEventListener("click", redo);
|
||||
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
else if (key === "y") { e.preventDefault(); redo(); }
|
||||
});
|
||||
|
||||
const setupCell = (td) => {
|
||||
td.contentEditable = "true";
|
||||
td.spellcheck = false;
|
||||
@@ -1463,7 +1620,8 @@ export function renderXlsxViewer(area, data) {
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
});
|
||||
td.addEventListener("focus", () => setActiveCell(td));
|
||||
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
|
||||
td.addEventListener("blur", commitEdit);
|
||||
td.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||
if (e.key === "Escape") {
|
||||
@@ -1508,6 +1666,7 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
panelEls.forEach(wireLazyRows);
|
||||
panelEls.forEach(applySheetMeta);
|
||||
panelEls.forEach(applyGridA11y);
|
||||
// The formula bar starts disabled: nothing is selected yet.
|
||||
setActiveCell(null);
|
||||
|
||||
@@ -1570,7 +1729,13 @@ export function renderXlsxViewer(area, data) {
|
||||
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
|
||||
const features = (err.details && err.details.features) || lossy;
|
||||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||||
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
|
||||
const proceed = await showConfirm({
|
||||
title: t("xlsx.lossy_title"),
|
||||
message: t("xlsx.lossy_confirm", { features: labels }),
|
||||
confirmLabel: t("xlsx.lossy_confirm_btn"),
|
||||
danger: true,
|
||||
});
|
||||
if (!proceed) throw err;
|
||||
lossyConfirmed = true;
|
||||
force = true;
|
||||
continue;
|
||||
@@ -1591,6 +1756,9 @@ export function renderXlsxViewer(area, data) {
|
||||
// A refused confirmation is a decision, not a failure: neutral toast.
|
||||
if (err && err.code === "xlsx_lossy_content") {
|
||||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||||
} else if (err && err.code === "conflict") {
|
||||
// #154-A2 — non-blocking: the edits are kept, the user may retry.
|
||||
showConflict(t("xlsx.conflict_msg"));
|
||||
} else {
|
||||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||||
}
|
||||
@@ -1774,13 +1942,17 @@ export function renderXlsxViewer(area, data) {
|
||||
renderXlsxViewer(area, fresh);
|
||||
};
|
||||
|
||||
const structureError = (err, retryActions) => {
|
||||
const structureError = async (err, retryActions) => {
|
||||
if (err && err.code === "xlsx_lossy_content") {
|
||||
const features = (err.details && err.details.features) || lossy;
|
||||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||||
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
|
||||
return putStructure(retryActions, true); // re-emitted with force
|
||||
}
|
||||
const proceed = await showConfirm({
|
||||
title: t("xlsx.lossy_title"),
|
||||
message: t("xlsx.lossy_confirm", { features: labels }),
|
||||
confirmLabel: t("xlsx.lossy_confirm_btn"),
|
||||
danger: true,
|
||||
});
|
||||
if (proceed) return putStructure(retryActions, true); // re-emitted with force
|
||||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||||
return null;
|
||||
}
|
||||
@@ -1788,11 +1960,14 @@ export function renderXlsxViewer(area, data) {
|
||||
return null;
|
||||
};
|
||||
|
||||
// #154-A1 — the “+” button mirrors the structure menu's « Ajouter une
|
||||
// #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
|
||||
// feuille » item, right where the tabs are (Excel-like).
|
||||
const tabAddBtn = area.querySelector("#xlsx-tab-add");
|
||||
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
|
||||
const name = prompt(t("xlsx.structure_prompt_add"));
|
||||
const name = await showPrompt({
|
||||
title: t("xlsx.sheet_add"),
|
||||
message: t("xlsx.structure_prompt_add"),
|
||||
});
|
||||
if (!name) return;
|
||||
const actions = [{ op: "sheet_add", name }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
@@ -1820,26 +1995,43 @@ export function renderXlsxViewer(area, data) {
|
||||
menu.appendChild(b);
|
||||
};
|
||||
item(t("xlsx.sheet_add"), async () => {
|
||||
const name = prompt(t("xlsx.structure_prompt_add"));
|
||||
const name = await showPrompt({
|
||||
title: t("xlsx.sheet_add"),
|
||||
message: t("xlsx.structure_prompt_add"),
|
||||
});
|
||||
if (!name) return;
|
||||
const actions = [{ op: "sheet_add", name }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_rename"), async () => {
|
||||
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
|
||||
const to = await showPrompt({
|
||||
title: t("xlsx.sheet_rename"),
|
||||
message: t("xlsx.structure_prompt_rename"),
|
||||
value: sheetName,
|
||||
});
|
||||
if (!to || to === sheetName) return;
|
||||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_duplicate"), async () => {
|
||||
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
|
||||
const as = await showPrompt({
|
||||
title: t("xlsx.sheet_duplicate"),
|
||||
message: t("xlsx.structure_prompt_add"),
|
||||
value: `${sheetName} (copie)`,
|
||||
});
|
||||
if (!as) return;
|
||||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_delete"), async () => {
|
||||
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||||
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
|
||||
const okDelete = await showConfirm({
|
||||
title: t("xlsx.sheet_delete"),
|
||||
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
|
||||
confirmLabel: t("xlsx.sheet_delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!okDelete) return;
|
||||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
@@ -1850,7 +2042,13 @@ export function renderXlsxViewer(area, data) {
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.row_delete"), async () => {
|
||||
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
|
||||
const okRow = await showConfirm({
|
||||
title: t("xlsx.row_delete"),
|
||||
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
|
||||
confirmLabel: t("xlsx.row_delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!okRow) return;
|
||||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
@@ -1859,7 +2057,13 @@ export function renderXlsxViewer(area, data) {
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_delete"), async () => {
|
||||
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
|
||||
const okCol = await showConfirm({
|
||||
title: t("xlsx.col_delete"),
|
||||
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
|
||||
confirmLabel: t("xlsx.col_delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!okCol) return;
|
||||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
|
||||
@@ -1827,7 +1827,12 @@
|
||||
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
|
||||
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
|
||||
"xlsx.lossy_cancelled": "Save cancelled",
|
||||
"xlsx.lossy_confirm_btn": "Save anyway",
|
||||
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
|
||||
"xlsx.conflict_retry": "Retry",
|
||||
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
||||
"xlsx.undo": "Undo the last change",
|
||||
"xlsx.redo": "Redo the change",
|
||||
"xlsx.cached_value_title": "Last value calculated by Excel",
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
@@ -1874,6 +1879,9 @@
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
"xlsx.dashboard_btn": "Dashboard",
|
||||
"xlsx.inspector_title": "Inspector",
|
||||
"xlsx.inspector_ai": "AI assistant",
|
||||
"xlsx.inspector_ai_short": "AI",
|
||||
"xlsx.dashboard_title": "Workbook dashboard",
|
||||
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
|
||||
"xlsx.dashboard_charts": "{{n}} chart(s)",
|
||||
|
||||
@@ -1827,7 +1827,12 @@
|
||||
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
|
||||
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
|
||||
"xlsx.lossy_cancelled": "Sauvegarde annulée",
|
||||
"xlsx.lossy_confirm_btn": "Enregistrer quand même",
|
||||
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
|
||||
"xlsx.conflict_retry": "Réessayer",
|
||||
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
|
||||
"xlsx.undo": "Annuler la dernière modification",
|
||||
"xlsx.redo": "Rétablir la modification",
|
||||
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
@@ -1874,6 +1879,9 @@
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
"xlsx.dashboard_btn": "Tableau de bord",
|
||||
"xlsx.inspector_title": "Inspecteur",
|
||||
"xlsx.inspector_ai": "Assistant IA",
|
||||
"xlsx.inspector_ai_short": "IA",
|
||||
"xlsx.dashboard_title": "Tableau de bord du classeur",
|
||||
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
|
||||
"xlsx.dashboard_charts": "{{n}} graphique(s)",
|
||||
|
||||
@@ -11051,6 +11051,55 @@ body.desktop-mode .editor-container {
|
||||
.xlsx-status-formula {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
|
||||
.xlsx-save-primary.is-dirty {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
.xlsx-tab-dirty::after {
|
||||
content: "•";
|
||||
margin-left: 5px;
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
/* #154-A2 — non-blocking banners (conflict, …). */
|
||||
.xlsx-banner-host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.xlsx-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-banner-conflict {
|
||||
border-left-color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-banner-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-banner-dismiss {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.modal-confirm-text {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.xlsx-table {
|
||||
background: var(--grid-bg);
|
||||
}
|
||||
@@ -11240,6 +11289,71 @@ body.desktop-mode .editor-container {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* #154-A3 — right-hand inspector (dashboard, assistant) beside the grid. */
|
||||
.xlsx-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.xlsx-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-inspector {
|
||||
flex: 0 0 340px;
|
||||
width: 340px;
|
||||
max-width: 45%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
max-height: 70vh;
|
||||
}
|
||||
.xlsx-inspector.open {
|
||||
display: flex;
|
||||
}
|
||||
.xlsx-inspector-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.xlsx-inspector-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-inspector-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-inspector-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.xlsx-inspector .xlsx-dashboard {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.xlsx-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.xlsx-inspector {
|
||||
flex-basis: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
|
||||
Colors come from the existing CSS variables (no hardcoded values). */
|
||||
.xlsx-dashboard {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.40.0",
|
||||
"version": "2.43.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": {
|
||||
|
||||
@@ -116,12 +116,6 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
let dialogMessage = null;
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="A2"]');
|
||||
await cell.click();
|
||||
await cell.fill('Total confirmé');
|
||||
@@ -131,7 +125,11 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
|
||||
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
|
||||
// #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
|
||||
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(dialog).toContainText('segments');
|
||||
await dialog.locator('[data-dialog="confirm"]').click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// La cellule reste modifiée côté UI (plus de marque « sale »).
|
||||
@@ -145,13 +143,19 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
const toggle = page.locator('#xlsx-formula-btn');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
// 409 → confirmation thémée, puis reprise avec force (le toggle reste désactivé).
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await cell.fill('=B1*3');
|
||||
await cell.press('Enter');
|
||||
await page.locator('#xlsx-save-btn').click();
|
||||
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
|
||||
// A lossy workbook asks for confirmation. A previous test's save may already
|
||||
// have dropped the slicers part, in which case the PUT succeeds directly.
|
||||
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
|
||||
if (await dialog.isVisible()) {
|
||||
await dialog.locator('[data-dialog="confirm"]').click();
|
||||
}
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,9 @@ async function test(name, fn) {
|
||||
if (apiQueue.length) return apiQueue.shift();
|
||||
return { ok: true, status: 200, body: { status: "ok" } };
|
||||
};
|
||||
// #154-A2 — themed dialogs live in document.body; clear any left behind by a
|
||||
// previous test so the helpers always reach the current one.
|
||||
document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove());
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
@@ -165,6 +168,17 @@ function editCell(area, ref, text) {
|
||||
return td;
|
||||
}
|
||||
|
||||
/** #154-A2 — the themed dialog is the last overlay in document.body. */
|
||||
const lastDialog = () => {
|
||||
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
|
||||
return overlays[overlays.length - 1] || null;
|
||||
};
|
||||
const clickDialog = (which) => {
|
||||
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
|
||||
assert.ok(btn, `dialog button "${which}" is present`);
|
||||
btn.click();
|
||||
};
|
||||
|
||||
const lossyError = {
|
||||
ok: false,
|
||||
status: 409,
|
||||
@@ -248,12 +262,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async ()
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
// #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
|
||||
// before the user answers.
|
||||
const dialog = lastDialog();
|
||||
assert.ok(dialog, "a themed confirmation is shown");
|
||||
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
|
||||
assert.equal(calls.length, 1, "nothing is retried before the answer");
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
// The prompt names the features the backend reported.
|
||||
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
|
||||
// Save succeeded → cells are no longer dirty.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
@@ -264,27 +283,52 @@ await test("confirming once is enough for the following saves", async () => {
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
editCell(area, "A1", "2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1, "the user is not asked twice");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[2].body.force, true);
|
||||
// Wait out the dialog close animation, then confirm none is left.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
|
||||
});
|
||||
|
||||
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => lossyError;
|
||||
confirmAnswer = false;
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
clickDialog("cancel");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1, "no retry after a refusal");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
});
|
||||
|
||||
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const banner = area.querySelector(".xlsx-banner-conflict");
|
||||
assert.ok(banner, "a conflict banner is shown");
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
|
||||
// The edit is preserved and the save button is usable again.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
// The retry re-runs the save.
|
||||
nextResponse = () => ({ ok: true, status: 200, body: {} });
|
||||
banner.querySelector(".xlsx-banner-retry").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
|
||||
});
|
||||
|
||||
await test("a non-409 failure is not retried", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
@@ -582,29 +626,25 @@ await test("CSV export downloads the visible sheet without the cached shadows",
|
||||
|
||||
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
addBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
globalThis.prompt = realPrompt;
|
||||
// #154-A2 — a themed prompt replaces window.prompt().
|
||||
const input = lastDialog()?.querySelector('[data-dialog="input"]');
|
||||
assert.ok(input, "a themed prompt asks for the sheet name");
|
||||
input.value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
||||
assert.ok(
|
||||
area.querySelector("#content-area, .xlsx-viewer") || area,
|
||||
"the viewer was rebuilt",
|
||||
);
|
||||
assert.ok(
|
||||
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
||||
"the re-render shows the fresh payload",
|
||||
@@ -626,9 +666,7 @@ await test("sheet_delete confirms and is refused on the last sheet", async () =>
|
||||
|
||||
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
// The prompt is answered through the themed dialog.
|
||||
apiQueue.push({
|
||||
ok: false, status: 409,
|
||||
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
||||
@@ -639,11 +677,15 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||
[...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const lossyDialog = lastDialog();
|
||||
assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown");
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
globalThis.prompt = realPrompt;
|
||||
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
});
|
||||
@@ -677,6 +719,34 @@ await test("the dashboard button fetches the metadata and renders named ranges +
|
||||
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
||||
});
|
||||
|
||||
await test("the dashboard opens in the right-hand inspector, not inline in the grid", async () => {
|
||||
const area = mount();
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { vault: "V", path: "data.xlsx", named_ranges: [], objects: { charts: 0, pivots: 0 }, sheets: [{ name: "Feuille1", cells: 1, rows: 1, cols: 1, formulas: 0, numeric: 0, kpi: [] }] },
|
||||
});
|
||||
area.querySelector("#xlsx-dashboard-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const inspector = area.querySelector("#xlsx-inspector");
|
||||
assert.equal(inspector.classList.contains("open"), true, "the inspector opens");
|
||||
assert.equal(inspector.getAttribute("aria-hidden"), "false");
|
||||
assert.ok(inspector.querySelector("#xlsx-inspector-body .xlsx-dashboard"), "the dashboard lives in the inspector");
|
||||
// The grid is still present beside it (not replaced).
|
||||
assert.ok(area.querySelector(".xlsx-main .xlsx-table"), "the grid stays visible");
|
||||
// Closing removes the panel and closes the inspector.
|
||||
area.querySelector("#xlsx-inspector-close").click();
|
||||
assert.equal(area.querySelector(".xlsx-dashboard"), null);
|
||||
assert.equal(inspector.classList.contains("open"), false);
|
||||
assert.equal(inspector.getAttribute("aria-hidden"), "true");
|
||||
});
|
||||
|
||||
await test("the inspector exposes an AI assistant entry point", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector("#xlsx-inspector-ai"), "AI button in the inspector head");
|
||||
const title = area.querySelector("#xlsx-inspector .xlsx-inspector-title");
|
||||
assert.equal(title.textContent, FR["xlsx.inspector_title"]);
|
||||
});
|
||||
|
||||
await test("a csv mounts without the dashboard button", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
@@ -743,8 +813,6 @@ await test("a csv shows no tab strip and no + button", () => {
|
||||
|
||||
await test("the + button adds a sheet through the structure endpoint", async () => {
|
||||
const area = mount();
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
@@ -752,7 +820,9 @@ await test("the + button adds a sheet through the structure endpoint", async ()
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-tab-add").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
globalThis.prompt = realPrompt;
|
||||
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
@@ -772,6 +842,7 @@ await test("a read-only workbook shows the read-only pill and offers no editing"
|
||||
assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent);
|
||||
assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only");
|
||||
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only");
|
||||
assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only");
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
|
||||
});
|
||||
|
||||
@@ -800,6 +871,42 @@ await test("the command bar exposes grouped actions with a primary save", () =>
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']"));
|
||||
});
|
||||
|
||||
// ── #154-A4 — undo/redo, ARIA ───────────────────────────────────────────────
|
||||
|
||||
await test("undo/redo restores cell edits from the command stack", () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
||||
const redoBtn = area.querySelector("#xlsx-redo-btn");
|
||||
assert.equal(undoBtn.disabled, true, "nothing to undo yet");
|
||||
td.focus();
|
||||
editCell(area, "A1", "42");
|
||||
td.dispatchEvent(new w.Event("blur"));
|
||||
assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit");
|
||||
undoBtn.click();
|
||||
assert.equal(td.textContent, "100", "undo restores the original value");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again");
|
||||
assert.equal(redoBtn.disabled, false, "redo becomes available");
|
||||
redoBtn.click();
|
||||
assert.equal(td.textContent, "42", "redo reapplies the edit");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again");
|
||||
});
|
||||
|
||||
await test("a no-op undo/redo pair leaves the buttons disabled", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true);
|
||||
assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true);
|
||||
});
|
||||
|
||||
await test("the grid exposes ARIA grid semantics", () => {
|
||||
const area = mount();
|
||||
const table = area.querySelector(".xlsx-table");
|
||||
assert.equal(table.getAttribute("role"), "grid");
|
||||
assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader");
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell");
|
||||
assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader");
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user