Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b926f01b85 | ||
|
|
8264e7ffae |
@@ -44,9 +44,13 @@ node tests/frontend/unit.test.mjs
|
||||
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
|
||||
node tests/frontend/pane-manager.test.mjs
|
||||
|
||||
# E2E (si UI touchée, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
|
||||
# E2E (si UI touchée, ~10 min) : reproduit le job CI e2e (port 2029, auth désactivée)
|
||||
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
|
||||
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
|
||||
|
||||
# Windows sans bash exploitable (WSL HS, git-bash bloqué par App Control) :
|
||||
npm run test:e2e:ps # équivalent PowerShell, mêmes conditions que le CI
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','nom du test')
|
||||
```
|
||||
|
||||
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
|
||||
|
||||
+47
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.19.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.20.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,52 @@ 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 `<img>`) : 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é
|
||||
|
||||
- **Lanceur E2E Windows/PowerShell** : `scripts/run-e2e-local.ps1` (+ script npm
|
||||
`test:e2e:ps`) reproduit localement le job CI `e2e` (uvicorn natif, auth
|
||||
désactivée, fixtures TestVault/TestDir, port 2029, projet `chromium-desktop`)
|
||||
sans dépendre de `bash` — indispensable sur les postes Windows où WSL ne
|
||||
démarre pas et où git-bash est bloqué par une politique de contrôle
|
||||
d'application. Le script `bash` reste la référence pour la CI/Linux.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-072 — visionneuse d'images** : le plein écran (lightbox) et le panneau
|
||||
« Métadonnées » sont désormais **conservés lors de la navigation** entre images
|
||||
(flèches ←/→ et clic sur la pellicule) ; auparavant chaque changement d'image
|
||||
recréait la visionneuse et perdait ces états. Le panneau de métadonnées s'affiche
|
||||
maintenant en **barre latérale à droite de l'image** (au lieu d'une bande sous la
|
||||
pellicule) et reste visible en plein écran.
|
||||
|
||||
---
|
||||
|
||||
## [2.19.1] — 2026-09-23
|
||||
|
||||
### Corrigé
|
||||
|
||||
+13
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -872,7 +872,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Tests backend | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
|
||||
|
||||
#### Tests E2E locaux (`npm run test:e2e`)
|
||||
|
||||
@@ -895,6 +895,15 @@ bash scripts/run-e2e-local.sh --headed # navigateur visible
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
|
||||
```
|
||||
|
||||
Sous Windows, si `bash` n'est pas exploitable (WSL indisponible, git-bash
|
||||
bloqué par une politique de contrôle d'application), utiliser le lanceur
|
||||
PowerShell équivalent :
|
||||
|
||||
```powershell
|
||||
npm run test:e2e:ps
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
|
||||
```
|
||||
|
||||
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
|
||||
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
|
||||
localement jusqu'à 100 %, puis seulement commiter.
|
||||
@@ -966,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.1).
|
||||
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.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.20.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1042,7 +1042,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
| Frontend import validation | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Frontend unit tests | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Backend tests | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
|
||||
|
||||
#### Local E2E Tests (`npm run test:e2e`)
|
||||
|
||||
@@ -1065,6 +1065,14 @@ bash scripts/run-e2e-local.sh --headed # visible browser
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filter on a test
|
||||
```
|
||||
|
||||
On Windows, when `bash` is unusable (WSL unavailable, git-bash blocked by an
|
||||
Application Control policy), use the equivalent PowerShell launcher:
|
||||
|
||||
```powershell
|
||||
npm run test:e2e:ps
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
|
||||
```
|
||||
|
||||
The suite must end with **all tests passing** (60 currently), with no failure
|
||||
or reliance on retries. In case of failure: fix and re-run locally until 100 %,
|
||||
then only commit.
|
||||
@@ -1142,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.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.20.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.19.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.20.0 | Last updated: September 2026*
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.19.1"
|
||||
version = "2.20.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.19.1"
|
||||
version = "2.20.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.19.1",
|
||||
"version": "2.20.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -180,6 +180,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
|
||||
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
|
||||
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
|
||||
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
|
||||
| | | | | | | | | | | |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
@@ -258,6 +259,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.19.1 | **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 |
|
||||
|
||||
@@ -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 `<img>`** 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).
|
||||
+210
-41
@@ -548,6 +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 — 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 };
|
||||
|
||||
/** Clamp a zoom factor into the supported [0.1, 8] range. */
|
||||
export function clampImageZoom(value) {
|
||||
@@ -571,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`;
|
||||
@@ -585,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);
|
||||
|
||||
@@ -614,27 +662,47 @@ export function renderImageViewer(area, data) {
|
||||
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
|
||||
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
|
||||
const downloadBtn = mkBtn("download", t("viewer.download"));
|
||||
const metaBtn = mkBtn("info", t("viewer.image_metadata"));
|
||||
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"));
|
||||
const metaBtn = mkBtn("info", t("viewer.image_metadata"), "image-btn-metadata");
|
||||
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox");
|
||||
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
|
||||
.forEach((b) => toolbar.appendChild(b));
|
||||
container.appendChild(toolbar);
|
||||
|
||||
// ── Stage (image + pan/zoom) ───────────────────────────────────────────
|
||||
// ── 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);
|
||||
container.appendChild(stage);
|
||||
|
||||
// ── 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) ─────────────────
|
||||
const metaPanel = el("div", { class: "image-meta-panel" });
|
||||
metaPanel.hidden = !_imageViewerState.meta;
|
||||
body.appendChild(metaPanel);
|
||||
container.appendChild(body);
|
||||
|
||||
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
|
||||
const strip = el("div", { class: "image-nav-strip", hidden: true });
|
||||
container.appendChild(strip);
|
||||
|
||||
// ── Metadata panel ─────────────────────────────────────────────────────
|
||||
const metaPanel = el("div", { class: "image-meta-panel" });
|
||||
metaPanel.hidden = true;
|
||||
container.appendChild(metaPanel);
|
||||
|
||||
let scale = 1;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
@@ -652,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]) => {
|
||||
@@ -667,41 +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;
|
||||
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) 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 */ }
|
||||
})();
|
||||
|
||||
@@ -712,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");
|
||||
@@ -739,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));
|
||||
@@ -748,22 +891,37 @@ 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");
|
||||
metaBtn.addEventListener("click", () => {
|
||||
metaPanel.hidden = !metaPanel.hidden;
|
||||
if (!metaPanel.hidden) renderMeta();
|
||||
_imageViewerState.meta = !_imageViewerState.meta;
|
||||
metaPanel.hidden = !_imageViewerState.meta;
|
||||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||||
if (_imageViewerState.meta) renderMeta();
|
||||
});
|
||||
const syncLightbox = () => {
|
||||
container.classList.toggle("lightbox", _imageViewerState.lightbox);
|
||||
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
|
||||
};
|
||||
syncLightbox();
|
||||
lightboxBtn.addEventListener("click", () => {
|
||||
container.classList.toggle("lightbox");
|
||||
lightboxBtn.setAttribute("aria-pressed", container.classList.contains("lightbox") ? "true" : "false");
|
||||
_imageViewerState.lightbox = !_imageViewerState.lightbox;
|
||||
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) => {
|
||||
@@ -772,7 +930,10 @@ export function renderImageViewer(area, data) {
|
||||
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
|
||||
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
|
||||
else if (e.key === "0") { e.preventDefault(); resetView(); }
|
||||
else if (e.key === "Escape") { container.classList.remove("lightbox"); }
|
||||
else if (e.key === "Escape") {
|
||||
_imageViewerState.lightbox = false;
|
||||
syncLightbox();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
|
||||
@@ -780,6 +941,7 @@ export function renderImageViewer(area, data) {
|
||||
area.appendChild(container);
|
||||
safeCreateIcons();
|
||||
applyTransform();
|
||||
if (_imageViewerState.meta) renderMeta();
|
||||
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
@@ -810,6 +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 / #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;
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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 <code>![[image.png]]</code> 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 <code>![[image.png]]</code> 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.",
|
||||
|
||||
@@ -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 <code>![[image.png]]</code>, 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 <code>![[image.png]]</code>, 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.",
|
||||
|
||||
+90
-4
@@ -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,6 +9680,26 @@ 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;
|
||||
position: relative;
|
||||
@@ -9680,7 +9707,10 @@ body.desktop-mode .editor-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
background: var(--surface2);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
@@ -9689,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;
|
||||
@@ -9701,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;
|
||||
@@ -9731,9 +9815,12 @@ body.desktop-mode .editor-container {
|
||||
opacity: 1;
|
||||
}
|
||||
.image-meta-panel {
|
||||
width: 280px;
|
||||
max-width: 40%;
|
||||
overflow-y: auto;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
@@ -9761,8 +9848,7 @@ body.desktop-mode .editor-container {
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.image-viewer-container.lightbox .image-nav-strip,
|
||||
.image-viewer-container.lightbox .image-meta-panel {
|
||||
.image-viewer-container.lightbox .image-nav-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.19.1",
|
||||
"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": {
|
||||
@@ -9,7 +9,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test:e2e": "bash scripts/run-e2e-local.sh"
|
||||
"test:e2e": "bash scripts/run-e2e-local.sh",
|
||||
"test:e2e:ps": "pwsh -NoProfile -File scripts/run-e2e-local.ps1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ObsiGate — E2E locaux (Playwright) sous Windows/PowerShell.
|
||||
|
||||
.DESCRIPTION
|
||||
Équivalent PowerShell de `scripts/run-e2e-local.sh`, pour les postes Windows
|
||||
où `bash` n'est pas utilisable (WSL indisponible, git-bash bloqué par une
|
||||
politique de contrôle d'application). Démarre le backend nativement via
|
||||
uvicorn (auth désactivée, fixtures TestVault/TestDir, port 2029 — mêmes
|
||||
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
|
||||
|
||||
.PARAMETER PlaywrightArgs
|
||||
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
|
||||
`--headed`.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/run-e2e-local.ps1
|
||||
./scripts/run-e2e-local.ps1 -g "BUG-072"
|
||||
./scripts/run-e2e-local.ps1 --headed
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$PlaywrightArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $Root
|
||||
|
||||
$Port = if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" }
|
||||
$BaseUrl = "http://127.0.0.1:$Port"
|
||||
$ServerLog = "data/e2e-server.log"
|
||||
$ServerErrLog = "data/e2e-server.err.log"
|
||||
|
||||
function Assert-Command([string]$Name, [string]$Hint) {
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "[ERR] $Name introuvable. $Hint"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
|
||||
Assert-Command "npx" "Installez Node.js (>= 20)."
|
||||
|
||||
# ----- Venv Python 3.11 (créé une seule fois) -----
|
||||
$Python = ".venv-e2e/Scripts/python.exe"
|
||||
if (-not (Test-Path -LiteralPath $Python)) {
|
||||
Write-Host "[INFO] Création du venv .venv-e2e (Python 3.11)..."
|
||||
uv venv .venv-e2e --python 3.11
|
||||
uv pip install --python $Python -r backend/requirements.txt
|
||||
}
|
||||
|
||||
# ----- Port déjà occupé ? -----
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
|
||||
Write-Host "[ERR] Quelque chose répond déjà sur $BaseUrl."
|
||||
Write-Host " Arrêtez-le ou choisissez un autre port : `$env:E2E_PORT=2030; ./scripts/run-e2e-local.ps1"
|
||||
exit 1
|
||||
} catch {
|
||||
# port libre
|
||||
}
|
||||
|
||||
# ----- Démarrage du serveur (mêmes conditions que le CI e2e) -----
|
||||
Write-Host "[INFO] Démarrage d'ObsiGate sur $BaseUrl (auth désactivée)..."
|
||||
New-Item -ItemType Directory -Force -Path "data" | Out-Null
|
||||
|
||||
$env:OBSIGATE_AUTH_ENABLED = "false"
|
||||
$env:VAULT_1_NAME = "TestVault"
|
||||
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
|
||||
$env:DIR_1_NAME = "TestDir"
|
||||
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
|
||||
|
||||
$server = Start-Process -FilePath $Python `
|
||||
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
|
||||
-RedirectStandardOutput $ServerLog -RedirectStandardError $ServerErrLog `
|
||||
-PassThru -WindowStyle Hidden
|
||||
|
||||
$exitCode = 1
|
||||
try {
|
||||
# ----- Attente du health check (30 s max, comme le CI) -----
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch {
|
||||
if ($server.HasExited) {
|
||||
Write-Host "[ERR] Le serveur a quitté prématurément. Log :"
|
||||
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
if (-not $ready) {
|
||||
Write-Host "[ERR] Serveur injoignable sur $BaseUrl. Log :"
|
||||
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[OK] Serveur prêt."
|
||||
|
||||
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
|
||||
npx playwright install chromium
|
||||
|
||||
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
|
||||
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
|
||||
$env:BASE_URL = $BaseUrl
|
||||
& npx playwright test --project=chromium-desktop @PlaywrightArgs
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
|
||||
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
|
||||
# uvicorn (via uv) peut lancer un interpréteur enfant : tuer le groupe resté sur le port.
|
||||
Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
@@ -95,4 +95,68 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
|
||||
await page.locator('#content-area .image-nav-strip img.image-thumb').first().click();
|
||||
await expect(page.locator('#content-area .image-title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('conserve le plein écran et le panneau métadonnées à la navigation (#BUG-072)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-image.png');
|
||||
|
||||
const container = page.locator('#content-area .image-viewer-container');
|
||||
const metaPanel = page.locator('#content-area .image-meta-panel');
|
||||
const title = page.locator('#content-area .image-title');
|
||||
await expect(container).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Métadonnées : barre latérale à droite de l'image (pas sous la pellicule).
|
||||
await page.locator('#content-area .image-btn-metadata').click();
|
||||
await expect(metaPanel).toBeVisible();
|
||||
const stageBox = await page.locator('#content-area .image-stage').boundingBox();
|
||||
const metaBox = await metaPanel.boundingBox();
|
||||
expect(metaBox.x).toBeGreaterThanOrEqual(stageBox.x + stageBox.width - 1);
|
||||
|
||||
// Plein écran activé.
|
||||
await page.locator('#content-area .image-btn-lightbox').click();
|
||||
await expect(container).toHaveClass(/lightbox/);
|
||||
|
||||
// Naviguer (flèche droite) : les deux états doivent survivre au re-render.
|
||||
const titleBefore = await title.innerText();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(container).toHaveClass(/lightbox/);
|
||||
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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,20 +77,49 @@ 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 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 \}/);
|
||||
// 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 <img> 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 ─────────────────────────────────────────────────────
|
||||
@@ -101,6 +130,40 @@ 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", () => {
|
||||
assert.match(css, /\.image-viewer-body\s*\{[\s\S]*?display:\s*flex/);
|
||||
const meta = /\.image-meta-panel\s*\{([^}]*)\}/.exec(css);
|
||||
assert.ok(meta, ".image-meta-panel rule not found");
|
||||
assert.match(meta[1], /border-left:/);
|
||||
assert.doesNotMatch(meta[1], /border-top:/);
|
||||
// The lightbox hide rule targets the filmstrip only — not the metadata sidebar.
|
||||
assert.match(css, /\.image-viewer-container\.lightbox \.image-nav-strip\s*\{/);
|
||||
assert.doesNotMatch(css, /\.image-viewer-container\.lightbox[^{]*\.image-meta-panel/);
|
||||
});
|
||||
|
||||
// ── Static checks: icon mapping + backend ──────────────────────────────────
|
||||
test("utils.js maps image extensions to the Lucide 'image' icon", () => {
|
||||
assert.match(utils, /"\.png": "image"/);
|
||||
|
||||
Reference in New Issue
Block a user