diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f59187d..4ec2253 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: node mobile-editor.test.mjs node semantic-search.test.mjs node desktop.test.mjs + node toolbar-order.test.mjs else echo "tests/frontend/node_modules missing - installing jsdom" npm install --no-audit --no-fund --silent @@ -63,6 +64,7 @@ jobs: node mobile-editor.test.mjs node semantic-search.test.mjs node desktop.test.mjs + node toolbar-order.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd8a1e..47f4c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). utilisateur. Le prompt Général est enrichi d'un bloc « Contexte applicatif actuel » (`build_general_system_prompt(vaults, app_context, recent_files)`). Fiche : [docs/features/ai-app-context.md](./docs/features/ai-app-context.md). +- **#90 Barre d'actions du document — regroupement fonctionnel** : les boutons + en haut du document sont réordonnés en quatre groupes séparés par des + barres verticales (`span.action-sep`) : + `[TOC] [pop-out] [Bookmark] | [Editer] [Source] [.md] [Forge] | [Copier] [PDF] [Export] | [Partager]`. + Le bouton « Pretty » (fichiers texte) rejoint le groupe édition, les + boutons non applicables à un type de fichier sont omis et les spacers d'un + groupe vide ne sont pas rendus. La fenêtre pop-out (`popout.html`) est + alignée sur le même ordre (groupes nav / édition / export / partage). + Tests : `tests/frontend/toolbar-order.test.mjs` (9). ### Corrigé diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 010259a..76db8e5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -135,6 +135,7 @@ | # | Domaine / fonctionnalité | Version | Détails | |---|---|---|---| +| 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) | | 1–8 | Fondations (FastAPI, TF-IDF + stemming, watchdog, SPA, sécurité, path traversal, gzip, PWA) | 1.0.0→1.4.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 9–20 | UX & productivité (share, webhooks, dashboard, conflits, backlinks, redaction, backups, graphe, header, aperçu) | 1.5.0→1.6.0 | [archive](./archive/COMPLETED_v1-v2.md) | @@ -177,7 +178,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88 | ~107 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–90 | ~107 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours | diff --git a/docs/archive/COMPLETED_v1-v2.md b/docs/archive/COMPLETED_v1-v2.md index bb9f407..99ac1f9 100644 --- a/docs/archive/COMPLETED_v1-v2.md +++ b/docs/archive/COMPLETED_v1-v2.md @@ -362,6 +362,24 @@ La barre de mise en forme flottante du mode édition mobile est remplacée par u --- +## #90 — Barre d'actions du document : regroupement fonctionnel ✅ TERMINÉ + +Réordonnancement des boutons du haut du document en quatre groupes +séparés par des barres verticales (`span.action-sep`) : + +`[TOC] [pop-out] [Bookmark] | [Editer] [Source] [.md] [Forge] | [Copier] [PDF] [Export] | [Partager]` + +- **Viewer principal** (`frontend/js/viewer.js`, `renderFile`) : construction par + groupes `navBtns` / `editBtns` / `exportBtns` / `shareBtns` ; « Pretty » + (fichiers texte) ancré au groupe édition ; les spacers d'un groupe vide + (ex. fichier image ou binaire) ne sont pas rendus. +- **Pop-out** (`frontend/popout.html`) : même assemblage groupé, ordre aligné. +- **CSS** (`frontend/style.css`) : règle `.file-actions .action-sep` + (trait 1 px, `var(--border-md)`, `align-self: stretch`). +- **Tests** : `tests/frontend/toolbar-order.test.mjs` (9, statiques). + +--- + ## Grosses fonctionnalités — fiches dédiées | # | Feature | Version | Fiche | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index ca1acfb..bae62cf 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -879,12 +879,28 @@ export function renderFile(data) { }); } - const fileActions = [copyBtn, sourceBtn]; - if (!data.is_image) fileActions.push(mdBtn); - if (data.is_markdown) fileActions.push(pdfBtn, exportWrap, forgeBtn, tocBtn); - if (!data.unsupported) fileActions.push(editBtn); - if (prettyBtn) fileActions.push(prettyBtn); - fileActions.push(openNewWindowBtn, shareBtn, bookmarkBtn); + // Separateur visuel entre les groupes d'actions (#90) + const actionSep = () => el("span", { class: "action-sep" }); + // Ordre des boutons par groupes fonctionnels (#90) : + // [TOC][pop-out][Bookmark] | [Editer][Source][.md][Forge] | [Copier][PDF][Export] | [Partager] + // Boutons non affiches selon le type de fichier (Pretty, PDF/Export hors + // markdown, ...) restent ancres a leur groupe ; spacer de groupe vide retire. + const navBtns = [tocBtn, openNewWindowBtn, bookmarkBtn]; + const editBtns = []; + if (!data.unsupported) editBtns.push(editBtn); + editBtns.push(sourceBtn); + if (!data.is_image) editBtns.push(mdBtn); + if (data.is_markdown) editBtns.push(forgeBtn); + if (prettyBtn) editBtns.push(prettyBtn); + const exportBtns = [copyBtn]; + if (data.is_markdown) exportBtns.push(pdfBtn, exportWrap); + const shareBtns = [shareBtn]; + const fileActions = []; + [navBtns, editBtns, exportBtns, shareBtns].forEach((group) => { + if (group.length === 0) return; + if (fileActions.length > 0) fileActions.push(actionSep()); + group.forEach((b) => fileActions.push(b)); + }); // Assemble area.innerHTML = ""; diff --git a/frontend/popout.html b/frontend/popout.html index f87e5fa..170ec64 100644 --- a/frontend/popout.html +++ b/frontend/popout.html @@ -734,9 +734,14 @@ const RightSidebarManager = { header.appendChild(tagsDiv); } - // Action buttons + // Action buttons — memes groupes/spacers que le viewer principal (#90) const actionsDiv = document.createElement("div"); actionsDiv.className = "file-actions"; + const actionSep = () => { + const s = document.createElement("span"); + s.className = "action-sep"; + return s; + }; const copyBtn = document.createElement("button"); copyBtn.className = "btn-action"; @@ -752,7 +757,6 @@ const RightSidebarManager = { if (window.lucide) window.lucide.createIcons(); } catch (e) {} }); - actionsDiv.appendChild(copyBtn); const sourceBtn = document.createElement("button"); sourceBtn.className = "btn-action"; @@ -782,7 +786,6 @@ const RightSidebarManager = { raw.style.display = "none"; } }); - actionsDiv.appendChild(sourceBtn); const dlBtn = document.createElement("button"); dlBtn.className = "btn-action"; @@ -796,7 +799,6 @@ const RightSidebarManager = { a.click(); document.body.removeChild(a); }); - actionsDiv.appendChild(dlBtn); const pdfBtn = document.createElement("button"); pdfBtn.className = "btn-action"; @@ -805,7 +807,6 @@ const RightSidebarManager = { pdfBtn.addEventListener("click", () => { window.open(`/api/file/${encodeURIComponent(vault)}/pdf?path=${encodeURIComponent(path)}`, "_blank"); }); - actionsDiv.appendChild(pdfBtn); const editBtn = document.createElement("button"); editBtn.className = "btn-action"; @@ -814,7 +815,6 @@ const RightSidebarManager = { editBtn.addEventListener("click", () => { openEditor(vault, path); }); - actionsDiv.appendChild(editBtn); const tocBtn = document.createElement("button"); tocBtn.className = "btn-action"; @@ -822,7 +822,6 @@ const RightSidebarManager = { tocBtn.title = "Afficher/Masquer le sommaire"; tocBtn.innerHTML = ' TOC'; tocBtn.addEventListener("click", () => { RightSidebarManager.toggle(); }); - actionsDiv.appendChild(tocBtn); // Share button const shareBtn = document.createElement("button"); @@ -844,7 +843,6 @@ const RightSidebarManager = { alert("Lien copié : " + url); } catch (err) { alert("Erreur: " + err.message); } }); - actionsDiv.appendChild(shareBtn); // Bookmark button const bookmarkBtn = document.createElement("button"); @@ -864,8 +862,15 @@ const RightSidebarManager = { bookmarkBtn.classList.toggle("active", bm.bookmarked); } catch (err) { alert("Erreur: " + err.message); } }); - actionsDiv.appendChild(bookmarkBtn); - + + // Assemble par groupes (ordre aligne sur le viewer principal, #90) : + // [TOC][Bookmark] | [Editer][Source][.md] | [Copier][PDF] | [Partager] + [[tocBtn, bookmarkBtn], [editBtn, sourceBtn, dlBtn], [copyBtn, pdfBtn], [shareBtn]] + .forEach(function (group) { + if (actionsDiv.childNodes.length > 0) actionsDiv.appendChild(actionSep()); + group.forEach(function (b) { actionsDiv.appendChild(b); }); + }); + header.appendChild(actionsDiv); area.appendChild(header); diff --git a/frontend/style.css b/frontend/style.css index a9eea4b..fc837b2 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1798,6 +1798,15 @@ select { gap: 6px; align-items: center; } +/* Separateur vertical entre groupes d'actions (#90) */ +.file-actions .action-sep { + align-self: stretch; + width: 1px; + min-height: 20px; + margin: 2px 4px; + background: var(--border-md, var(--border)); + flex-shrink: 0; +} .btn-action { font-family: "JetBrains Mono", monospace; font-size: 0.75rem; diff --git a/tests/frontend/toolbar-order.test.mjs b/tests/frontend/toolbar-order.test.mjs new file mode 100644 index 0000000..e841ec4 --- /dev/null +++ b/tests/frontend/toolbar-order.test.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * ObsiGate — Toolbar action grouping tests (#90). + * + * Static non-regression checks on the source of the document action bar: + * the buttons must be assembled in functional groups separated by spacers: + * [TOC][pop-out][Bookmark] | [Editer][Source][.md][Forge] | [Copier][PDF][Export] | [Partager] + * + * Covers frontend/js/viewer.js (main app), frontend/popout.html (popout + * window, kept aligned) and frontend/style.css (.action-sep rule). + * + * Usage: node tests/frontend/toolbar-order.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, "..", ".."); + +const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8"); +const popout = readFileSync(path.join(ROOT, "frontend", "popout.html"), "utf8"); +const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); + +function test(label, fn) { + try { + fn(); + console.log(" \u2713 " + label); + } catch (err) { + console.error(" \u2717 " + label + "\n " + err.message); + process.exitCode = 1; + } +} + +// ── viewer.js : définition des groupes ─────────────────────────────────── +test("viewer.js — group nav = [toc, pop-out, bookmark]", () => { + assert.match( + viewer, + /const navBtns = \[\s*tocBtn,\s*openNewWindowBtn,\s*bookmarkBtn\s*\];/, + ); +}); + +test("viewer.js — group edit = [edit, source, .md, forge] (pretty appended)", () => { + const m = viewer.match( + /const editBtns = \[\];([\s\S]*?)const exportBtns/, + ); + assert.ok(m, "editBtns block not found"); + const block = m[1]; + const order = []; + const re = /editBtns\.push\((\w+)\)/g; + let g; + while ((g = re.exec(block))) order.push(g[1]); + // editBtn conditional, then sourceBtn, then mdBtn conditional, then forge conditional + assert.deepEqual(order, ["editBtn", "sourceBtn", "mdBtn", "forgeBtn", "prettyBtn"]); + assert.ok(block.includes("if (!data.unsupported) editBtns.push(editBtn)")); + assert.ok(block.includes("if (!data.is_image) editBtns.push(mdBtn)")); + assert.ok(block.includes("if (data.is_markdown) editBtns.push(forgeBtn)")); +}); + +test("viewer.js — group export = [copier, pdf, export]", () => { + const m = viewer.match(/const exportBtns = \[([\s\S]*?)\];([\s\S]*?)const shareBtns/); + assert.ok(m, "exportBtns block not found"); + assert.ok(m[1].includes("copyBtn"), "copyBtn must be first in export group"); + assert.match(m[2], /exportBtns\.push\(pdfBtn,\s*exportWrap\)/); +}); + +test("viewer.js — group share = [partager]", () => { + assert.match(viewer, /const shareBtns = \[\s*shareBtn\s*\];/); +}); + +test("viewer.js — assembly inserts an action-sep between non-empty groups", () => { + const m = viewer.match( + /\[navBtns,\s*editBtns,\s*exportBtns,\s*shareBtns\]\.forEach\(\(group\)\s*=>\s*\{([\s\S]*?)\}\);/, + ); + assert.ok(m, "group assembly loop not found"); + const body = m[1]; + assert.match(body, /if \(group\.length === 0\) return;/, "empty group must be skipped"); + assert.match(body, /if \(fileActions\.length > 0\) fileActions\.push\(actionSep\(\)\);/, + "spacer must be inserted between groups"); +}); + +test("viewer.js — no legacy flat fileActions.push order remains", () => { + assert.doesNotMatch( + viewer, + /const fileActions = \[\s*copyBtn,\s*sourceBtn\s*\];/, + "old flat construction should be gone", + ); +}); + +// ── popout.html : alignement ─────────────────────────────────────────────── +test("popout.html — grouped assembly matches viewer order", () => { + assert.match( + popout, + /\[\[tocBtn,\s*bookmarkBtn\],\s*\[editBtn,\s*sourceBtn,\s*dlBtn\],\s*\[copyBtn,\s*pdfBtn\],\s*\[shareBtn\]\]/, + ); + assert.match(popout, /if \(actionsDiv\.childNodes\.length > 0\) actionsDiv\.appendChild\(actionSep\(\)\);/); +}); + +test("popout.html — no leftover sequential appends remain", () => { + assert.doesNotMatch(popout, /actionsDiv\.appendChild\((copyBtn|sourceBtn|dlBtn|pdfBtn|editBtn|tocBtn|shareBtn|bookmarkBtn)\);/, + "buttons must be appended only via the grouped loop"); +}); + +// ── style.css : règle du séparateur ────────────────────────────────────── +test("style.css — .action-sep rule exists", () => { + assert.match(css, /\.file-actions \.action-sep\s*\{[^}]*background:[^;]*;/); +}); + +if (process.exitCode) { + console.error("\nToolbar order tests FAILED"); +} else { + console.log("\nAll toolbar order tests passed."); +}