From 48e023ba25bc6fd3e48ece6953736998234b6739 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 12:18:21 -0400 Subject: [PATCH] feat: navigation clavier et barre de formule dans la visionneuse XLSX #153 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A7 : barre de formule sous la barre d'outils (nom de cellule active A1 + miroir du contenu, édition live, Entrée valide, Échap annule puis refocalise) ; flèches et Tab déplacent la cellule active dans les 4 directions (Shift inverse Tab), outline persistant sur la cellule active quand le focus passe à la barre ; Maj+Entrée ne valide plus (réservé au multiligne A7+). td.tabIndex=0 pour la focalisation clavier. Vérifié : xlsx-viewer.test.mjs 25/25 (6 nouveaux), E2E 9/9 (barre + flèches + Échap sur fixture réelle), validate-imports, i18n parity (xlsx.formula_bar_placeholder, xlsx.active_cell). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 6 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 2 +- frontend/js/viewer.js | 127 +++++++++++++++++++++++++++- frontend/locales/en.json | 2 + frontend/locales/fr.json | 2 + frontend/style.css | 50 +++++++++++ package.json | 2 +- tests/e2e/xlsx-viewer.spec.js | 24 ++++++ tests/frontend/xlsx-viewer.test.mjs | 81 +++++++++++++++++- 15 files changed, 299 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb04b6..1ad390f 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.33.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.34.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.34.0] — 2026-09-28 + +--- + ## [2.33.0] — 2026-09-28 --- diff --git a/README.fr.md b/README.fr.md index be17edc..a207018 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.33.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.34.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.33.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.34.0). --- -*Projet : ObsiGate | Version : 2.33.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.34.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index e1f55f4..dee9f60 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.33.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.34.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.33.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.34.0). --- -*Project: ObsiGate | Version: 2.33.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.34.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 3afbaeb..dc15918 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.33.0 +2.34.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 6fffc16..ae655e2 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.33.0" +version = "2.34.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 6a6386e..a3eef3f 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.33.0" +version = "2.34.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 464c6df..ac22010 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.33.0", + "version": "2.34.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6cc3aa3..647e80c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.33.0 | **Dernière mise à jour :** 2026-09-28 +> **Version :** 2.34.0 | **Dernière mise à jour :** 2026-09-28 > **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)** diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 1fce999..984603b 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1077,6 +1077,12 @@ export function renderXlsxViewer(area, data) { ${lossWarning} +
+ A1 + + +
${panels}
`; @@ -1158,21 +1164,135 @@ export function renderXlsxViewer(area, data) { }); }; + // ── #153 A7 — keyboard navigation & formula bar ──────────────────────── + // One active cell per viewer: clicking or arrowing into a cell shows its + // A1 name in the bar; the input mirrors the cell text and typing there + // edits the cell live (Enter commits, Escape reverts, then re-focuses). + let activeTd = null; + const activeCellEl = area.querySelector("#xlsx-active-cell"); + const formulaInput = area.querySelector("#xlsx-formula-input"); + const syncing = { value: false }; // guard against input-event feedback loops + + const cellName = (td) => td?.dataset.cell || ""; + + const setActiveCell = (td) => { + activeTd = td || null; + if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active"); + if (!td) { + activeCellEl.textContent = "—"; + formulaInput.value = ""; + formulaInput.disabled = true; + return; + } + td.classList.add("xlsx-active"); + activeCellEl.textContent = cellName(td); + formulaInput.disabled = false; + formulaInput.value = td.textContent; + }; + + const syncFormulaBar = (td) => { + if (td !== activeTd) return; + if (!syncing.value) formulaInput.value = td.textContent; + }; + + // Parse an A1 reference into its (row, col) parts. + const parseRef = (ref) => { + const m = /^([A-Z]+)(\d+)$/.exec(ref || ""); + if (!m) return null; + let col = 0; + for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64); + return { row: Number(m[2]), col }; + }; + + const findTd = (panel, row, col) => + panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`); + + const columnName = (col) => { + let name = ""; + while (col > 0) { + const rem = (col - 1) % 26; + name = String.fromCharCode(65 + rem) + name; + col = Math.floor((col - 1) / 26); + } + return name; + }; + + const moveActive = (td, key, forward = true) => { + const ref = parseRef(cellName(td)); + if (!ref) return; + let { row, col } = ref; + if (key === "Tab") col += forward ? 1 : -1; + else if (key === "ArrowRight") col += 1; + else if (key === "ArrowLeft") col -= 1; + else if (key === "ArrowDown") row += 1; + else if (key === "ArrowUp") row -= 1; + if (row < 1 || col < 1) return; + const panel = td.closest(".xlsx-panel"); + const next = findTd(panel, row, col); + if (!next) return; // edge of the rendered window: no wrap + td.blur(); + next.focus(); + setActiveCell(next); + }; + + formulaInput.addEventListener("input", () => { + if (!activeTd) return; + syncing.value = true; + activeTd.textContent = formulaInput.value; + syncing.value = false; + activeTd.classList.add("xlsx-dirty"); + refreshSaveState(); + }); + formulaInput.addEventListener("keydown", (e) => { + if (!activeTd) return; + if (e.key === "Enter") { + e.preventDefault(); + activeTd.blur(); + activeTd.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + activeTd.textContent = activeTd.dataset.orig; + activeTd.classList.remove("xlsx-dirty"); + syncFormulaBar(activeTd); + refreshSaveState(); + activeTd.focus(); + } + }); + const setupCell = (td) => { td.contentEditable = "true"; td.spellcheck = false; + // Focusable without a pointing device: JSDOM requires it to fire focus + // events on contenteditable cells, and a keyboard user tabbing into the + // table from outside lands on the first cell thanks to it. + td.tabIndex = 0; td.dataset.orig = td.textContent; td.addEventListener("input", () => { td.classList.add("xlsx-dirty"); + syncFormulaBar(td); refreshSaveState(); }); + td.addEventListener("focus", () => setActiveCell(td)); td.addEventListener("keydown", (e) => { - if (e.key === "Enter") { e.preventDefault(); td.blur(); } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); } if (e.key === "Escape") { td.textContent = td.dataset.orig; td.classList.remove("xlsx-dirty"); + syncFormulaBar(td); refreshSaveState(); } + if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown" + || e.key === "ArrowLeft" || e.key === "ArrowRight") { + // Excel-like: arrows and Tab move to the neighbour cell. Direction is + // the key's own (Left/Up = back, Right/Down = forward); Tab follows + // Shift. Prevented so Tab never leaves the table and arrows never + // move the caret (they move the SELECTION instead). + e.preventDefault(); + const forward = e.key === "Tab" + ? !e.shiftKey + : (e.key === "ArrowRight" || e.key === "ArrowDown"); + moveActive(td, e.key, forward); + } }); td.addEventListener("paste", (e) => { e.preventDefault(); @@ -1187,9 +1307,11 @@ export function renderXlsxViewer(area, data) { el.title = t("xlsx.cached_value_title"); }); - // Editable cells: Enter blurs, Escape reverts, paste stays single-line. + // Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate. area.querySelectorAll(".xlsx-table td").forEach(setupCell); panelEls.forEach(wireLazyRows); + // The formula bar starts disabled: nothing is selected yet. + setActiveCell(null); area.querySelectorAll(".xlsx-tab").forEach((tab) => { tab.addEventListener("click", () => { @@ -1253,6 +1375,7 @@ export function renderXlsxViewer(area, data) { td.classList.remove("xlsx-dirty"); td.dataset.orig = td.textContent; }); + if (activeTd) syncFormulaBar(activeTd); refreshSaveState(); showToast(t("editor.saved"), "success"); } catch (err) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 2fcb663..3ec3332 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1836,6 +1836,8 @@ "xlsx.load_more": "Load more", "xlsx.loading_more": "Loading…", "xlsx.load_error": "Could not load the remaining rows", + "xlsx.formula_bar_placeholder": "Active cell content", + "xlsx.active_cell": "Cell", "xlsx.feature_cached_values": "cached values", "xlsx.feature_slicers": "slicers and timelines", "xlsx.feature_form_controls": "form controls", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index ae8f524..59ef5b6 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1836,6 +1836,8 @@ "xlsx.load_more": "Charger la suite", "xlsx.loading_more": "Chargement…", "xlsx.load_error": "Chargement de la suite impossible", + "xlsx.formula_bar_placeholder": "Contenu de la cellule active", + "xlsx.active_cell": "Cellule", "xlsx.feature_cached_values": "valeurs calculées", "xlsx.feature_slicers": "segments et chronologies", "xlsx.feature_form_controls": "contrôles de formulaire", diff --git a/frontend/style.css b/frontend/style.css index 179db2e..455ac76 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -10996,6 +10996,56 @@ body.desktop-mode .editor-container { outline: 2px solid var(--accent, #4a90d9); outline-offset: -2px; } +/* #153 A7 — the active cell keeps its outline even when focus moves to the + formula bar, so the user never loses track of what the bar edits. */ +.xlsx-table td.xlsx-active:not(:focus) { + outline: 2px dashed var(--accent, #4a90d9); + outline-offset: -2px; +} + +/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */ +.xlsx-formula-bar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} +.xlsx-active-cell { + min-width: 52px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.8rem; + text-align: center; + font-variant-numeric: tabular-nums; +} +.xlsx-formula-sep { + width: 14px; + height: 14px; + flex: 0 0 auto; + color: var(--text-muted); +} +.xlsx-formula-input { + flex: 1; + min-width: 0; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.82rem; +} +.xlsx-formula-input:focus { + outline: 2px solid var(--accent, #4a90d9); + outline-offset: -2px; +} +.xlsx-formula-input:disabled { + opacity: 0.55; +} .xlsx-table td.xlsx-dirty { background: rgba(255, 196, 0, 0.18); } diff --git a/package.json b/package.json index c6ef765..f2099d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.33.0", + "version": "2.34.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/e2e/xlsx-viewer.spec.js b/tests/e2e/xlsx-viewer.spec.js index 0de9ac4..2908481 100644 --- a/tests/e2e/xlsx-viewer.spec.js +++ b/tests/e2e/xlsx-viewer.spec.js @@ -231,4 +231,28 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => { // Tout est chargé → le pied de page est masqué. await expect(foot).toBeHidden(); }); + + test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => { + await login(page); + await openXlsx(page, LARGE); + + // Un clic sur une cellule active la barre avec son nom et son contenu. + const cell = page.locator('#content-area td[data-cell="B2"]'); + await cell.click(); + await expect(page.locator('#xlsx-active-cell')).toHaveText('B2'); + const bar = page.locator('#xlsx-formula-input'); + await expect(bar).toHaveValue(/Operation 1/); + + // Les flèches déplacent la cellule active. + await cell.press('ArrowDown'); + await expect(page.locator('#xlsx-active-cell')).toHaveText('B3'); + await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight'); + await expect(page.locator('#xlsx-active-cell')).toHaveText('C3'); + + // Éditer depuis la barre marque la cellule dirty, Échap annule. + await bar.fill('Operation 2 modifiee'); + await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/); + await page.locator('#content-area td[data-cell="C3"]').press('Escape'); + await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0); + }); }); diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index 4b243d8..f3607d6 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -11,6 +11,8 @@ * - A9bis : the tail of a truncated sheet is fetched window by window from * GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are * editable like the initial ones. + * - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing + * from the bar marks the cell dirty; Escape reverts. * * Usage: node tests/frontend/xlsx-viewer.test.mjs */ @@ -121,8 +123,9 @@ await initI18n(); const sheetHtml = (value) => '
' + - '' + - `` + + '' + + `` + + '' + "
A
1${value}
AB
1${value}B1
2A2B2
"; // The JSDOM fetch double serves locale files; everything else is a recorded @@ -414,6 +417,80 @@ await test("a failed window fetch keeps the footnote and shows an error toast", assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true); }); +// ── A7 — formula bar & keyboard navigation ────────────────────────────────── + +await test("the formula bar starts empty and disabled", () => { + const area = mount(); + assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—"); + assert.equal(area.querySelector("#xlsx-formula-input").disabled, true); +}); + +await test("focusing a cell shows its name and content in the bar", () => { + const area = mount(); + const td = area.querySelector('td[data-cell="A1"]'); + td.dispatchEvent(new w.Event("focus", { bubbles: false })); + // JSDOM does not run the default focus behaviour on dispatchEvent, so go + // through the real API: + td.focus(); + assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1"); + assert.equal(area.querySelector("#xlsx-formula-input").value, "100"); + assert.equal(area.querySelector("#xlsx-formula-input").disabled, false); +}); + +await test("typing in the bar edits the cell live and marks it dirty", () => { + const area = mount(); + const td = area.querySelector('td[data-cell="A1"]'); + td.focus(); + const input = area.querySelector("#xlsx-formula-input"); + input.value = "depuis la barre"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + assert.equal(td.textContent, "depuis la barre"); + assert.equal(td.classList.contains("xlsx-dirty"), true); + assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); + // The save payload carries the cell edit. + area.querySelector("#xlsx-save-btn").click(); + return new Promise((r) => setTimeout(r, 5)).then(() => { + assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" }); + }); +}); + +await test("Tab and arrows move to the neighbour cell", () => { + const area = mount(); + const a1 = area.querySelector('td[data-cell="A1"]'); + a1.focus(); + a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1"); + const b1 = area.querySelector('td[data-cell="B1"]'); + b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B2"); + const b2 = area.querySelector('td[data-cell="B2"]'); + b2.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A2"); +}); + +await test("Enter commits and Shift+Tab goes backwards", () => { + const area = mount(); + const a1 = area.querySelector('td[data-cell="A1"]'); + a1.focus(); + a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + const b1 = area.querySelector('td[data-cell="B1"]'); + b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); + assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1"); +}); + +await test("a saved edit from the bar resets the dirty flag and orig value", async () => { + const area = mount(); + const td = area.querySelector('td[data-cell="A1"]'); + td.focus(); + const input = area.querySelector("#xlsx-formula-input"); + input.value = "200"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); + assert.equal(td.dataset.orig, "200"); +}); + await test("two windows in a row walk the whole sheet", async () => { const area = mountTruncated({ total: 1200 }); apiQueue.push({