Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
383ffa6a65 |
+19
-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.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
@@ -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.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*
|
||||
|
||||
@@ -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.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*
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.2"
|
||||
version = "2.44.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user