feat: menu contextuel et selection type Excel dans la grille XLSX #155
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m41s
CI / e2e (push) Successful in 15m30s

This commit is contained in:
2026-09-29 16:43:39 -04:00
parent 69927176df
commit 383ffa6a65
18 changed files with 681 additions and 44 deletions
+19 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.43.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.44.0**.
---
@@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.44.0] — 2026-09-29
### Ajouté
- **#155 — Ergonomie du tableur : menu contextuel (clic droit / appui long) et sélection type
Excel.** Un **menu contextuel** thémé (nouveau module `frontend/js/xlsx/context-menu.js`)
s'ouvre au **clic droit** sur une cellule, un numéro de ligne ou un en-tête de colonne, et par
**appui long** sur mobile : insérer une ligne au-dessus/en dessous, une colonne à gauche/à
droite (avec `count` pour une plage), supprimer la/les ligne(s) ou colonne(s) après confirmation,
trier A→Z / Z→A et effacer le contenu. La grille adopte la **sélection d'Excel** : plage par
**glisser**, extension par **`Maj+clic`** et **`Maj+flèches`**, sélection de la ligne/colonne
entière au clic sur son en-tête, **curseur croix** sur les cellules et zone **Nom** affichant la
plage (`A1:B3`). Le tri par simple clic sur l'en-tête laisse place à la sélection (tri déplacé
dans le menu contextuel). `SW_VERSION` passe à `v29`. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (60, dont 8 nouveaux) et E2E (9) verts.
---
## [2.43.2] — 2026-09-29
### Corrigé
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.43.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.44.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.44.0).
---
*Projet : ObsiGate | Version : 2.43.2 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.44.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.43.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.44.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.44.0).
---
*Project: ObsiGate | Version: 2.43.2 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.44.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.43.2
2.44.0
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.43.2"
version = "2.44.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.43.2"
version = "2.44.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.43.2",
"version": "2.44.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+10 -5
View File
@@ -218,11 +218,16 @@ 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
correspondance, et la feuille s'exporte en CSV. Rien de tout cela ne
modifie le classeur.
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
droit** (ou un **appui long** sur mobile) ouvre un menu : insérer/supprimer
une ligne ou une colonne, trier A→Z / Z→A, effacer le contenu.
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent,
la recherche (`Ctrl+F` du panneau) va de correspondance en correspondance, et
la feuille s'exporte en CSV. Rien de tout cela ne modifie le classeur.
**Structure** — le menu **Structure** de la barre d'outils ajoute,
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
+22 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.43.2 | **Dernière mise à jour :** 2026-09-29
> **Version :** 2.44.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)**
@@ -119,6 +119,24 @@
---
## 🔵 En cours — Ergonomie tableur
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
- **Effort :** 2-4 jours | **Impact :** 🟡
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
- **Sous-tâches :**
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
trier, effacer le contenu
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
ligne/colonne, curseur croix, zone Nom affichant la plage
---
## ⚪ Backlog — Priorité 4 (P4)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
@@ -168,6 +186,7 @@
|---|---|---|---|
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -248,6 +267,7 @@
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
@@ -257,6 +277,7 @@
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
+72
View File
@@ -0,0 +1,72 @@
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
> **Statut :** ✅ **livré** — 2026-09-29
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
---
## 1. Demande
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
cellule, un numéro de ligne ou un en-tête de colonne.
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
d'ajouter lignes/colonnes.
## 2. Conception
### Menu contextuel
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
- Contenu selon la cible et le mode :
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
sauvegardée tant que l'utilisateur n'enregistre pas).
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
`.csv`.
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
### Sélection type Excel
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
(`A1` ou `A1:B3`).
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
conformément au comportement Excel.
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
## 3. Fichiers
| Couche | Fichier | Rôle |
|---|---|---|
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
## 4. Critères d'acceptation
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
ailleurs le ferme.
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
(avec `count` pour une plage) puis re-rend.
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
## 5. Historique
| Date | Événement |
|---|---|
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
+288 -19
View File
@@ -19,6 +19,7 @@ import { openAssistant } from './ai-fab.js';
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { buildCommandBar } from './xlsx/command-bar.js';
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -1193,6 +1194,9 @@ export function renderXlsxViewer(area, data) {
</div>
</div>`;
// A re-render invalidates any open context menu (it targets the old DOM).
closeContextMenu();
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
// Fetched lazily on first open of the dashboard tab; a workbook without
// anything notable hides the button entirely (no empty panel).
@@ -1420,9 +1424,67 @@ export function renderXlsxViewer(area, data) {
const activeCellEl = area.querySelector("#xlsx-active-cell");
const formulaInput = area.querySelector("#xlsx-formula-input");
const syncing = { value: false }; // guard against input-event feedback loops
// #155 — Excel-like range selection: { panel, anchor, focus } in (row, col).
let selection = null;
let suppressSelectionReset = false;
const cellName = (td) => td?.dataset.cell || "";
const clearSelectionClasses = () => {
const host = selection && selection.panel ? selection.panel : area;
host.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
};
const selectionBounds = () => {
if (!selection) return null;
const { anchor, focus } = selection;
return {
r1: Math.min(anchor.row, focus.row),
r2: Math.max(anchor.row, focus.row),
c1: Math.min(anchor.col, focus.col),
c2: Math.max(anchor.col, focus.col),
};
};
const rangeLabel = () => {
const b = selectionBounds();
if (!b) return "";
const first = `${columnName(b.c1)}${b.r1}`;
return b.r1 === b.r2 && b.c1 === b.c2 ? first : `${first}:${columnName(b.c2)}${b.r2}`;
};
// Paint the current selection and show its address in the Name Box.
const applySelection = () => {
if (!selection) return;
const { panel } = selection;
const b = selectionBounds();
panel.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (td) td.classList.add("xlsx-selected");
}
}
activeCellEl.textContent = rangeLabel();
};
const setSelection = (anchorTd, focusTd = anchorTd) => {
if (!anchorTd) { clearSelectionClasses(); selection = null; return; }
const panel = anchorTd.closest(".xlsx-panel");
const anchor = parseRef(cellName(anchorTd));
const focus = parseRef(cellName(focusTd));
if (!panel || !anchor || !focus) return;
selection = { panel, anchor, focus };
applySelection();
};
const inSelection = (td) => {
const b = selectionBounds();
const p = parseRef(cellName(td));
if (!b || !p || !selection || selection.panel !== td.closest(".xlsx-panel")) return false;
return p.row >= b.r1 && p.row <= b.r2 && p.col >= b.c1 && p.col <= b.c2;
};
const setActiveCell = (td) => {
activeTd = td || null;
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
@@ -1430,12 +1492,14 @@ export function renderXlsxViewer(area, data) {
activeCellEl.textContent = "—";
formulaInput.value = "";
formulaInput.disabled = true;
setSelection(null);
return;
}
td.classList.add("xlsx-active");
activeCellEl.textContent = cellName(td);
formulaInput.disabled = false;
formulaInput.value = td.textContent;
setSelection(td);
};
const syncFormulaBar = (td) => {
@@ -1463,7 +1527,7 @@ export function renderXlsxViewer(area, data) {
// #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js.
const moveActive = (td, key, forward = true) => {
const moveActive = (td, key, forward = true, extend = false) => {
const ref = parseRef(cellName(td));
if (!ref) return;
let { row, col } = ref;
@@ -1476,6 +1540,17 @@ export function renderXlsxViewer(area, data) {
const panel = td.closest(".xlsx-panel");
const next = findTd(panel, row, col);
if (!next) return; // edge of the rendered window: no wrap
// #155 — Shift+arrow extends the selection from the current anchor.
if (extend && selection && selection.panel === panel) {
suppressSelectionReset = true;
next.focus();
suppressSelectionReset = false;
selection.focus = { row, col };
applySelection();
activeTd = next;
formulaInput.value = next.textContent;
return;
}
td.blur();
next.focus();
setActiveCell(next);
@@ -1577,7 +1652,18 @@ export function renderXlsxViewer(area, data) {
syncFormulaBar(td);
refreshSaveState();
});
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
td.addEventListener("focus", () => {
beginEdit(td);
// #155 — a range extension keeps the selection; only the bar follows.
if (suppressSelectionReset) {
activeTd = td;
activeCellEl.textContent = cellName(td);
formulaInput.disabled = false;
formulaInput.value = td.textContent;
return;
}
setActiveCell(td);
});
td.addEventListener("blur", commitEdit);
td.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
@@ -1597,7 +1683,7 @@ export function renderXlsxViewer(area, data) {
const forward = e.key === "Tab"
? !e.shiftKey
: (e.key === "ArrowRight" || e.key === "ArrowDown");
moveActive(td, e.key, forward);
moveActive(td, e.key, forward, e.shiftKey && e.key.startsWith("Arrow"));
}
});
td.addEventListener("paste", (e) => {
@@ -1762,22 +1848,38 @@ export function renderXlsxViewer(area, data) {
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
};
// Header click cycles: asc → desc → back to the sheet order.
const wireHeaderSort = (panel) => {
const thead = panel.querySelector(".xlsx-table thead");
if (!thead || thead.dataset.sortWired) return;
thead.dataset.sortWired = "1";
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
// #155 — Excel-like: clicking a column/row header selects the whole
// column/row. Sorting moved to the context menu (and the sort reset button).
const selectColumn = (panel, col) => {
const rows = panel.querySelectorAll("th.xlsx-rownum").length;
if (!rows || !col) return;
selection = { panel, anchor: { row: 1, col }, focus: { row: rows, col } };
applySelection();
const td = findTd(panel, 1, col);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
};
const selectRow = (panel, row) => {
const cols = panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length;
if (!row || !cols) return;
selection = { panel, anchor: { row, col: 1 }, focus: { row, col: cols } };
applySelection();
const td = findTd(panel, row, 1);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
};
const wireHeaderSelection = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table || table.dataset.selectWired) return;
table.dataset.selectWired = "1";
table.querySelectorAll("thead th:not(.xlsx-corner)").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.sort_asc");
th.addEventListener("click", () => {
// The corner th is column 0, so the child index IS the column number.
const col = [...th.parentElement.children].indexOf(th);
const current = sortState && sortState.col === col ? sortState.dir : null;
const dir = current === "asc" ? "desc" : "asc";
applySort(panel, col, dir);
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
});
th.title = t("xlsx.select_column");
th.addEventListener("click", () => selectColumn(panel, [...th.parentElement.children].indexOf(th)));
});
table.querySelectorAll("th.xlsx-rownum").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.select_row");
th.addEventListener("click", () => selectRow(panel, Number(th.textContent)));
});
};
@@ -1791,7 +1893,7 @@ export function renderXlsxViewer(area, data) {
if (n) sortResetBtn.style.display = "";
};
panelEls.forEach(wireHeaderSort);
panelEls.forEach(wireHeaderSelection);
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
sortResetBtn.addEventListener("click", () => {
@@ -2055,6 +2157,173 @@ export function renderXlsxViewer(area, data) {
URL.revokeObjectURL(a.href);
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
const viewerEl = area.querySelector(".xlsx-viewer");
const contextTargetFrom = (target) => {
if (!target || !target.closest) return null;
const panel = target.closest(".xlsx-panel");
if (!panel) return null;
const rownum = target.closest("th.xlsx-rownum");
if (rownum) return { panel, kind: "row", row: Number(rownum.textContent) };
const header = target.closest(".xlsx-table thead th:not(.xlsx-corner)");
if (header) return { panel, kind: "col", col: [...header.parentElement.children].indexOf(header) };
const td = target.closest("td[data-cell]");
if (td) return { panel, kind: "cell", td };
return null;
};
const selectForTarget = (target) => {
if (target.kind === "row") selectRow(target.panel, target.row);
else if (target.kind === "col") selectColumn(target.panel, target.col);
else if (target.kind === "cell"
&& (!selection || selection.panel !== target.panel || !inSelection(target.td))) {
setActiveCell(target.td);
}
};
// One structure write (insert/delete) with the usual lossy/error handling;
// destructive actions ask for confirmation first.
const structureAction = (action, confirmMsg) => {
const run = async () => {
try { await putStructure([action]); } catch (err) { structureError(err, [action]); }
};
if (confirmMsg) {
showConfirm({
title: t("xlsx.structure_title"),
message: confirmMsg,
confirmLabel: t("common.confirm"),
danger: true,
}).then((ok) => { if (ok) run(); });
} else {
run();
}
};
const clearContents = (panel, b) => {
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (td && td.textContent !== "") { td.textContent = ""; td.classList.add("xlsx-dirty"); }
}
}
refreshSaveState();
};
const buildContextItems = (target) => {
const panel = target.panel;
const sheetName = sheets[Number(panel.dataset.sheet)]?.name || "";
const b = selectionBounds();
if (!b) return [];
const rowCount = b.r2 - b.r1 + 1;
const colCount = b.c2 - b.c1 + 1;
const items = [];
if (editable) {
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
const openGridMenu = (x, y, target) => {
selectForTarget(target);
openContextMenu(x, y, buildContextItems(target));
};
viewerEl.addEventListener("contextmenu", (e) => {
const target = contextTargetFrom(e.target);
if (!target) return;
e.preventDefault();
openGridMenu(e.clientX, e.clientY, target);
});
// Long-press (touch) — the mobile equivalent of a right-click.
let longPressTimer = null;
const cancelLongPress = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
viewerEl.addEventListener("touchstart", (e) => {
const target = contextTargetFrom(e.target);
if (!target || !e.touches || !e.touches[0]) return;
const t0 = e.touches[0];
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
openGridMenu(t0.clientX, t0.clientY, target);
}, 500);
}, { passive: true });
viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true });
viewerEl.addEventListener("touchend", cancelLongPress);
viewerEl.addEventListener("touchcancel", cancelLongPress);
// Drag-to-select a rectangular range + Shift+click extension.
let dragAnchorTd = null;
let dragActive = false;
let dragExtended = false;
viewerEl.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td) return;
if (e.shiftKey && selection && selection.panel === td.closest(".xlsx-panel")) {
e.preventDefault();
selection.focus = parseRef(cellName(td)) || selection.focus;
applySelection();
activeTd = td;
formulaInput.disabled = false;
formulaInput.value = td.textContent;
return;
}
dragAnchorTd = td;
dragActive = true;
dragExtended = false;
});
viewerEl.addEventListener("mouseover", (e) => {
if (!dragActive || !dragAnchorTd) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td || td === dragAnchorTd) return;
const panel = dragAnchorTd.closest(".xlsx-panel");
if (td.closest(".xlsx-panel") !== panel) return;
const a = parseRef(cellName(dragAnchorTd));
const f = parseRef(cellName(td));
if (!a || !f) return;
if (!dragExtended) {
dragExtended = true;
// Stop editing the anchor cell and drop any in-cell text selection.
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
if (document.getSelection) { const s = document.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
viewerEl.classList.add("xlsx-selecting");
}
selection = { panel, anchor: a, focus: f };
applySelection();
});
const endDrag = () => {
if (dragActive && dragExtended && dragAnchorTd) {
activeTd = dragAnchorTd;
formulaInput.disabled = false;
formulaInput.value = dragAnchorTd.textContent;
}
dragActive = false;
dragExtended = false;
dragAnchorTd = null;
viewerEl.classList.remove("xlsx-selecting");
};
viewerEl.addEventListener("mouseup", endDrag);
viewerEl.addEventListener("mouseleave", endDrag);
safeCreateIcons();
}
+85
View File
@@ -0,0 +1,85 @@
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
*
* Builds a small menu anchored at a screen position, closes it on outside
* click / Escape / window blur. Labels and callbacks come from the caller, so
* no i18n lives here.
*/
let _menu = null;
let _openedAt = 0;
let _dismissBound = false;
export function closeContextMenu() {
if (_menu) {
_menu.remove();
_menu = null;
}
}
export function isContextMenuOpen() {
return _menu !== null;
}
function bindDismiss() {
if (_dismissBound) return;
_dismissBound = true;
document.addEventListener("click", (e) => {
if (!_menu) return;
// Ignore the very gesture that opened the menu (touch long-press can emit
// a click right after).
if (Date.now() - _openedAt < 250) return;
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
closeContextMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeContextMenu();
});
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
}
/**
* @param {number} x - viewport X of the pointer
* @param {number} y - viewport Y of the pointer
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
bindDismiss();
closeContextMenu();
const menu = document.createElement("div");
menu.className = "xlsx-context-menu";
menu.setAttribute("role", "menu");
for (const item of items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "xlsx-context-sep";
menu.appendChild(sep);
continue;
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
btn.disabled = Boolean(item.disabled);
btn.addEventListener("click", () => {
closeContextMenu();
if (!item.disabled && item.onClick) item.onClick();
});
menu.appendChild(btn);
}
document.body.appendChild(menu);
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
// which simply leaves the requested position).
const rect = menu.getBoundingClientRect();
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
_menu = menu;
_openedAt = Date.now();
return menu;
}
+11
View File
@@ -1875,6 +1875,17 @@
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
"xlsx.select_column": "Select the column",
"xlsx.select_row": "Select the row",
"xlsx.insert_row_above": "Insert a row above",
"xlsx.insert_row_below": "Insert a row below",
"xlsx.insert_col_left": "Insert a column to the left",
"xlsx.insert_col_right": "Insert a column to the right",
"xlsx.delete_rows": "Delete {n} row(s)",
"xlsx.delete_cols": "Delete {n} column(s)",
"xlsx.clear_contents": "Clear contents",
"xlsx.confirm_delete_rows": "Delete {n} row(s)? This changes the file (a backup is created).",
"xlsx.confirm_delete_cols": "Delete {n} column(s)? This changes the file (a backup is created).",
"xlsx.structure_saved": "Structure updated",
"xlsx.structure_error": "Could not change the structure",
"xlsx.last_sheet": "The last sheet cannot be deleted",
+11
View File
@@ -1875,6 +1875,17 @@
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
"xlsx.select_column": "Sélectionner la colonne",
"xlsx.select_row": "Sélectionner la ligne",
"xlsx.insert_row_above": "Insérer une ligne au-dessus",
"xlsx.insert_row_below": "Insérer une ligne en dessous",
"xlsx.insert_col_left": "Insérer une colonne à gauche",
"xlsx.insert_col_right": "Insérer une colonne à droite",
"xlsx.delete_rows": "Supprimer {n} ligne(s)",
"xlsx.delete_cols": "Supprimer {n} colonne(s)",
"xlsx.clear_contents": "Effacer le contenu",
"xlsx.confirm_delete_rows": "Supprimer {n} ligne(s) ? Cette action modifie le fichier (un backup est créé).",
"xlsx.confirm_delete_cols": "Supprimer {n} colonne(s) ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_saved": "Structure mise à jour",
"xlsx.structure_error": "Modification de la structure impossible",
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
+51 -1
View File
@@ -11148,7 +11148,8 @@ body.desktop-mode .editor-container {
border-bottom: 2px solid var(--grid-border);
}
.xlsx-table td[contenteditable] {
cursor: text;
/* #155 — Excel-style cross cursor over the cells. */
cursor: cell;
min-width: 40px;
white-space: pre-wrap;
}
@@ -11473,6 +11474,55 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
}
.xlsx-viewer.xlsx-selecting,
.xlsx-viewer.xlsx-selecting td {
user-select: none;
}
.xlsx-viewer.xlsx-selecting td[contenteditable] {
cursor: crosshair;
}
.xlsx-context-menu {
position: fixed;
z-index: 10050;
display: flex;
flex-direction: column;
min-width: 220px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
}
.xlsx-context-item {
text-align: left;
border: none;
background: transparent;
color: var(--text-primary);
border-radius: 4px;
padding: 6px 10px;
font-size: 0.82rem;
cursor: pointer;
}
.xlsx-context-item:hover:not(:disabled) {
background: var(--bg-hover);
}
.xlsx-context-item:disabled {
opacity: 0.5;
cursor: default;
}
.xlsx-context-danger {
color: var(--danger);
}
.xlsx-context-sep {
height: 1px;
margin: 4px 0;
background: var(--border);
}
/* JSDOM shims for the tests that click anchors */
mark {
font: inherit;
+2 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v28';
const SW_VERSION = 'v29';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -33,6 +33,7 @@ const PRECACHE_URLS = [
'/static/js/xlsx/refs.js',
'/static/js/xlsx/command-bar.js',
'/static/js/xlsx/dashboard.js',
'/static/js/xlsx/context-menu.js',
'/static/js/search.js',
'/static/js/config.js',
'/static/js/utils.js',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.43.2",
"version": "2.44.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": {
+99 -5
View File
@@ -179,6 +179,16 @@ const clickDialog = (which) => {
btn.click();
};
/** #155 — open the grid context menu on a cell (right-click). */
const openMenuOn = (area, ref) => {
const td = area.querySelector(`td[data-cell="${ref}"]`);
assert.ok(td, `cell ${ref} exists`);
td.dispatchEvent(new w.MouseEvent("contextmenu", { bubbles: true, clientX: 12, clientY: 12 }));
return document.querySelector(".xlsx-context-menu");
};
const menuItem = (label) =>
[...document.querySelectorAll(".xlsx-context-item")].find((b) => b.textContent === label);
const lossyError = {
ok: false,
status: 409,
@@ -559,14 +569,16 @@ const mountGrid = () => {
return area;
};
await test("clicking a header sorts the rows numerically or lexically", () => {
await test("the context menu sorts the rows numerically or lexically", () => {
const area = mountGrid();
// Sort by column B (numbers) ascending: 2, 3, 10.
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_asc"]).click();
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
assert.deepEqual(cells, ["2", "3", "10"]);
// Second click: descending.
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
// Re-open and sort descending.
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_desc"]).click();
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
assert.deepEqual(cells, ["10", "3", "2"]);
});
@@ -574,7 +586,8 @@ await test("clicking a header sorts the rows numerically or lexically", () => {
await test("a dirty cell travels with its row during a sort", () => {
const area = mountGrid();
editCell(area, "A3", "Cerise modifiée");
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_asc"]).click();
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
@@ -963,6 +976,87 @@ await test("the inspector exposes a resize handle", () => {
assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present");
});
// ── #155 — selection & context menu ─────────────────────────────────────────
await test("a right-click on a cell opens a themed context menu", () => {
const area = mount();
const menu = openMenuOn(area, "A1");
assert.ok(menu, "menu rendered");
assert.ok(menuItem(FR["xlsx.insert_row_above"]), "insert row item");
assert.ok(menuItem(FR["xlsx.sort_asc"]), "sort item");
assert.ok(menuItem(FR["xlsx.clear_contents"]), "clear item");
});
await test("the context menu inserts a row through the structure endpoint", async () => {
const area = mount();
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: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] },
}); // re-read
openMenuOn(area, "A2");
menuItem(FR["xlsx.insert_row_above"]).click();
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: "row_insert", sheet: "Feuille1", at: 2, count: 1 }]);
});
await test("clear contents empties the selected cells (local edit)", () => {
const area = mount();
openMenuOn(area, "A1");
menuItem(FR["xlsx.clear_contents"]).click();
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
});
await test("clicking a column header selects the whole column", () => {
const area = mount();
area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:A2");
const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort();
assert.deepEqual(selected, ["A1", "A2"]);
});
await test("clicking a row header selects the whole row", () => {
const area = mount();
area.querySelector("th.xlsx-rownum").click(); // row 1
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
});
await test("dragging from a cell to another selects the range", () => {
const area = mount();
const viewer = area.querySelector(".xlsx-viewer");
const a1 = area.querySelector('td[data-cell="A1"]');
const b2 = area.querySelector('td[data-cell="B2"]');
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 }));
viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B2");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4);
});
await test("Shift+Arrow extends the selection from the anchor", () => {
const area = mount();
const a1 = area.querySelector('td[data-cell="A1"]');
a1.focus();
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2);
});
await test("the context menu module exposes open/close helpers", async () => {
const mod = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")).href
);
const menu = mod.openContextMenu(5, 5, [{ label: "Only" }]);
assert.equal(menu.querySelectorAll(".xlsx-context-item").length, 1);
assert.equal(mod.isContextMenuOpen(), true);
mod.closeContextMenu();
assert.equal(mod.isContextMenuOpen(), false);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);