diff --git a/CHANGELOG.md b/CHANGELOG.md index 6752102..020364e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/README.fr.md b/README.fr.md index 91da637..7772b0a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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* diff --git a/README.md b/README.md index 1bb48f9..51bdff7 100644 --- a/README.md +++ b/README.md @@ -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* diff --git a/VERSION b/VERSION index 29a3ac7..3e19747 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.43.2 +2.44.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index af90bf0..b148ebf 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.43.2" +version = "2.44.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 0bbe8e9..acd93ba 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 1e52a7f..a9b7176 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -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", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index a921521..d448ec1 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4032592..73959a1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/docs/features/xlsx-context-menu.md b/docs/features/xlsx-context-menu.md new file mode 100644 index 0000000..404ec7f --- /dev/null +++ b/docs/features/xlsx-context-menu.md @@ -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) | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index d01eae1..33e3baa 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -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) { `; + // 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(); } diff --git a/frontend/js/xlsx/context-menu.js b/frontend/js/xlsx/context-menu.js new file mode 100644 index 0000000..eb24cd4 --- /dev/null +++ b/frontend/js/xlsx/context-menu.js @@ -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; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index f5a1bae..6a38d40 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 21ab8ae..cde6c37 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/style.css b/frontend/style.css index c8b3ca4..384ece0 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; diff --git a/frontend/sw.js b/frontend/sw.js index a97e95c..e5f0886 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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', diff --git a/package.json b/package.json index 7441740..39c96ba 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index e451376..3856b38 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -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);