Files
ObsiGate/docs/features/excalidraw.md
T

18 KiB

#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 · Changelog — 2.2.0

  • 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)

  • 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.
  • A2. Import Excalidraw :
    <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.
  • 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).
  • A4. Initialisation React (import map esm.sh → React 18.3.1 épinglé) : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
    <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>
    
  • A5. Rendu du composant : Monter <ExcalidrawLib.Excalidraw> dans le conteneur avec les initialData reçues. Configurer les callbacks onChange pour détecter les modifications.
  • 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
  • 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.
  • 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.
  • 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)

  • B1. Ajout à SUPPORTED_EXTENSIONS : Ajouter .excalidraw dans backend/indexer.py:56 pour que les fichiers apparaissent dans l'arborescence et soient indexés.
  • B2. Icône : Ajouter .excalidraw dans EXT_ICONS (frontend/js/utils.js) → icône pen-tool ou edit-3 (Lucide).
  • 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
  • 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.
  • 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.
  • B6. Contenu initial pour nouveaux fichiers : Définir le squelette JSON minimum pour un fichier .excalidraw vide :
    {"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)

  • 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
  • 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
  • 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
  • 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).
  • C5. Raccourci Ctrl+S : L'iframe intercepte Ctrl+S → envoie save au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
  • 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.
  • 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)
  • 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)

  • D1. Styles de l'iframe dans ObsiGate : L'iframe occupe 100% du content-area (width: 100%; height: 100%; border: none;). Aucun padding ni marge.
  • 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.
  • 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.
  • 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)

  • 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].
  • 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).
  • 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: [].
  • 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 ».
  • 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.
  • 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)

  • 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
  • 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/[email protected]/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

  • H1. Mettre à jour README : ajouter .excalidraw dans les formats supportés (FR + EN)
  • H2. Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw » (i18n FR/EN)
  • H3. Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »