feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
This commit is contained in:
+15
-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).
|
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
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.42.0**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.43.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,20 @@ 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
|
## [2.42.0] — 2026-09-29
|
||||||
|
|
||||||
### Ajouté
|
### 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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.42.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.42.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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.42.0).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.42.0 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.43.0 | Last updated: September 2026*
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.42.0"
|
version = "2.43.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.42.0"
|
version = "2.43.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.42.0",
|
"version": "2.43.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -215,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
|
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
|
||||||
plage se copie telle quelle vers un tableur.
|
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 /
|
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||||
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
||||||
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
||||||
|
|||||||
+7
-4
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.42.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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -97,11 +97,13 @@
|
|||||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
### 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 :** 🟡
|
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
|
||||||
- **Statut :** 🔵 **en cours** — **Lots 1, 2 & 3 livrés le 2026-09-29** (ruban de commandes groupé,
|
- **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
|
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`,
|
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
|
bandeau de conflit 409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** pour le
|
||||||
tableau de bord avec entrée Assistant IA). Lot suivant : découpage modulaire + undo/redo.
|
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)
|
- **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 :
|
- **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
|
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
|
||||||
@@ -113,7 +115,8 @@
|
|||||||
badges d'état, tokens de grille et affordances visuelles (Lot 1)
|
badges d'état, tokens de grille et affordances visuelles (Lot 1)
|
||||||
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
|
- [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] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
|
||||||
- [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4)
|
- [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)
|
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
|
||||||
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
||||||
> **Statut :** 🔵 **en cours** — Lots 1, 2 & 3 livrés le 2026-09-29
|
> **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)
|
> **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
|
> **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.**
|
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
|
||||||
|
|
||||||
@@ -87,12 +87,19 @@ la grille ni le backend.
|
|||||||
existant) et bouton de fermeture.
|
existant) et bouton de fermeture.
|
||||||
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
|
- **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`,
|
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
|
||||||
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api`.
|
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api` (refactor sans changement de
|
||||||
- **Undo/redo** local (pile de commandes), défilement paresseux via `IntersectionObserver`,
|
comportement, sous couvert des tests JSDOM/E2E existants).
|
||||||
accessibilité ARIA (`role="grid"`, *roving tabindex*).
|
|
||||||
- Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur
|
- Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur
|
||||||
**redimensionnable**, détachement en split via `PaneManager.splitRight()`.
|
**redimensionnable**, détachement en split via `PaneManager.splitRight()`.
|
||||||
|
|
||||||
@@ -126,3 +133,4 @@ la grille ni le backend.
|
|||||||
| 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 | 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 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 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 |
|
||||||
|
|||||||
+98
-6
@@ -1113,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
|
const panels = isCsv
|
||||||
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
||||||
: sheets.map((s, i) =>
|
: sheets.map((s, i) =>
|
||||||
@@ -1148,6 +1161,14 @@ export function renderXlsxViewer(area, data) {
|
|||||||
// later into the dedicated (empty) “view” group.
|
// later into the dedicated (empty) “view” group.
|
||||||
const actionGroups = [];
|
const actionGroups = [];
|
||||||
if (!readOnly) {
|
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">
|
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
<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>
|
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||||
@@ -1392,6 +1413,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
||||||
applySheetMeta(panel);
|
applySheetMeta(panel);
|
||||||
}
|
}
|
||||||
|
applyGridA11y(panel);
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1415,11 +1437,20 @@ export function renderXlsxViewer(area, data) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
|
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
|
||||||
wrapper.addEventListener("scroll", () => {
|
// scroll listener); the scroll listener stays as a fallback for
|
||||||
if (loading || done) return;
|
// environments without IO (JSDOM).
|
||||||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
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 ────────────────────────
|
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||||||
@@ -1517,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) => {
|
const setupCell = (td) => {
|
||||||
td.contentEditable = "true";
|
td.contentEditable = "true";
|
||||||
td.spellcheck = false;
|
td.spellcheck = false;
|
||||||
@@ -1530,7 +1620,8 @@ export function renderXlsxViewer(area, data) {
|
|||||||
syncFormulaBar(td);
|
syncFormulaBar(td);
|
||||||
refreshSaveState();
|
refreshSaveState();
|
||||||
});
|
});
|
||||||
td.addEventListener("focus", () => setActiveCell(td));
|
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
|
||||||
|
td.addEventListener("blur", commitEdit);
|
||||||
td.addEventListener("keydown", (e) => {
|
td.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -1575,6 +1666,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
}
|
}
|
||||||
panelEls.forEach(wireLazyRows);
|
panelEls.forEach(wireLazyRows);
|
||||||
panelEls.forEach(applySheetMeta);
|
panelEls.forEach(applySheetMeta);
|
||||||
|
panelEls.forEach(applyGridA11y);
|
||||||
// The formula bar starts disabled: nothing is selected yet.
|
// The formula bar starts disabled: nothing is selected yet.
|
||||||
setActiveCell(null);
|
setActiveCell(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1831,6 +1831,8 @@
|
|||||||
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
|
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
|
||||||
"xlsx.conflict_retry": "Retry",
|
"xlsx.conflict_retry": "Retry",
|
||||||
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
"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.cached_value_title": "Last value calculated by Excel",
|
||||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||||
"xlsx.readonly_badge": "Read-only",
|
"xlsx.readonly_badge": "Read-only",
|
||||||
|
|||||||
@@ -1831,6 +1831,8 @@
|
|||||||
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
|
"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.conflict_retry": "Réessayer",
|
||||||
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
|
"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.cached_value_title": "Dernière valeur calculée par Excel",
|
||||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||||
"xlsx.readonly_badge": "Lecture seule",
|
"xlsx.readonly_badge": "Lecture seule",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.42.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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -842,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.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-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-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");
|
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -870,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']"));
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Report ──────────────────────────────────────────────────────────────────
|
||||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||||
process.exit(passCount === testCount ? 0 : 1);
|
process.exit(passCount === testCount ? 0 : 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user