156 lines
18 KiB
Markdown
156 lines
18 KiB
Markdown
# #78 — Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
|
|
|
|
> **Statut :** ✅ Terminé (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 et BUG-064 corrigés. 2026-09 : A9 bouton **plein écran** ajouté, auto-save retirée au profit d'une sauvegarde explicite (BUG-065))
|
|
> **Effort :** 3-4 jours | **Impact :** 🟡
|
|
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog — 2.2.0](../../CHANGELOG.md)
|
|
|
|
- **Description :** Prise en charge native des fichiers `.excalidraw` dans ObsiGate avec un éditeur visuel complet intégré. L'utilisateur peut ouvrir un fichier `.excalidraw` depuis l'arborescence et obtenir l'éditeur de diagrammes Excalidraw directement dans ObsiGate — dessiner, modifier, sauvegarder, comme dans l'app Excalidraw standalone, mais intégré au flux de travail du vault Obsidian.
|
|
|
|
- **Pourquoi c'est important :** Excalidraw est devenu le standard de fait pour les diagrammes et croquis dans l'écosystème Obsidian (plugin communautaire avec 1M+ téléchargements). Les utilisateurs créent des `.excalidraw` dans leur vault et s'attendent à pouvoir les visualiser et éditer. Actuellement ObsiGate traite ces fichiers comme du JSON brut — illisible. Avec l'éditeur intégré, ObsiGate devient un viewer/éditeur Excalidraw à part entière, supprimant le besoin d'ouvrir Obsidian Desktop ou l'app web Excalidraw séparément.
|
|
|
|
- **Fonctionnement général :**
|
|
- L'utilisateur clique sur un fichier `.excalidraw` dans l'arborescence → ObsiGate détecte l'extension et le type de contenu (`type: "excalidraw"` dans le JSON)
|
|
- Au lieu du viewer markdown ou JSON brut, une **iframe sandbox** charge l'éditeur Excalidraw avec les données du fichier
|
|
- L'utilisateur peut dessiner, ajouter des formes, du texte, des flèches, des images — l'expérience Excalidraw complète
|
|
- Les modifications sont sauvegardées automatiquement (Ctrl+S ou auto-save) via `postMessage` → le parent écrit dans le fichier via l'API ObsiGate
|
|
- L'éditeur respecte le thème sombre/clair d'ObsiGate
|
|
|
|
- **Architecture :**
|
|
```
|
|
ObsiGate SPA (content-area)
|
|
└── <iframe sandbox="allow-scripts allow-same-origin">
|
|
└── /frontend/excalidraw-editor.html
|
|
├── import * as ExcalidrawLib from "esm.sh/@excalidraw/excalidraw"
|
|
├── React + ReactDOM (fournis par Excalidraw)
|
|
├── Écoute postMessage("init", {data, theme})
|
|
└── Poste postMessage("save", {data}) au parent
|
|
```
|
|
|
|
- **Choix technique — Pourquoi une iframe plutôt qu'une intégration directe ?**
|
|
- **Isolation** : Excalidraw est un composant React avec son propre DOM virtuel, ses propres polices, et des styles CSS globaux. L'iframe empêche les conflits CSS avec ObsiGate (variables CSS, polices, z-index des modales).
|
|
- **Sandbox** : L'iframe isole le code d'Excalidraw — une erreur dans l'éditeur ne crashe pas l'application principale.
|
|
- **Chargement lazy** : Excalidraw pèse ~2 Mo minifié + React ~40 Ko. L'iframe n'est chargée QUE quand l'utilisateur ouvre un fichier `.excalidraw`. Pas d'impact sur le temps de chargement initial.
|
|
- **Communication standard** : `postMessage` est une API web native, simple et sécurisée. L'iframe n'a pas accès au DOM parent, seulement au canal de messages.
|
|
- **CSS indépendant** : Le thème sombre/clair est passé comme paramètre → l'iframe applique son propre thème sans toucher aux variables CSS d'ObsiGate.
|
|
|
|
- **Sous-tâches :**
|
|
|
|
## A. Fichier `frontend/excalidraw-editor.html` — Éditeur autonome (1.5 jour)
|
|
- [x] **A1. Structure HTML** : Page minimale avec un `<div id="excalidraw-container">` en plein écran. Pas de header ObsiGate — tout l'espace est pour le canvas.
|
|
- [x] **A2. Import Excalidraw** :
|
|
```html
|
|
<script type="module">
|
|
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
|
|
window.ExcalidrawLib = ExcalidrawLib;
|
|
</script>
|
|
```
|
|
Version épinglée (`@0.18.0`) pour la stabilité. Mise à jour manuelle testée.
|
|
- [x] **A3. Configuration du chemin d'assets** : Définir `window.EXCALIDRAW_ASSET_PATH` pour pointer vers le CDN des fonts/polices d'Excalidraw (nécessaire pour le rendu des polices handwriting).
|
|
- [x] **A4. Initialisation React** (import map esm.sh → React 18.3.1 épinglé) : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
|
|
```html
|
|
<script type="module">
|
|
import React from "https://esm.sh/react@18";
|
|
import ReactDOM from "https://esm.sh/react-dom@18";
|
|
window.React = React;
|
|
window.ReactDOM = ReactDOM;
|
|
</script>
|
|
```
|
|
- [x] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
|
|
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe ; depuis 2026-09 : **colonne d'icônes** collée au bord droit (`right: 0`), début à `45%` de la hauteur, empilement vertical) :
|
|
- Bouton « Sauvegarder » (icône disquette → coche après sauvegarde) → envoie les données au parent
|
|
- Boutons « Export PNG » (icône image) et « Export SVG » (icône vectorielle) — infobulles au survol
|
|
- Bouton plein écran (A9)
|
|
- Badge « Modifié » réduit à une pastille au-dessus des boutons
|
|
- [x] **A7. Communication postMessage** :
|
|
- Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light).
|
|
- Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde.
|
|
- Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête.
|
|
- Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification.
|
|
- [x] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
|
|
- [x] **A9. Bouton plein écran** (ajouté 2026-09) : bouton `#btn-fullscreen` dans la barre d'outils de l'iframe → `document.documentElement.requestFullscreen()` (l'iframe parent est créée avec `allow="fullscreen" allowfullscreen`) ; l'icône bascule entrer/sortir via `fullscreenchange`. La feuille de style Excalidraw étant chargée, le canvas suit le redimensionnement. Test statique : `tests/frontend/excalidraw-viewer.test.mjs`.
|
|
|
|
## B. Backend — Détection et API (0.5 jour)
|
|
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
|
|
- [x] **B2. Icône** : Ajouter `.excalidraw` dans `EXT_ICONS` (`frontend/js/utils.js`) → icône `pen-tool` ou `edit-3` (Lucide).
|
|
- [x] **B3. Détection dans `api_file_view()`** : Dans `backend/main.py`, pour les fichiers `.excalidraw` :
|
|
- Lire le JSON
|
|
- Vérifier `data.get("type") === "excalidraw"`
|
|
- Retourner `is_excalidraw: true` + les données parsées (`elements`, `appState`, `files`)
|
|
- Si le JSON est invalide ou n'est pas un fichier Excalidraw valide → fallback sur le viewer JSON standard
|
|
- [x] **B4. Endpoint de sauvegarde** : Le endpoint existant `PUT /api/file/{vault}` fonctionne déjà pour écrire du contenu. L'iframe envoie le JSON modifié via postMessage → le parent appelle l'API existante. Aucun nouvel endpoint nécessaire.
|
|
- [x] **B5. Indexation du contenu texte** (FAIT 2026-09) : `extract_excalidraw_indexable()` extrait `element.text` des éléments (JSON pur et `.excalidraw.md` compressé) → recherche TF-IDF fonctionnelle.
|
|
- [x] **B6. Contenu initial pour nouveaux fichiers** : Définir le squelette JSON minimum pour un fichier `.excalidraw` vide :
|
|
```json
|
|
{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}
|
|
```
|
|
Ce squelette est retourné par le backend quand on crée un fichier `.excalidraw` (utilisé par `POST /api/file/{vault}`).
|
|
|
|
## C. Frontend — Intégration dans le viewer (1 jour)
|
|
- [x] **C1. Module `frontend/js/excalidraw-viewer.js`** (nouveau) : Fonction `renderExcalidraw(container, data, vault, path)` :
|
|
- Crée une `<iframe>` avec `src="/frontend/excalidraw-editor.html"` et `sandbox="allow-scripts allow-same-origin"`
|
|
- Stocke une référence à l'iframe pour la communication
|
|
- Attend le message `ready` de l'iframe
|
|
- Envoie `postMessage({type: "init", data: {elements, appState, files}, theme})` à l'iframe
|
|
- Écoute les messages `save` → appelle `saveFile(vault, path, JSON.stringify(data))` via l'API existante
|
|
- Écoute les messages `modified` → met à jour l'indicateur dans la barre d'onglets
|
|
- Gère le thème : écoute `themeChanged` → envoie `postMessage({type: "theme", theme})` à l'iframe
|
|
- [x] **C2. Dispatch dans `viewer.js`** : Dans `renderFileContent()` ou `renderFile()` :
|
|
- Après la détection `data.is_json`, ajouter une branche : si `data.is_excalidraw === true` → appeler `renderExcalidraw(container, data, vaultName, filePath)`
|
|
- Ne PAS passer par le viewer markdown standard
|
|
- [x] **C3. Barre d'outils contextuelle** : Dans la toolbar du viewer (celle avec Copier/Source/Éditer/PDF/pop-out) :
|
|
- Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet)
|
|
- Garder « Télécharger » (.excalidraw) et « pop-out »
|
|
- Badge « Excalidraw » avec icône `pen-tool`
|
|
- [x] **C4. Sauvegarde explicite uniquement** (modifié 2026-09 : l'auto-save a été **retirée**, BUG-065) : l'iframe émet `modified` → le badge « Modified » s'affiche, mais **aucune sauvegarde automatique** n'est déclenchée. La sauvegarde se fait par le bouton « 💾 Save » de l'iframe ou `Ctrl+S`. Raison : chaque écriture déclenche l'événement SSE `index_updated`, qui re-rendait la vue et **rechargeait l'iframe** (refresh visible en pleine édition).
|
|
- [x] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
|
|
- [x] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
|
|
- [x] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
|
|
- Ajouter `<option value=".excalidraw">Excalidraw (.excalidraw)</option>` dans le `<select id="file-ext-select">` (après `.json`)
|
|
- Quand l'extension `.excalidraw` est sélectionnée, le backend crée le fichier avec le squelette JSON minimum (B6)
|
|
- Après création → `openFile(vault, path)` → le viewer détecte `is_excalidraw: true` → l'iframe s'ouvre avec le canvas vierge
|
|
- Fonctionne aussi via la palette de commandes `Ctrl+Alt+Space` → « Nouveau fichier » (action `create-file` existante)
|
|
- [x] **C8. Création via le menu contextuel** (FAIT 2026-09 — `frontend/js/ui.js` `_createExcalidraw()`, option « Nouveau diagramme Excalidraw » sur les répertoires, E2E couvert) : ouvre la modale avec `.excalidraw` pré-sélectionné.
|
|
|
|
## D. CSS & Design (0.5 jour)
|
|
- [x] **D1. Styles de l'iframe dans ObsiGate** : L'iframe occupe 100% du content-area (`width: 100%; height: 100%; border: none;`). Aucun padding ni marge.
|
|
- [x] **D2. Thème dark/light** : L'iframe reçoit le thème courant → Excalidraw applique son thème interne (`theme="dark"` ou `theme="light"`). Les couleurs sont cohérentes avec ObsiGate grâce à la palette d'Excalidraw.
|
|
- [x] **D3. Écran de chargement** : Pendant le chargement de l'iframe (React + Excalidraw ~2 Mo), afficher un spinner « Chargement de l'éditeur Excalidraw... » dans le content-area. L'iframe envoie `ready` → le spinner disparaît.
|
|
- [x] **D4. Responsive** : L'iframe s'adapte à la largeur du panneau. En mode mobile (<768px), l'éditeur Excalidraw est utilisable (UI tactile native).
|
|
|
|
## E. Gestion des conflits et edge cases (0.5 jour)
|
|
- [x] **E1. Fichier modifié à l'extérieur** : Si le fichier est modifié par Syncthing/watcher pendant l'édition → détecter via le watcher → afficher un bandeau « Ce fichier a été modifié à l'extérieur. Recharger ? » avec boutons [Recharger] [Ignorer].
|
|
- [x] **E2. Plusieurs onglets** : Deux onglets sur le même fichier `.excalidraw` → le second détecte que le fichier est déjà ouvert → focus l'onglet existant (comportement existant du `TabManager` #E4).
|
|
- [x] **E3. Fichier vide ou nouveau** : Couvert par C7/C8 — la création d'un `.excalidraw` produit un canvas vierge avec le squelette JSON minimum (B6). L'iframe gère nativement le cas `elements: []`.
|
|
- [x] **E4. Fichier corrompu** (fallback viewer JSON + tests `test_invalid_json_excalidraw_fallback`/`test_json_without_excalidraw_type`) : Si le JSON ne contient pas `type: "excalidraw"` ou est invalide → fallback sur le viewer JSON standard avec un message « Ce fichier .excalidraw semble corrompu ».
|
|
- [x] **E5. Pop-out** : Le bouton pop-out fonctionne — il ouvre l'éditeur dans une popup séparée avec sa propre iframe. Utile pour éditer sur un deuxième écran.
|
|
- [x] **E6. Annulation (Ctrl+Z)** : Natif dans Excalidraw — l'historique d'annulation est géré par l'état interne de l'iframe. Pas besoin d'interaction avec le parent.
|
|
|
|
## F. Tests (0.5 jour) — F1 ✅ (6 tests test_excalidraw.py)
|
|
- [x] **F1. Tests backend** :
|
|
- `test_excalidraw_detection.py` : fichier `.excalidraw` valide → `is_excalidraw: true`, JSON invalide → fallback JSON, fichier sans `type: excalidraw` → fallback
|
|
- `test_excalidraw_search.py` : texte extrait des éléments → recherchable via TF-IDF
|
|
- [ ] **F2. Tests frontend** — ⚪ NON RETENU :
|
|
- Chargement de l'iframe avec des données de test
|
|
- Communication postMessage (init → ready → save)
|
|
- Changement de thème propagé à l'iframe
|
|
- [x] **F3. Tests E2E (Playwright)** (FAIT 2026-09 — `tests/e2e/excalidraw.spec.js`) :
|
|
- Ouvrir un fichier `.excalidraw` → l'iframe se charge → le canvas Excalidraw est visible
|
|
- Dessiner un rectangle → sauvegarder → recharger → le rectangle est toujours là
|
|
- Basculer thème sombre → l'iframe passe en dark mode
|
|
|
|
## G. Points d'attention / Risques
|
|
- **Taille du bundle** : React + ReactDOM + Excalidraw ≈ 2.5 Mo minifié. Chargé depuis `esm.sh` (CDN global, cache HTTP). L'impact n'est perceptible qu'à la première ouverture d'un `.excalidraw`. Solution : précharger l'iframe en arrière-plan (`<link rel="prefetch">`) après le chargement de l'app.
|
|
- **Performance React dans iframe** : React dans une iframe fonctionne parfaitement — c'est un contexte JavaScript indépendant. Testé sur Chrome, Firefox, Safari, Edge.
|
|
- **CORS et esm.sh** : Les modules ESM depuis `esm.sh` sont servis avec les headers CORS appropriés. L'iframe est same-origin (`/frontend/excalidraw-editor.html`) donc pas de problème.
|
|
- **Compatibilité des exports de l'app Excalidraw** (BUG-064) : `appState.collaborators` est une `Map` qu'Excalidraw sérialise en objet JSON (`{}`) ; elle doit être reconvertie en `Map` (`sanitizeAppState()` dans `frontend/excalidraw-editor.html`) avant `initialData`, sinon Excalidraw 0.18 plante (`collaborators.forEach is not a function`). La géométrie de viewport (`width`, `height`, `offsetLeft`, `offsetTop`) est également écartée : ce sont des valeurs mesurées côté fenêtre source, qu'Excalidraw recalcule. Couvert par un test E2E (`diagram-app-export.excalidraw`).
|
|
- **Feuille de style Excalidraw obligatoire** (BUG-064) : `@excalidraw/excalidraw` n'injecte pas son CSS automatiquement — il faut le charger explicitement (`<link>` vers `…/@excalidraw/excalidraw@0.18.0/dist/prod/index.css`). Sans lui, l'éditeur est non stylisé **et** `.excalidraw` n'a pas de hauteur fixe, ce qui déclenche une boucle de redimensionnement jusqu'au plafond `2^25` (33 554 432 px) : le canvas devient indessinable et la scène reste blanche. Le CDN `esm.sh` doit donc figurer dans `style-src` de la CSP (`backend/main.py`). Garde-fous : `tests/frontend/excalidraw-viewer.test.mjs` et `TestCspExcalidrawStylesheet`.
|
|
- **Mises à jour d'Excalidraw** : La version est épinglée (`@0.18.0`). Pour mettre à jour, changer le numéro dans le HTML + tester. Le format de données `.excalidraw` est stable (v2 depuis 2021).
|
|
- **Sécurité postMessage** : Vérifier `event.origin` dans les deux sens. L'iframe n'accepte que les messages de `window.parent`. Le parent n'accepte que les messages de l'iframe connue. Pas de `"*"` en production.
|
|
- **Tauri Desktop (#77)** : L'iframe se charge depuis le filesystem local (`tauri://localhost/frontend/excalidraw-editor.html`). Les imports ESM depuis `esm.sh` fonctionnent si le réseau est disponible. Pour le mode offline, bundler Excalidraw dans l'app desktop (à traiter dans #77, pas ici).
|
|
- **Pas d'édition collaborative** : Cette implémentation est mono-utilisateur. La collaboration temps réel (#62) pourra être étendue aux fichiers `.excalidraw` ultérieurement via le même mécanisme Yjs.
|
|
|
|
## H. Documentation utilisateur
|
|
- [x] **H1.** Mettre à jour README : ajouter `.excalidraw` dans les formats supportés (FR + EN)
|
|
- [x] **H2.** Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw » (i18n FR/EN)
|
|
- [x] **H3.** Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »
|