diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c2a0c..8c74e75 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.19.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.20.0**. --- @@ -14,6 +14,30 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.20.0] — 2026-09-23 + +### Ajouté + +- **#111 — visionneuse d'images : flèches latérales translucides.** Deux boutons + superposés le long des bords du cadre passent à l'image précédente/suivante ; + quasi invisibles au repos, ils se révèlent au survol (et restent visibles sur + les appareils tactiles). Un compteur `n / total` complète la barre d'outils. + +### Modifié + +- **#111 — navigation d'images fluide.** Changer d'image se fait désormais **en + place** (`showSibling` remplace le `src` du ``) : plus de rechargement de + la vue ni de nouvel appel `/api/browse` à chaque flèche, **même dans un dossier + contenant beaucoup d'images**. La liste du dossier est mise en cache (TTL court) + et les images voisines sont préchargées ; la **pellicule de miniatures reste + visible** et la vignette active est simplement re-marquée. +- **#111 — image toujours ajustée au cadre.** La visionneuse occupe tout l'espace + disponible (zone de contenu en flex, sans défilement de page) et l'image est + systématiquement redimensionnée dans son cadre, y compris panneau + « Métadonnées » ouvert. + +--- + ## [2.19.2] — 2026-09-23 ### Ajouté diff --git a/README.fr.md b/README.fr.md index b3b1899..9ccff85 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.19.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.20.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/) @@ -975,8 +975,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.19.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.20.0). --- -*Projet : ObsiGate | Version : 2.19.2 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.20.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index a053a50..f77a701 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.19.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.20.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/) @@ -1150,8 +1150,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.19.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.20.0). --- -*Project: ObsiGate | Version: 2.19.2 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.20.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 17bdb70..7329e21 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.19.2 +2.20.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 308eafe..4d818fd 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.19.2" +version = "2.20.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 4b8f32d..bd01a95 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.19.2" +version = "2.20.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 2e4a5cf..671de7a 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.19.2", + "version": "2.20.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 72671e0..eee8c72 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.19.2 | **Dernière mise à jour :** 2026-09-23 +> **Version :** 2.20.0 | **Dernière mise à jour :** 2026-09-23 > **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)** @@ -40,8 +40,6 @@ - [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV - [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md) ---- - ## ⚪ Backlog — Priorité 4 (P4) ### 73. Synchronisation multi-appareils — Obsidian Sync compatible @@ -185,6 +183,7 @@ | 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) | | 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) | | 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) | +| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) | --- @@ -192,7 +191,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–110, #92 | ~130 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–111, #92 | ~130 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, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours | diff --git a/docs/features/image-navigation-111.md b/docs/features/image-navigation-111.md new file mode 100644 index 0000000..64eec00 --- /dev/null +++ b/docs/features/image-navigation-111.md @@ -0,0 +1,80 @@ +# #111 — Visionneuse d'images : navigation fluide + +> **Version livrée :** 2.20.0 · **Statut :** 🟢 · **Impact :** 🟡 +> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/style.css`, i18n FR/EN). +> **Améliore :** [#108](./image-support.md) (visionneuse livrée en 2.17.0). + +## Contexte + +La visionneuse d'images de #108 fonctionnait mais restait perfectible sur l'usage +quotidien : + +- certaines images s'affichaient **plus grandes que le cadre** de présentation ; +- changer d'image (←/→, flèches, vignette) appelait `openFile` → `renderFile` → + `renderImageViewer`, soit **un rechargement complet** de la vue **et** un + nouvel appel `/api/browse` à *chaque* image — sensible dès qu'un dossier en + contient beaucoup ; +- la **pellicule de miniatures disparaissait** le temps du re-rendu ; +- aucune zone de clic latérale ne permettait de changer d'image. + +## Ce qui a été livré + +### A. Ajustement au cadre + +- La zone de contenu devient un conteneur **flex** dédié à la visionneuse + (`.content-area:has(> .image-viewer-container) { padding: 0; overflow: hidden }`) : + plus de défilement de page, la visionneuse occupe tout l'espace disponible. +- `.image-stage` gagne `min-height: 0`, un `padding` de 16 px (cadre) et + `box-sizing: border-box` ; `.image-main` conserve `max-width/height: 100%` + avec `width/height: auto`. L'image est donc **toujours redimensionnée dans + l'espace disponible**, y compris quand le panneau « Métadonnées » s'ouvre + (la scène se réduit, l'image suit). + +### B. Navigation « en place » (performance) + +- `renderImageViewer` ne se contente plus de rendre une image : il gère un état + mutable (`currentPath`, `currentTitle`, `imgUrl`, `currentMeta`) et expose + `showSibling(index)` qui **remplace le `src` du ``** sans reconstruire le + DOM. Les flèches, le clavier ←/→ et les miniatures passent tous par là. +- Conséquences : plus de `openFile`, plus de re-rendu, **plus de refetch du + fichier ni de `/api/browse`** à chaque image. +- **Cache annuaire** `_imageDirCache` (`Map`, TTL 15 s) : la liste des images + d'un dossier n'est récupérée qu'une fois par courte fenêtre. +- **Préchargement** des images voisines (`new Image()`), avec un `Set` pour + éviter les doublons. + +### C. Pellicule persistante + +- La pellicule de miniatures n'est plus recréée à chaque navigation ; la + vignette active est simplement re-marquée (`.active`) et **amenée dans la vue + par défilement horizontal du film uniquement** (jamais la page). +- Miniatures en `loading="lazy"` + `decoding="async"`. + +### D. Flèches latérales translucides + +- Deux boutons superposés `.image-nav-arrow` (prev/next) longent les bords du + cadre, avec une icône `chevron` et une ombre portée pour rester lisibles sur + toute image. +- Opacité quasi nulle au repos, révélée au **survol du cadre** (`0.4`) puis du + bouton (`1`, avec dégradé sombre). Toujours visibles (opacité moyenne) sur les + appareils tactiles (`@media (hover: none)`). +- Le `pointerdown`/`dblclick` des flèches n'est pas propagé à la scène : le + pan (glisser) et le double-clic de réinitialisation du zoom restent intacts. +- Un **compteur** `n / total` est ajouté à la barre d'outils. + +## Tests + +- `tests/frontend/image-viewer.test.mjs` : helpers purs inchangés + vérifications + statiques de la navigation en place (`showSibling`, absence de + `_imageViewerNavPending`, cache annuaire), des flèches et du CSS + (`:has(> .image-viewer-container)`, `max-width/height`, `opacity`). +- `tests/e2e/image-viewer.spec.js` (+1) : image contenue dans le cadre, flèche + superposée révélée au survol, titre mis à jour **sans recréer le conteneur** + (marqueur `data-inplace`), pellicule toujours visible et compteur affiché. + +## Limitations connues + +- Le cache annuaire a un TTL court : une image ajoutée puis ouverte dans les + quelques secondes peut ne pas apparaître tout de suite dans la pellicule. +- Les flèches latérales n'apparaissent pas en mode lightbox plein écran + (navigation clavier ←/→ et pellicule masquée conservées). diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index be6c173..efb3d67 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -548,13 +548,10 @@ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ". const IMAGE_ZOOM_MIN = 0.1; const IMAGE_ZOOM_MAX = 8; let _imageViewerCleanup = null; -// BUG-072 — the lightbox and metadata panel states must survive the re-render -// triggered by ←/→ navigation (openFile → renderFile → renderImageViewer). -// Reset as soon as a non-image file is rendered. +// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file +// is rendered. #111 — image→image navigation now happens **in place**, so the +// viewer (and these states) is no longer destroyed between siblings. const _imageViewerState = { lightbox: false, meta: false }; -// Set right before an image→image navigation (←/→ / filmstrip click) so -// renderFile knows the state above must be carried over instead of reset. -let _imageViewerNavPending = false; /** Clamp a zoom factor into the supported [0.1, 8] range. */ export function clampImageZoom(value) { @@ -578,6 +575,40 @@ function buildThumbUrl(vault, path, size) { return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`; } +// #111 — directory listing cache. Opening / switching to another image must not +// hammer `/api/browse` (the filmstrip and the sibling list share one listing). +const _imageDirCache = new Map(); +const IMAGE_DIR_CACHE_TTL = 15000; + +/** List the viewable image siblings of *dir*, memoised for a short TTL. */ +async function listImageSiblings(vault, dir) { + const key = `${vault}\u0000${dir}`; + const cached = _imageDirCache.get(key); + if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items; + const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`); + const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path)); + _imageDirCache.set(key, { items, ts: Date.now() }); + return items; +} + +const IMAGE_MIME = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".ico": "image/x-icon", +}; + +/** Best-effort MIME type for an image path (metadata panel). */ +function imageMimeFor(path) { + const lower = (path || "").toLowerCase(); + const dot = lower.lastIndexOf("."); + return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || ""; +} + function formatBytes(n) { if (!n && n !== 0) return ""; if (n < 1024) return `${n} o`; @@ -592,20 +623,30 @@ function formatBytes(n) { */ export function renderImageViewer(area, data) { const vault = data.vault; - const path = data.path; - const fileName = (path || "").split("/").pop(); - const imgUrl = buildImageUrl(vault, path); + // #111 — mutable current-image state: navigating swaps these in place instead + // of re-rendering the whole viewer (which used to refetch the file + listing). + let currentPath = data.path; + let currentTitle = data.title || (currentPath || "").split("/").pop(); + let imgUrl = buildImageUrl(vault, currentPath); + let currentMeta = { + image_mime: data.image_mime || imageMimeFor(currentPath), + size_bytes: data.size_bytes, + modified: data.modified, + }; area.innerHTML = ""; const container = el("div", { class: "image-viewer-container", tabindex: "0" }); // ── Toolbar ──────────────────────────────────────────────────────────── const toolbar = el("div", { class: "image-toolbar" }); - toolbar.appendChild(el("span", { class: "image-title", title: path }, [ - document.createTextNode(data.title || fileName), - ])); + const titleEl = el("span", { class: "image-title", title: currentPath }, [ + document.createTextNode(currentTitle), + ]); + toolbar.appendChild(titleEl); toolbar.appendChild(el("div", { class: "image-toolbar-spacer" })); + const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]); + toolbar.appendChild(counter); const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]); toolbar.appendChild(zoomBadge); @@ -630,8 +671,26 @@ export function renderImageViewer(area, data) { // ── Body: image stage + metadata sidebar (right) ─────────────────────── const body = el("div", { class: "image-viewer-body" }); const stage = el("div", { class: "image-stage" }); - const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" }); + const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" }); stage.appendChild(img); + + // ── Overlay arrows along the frame edges (#111) ──────────────────────── + const prevArrow = el("button", { + class: "image-nav-arrow image-nav-arrow-prev", + type: "button", + title: t("viewer.image_prev"), + "aria-label": t("viewer.image_prev"), + hidden: true, + }, [icon("chevron-left", 32)]); + const nextArrow = el("button", { + class: "image-nav-arrow image-nav-arrow-next", + type: "button", + title: t("viewer.image_next"), + "aria-label": t("viewer.image_next"), + hidden: true, + }, [icon("chevron-right", 32)]); + stage.appendChild(prevArrow); + stage.appendChild(nextArrow); body.appendChild(stage); // ── Metadata sidebar (kept open across ←/→ navigation) ───────────────── @@ -661,12 +720,12 @@ export function renderImageViewer(area, data) { const renderMeta = () => { const rows = [ - [t("viewer.image_type"), data.image_mime || ""], + [t("viewer.image_type"), currentMeta.image_mime || ""], [t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""], - [t("viewer.metadata_size"), formatBytes(data.size_bytes)], - [t("viewer.metadata_path"), path], + [t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)], + [t("viewer.metadata_path"), currentPath], ]; - if (data.modified) rows.push([t("viewer.metadata_modified"), data.modified]); + if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]); metaPanel.innerHTML = ""; const dl = el("dl", {}); rows.forEach(([k, v]) => { @@ -676,46 +735,106 @@ export function renderImageViewer(area, data) { metaPanel.appendChild(dl); }; - // ── Sibling navigation ───────────────────────────────────────────────── + // ── Sibling navigation (in place, #111) ──────────────────────────────── let siblings = []; let currentIndex = -1; + let thumbs = []; + const preloaded = new Set(); + + const preload = (i) => { + if (!siblings.length) return; + const it = siblings[(i % siblings.length + siblings.length) % siblings.length]; + if (!it || preloaded.has(it.path)) return; + preloaded.add(it.path); + const pre = new Image(); + pre.src = buildImageUrl(vault, it.path); + }; + + const updateStripActive = () => { + thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex)); + const active = thumbs[currentIndex]; + if (!active) return; + // Scroll the filmstrip only (never the document) to reveal the active thumb. + const left = active.offsetLeft; + const right = left + active.offsetWidth; + if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8); + else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8; + }; + + const updateArrows = () => { + const multi = siblings.length > 1; + prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi; + prevArrow.disabled = nextArrow.disabled = !multi; + counter.hidden = !multi; + counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : ""; + }; + + /** Swap to the sibling at *index* without rebuilding the viewer. */ + const showSibling = (index) => { + const item = siblings[index]; + if (!item || item.path === currentPath) return; + currentIndex = index; + currentPath = item.path; + currentTitle = item.name; + imgUrl = buildImageUrl(vault, currentPath); + currentMeta = { + image_mime: imageMimeFor(currentPath), + size_bytes: item.size, + modified: null, + }; + scale = 1; tx = 0; ty = 0; + applyTransform(); + img.style.display = ""; + const errEl = stage.querySelector(".image-error"); + if (errEl) errEl.remove(); + img.alt = currentTitle; + img.src = imgUrl; + titleEl.textContent = currentTitle; + titleEl.title = currentPath; + updateStripActive(); + updateArrows(); + state.currentVault = vault; + state.currentPath = currentPath; + try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ } + preload(index - 1); + preload(index + 1); + if (!metaPanel.hidden) renderMeta(); + }; const go = (delta) => { if (siblings.length < 2 || currentIndex < 0) return; - const next = (currentIndex + delta + siblings.length) % siblings.length; - _imageViewerNavPending = true; - openFile(vault, siblings[next].path); + showSibling((currentIndex + delta + siblings.length) % siblings.length); }; const renderStrip = () => { if (siblings.length < 2) { strip.hidden = true; return; } strip.hidden = false; strip.innerHTML = ""; - siblings.forEach((s, i) => { + thumbs = siblings.map((s, i) => { const thumb = el("img", { class: `image-thumb${i === currentIndex ? " active" : ""}`, src: buildThumbUrl(vault, s.path, 96), alt: s.name, title: s.name, loading: "lazy", + decoding: "async", }); - thumb.addEventListener("click", () => { - if (s.path === path) return; - _imageViewerNavPending = true; - openFile(vault, s.path); - }); + thumb.addEventListener("click", () => showSibling(i)); strip.appendChild(thumb); + return thumb; }); + updateStripActive(); }; (async () => { try { - const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; - const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`); - siblings = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path)); - currentIndex = siblings.findIndex((it) => it.path === path); - prevBtn.disabled = nextBtn.disabled = siblings.length < 2; + const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : ""; + siblings = await listImageSiblings(vault, dir); + currentIndex = siblings.findIndex((it) => it.path === currentPath); renderStrip(); + updateArrows(); + preload(currentIndex - 1); + preload(currentIndex + 1); } catch (_) { /* navigation is best-effort */ } })(); @@ -726,13 +845,15 @@ export function renderImageViewer(area, data) { setZoom(scale * factor); }, { passive: false }); + const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow")); + let dragging = false; let startX = 0; let startY = 0; let startTx = 0; let startTy = 0; stage.addEventListener("pointerdown", (e) => { - if (e.button !== 0) return; + if (e.button !== 0 || isArrow(e)) return; dragging = true; startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty; stage.classList.add("panning"); @@ -753,7 +874,15 @@ export function renderImageViewer(area, data) { }; stage.addEventListener("pointerup", endDrag); stage.addEventListener("pointercancel", endDrag); - stage.addEventListener("dblclick", resetView); + stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); }); + + const stopArrowPointer = (e) => e.stopPropagation(); + prevArrow.addEventListener("pointerdown", stopArrowPointer); + nextArrow.addEventListener("pointerdown", stopArrowPointer); + prevArrow.addEventListener("dblclick", stopArrowPointer); + nextArrow.addEventListener("dblclick", stopArrowPointer); + prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); }); + nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); }); zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25)); zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25)); @@ -762,7 +891,7 @@ export function renderImageViewer(area, data) { nextBtn.addEventListener("click", () => go(1)); originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank")); downloadBtn.addEventListener("click", () => { - const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`; + const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`; window.open(dlUrl, "_blank"); }); metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false"); @@ -782,10 +911,17 @@ export function renderImageViewer(area, data) { syncLightbox(); }); - img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); }); + img.addEventListener("load", () => { + img.style.display = ""; + const errEl = stage.querySelector(".image-error"); + if (errEl) errEl.remove(); + if (!metaPanel.hidden) renderMeta(); + }); img.addEventListener("error", () => { - stage.innerHTML = ""; - stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))])); + img.style.display = "none"; + if (!stage.querySelector(".image-error")) { + stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))])); + } }); const onKey = (e) => { @@ -836,13 +972,13 @@ export function renderFile(data) { // #108 — release the image viewer's document-level shortcuts before swapping // the content area (otherwise they leak on every re-render). if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; } - // BUG-072 — carry the lightbox / metadata-panel state over an image→image - // navigation; any other render starts from a clean viewer. - if (!data.is_image || !_imageViewerNavPending) { + // BUG-072 / #111 — any render that is not an image viewer starts from a clean + // viewer. Image→image navigation now happens in place (see renderImageViewer), + // so the viewer is never rebuilt between siblings. + if (!data.is_image) { _imageViewerState.lightbox = false; _imageViewerState.meta = false; } - _imageViewerNavPending = false; const area = getContentArea(); // #110 — if this render is about to replace the surface that currently hosts // the shared media element, hand it back to the persistent dock first. diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 36c6df9..66e8264 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -2146,7 +2146,7 @@ "guide105.lib_h3_conflicts": "Sync conflicts", "guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.", "guide105.lib_h3_attach": "Attachments & media", - "guide105.lib_attach": "Inline ![[image.png]] images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.", + "guide105.lib_attach": "Inline ![[image.png]] images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, smooth navigation between images in the folder — image resized to the frame, hover side arrows, persistent thumbnail filmstrip —, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.", "guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.", "guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.", "guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 65e7b63..0850017 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -2146,7 +2146,7 @@ "guide105.lib_h3_conflicts": "Conflits de synchronisation", "guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.", "guide105.lib_h3_attach": "Fichiers joints & médias", - "guide105.lib_attach": "Les images ![[image.png]], pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.", + "guide105.lib_attach": "Les images ![[image.png]], pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation fluide entre les images du dossier — image redimensionnée au cadre, flèches latérales au survol, pellicule de miniatures persistante —, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.", "guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.", "guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.", "guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.", diff --git a/frontend/style.css b/frontend/style.css index e156ad5..c90d4c5 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9640,10 +9640,17 @@ body.desktop-mode .editor-container { } /* #108-D — dedicated image viewer: zoom, pan, filmstrip, metadata, lightbox */ +/* #111 — the viewer owns the whole content box (no page scrolling): the image + is always resized to the available frame and the toolbar/filmstrip stay put. */ +.content-area:has(> .image-viewer-container) { + padding: 0; + overflow: hidden; +} .image-viewer-container { display: flex; flex-direction: column; height: 100%; + min-height: 0; outline: none; } .image-toolbar { @@ -9673,10 +9680,25 @@ body.desktop-mode .editor-container { min-width: 46px; text-align: center; } +.image-counter { + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + color: var(--text-secondary); + padding: 0 6px; + white-space: nowrap; +} +.image-counter[hidden] { + display: none; +} .image-viewer-body { flex: 1; display: flex; + /* #111 — stretch (not center) so .image-stage is capped to the body height and + the image can actually resolve max-height:100% against a definite frame. */ + align-items: stretch; + padding: 0; min-height: 0; + overflow: hidden; } .image-stage { flex: 1; @@ -9686,6 +9708,9 @@ body.desktop-mode .editor-container { align-items: center; justify-content: center; min-width: 0; + min-height: 0; + padding: 16px; + box-sizing: border-box; background: var(--surface2); cursor: grab; touch-action: none; @@ -9694,6 +9719,8 @@ body.desktop-mode .editor-container { cursor: grabbing; } .image-main { + width: auto; + height: auto; max-width: 100%; max-height: 100%; object-fit: contain; @@ -9706,6 +9733,58 @@ body.desktop-mode .editor-container { color: var(--text-secondary); font-size: 0.9rem; } + +/* #111 — translucent overlay arrows along the frame edges (revealed on hover) */ +.image-nav-arrow { + position: absolute; + top: 0; + bottom: 0; + width: clamp(44px, 6vw, 84px); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: #fff; + opacity: 0; + cursor: pointer; + z-index: 6; + transition: opacity 160ms ease, background 160ms ease; +} +.image-nav-arrow[hidden] { + display: none; +} +.image-nav-arrow-prev { + left: 0; +} +.image-nav-arrow-next { + right: 0; +} +.image-nav-arrow-prev:hover, +.image-nav-arrow-prev:focus-visible { + background: linear-gradient(to right, rgba(0, 0, 0, 0.42), transparent); +} +.image-nav-arrow-next:hover, +.image-nav-arrow-next:focus-visible { + background: linear-gradient(to left, rgba(0, 0, 0, 0.42), transparent); +} +.image-stage:hover .image-nav-arrow { + opacity: 0.4; +} +.image-stage .image-nav-arrow:hover, +.image-stage .image-nav-arrow:focus-visible { + opacity: 1; + outline: none; +} +.image-nav-arrow .icon { + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85)); +} +@media (hover: none) { + .image-nav-arrow { + opacity: 0.55; + } +} .image-nav-strip { display: flex; gap: 6px; diff --git a/package.json b/package.json index 4745484..93c4da0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.19.2", + "version": "2.20.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/image-viewer.spec.js b/tests/e2e/image-viewer.spec.js index cb8515e..37b2e7f 100644 --- a/tests/e2e/image-viewer.spec.js +++ b/tests/e2e/image-viewer.spec.js @@ -123,4 +123,40 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => { await expect(metaPanel).toBeVisible(); await expect(title).not.toHaveText(titleBefore); }); + + test('navigation en place : flèches latérales, pellicule persistante et cadre ajusté (#111)', async ({ page }) => { + await login(page); + await openFile(page, 'TestVault', 'sample-image.png'); + + const container = page.locator('#content-area .image-viewer-container'); + const strip = page.locator('#content-area .image-nav-strip'); + const main = page.locator('#content-area img.image-main'); + await expect(strip).toBeVisible({ timeout: 10000 }); + await expect(strip.locator('img.image-thumb')).toHaveCount(2); + + // Marqueur : la navigation en place ne doit PAS recréer le conteneur. + await container.evaluate((el) => { el.dataset.inplace = '1'; }); + + // L'image reste contenue dans le cadre (jamais plus grande). + const stageBox = await page.locator('#content-area .image-stage').boundingBox(); + const imgBox = await main.boundingBox(); + expect(imgBox.width).toBeLessThanOrEqual(stageBox.width + 1); + expect(imgBox.height).toBeLessThanOrEqual(stageBox.height + 1); + + // Flèche superposée révélée au survol. + const nextArrow = page.locator('#content-area .image-nav-arrow-next'); + await expect(nextArrow).toBeAttached(); + await nextArrow.hover(); + await expect.poll(() => nextArrow.evaluate((el) => getComputedStyle(el).opacity)).toBe('1'); + + const title = page.locator('#content-area .image-title'); + const before = await title.innerText(); + await nextArrow.click(); + + await expect(title).not.toHaveText(before); + await expect(strip).toBeVisible(); + await expect(strip.locator('img.image-thumb')).toHaveCount(2); + await expect(container).toHaveAttribute('data-inplace', '1'); + await expect(page.locator('#content-area .image-counter')).toContainText('/'); + }); }); diff --git a/tests/frontend/image-viewer.test.mjs b/tests/frontend/image-viewer.test.mjs index 7c90482..1cce8a1 100644 --- a/tests/frontend/image-viewer.test.mjs +++ b/tests/frontend/image-viewer.test.mjs @@ -77,36 +77,51 @@ test("renderImageViewer never uses the JSON raw endpoint as an image src", () => assert.ok(start !== -1 && stop > start, "renderImageViewer block not found"); const block = viewer.slice(start, stop); assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw"); - assert.match(block, /buildImageUrl\(vault, path\)/); + assert.match(block, /buildImageUrl\(vault, currentPath\)/); }); test("image viewer supports wheel zoom, drag pan and double-click reset", () => { assert.match(viewer, /stage\.addEventListener\("wheel"/); assert.match(viewer, /stage\.addEventListener\("pointerdown"/); - assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/); + assert.match(viewer, /stage\.addEventListener\("dblclick", \(e\) => \{ if \(!isArrow\(e\)\) resetView\(\); \}\)/); assert.match(viewer, /renderImageViewer/); }); test("image viewer exposes thumbnail navigation via /api/media thumb", () => { assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/); assert.match(viewer, /let siblings = \[\]/); - assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/); + // #111 — the sibling list comes from the memoised directory listing. + assert.match(viewer, /listImageSiblings/); }); -// BUG-072 — lightbox + metadata panel must survive ←/→ (and filmstrip) navigation. +// BUG-072 — lightbox + metadata panel keep their state; #111 — navigation is +// in place, so the viewer (and its filmstrip) is no longer rebuilt. test("image viewer persists lightbox + metadata state across navigation", () => { assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/); - assert.match(viewer, /let _imageViewerNavPending = false/); - // Both navigation paths flag the next render as an image→image navigation. - assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, siblings\[next\]\.path\)/); - assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, s\.path\)/); - // renderFile resets the state only when the next render is not a carried-over image. - assert.match(viewer, /if \(!data\.is_image \|\| !_imageViewerNavPending\)/); + // renderFile only resets the viewer state when the next render is not an image. + assert.match(viewer, /if \(!data\.is_image\) \{\s*\n\s*_imageViewerState\.lightbox = false;/); // The persisted state drives the container class and the panel visibility. assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/); assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/); }); +// #111 — switching siblings swaps the in place instead of re-rendering +// (no openFile round-trip, filmstrip stays in the DOM, neighbours preloaded). +test("image viewer navigates between siblings in place (#111)", () => { + assert.doesNotMatch(viewer, /_imageViewerNavPending/); + assert.match(viewer, /const showSibling = \(index\) =>/); + assert.match(viewer, /const go = \(delta\) => \{\s*\n\s*if \(siblings\.length < 2[^)]*\) return;\s*\n\s*showSibling\(/); + // Thumbnail clicks and the overlay arrows both call showSibling/go in place. + assert.match(viewer, /thumb\.addEventListener\("click", \(\) => showSibling\(i\)\)/); + assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-prev"/); + assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-next"/); + // The active thumbnail is only re-highlighted / scrolled, never rebuilt. + assert.match(viewer, /updateStripActive/); + assert.match(viewer, /const preload = \(i\) =>/); + // Directory listing is cached for a short TTL. + assert.match(viewer, /const _imageDirCache = new Map\(\)/); +}); + // ── Static checks: CSS ───────────────────────────────────────────────────── test("style.css defines the image viewer layout + lightbox", () => { assert.match(css, /\.image-stage\s*\{/); @@ -115,6 +130,27 @@ test("style.css defines the image viewer layout + lightbox", () => { assert.match(css, /\.image-viewer-container\.lightbox\s*\{/); }); +// #111 — the viewer fills the content box and the image is capped to the frame. +test("style.css resizes the image to the available frame (#111)", () => { + assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*padding:\s*0/); + assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*overflow:\s*hidden/); + const main = /\.image-main\s*\{([^}]*)\}/.exec(css); + assert.ok(main, ".image-main rule not found"); + assert.match(main[1], /max-width:\s*100%/); + assert.match(main[1], /max-height:\s*100%/); +}); + +// #111 — translucent overlay arrows on both frame edges, revealed on hover. +test("style.css styles the overlay navigation arrows (#111)", () => { + assert.match(css, /\.image-nav-arrow\s*\{/); + assert.match(css, /\.image-nav-arrow-prev\s*\{\s*left:\s*0/); + assert.match(css, /\.image-nav-arrow-next\s*\{\s*right:\s*0/); + const arrow = /\.image-nav-arrow\s*\{([^}]*)\}/.exec(css); + assert.ok(arrow, ".image-nav-arrow rule not found"); + assert.match(arrow[1], /opacity:\s*0/); + assert.match(css, /\.image-nav-arrow:hover[\s\S]*?opacity:\s*1/); +}); + // BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip, // and it stays visible in lightbox mode. test("style.css lays the metadata panel out as a right sidebar", () => {