OpenMind v1 : cartes mentales libres, local-first et dockerisees

Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

Architecture
- Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react,
  etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre.
- Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les
  fichiers statiques du frontend compile.
- Image Docker multi-stage, un seul service, bind mount ./data pour SQLite.

Local-first
Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur
demande explicite de partage.

Partage
Deux secrets distincts : share_token public en lecture seule dans l'URL, et
management_token conserve uniquement cote client, transmis en en-tete pour les
modifications et stocke hashe en base.

Fonctionnalites
Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis,
branches repliables, recherche), 9 themes, 4 dispositions automatiques,
panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression,
page publique en lecture seule, PWA.

Qualite
25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur,
TypeScript strict sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:19:18 +02:00
commit 0b2e5e326d
113 changed files with 14576 additions and 0 deletions

27
.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
# Rien de tout cela ne doit entrer dans le contexte de build.
.git
.gitignore
.github
node_modules
frontend/node_modules
frontend/dist
data
*.db
*.db-wal
*.db-shm
.env
.env.*
!.env.example
__pycache__
*.py[cod]
.venv
venv
.pytest_cache
.ruff_cache
coverage
.vscode
.idea
.DS_Store
Thumbs.db
README.md
docs

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# ---------------------------------------------------------------------------
# Configuration OpenMind. Copiez ce fichier en .env puis adaptez si besoin.
# Aucun secret n'est necessaire : l'application ne gere aucun compte.
# ---------------------------------------------------------------------------
# Nom du produit, affiche dans l'interface et l'onglet du navigateur.
APP_NAME=OpenMind
# Port publie sur l'hote. L'application ecoute toujours sur 8080 dans le conteneur.
APP_PORT=8080
# Base SQLite des cartes PARTAGEES uniquement.
# /app/data est monte depuis ./data sur l'hote (bind mount).
DATABASE_URL=sqlite:////app/data/openmind.db
# Limites de securite appliquees aux cartes envoyees au serveur.
MAX_MAP_SIZE_MB=10
MAX_NODES=5000
# Origines autorisees en CORS, separees par des virgules.
# Vide = aucune origine externe (l'interface est servie par ce meme serveur).
CORS_ORIGINS=
# Niveau de logs backend : DEBUG, INFO, WARNING, ERROR.
LOG_LEVEL=INFO

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# Fins de ligne normalisees en LF dans le depot : le build Docker se fait sous
# Linux, un CRLF casserait les scripts et le service worker.
* text=auto eol=lf
# Fichiers binaires : aucune conversion.
*.png binary
*.jpg binary
*.ico binary
*.pdf binary

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependances
node_modules/
frontend/dist/
__pycache__/
*.py[cod]
.venv/
venv/
# Donnees locales (bind mount SQLite)
data/
*.db
*.db-wal
*.db-shm
# Environnement
.env
.env.*
!.env.example
# Outils
.vscode/
.idea/
.DS_Store
Thumbs.db
coverage/
.pytest_cache/
.ruff_cache/

63
Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Etape 1 : compilation du frontend React / Vite
# ---------------------------------------------------------------------------
FROM node:20-alpine AS frontend-build
WORKDIR /build
# Les dependances sont installees avant le code source pour profiter du cache.
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Etape 2 : dependances Python
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS backend-deps
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /install
COPY backend/requirements.txt ./
RUN pip install --prefix=/opt/python -r requirements.txt
# ---------------------------------------------------------------------------
# Etape 3 : image finale (un seul service : FastAPI sert l'API et le frontend)
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/python/bin:$PATH" \
PYTHONPATH="/opt/python/lib/python3.12/site-packages" \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:////app/data/openmind.db
# curl sert uniquement au healthcheck du conteneur.
RUN apt-get update \
&& apt-get install --no-install-recommends -y curl \
&& rm -rf /var/lib/apt/lists/*
# L'application ne tourne jamais en root.
RUN useradd --create-home --uid 10001 openmind
WORKDIR /app
COPY --from=backend-deps /opt/python /opt/python
COPY backend/app ./app
COPY --from=frontend-build /build/dist ./static
RUN mkdir -p /app/data && chown -R openmind:openmind /app
USER openmind
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Florian (Harmonia IT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

530
README.md Normal file
View File

@@ -0,0 +1,530 @@
# OpenMind
**Cartes mentales libres, local-first, sans compte et sans abonnement.**
OpenMind est une alternative gratuite et open source aux services de mind mapping
qui placent l'export, les themes ou le nombre de cartes derriere un abonnement.
Toutes les fonctionnalites sont disponibles, sans exception et sans compte.
- Aucune limitation artificielle, aucun paiement, aucun "premium".
- Aucun compte obligatoire : il n'y a d'ailleurs aucun systeme de comptes.
- Aucune telemetrie, aucun tracker, aucune publicite.
- Vos cartes vous appartiennent : elles restent dans votre navigateur.
- Export PDF, PNG, SVG et impression : gratuits.
- Un partage par lien, uniquement si vous le demandez explicitement.
- Un seul conteneur Docker a demarrer.
---
## Philosophie local-first
Une carte que vous creez n'est **jamais** envoyee au serveur.
Elle est enregistree dans l'**IndexedDB de votre navigateur**, automatiquement,
apres une courte periode d'inactivite. Le backend n'existe que pour une seule
chose : generer un lien de partage lorsque vous cliquez explicitement sur
**Partager**.
Consequences concretes :
- Fermer le navigateur, redemarrer le PC ou redemarrer le conteneur ne fait
perdre aucune carte locale.
- Une fois l'application chargee une premiere fois, elle continue de fonctionner
sans reseau (PWA) : creation, edition, export, impression.
- Vider les donnees du site, ou changer de navigateur/machine, fait disparaitre
les cartes locales. **Exportez vos cartes en `.mmap`** pour en conserver une
sauvegarde portable.
---
## Demarrage rapide
Prerequis : Docker et Docker Compose.
```bash
git clone <url-du-depot>
cd Mind-Mapping
cp .env.example .env
docker compose up -d --build
```
Puis ouvrez :
```
http://localhost:8080
```
Verifier que le service repond :
```bash
curl http://localhost:8080/api/health
```
Reponse attendue : `{"status":"ok"}`
Etat du conteneur (le healthcheck Docker interroge `/api/health`) :
```bash
docker compose ps
```
Arreter :
```bash
docker compose down
```
---
## Architecture
Un seul service applicatif. FastAPI sert a la fois l'API et les fichiers
statiques du frontend compile.
```
Navigateur
|
+-- React / TypeScript (Vite, Tailwind, React Flow, Zustand)
|
+-- IndexedDB (Dexie)
| +-- cartes privees/locales <-- ne quittent jamais le navigateur
|
+-- API, uniquement pour les fonctions serveur (partage)
|
v
FastAPI (+ fichiers statiques du frontend)
|
v
SQLite
|
+-- cartes explicitement partagees
```
Choix structurants :
| Sujet | Choix | Pourquoi |
|---|---|---|
| Hierarchie | `parentId` fait autorite, les liens en sont derives | un seul invariant a maintenir, pas de desynchronisation |
| Modele | types metier propres, adaptes vers React Flow a l'affichage | le format de fichier ne depend pas de la bibliotheque de rendu |
| Historique | instantanes `{nodes, edges}`, groupes par action | un glisser-deposer = une seule annulation |
| Auto-layout | dagre, declenche uniquement a la demande | les deplacements manuels ne sont jamais ecrases |
| Versionnement | champ `version` + point d'extension de migration | le format peut evoluer sans casser les fichiers existants |
---
## Variables d'environnement
Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis.
| Variable | Defaut | Role |
|---|---|---|
| `APP_NAME` | `OpenMind` | Nom du produit, affiche dans l'interface et l'onglet |
| `APP_PORT` | `8080` | Port publie sur l'hote (le conteneur ecoute toujours 8080) |
| `DATABASE_URL` | `sqlite:////app/data/openmind.db` | Base des cartes partagees |
| `MAX_MAP_SIZE_MB` | `10` | Taille maximale d'une carte envoyee au serveur |
| `MAX_NODES` | `5000` | Nombre maximal de noeuds par carte |
| `CORS_ORIGINS` | vide | Origines externes autorisees, separees par des virgules |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
`GET /api/config` : changer `APP_NAME` suffit, sans recompiler.
---
## Donnees et sauvegarde
Deux stockages **totalement distincts** :
| Donnee | Ou | Sauvegarde |
|---|---|---|
| Cartes privees | IndexedDB du navigateur | Export `.mmap` depuis le tableau de bord ou l'editeur |
| Cartes partagees | SQLite, dans `./data/openmind.db` | Copie du dossier `./data` |
Le dossier `./data` est monte en **bind mount** (aucun volume Docker nomme) :
les fichiers sont donc directement lisibles depuis l'hote.
```bash
docker compose stop
tar czf openmind-partages-$(date +%F).tar.gz data/
docker compose start
```
SQLite fonctionne en mode WAL : `openmind.db`, `openmind.db-wal` et
`openmind.db-shm` doivent etre sauvegardes ensemble.
Redemarrer le serveur n'a **aucun impact** sur les cartes privees locales.
---
## Fonctionnement du partage
Une carte n'est envoyee au serveur que lorsque vous cliquez sur **Partager**,
apres une explication et une confirmation.
Deux secrets distincts sont generes cote serveur avec le module Python
`secrets` :
| Jeton | Entropie | Ou il vit | A quoi il sert |
|---|---|---|---|
| `share_token` | `secrets.token_urlsafe(12)`, environ 96 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
| `management_token` | `secrets.token_urlsafe(32)`, environ 256 bits | **uniquement** dans l'IndexedDB du proprietaire | modification et suppression |
Regles appliquees :
- Le `management_token` n'apparait **jamais** dans une URL, jamais dans un export
`.mmap`, jamais dans les logs.
- Il est transmis dans l'en-tete `X-Management-Token` pour `PUT` et `DELETE`.
- Seul son **hash SHA-256** est stocke en base ; la comparaison est faite en
temps constant avec `secrets.compare_digest`.
- Connaitre le `share_token` ne permet donc **ni** de modifier **ni** de
supprimer un partage.
- Le lien est **non repertorie** : il n'apparait dans aucune liste publique,
mais toute personne qui le connait peut consulter la carte.
Cycle de vie :
1. **Partager** : un instantane de la carte est copie sur le serveur, un lien est
renvoye, par exemple `http://localhost:8080/s/a8F3xP9k`.
2. **Mettre a jour le partage** : remplace l'instantane par la version actuelle.
Tant que vous ne le faites pas, le lien public montre l'ancienne version.
3. **Supprimer le partage** : efface l'instantane serveur. Le lien affiche alors
une page propre indiquant que le partage n'existe plus. La carte locale n'est
pas affectee.
Si vous supprimez une carte locale, sa reference de partage locale part avec
elle : supprimez d'abord le partage si vous voulez aussi retirer la copie
serveur.
### API
| Methode | Route | Auth | Role |
|---|---|---|---|
| `GET` | `/api/health` | - | Etat du service |
| `GET` | `/api/config` | - | Nom de l'application et limites |
| `POST` | `/api/shares` | - | Creer un partage |
| `GET` | `/api/shares/{share_token}` | - | Lire une carte partagee |
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour |
| `DELETE` | `/api/shares/{share_token}` | `X-Management-Token` | Supprimer |
Documentation interactive : `http://localhost:8080/api/docs`.
---
## Fonctionnalites
**Tableau de bord** : creation, import, recherche, date de derniere
modification, renommage, duplication, export, suppression avec confirmation.
Une carte d'exemple est creee au tout premier lancement.
**Editeur** : canvas infini, zoom, deplacement, zoom-to-fit, minimap, grille
optionnelle, creation/suppression/deplacement de noeuds, selection multiple,
connexion entre noeuds avec detection de boucle, edition du texte au
double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
**Mind mapping** : noeud racine et noeuds standard, branches droites, courbes ou
Bezier, branches repliables avec compteur d'elements masques.
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
arbre horizontal, arbre vertical.
**Proprietes du noeud** : texte, couleurs de fond/texte/bordure, forme
(rectangle, arrondi, capsule, cercle), taille du texte, gras, italique,
alignement, icone, URL ouverte dans un nouvel onglet, note.
**Themes** : Default, Minimal, Dark, Midnight, Ocean, Forest, Pastel,
Monochrome, Blueprint. Plus un mode clair/sombre pour l'interface. Changer de
theme ne supprime jamais une personnalisation explicite d'un noeud.
**Recherche dans la carte** : resultats listes, navigation entre eux, centrage
et mise en evidence temporaire du noeud, depliage automatique des branches
repliees.
**Exports** : `.mmap` (format natif), PNG (x1, x2, fond transparent), SVG, PDF
(A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond,
titre, date), impression via `@media print`.
**PWA** : installable, et utilisable hors ligne pour les cartes locales une fois
l'application chargee.
---
## Raccourcis clavier
`Cmd` remplace `Ctrl` sur macOS.
| Raccourci | Action |
|---|---|
| `Tab` | Ajouter un noeud enfant |
| `Entree` | Ajouter un noeud frere |
| `Suppr` / `Retour arriere` | Supprimer la selection, hors saisie de texte |
| `F2` | Editer le texte du noeud selectionne |
| Double-clic | Editer le texte du noeud |
| `Ctrl+Z` | Annuler |
| `Ctrl+Y` ou `Ctrl+Shift+Z` | Retablir |
| `Ctrl+C` | Copier |
| `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer |
| `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite |
| `Ctrl+F` | Rechercher dans la carte |
| `F` | Adapter la carte a l'ecran |
| `Echap` | Quitter l'edition ou deselectionner |
---
## Format de fichier `.mmap`
Un `.mmap` est un fichier **JSON UTF-8** contenant le document complet de la
carte. Il ne contient jamais de jeton de partage.
```json
{
"version": 1,
"id": "uuid",
"name": "Architecture Homelab",
"createdAt": "2026-01-01T10:00:00.000Z",
"updatedAt": "2026-01-01T12:30:00.000Z",
"nodes": [
{
"id": "uuid",
"type": "root",
"text": "Infrastructure",
"position": { "x": 0, "y": 0 },
"parentId": null,
"collapsed": false,
"icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr",
"note": "Texte libre",
"style": { "backgroundColor": "#eef2ff", "shape": "rounded" }
}
],
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }],
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"settings": {
"theme": "default",
"layout": "mindmap-right",
"branchStyle": "bezier",
"showGrid": true
}
}
```
A l'import, le fichier est **valide et normalise** : structure, version, champs
essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:`
sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni
interprete comme du HTML ; un fichier invalide produit un message clair.
Le champ `version` permet les migrations futures : le point d'extension se
trouve dans `frontend/src/services/mapSerialization.ts`, fonction `migrate`.
---
## Developpement
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
DATABASE_URL=sqlite:///./openmind-dev.db STATIC_DIR=./static uvicorn app.main:app --reload --port 8080
```
Sous Windows, activez l'environnement avec `.venv\Scripts\activate`.
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Le serveur Vite ecoute sur `http://localhost:5173` et proxyfie `/api` vers
`http://localhost:8080`.
Autres commandes :
```bash
npm run build
npm run lint
npm run typecheck
npm run test
```
Regenerer les icones de la PWA :
```bash
python scripts/generate_icons.py
```
---
## Tests et qualite
Backend :
```bash
cd backend
python -m pytest -q
ruff check .
ruff format --check .
```
Frontend :
```bash
cd frontend
npm run test
npm run lint
npm run typecheck
```
Sans installation locale de Node ou Python, tout peut tourner via Docker :
```bash
docker run --rm -v "$PWD/backend:/work" -w /work python:3.12-slim sh -c "pip install -q -r requirements-dev.txt && ruff check . && python -m pytest -q"
```
```bash
docker run --rm -v "$PWD/frontend:/app" -w /app node:20-alpine sh -c "npm ci && npm run lint && npm run test && npm run build"
```
Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et
suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
jeton, partage inexistant, payload invalide, limites de taille et de noeuds,
non-divulgation du jeton de gestion, repli SPA.
Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
d'import, migrations, store Zustand (creation, suppression de branche,
undo/redo, groupement du glisser-deposer, copier/coller, duplication, detection
de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
---
## Structure du projet
```
.
├── Dockerfile # build multi-stage : node -> python -> image finale
├── docker-compose.yml # un seul service, bind mount ./data
├── .env.example
├── README.md
├── scripts/
│ └── generate_icons.py # generation des icones PWA
├── data/ # bind mount : SQLite des partages
├── backend/
│ ├── requirements.txt
│ ├── pyproject.toml # configuration ruff et pytest
│ ├── app/
│ │ ├── main.py # assemblage uniquement
│ │ ├── config.py
│ │ ├── logging_config.py
│ │ ├── middleware.py # en-tetes de securite, limite de taille
│ │ ├── web.py # service des fichiers statiques du frontend
│ │ ├── api/ # routes health, config, shares
│ │ ├── db/ # moteur, session, initialisation du schema
│ │ ├── models/ # SharedMap (SQLAlchemy)
│ │ ├── schemas/ # validation Pydantic
│ │ └── services/ # jetons, logique de partage, erreurs metier
│ └── tests/
└── frontend/
├── index.html
├── public/ # manifest PWA, service worker, icones
└── src/
├── config/app.ts # nom du produit et constantes
├── types/map.ts # modele metier versionne
├── db/ # schema Dexie
├── services/ # depots, serialisation, client API
├── stores/ # Zustand : editeur, interface, configuration
├── hooks/ # routeur minimal, debounce
├── components/ # briques d'interface reutilisables
├── features/
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, themes
│ ├── maps/ # tableau de bord
│ ├── export/ # PNG, SVG, PDF, impression
│ └── sharing/ # dialogue de partage, page publique
├── utils/
└── styles/
```
---
## Securite
- Le texte d'un noeud est **toujours** traite comme du texte. Aucun
`dangerouslySetInnerHTML`, aucun HTML issu d'une carte n'est interprete.
- Les URL sont assainies : seuls `http`, `https` et `mailto` sont acceptes. Les
liens s'ouvrent avec `rel="noopener noreferrer nofollow"`.
- Validation Pydantic de tout ce qui entre dans l'API, avec limites de taille et
de nombre de noeuds ; un depassement produit un message clair, jamais un
plantage.
- En-tetes de securite : CSP stricte, `X-Content-Type-Options`,
`X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`,
`Cross-Origin-Opener-Policy`.
- CORS restrictif : aucune origine externe par defaut.
- Acces SQL exclusivement via l'ORM SQLAlchemy.
- Jetons cryptographiquement aleatoires, jeton de gestion stocke hashe et jamais
journalise.
- Le conteneur tourne avec un utilisateur non-root.
- Aucun secret dans le frontend.
---
## Limitations connues
- **Export SVG** : les noeuds sont encapsules dans un `foreignObject` (HTML
embarque). Le fichier reste vectoriel et se redimensionne sans perte, les
branches sont de vrais chemins SVG, mais certains editeurs vectoriels
n'exploitent pas le contenu HTML des noeuds. Preferez PNG ou PDF pour un usage
bureautique.
- **Exports image et PDF en arriere-plan** : la generation s'appuie sur une
frame d'animation du navigateur, qui n'est pas produite lorsque l'onglet est
masque. Gardez l'onglet au premier plan pendant un export ; sinon l'operation
est interrompue au bout de 45 secondes avec un message explicite.
- **PDF en taille reelle** : si la carte depasse la page choisie, elle est
reduite pour tenir entierement plutot que d'etre coupee ; un message le
signale.
- **Gestion d'un partage** : le jeton de gestion vit dans l'IndexedDB du
navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis
a jour ni supprime.
- **Dispositions reservees** : `mindmap-both`, `organization` et `freeform`
existent dans le modele de donnees mais ne sont pas proposees dans
l'interface.
- **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
acceptes par le format, mais la V1 ne produit que `root` et `standard`.
- **Migrations de schema SQLite** : la V1 initialise le schema avec
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra
l'introduire au premier changement de schema.
- **Mobile** : consultation et edition basique fonctionnent, mais l'interface
est concue pour desktop et tablette.
- **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de
collaboration temps reel, pas de synchronisation cloud.
---
## Licences des dependances
Toutes les dependances sont sous licence libre permissive, compatible avec une
application publique et gratuite. Aucune bibliotheque a licence commerciale
n'est utilisee.
| Dependance | Licence |
|---|---|
| React, React DOM | MIT |
| `@xyflow/react` (React Flow) | MIT |
| Zustand | MIT |
| Dexie | Apache-2.0 |
| dagre | MIT |
| html-to-image | MIT |
| jsPDF | MIT |
| Vite, Tailwind CSS, TypeScript, ESLint, Vitest | MIT |
| FastAPI, Starlette, Pydantic, SQLAlchemy, Uvicorn | MIT / BSD |
---
## Licence
OpenMind est distribue sous licence **MIT** : voir le fichier [LICENSE](LICENSE).

0
backend/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,11 @@
"""Agregation des routeurs sous le prefixe /api."""
from fastapi import APIRouter
from app.api import health, shares
api_router = APIRouter(prefix="/api")
api_router.include_router(health.router)
api_router.include_router(shares.router)
__all__ = ["api_router"]

29
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,29 @@
"""Dependances communes aux routes."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db.session import get_db
from app.services.errors import ShareForbiddenError
MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
DbSession = Annotated[Session, Depends(get_db)]
AppSettings = Annotated[Settings, Depends(get_settings)]
def require_management_token(
x_management_token: Annotated[str | None, Header(alias=MANAGEMENT_TOKEN_HEADER)] = None,
) -> str:
"""Recupere le secret de gestion depuis l'en-tete (jamais depuis l'URL)."""
if not x_management_token:
raise ShareForbiddenError("Jeton de gestion manquant.")
return x_management_token
ManagementToken = Annotated[str, Depends(require_management_token)]

25
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,25 @@
"""Endpoints de sante et de configuration publique."""
from __future__ import annotations
from fastapi import APIRouter
from app.api.deps import AppSettings
router = APIRouter(tags=["system"])
@router.get("/health", summary="Etat du service")
def health() -> dict[str, str]:
return {"status": "ok"}
@router.get("/config", summary="Configuration publique exposee au frontend")
def public_config(settings: AppSettings) -> dict[str, object]:
"""Aucun secret ici : uniquement ce dont l'interface a besoin."""
return {
"appName": settings.app_name,
"maxNodes": settings.max_nodes,
"maxMapSizeMb": settings.max_map_size_mb,
"sharingEnabled": True,
}

98
backend/app/api/shares.py Normal file
View File

@@ -0,0 +1,98 @@
"""API REST de partage de cartes."""
from __future__ import annotations
from fastapi import APIRouter, Response, status
from app.api.deps import DbSession, ManagementToken
from app.schemas.share import (
ShareCreatedOut,
ShareCreateIn,
SharePublicOut,
ShareUpdatedOut,
)
from app.services import shares as share_service
router = APIRouter(prefix="/shares", tags=["shares"])
def _share_path(share_token: str) -> str:
return f"/s/{share_token}"
@router.post(
"",
response_model=ShareCreatedOut,
status_code=status.HTTP_201_CREATED,
summary="Publier un snapshot de carte et obtenir un lien non repertorie",
)
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
share, management_token = share_service.create_share(
session, title=payload.title, document=payload.map
)
return ShareCreatedOut(
share_token=share.share_token,
management_token=management_token,
share_path=_share_path(share.share_token),
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
)
@router.get(
"/{share_token}",
response_model=SharePublicOut,
summary="Lire une carte partagee (public, lecture seule)",
)
def read_share(share_token: str, session: DbSession) -> SharePublicOut:
share = share_service.get_share(session, share_token)
return SharePublicOut(
share_token=share.share_token,
title=share.title,
map_version=share.map_version,
map=share_service.load_map_data(share),
created_at=share.created_at,
updated_at=share.updated_at,
)
@router.put(
"/{share_token}",
response_model=ShareUpdatedOut,
summary="Mettre a jour le snapshot partage (necessite le jeton de gestion)",
)
def update_share(
share_token: str,
payload: ShareCreateIn,
session: DbSession,
management_token: ManagementToken,
) -> ShareUpdatedOut:
share = share_service.update_share(
session,
share_token,
management_token=management_token,
title=payload.title,
document=payload.map,
)
return ShareUpdatedOut(
share_token=share.share_token,
share_path=_share_path(share.share_token),
title=share.title,
updated_at=share.updated_at,
)
@router.delete(
"/{share_token}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Supprimer le partage (necessite le jeton de gestion)",
)
def delete_share(
share_token: str,
session: DbSession,
management_token: ManagementToken,
) -> Response:
share_service.delete_share(session, share_token, management_token=management_token)
return Response(status_code=status.HTTP_204_NO_CONTENT)

52
backend/app/config.py Normal file
View File

@@ -0,0 +1,52 @@
"""Configuration applicative, entierement pilotee par variables d'environnement."""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Parametres du backend.
Chaque champ est surchargeable via une variable d'environnement de meme nom
(insensible a la casse) : APP_NAME, DATABASE_URL, MAX_NODES, ...
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
app_name: str = "OpenMind"
app_port: int = 8080
app_env: str = "production"
database_url: str = "sqlite:////app/data/openmind.db"
static_dir: str = "/app/static"
max_map_size_mb: float = Field(default=10.0, gt=0)
max_nodes: int = Field(default=5000, gt=0)
max_title_length: int = Field(default=200, gt=0)
# Liste d'origines autorisees, separees par des virgules.
# Vide = aucune origine externe (l'app est servie par ce meme serveur).
cors_origins: str = ""
log_level: str = "INFO"
@property
def max_map_size_bytes(self) -> int:
return int(self.max_map_size_mb * 1024 * 1024)
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def is_debug(self) -> bool:
return self.app_env.lower() in {"dev", "development", "debug"}
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

9
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,9 @@
"""Base declarative SQLAlchemy commune a tous les modeles."""
from __future__ import annotations
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Classe de base des modeles ORM."""

26
backend/app/db/init_db.py Normal file
View File

@@ -0,0 +1,26 @@
"""Initialisation du schema SQLite.
La V1 n'utilise pas Alembic : le schema tient dans une seule table et
`create_all` suffit. Toute evolution future du schema devra soit passer par
Alembic, soit par une migration explicite ajoutee ici (voir README).
"""
from __future__ import annotations
import logging
from sqlalchemy import Engine
from app.db.base import Base
from app.db.session import engine as default_engine
from app.models import share as _share_model # noqa: F401 (enregistre la table)
logger = logging.getLogger(__name__)
SCHEMA_VERSION = 1
def init_db(engine: Engine | None = None) -> None:
target = engine or default_engine
Base.metadata.create_all(bind=target)
logger.info("Schema SQLite initialise (version %s)", SCHEMA_VERSION)

62
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,62 @@
"""Creation du moteur SQLAlchemy et de la session de requete."""
from __future__ import annotations
import logging
from collections.abc import Generator
from pathlib import Path
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from app.config import get_settings
logger = logging.getLogger(__name__)
_SQLITE_FILE_PREFIX = "sqlite:///"
def _ensure_sqlite_directory(database_url: str) -> None:
"""Cree le dossier parent du fichier SQLite s'il n'existe pas encore."""
if not database_url.startswith(_SQLITE_FILE_PREFIX):
return
raw_path = database_url[len(_SQLITE_FILE_PREFIX) :]
if not raw_path or raw_path == ":memory:":
return
path = Path(raw_path)
if path.parent and str(path.parent) not in ("", "."):
path.parent.mkdir(parents=True, exist_ok=True)
def create_db_engine(database_url: str | None = None) -> Engine:
settings = get_settings()
url = database_url or settings.database_url
_ensure_sqlite_directory(url)
connect_args = {"check_same_thread": False} if url.startswith("sqlite") else {}
engine = create_engine(url, connect_args=connect_args, future=True, echo=False)
if url.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, _connection_record): # type: ignore[no-untyped-def]
cursor = dbapi_connection.cursor()
# WAL : meilleure resistance aux arrets brutaux du conteneur.
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
engine: Engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator[Session, None, None]:
"""Dependance FastAPI : ouvre une session et la referme systematiquement."""
session = SessionLocal()
try:
yield session
finally:
session.close()

View File

@@ -0,0 +1,33 @@
"""Configuration des logs backend : lisible, structure, sans donnee sensible."""
from __future__ import annotations
import logging
import sys
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
# Champs qui ne doivent jamais apparaitre dans les logs.
REDACTED_KEYS = frozenset({"management_token", "managementToken", "authorization"})
def configure_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level.upper())
# Uvicorn possede ses propres handlers : on les aligne sur les notres.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name)
logger.handlers.clear()
logger.propagate = True
def safe_extra(data: dict[str, object]) -> dict[str, object]:
"""Retire les valeurs sensibles avant de les envoyer dans un log."""
return {k: ("***" if k in REDACTED_KEYS else v) for k, v in data.items()}

105
backend/app/main.py Normal file
View File

@@ -0,0 +1,105 @@
"""Point d'entree de l'application FastAPI.
Ce fichier se limite a l'assemblage : configuration, middlewares, gestion des
erreurs, montage des routeurs et du frontend statique.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import api_router
from app.config import get_settings
from app.db.init_db import init_db
from app.logging_config import configure_logging
from app.middleware import MaxBodySizeMiddleware, SecurityHeadersMiddleware
from app.services.errors import ShareError
from app.web import mount_frontend
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
settings = get_settings()
configure_logging(settings.log_level)
init_db()
logger.info(
"%s demarre (max_nodes=%s, max_map_size_mb=%s)",
settings.app_name,
settings.max_nodes,
settings.max_map_size_mb,
)
yield
logger.info("Arret de %s", settings.app_name)
def create_app() -> FastAPI:
settings = get_settings()
configure_logging(settings.log_level)
app = FastAPI(
title=f"{settings.app_name} API",
version="1.0.0",
description="API de partage de cartes. Les cartes privees restent dans le navigateur.",
lifespan=lifespan,
docs_url="/api/docs",
openapi_url="/api/openapi.json",
redoc_url=None,
)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(MaxBodySizeMiddleware, max_body_size=settings.max_map_size_bytes)
# CORS restrictif : par defaut aucune origine externe, l'app etant servie
# par ce meme serveur (meme origine).
origins = settings.cors_origin_list
if origins:
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Management-Token"],
)
register_exception_handlers(app)
app.include_router(api_router)
mount_frontend(app, settings.static_dir)
return app
def register_exception_handlers(app: FastAPI) -> None:
"""Aucune erreur brute ne doit remonter au client."""
@app.exception_handler(ShareError)
async def _share_error(_request: Request, exc: ShareError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
@app.exception_handler(RequestValidationError)
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
# On expose un message lisible, pas la structure interne de validation.
first = exc.errors()[0] if exc.errors() else None
detail = "Les donnees envoyees ne forment pas une carte valide."
if first and first.get("msg"):
reason = str(first["msg"]).replace("Value error, ", "")
detail = f"{detail} ({reason})"
return JSONResponse(status_code=422, content={"detail": detail})
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception) -> JSONResponse:
# La traceback complete reste cote serveur.
logger.exception("Erreur non geree sur %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={"detail": "Une erreur interne est survenue. Reessayez plus tard."},
)
app = create_app()

75
backend/app/middleware.py Normal file
View File

@@ -0,0 +1,75 @@
"""Middlewares HTTP : en-tetes de securite et limite de taille des requetes."""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
CONTENT_SECURITY_POLICY = "; ".join(
[
"default-src 'self'",
# Les styles inline sont utilises par React Flow et par l'export image.
"style-src 'self' 'unsafe-inline'",
"script-src 'self'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
# Service worker et manifeste de la PWA.
"worker-src 'self'",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
]
)
SECURITY_HEADERS = {
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Permissions-Policy": "geolocation=(), microphone=(), camera=(), interest-cohort=()",
"Cross-Origin-Opener-Policy": "same-origin",
}
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request)
for header, value in SECURITY_HEADERS.items():
response.headers.setdefault(header, value)
return response
class MaxBodySizeMiddleware(BaseHTTPMiddleware):
"""Refuse proprement les corps de requete trop volumineux.
On s'appuie sur Content-Length : le client est rejete avant toute lecture
complete du corps, et recoit un message clair plutot qu'une erreur brute.
"""
def __init__(self, app, max_body_size: int) -> None: # type: ignore[no-untyped-def]
super().__init__(app)
self.max_body_size = max_body_size
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
content_length = request.headers.get("content-length")
if content_length is not None:
try:
size = int(content_length)
except ValueError:
size = 0
if size > self.max_body_size:
limit_mb = self.max_body_size / (1024 * 1024)
return JSONResponse(
status_code=413,
content={
"detail": (
"La carte est trop volumineuse pour etre partagee "
f"(limite : {limit_mb:.0f} Mo)."
)
},
)
return await call_next(request)

View File

@@ -0,0 +1,3 @@
from app.models.share import SharedMap
__all__ = ["SharedMap"]

View File

@@ -0,0 +1,41 @@
"""Modele ORM d'une carte explicitement partagee par son proprietaire."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def utcnow() -> datetime:
return datetime.now(UTC)
class SharedMap(Base):
"""Snapshot serveur d'une carte, cree uniquement sur action de l'utilisateur."""
__tablename__ = "shared_maps"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Identifiant public present dans l'URL /s/<share_token>.
share_token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# Seul le hash du management token est stocke : le secret n'existe que
# dans l'IndexedDB du proprietaire.
management_token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
map_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
map_data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
def __repr__(self) -> str: # pragma: no cover - aide au debug uniquement
return f"<SharedMap id={self.id} share_token={self.share_token!r}>"

View File

@@ -0,0 +1,15 @@
from app.schemas.map import MapDocumentIn
from app.schemas.share import (
ShareCreatedOut,
ShareCreateIn,
SharePublicOut,
ShareUpdatedOut,
)
__all__ = [
"MapDocumentIn",
"ShareCreateIn",
"ShareCreatedOut",
"SharePublicOut",
"ShareUpdatedOut",
]

View File

@@ -0,0 +1,62 @@
"""Validation Pydantic du document carte envoye au serveur.
Le backend ne cherche pas a comprendre finement une carte : il valide sa
structure, ses limites de taille, puis la stocke telle quelle. `extra="allow"`
garantit qu'une version future du format frontend reste acceptee.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.config import get_settings
MAX_SUPPORTED_MAP_VERSION = 100
class MapNodeIn(BaseModel):
model_config = ConfigDict(extra="allow")
id: str = Field(min_length=1, max_length=128)
text: str = Field(default="", max_length=5000)
class MapEdgeIn(BaseModel):
model_config = ConfigDict(extra="allow")
id: str = Field(min_length=1, max_length=256)
source: str = Field(min_length=1, max_length=128)
target: str = Field(min_length=1, max_length=128)
class MapDocumentIn(BaseModel):
"""Document carte complet, tel que produit par le frontend."""
model_config = ConfigDict(extra="allow")
version: int = Field(ge=1, le=MAX_SUPPORTED_MAP_VERSION)
id: str = Field(min_length=1, max_length=128)
name: str = Field(default="", max_length=255)
nodes: list[MapNodeIn] = Field(default_factory=list)
edges: list[MapEdgeIn] = Field(default_factory=list)
viewport: dict[str, Any] = Field(default_factory=dict)
settings: dict[str, Any] = Field(default_factory=dict)
@field_validator("nodes")
@classmethod
def _check_node_count(cls, value: list[MapNodeIn]) -> list[MapNodeIn]:
max_nodes = get_settings().max_nodes
if len(value) > max_nodes:
raise ValueError(
f"La carte depasse la limite de {max_nodes} noeuds ({len(value)} noeuds fournis)."
)
return value
@model_validator(mode="after")
def _check_edge_count(self) -> MapDocumentIn:
max_edges = get_settings().max_nodes * 4
if len(self.edges) > max_edges:
raise ValueError(f"La carte depasse la limite de {max_edges} liens.")
return self

View File

@@ -0,0 +1,51 @@
"""Schemas d'entree/sortie de l'API de partage."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.map import MapDocumentIn
class ShareCreateIn(BaseModel):
"""Corps de POST /api/shares et de PUT /api/shares/{token}."""
model_config = ConfigDict(extra="forbid")
title: str = Field(default="", max_length=255)
map: MapDocumentIn
class ShareCreatedOut(BaseModel):
"""Reponse renvoyee une seule fois, a la creation du partage.
`management_token` n'est jamais renvoye ailleurs et n'apparait jamais
dans une URL publique : il est conserve par le proprietaire.
"""
share_token: str
management_token: str
share_path: str
title: str
created_at: datetime
updated_at: datetime
class ShareUpdatedOut(BaseModel):
share_token: str
share_path: str
title: str
updated_at: datetime
class SharePublicOut(BaseModel):
"""Vue publique : aucune information de gestion."""
share_token: str
title: str
map_version: int
map: dict
created_at: datetime
updated_at: datetime

View File

View File

@@ -0,0 +1,34 @@
"""Erreurs metier du domaine partage.
Elles sont converties en reponses HTTP propres par les handlers de `app.main` :
aucune trace technique n'est exposee au client.
"""
from __future__ import annotations
class ShareError(Exception):
"""Erreur metier generique."""
status_code = 400
message = "Requete invalide."
def __init__(self, message: str | None = None) -> None:
super().__init__(message or self.message)
if message:
self.message = message
class ShareNotFoundError(ShareError):
status_code = 404
message = "Ce partage n'existe pas ou a ete supprime."
class ShareForbiddenError(ShareError):
status_code = 403
message = "Jeton de gestion invalide."
class SharePayloadTooLargeError(ShareError):
status_code = 413
message = "La carte depasse la taille maximale autorisee."

View File

@@ -0,0 +1,131 @@
"""Logique metier du partage de cartes."""
from __future__ import annotations
import json
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.share import SharedMap, utcnow
from app.schemas.map import MapDocumentIn
from app.services.errors import (
ShareForbiddenError,
ShareNotFoundError,
SharePayloadTooLargeError,
)
from app.services.tokens import (
generate_management_token,
generate_share_token,
hash_token,
verify_token,
)
logger = logging.getLogger(__name__)
def serialize_map(document: MapDocumentIn) -> str:
"""Serialise le document carte en JSON compact et deterministe."""
return json.dumps(
document.model_dump(mode="json"),
ensure_ascii=False,
separators=(",", ":"),
)
def _guard_payload_size(payload: str) -> None:
settings = get_settings()
size = len(payload.encode("utf-8"))
if size > settings.max_map_size_bytes:
raise SharePayloadTooLargeError(
f"La carte fait {size / (1024 * 1024):.1f} Mo, "
f"la limite est de {settings.max_map_size_mb} Mo."
)
def _get_by_token(session: Session, share_token: str) -> SharedMap:
stmt = select(SharedMap).where(SharedMap.share_token == share_token)
share = session.execute(stmt).scalar_one_or_none()
if share is None:
raise ShareNotFoundError()
return share
def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tuple[SharedMap, str]:
"""Cree un partage et retourne (entite, management_token en clair).
Le management token en clair n'est renvoye qu'ici, une seule fois.
"""
payload = serialize_map(document)
_guard_payload_size(payload)
management_token = generate_management_token()
share = SharedMap(
share_token=generate_share_token(),
management_token_hash=hash_token(management_token),
title=(title or document.name or "Carte sans titre")[: get_settings().max_title_length],
map_version=document.version,
map_data=payload,
)
session.add(share)
session.commit()
session.refresh(share)
logger.info("Partage cree share_token=%s nodes=%s", share.share_token, len(document.nodes))
return share, management_token
def get_share(session: Session, share_token: str) -> SharedMap:
return _get_by_token(session, share_token)
def load_map_data(share: SharedMap) -> dict:
"""Deserialise le snapshot stocke. Ne fait qu'un json.loads : aucun code execute."""
try:
data = json.loads(share.map_data)
except json.JSONDecodeError: # pragma: no cover - donnee corrompue
logger.exception("Snapshot illisible pour share_token=%s", share.share_token)
raise ShareNotFoundError("Le contenu de ce partage est illisible.") from None
if not isinstance(data, dict): # pragma: no cover - donnee corrompue
raise ShareNotFoundError("Le contenu de ce partage est illisible.")
return data
def update_share(
session: Session,
share_token: str,
*,
management_token: str,
title: str,
document: MapDocumentIn,
) -> SharedMap:
share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash):
logger.warning("Tentative de mise a jour refusee share_token=%s", share_token)
raise ShareForbiddenError()
payload = serialize_map(document)
_guard_payload_size(payload)
share.title = (title or document.name or share.title)[: get_settings().max_title_length]
share.map_version = document.version
share.map_data = payload
share.updated_at = utcnow()
session.commit()
session.refresh(share)
logger.info("Partage mis a jour share_token=%s", share_token)
return share
def delete_share(session: Session, share_token: str, *, management_token: str) -> None:
share = _get_by_token(session, share_token)
if not verify_token(management_token, share.management_token_hash):
logger.warning("Tentative de suppression refusee share_token=%s", share_token)
raise ShareForbiddenError()
session.delete(share)
session.commit()
logger.info("Partage supprime share_token=%s", share_token)

View File

@@ -0,0 +1,45 @@
"""Generation et verification des jetons de partage.
Deux secrets distincts sont produits a la creation d'un partage :
* ``share_token`` : identifiant public, present dans /s/<token>, lecture seule.
* ``management_token`` : secret de gestion (PUT/DELETE), jamais dans une URL,
stocke uniquement dans l'IndexedDB du proprietaire.
Seul le hash SHA-256 du management token est conserve en base.
"""
from __future__ import annotations
import hashlib
import secrets
# 12 octets -> 16 caracteres url-safe, ~96 bits d'entropie : non enumerable.
SHARE_TOKEN_BYTES = 12
# 32 octets -> ~256 bits d'entropie pour le secret de gestion.
MANAGEMENT_TOKEN_BYTES = 32
def generate_share_token() -> str:
return secrets.token_urlsafe(SHARE_TOKEN_BYTES)
def generate_management_token() -> str:
return secrets.token_urlsafe(MANAGEMENT_TOKEN_BYTES)
def hash_token(token: str) -> str:
"""Hash du secret de gestion.
Le token est deja un secret aleatoire de haute entropie : un SHA-256 simple
suffit (pas de risque de force brute par dictionnaire, contrairement a un
mot de passe choisi par un humain).
"""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_token(token: str, expected_hash: str) -> bool:
"""Comparaison a temps constant."""
if not token or not expected_hash:
return False
return secrets.compare_digest(hash_token(token), expected_hash)

76
backend/app/web.py Normal file
View File

@@ -0,0 +1,76 @@
"""Service des fichiers statiques du frontend (SPA) par FastAPI.
Le build Vite est copie dans `settings.static_dir`. Toute route qui n'est ni
/api/... ni un fichier existant retourne index.html, afin que le routeur
cote client puisse gerer /map/:id et /s/:token.
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
_PLACEHOLDER = """<!doctype html>
<html lang="fr"><head><meta charset="utf-8"><title>Frontend non compile</title></head>
<body style="font-family:system-ui;padding:2rem;max-width:40rem;margin:auto">
<h1>Frontend non compile</h1>
<p>L'API est disponible sur <code>/api/health</code>.</p>
<p>En developpement, lancez <code>npm run dev</code> dans <code>frontend/</code>.</p>
</body></html>"""
# Fichiers servis a la racine et qui ne doivent pas etre mis en cache longtemps.
_NO_CACHE_FILES = {"index.html", "sw.js", "manifest.webmanifest"}
def _safe_resolve(static_dir: Path, relative_path: str) -> Path | None:
"""Empeche toute sortie du dossier statique (path traversal)."""
candidate = (static_dir / relative_path.lstrip("/")).resolve()
try:
candidate.relative_to(static_dir.resolve())
except ValueError:
return None
return candidate
def mount_frontend(app: FastAPI, static_dir_path: str) -> None:
static_dir = Path(static_dir_path)
router = APIRouter(include_in_schema=False)
if (static_dir / "assets").is_dir():
# Les assets Vite sont hashes : cache long sans risque.
app.mount(
"/assets",
StaticFiles(directory=static_dir / "assets"),
name="assets",
)
@router.api_route("/{full_path:path}", methods=["GET", "HEAD"])
def serve_spa(full_path: str, request: Request): # type: ignore[no-untyped-def]
if full_path.startswith("api/"):
return JSONResponse(status_code=404, content={"detail": "Ressource introuvable."})
if not static_dir.is_dir():
return HTMLResponse(_PLACEHOLDER, status_code=200)
if full_path:
candidate = _safe_resolve(static_dir, full_path)
if candidate is not None and candidate.is_file():
headers = (
{"Cache-Control": "no-cache"}
if candidate.name in _NO_CACHE_FILES
else {"Cache-Control": "public, max-age=3600"}
)
return FileResponse(candidate, headers=headers)
index = static_dir / "index.html"
if index.is_file():
return FileResponse(index, headers={"Cache-Control": "no-cache"})
return HTMLResponse(_PLACEHOLDER, status_code=200)
app.include_router(router)

22
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "openmind-backend"
version = "1.0.0"
description = "Backend FastAPI d'OpenMind : partage de cartes en lecture seule."
requires-python = ">=3.11"
[tool.ruff]
line-length = 100
target-version = "py311"
exclude = [".venv", "__pycache__"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "C4", "SIM", "RUF"]
ignore = ["B008"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
filterwarnings = ["ignore::DeprecationWarning"]

View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.3,<9
httpx>=0.27,<0.29
ruff>=0.8,<0.14

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi>=0.115,<0.117
uvicorn[standard]>=0.32,<0.36
SQLAlchemy>=2.0.36,<2.1
pydantic>=2.9,<3
pydantic-settings>=2.6,<3

View File

84
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,84 @@
"""Fixtures de test : base SQLite temporaire, isolee du volume de production."""
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterator
import pytest
# Doit etre defini AVANT le premier import de l'application.
_TMP_DIR = tempfile.mkdtemp(prefix="openmind-tests-")
os.environ["DATABASE_URL"] = f"sqlite:///{_TMP_DIR}/test.db"
os.environ["STATIC_DIR"] = f"{_TMP_DIR}/static"
os.environ["APP_NAME"] = "OpenMind"
os.environ["MAX_NODES"] = "5000"
os.environ["MAX_MAP_SIZE_MB"] = "10"
from fastapi.testclient import TestClient # noqa: E402
from app.config import get_settings # noqa: E402
from app.db.base import Base # noqa: E402
from app.db.session import engine # noqa: E402
from app.main import app # noqa: E402
@pytest.fixture(autouse=True)
def _fresh_database() -> Iterator[None]:
get_settings.cache_clear()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
yield
get_settings.cache_clear()
@pytest.fixture()
def client() -> Iterator[TestClient]:
with TestClient(app) as test_client:
yield test_client
def build_map(node_count: int = 3, name: str = "Carte de test") -> dict:
"""Construit un document carte minimal mais valide."""
nodes = [
{
"id": "root",
"type": "root",
"text": name,
"position": {"x": 0, "y": 0},
"parentId": None,
}
]
edges = []
for index in range(1, node_count):
node_id = f"n{index}"
nodes.append(
{
"id": node_id,
"type": "standard",
"text": f"Noeud {index}",
"position": {"x": 200, "y": index * 80},
"parentId": "root",
}
)
edges.append({"id": f"e-root-{node_id}", "source": "root", "target": node_id})
return {
"version": 1,
"id": "11111111-1111-4111-8111-111111111111",
"name": name,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"nodes": nodes,
"edges": edges,
"viewport": {"x": 0, "y": 0, "zoom": 1},
"settings": {"theme": "default", "layout": "mindmap-right", "branchStyle": "bezier"},
}
@pytest.fixture()
def created_share(client: TestClient) -> dict:
response = client.post("/api/shares", json={"title": "Carte de test", "map": build_map()})
assert response.status_code == 201, response.text
return response.json()

View File

@@ -0,0 +1,28 @@
from fastapi.testclient import TestClient
def test_health_returns_ok(client: TestClient) -> None:
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_public_config_exposes_no_secret(client: TestClient) -> None:
response = client.get("/api/config")
assert response.status_code == 200
body = response.json()
assert body["appName"]
assert body["maxNodes"] > 0
assert "management" not in response.text.lower()
def test_security_headers_present(client: TestClient) -> None:
response = client.get("/api/health")
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert "default-src" in response.headers["Content-Security-Policy"]
def test_unknown_api_route_returns_clean_404(client: TestClient) -> None:
response = client.get("/api/does-not-exist")
assert response.status_code == 404
assert response.json()["detail"]

View File

@@ -0,0 +1,109 @@
from fastapi.testclient import TestClient
from tests.conftest import build_map
def test_create_share_returns_two_distinct_tokens(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "Homelab", "map": build_map()})
assert response.status_code == 201, response.text
body = response.json()
assert body["share_token"]
assert body["management_token"]
assert body["share_token"] != body["management_token"]
# Entropie suffisante : token_urlsafe(12) et token_urlsafe(32).
assert len(body["share_token"]) >= 16
assert len(body["management_token"]) >= 40
assert body["share_path"] == "/s/" + body["share_token"]
def test_read_share_returns_map_without_management_token(
client: TestClient, created_share: dict
) -> None:
response = client.get("/api/shares/" + created_share["share_token"])
assert response.status_code == 200
body = response.json()
assert body["title"] == "Carte de test"
assert body["map_version"] == 1
assert len(body["map"]["nodes"]) == 3
assert "management_token" not in body
assert created_share["management_token"] not in response.text
def test_read_unknown_share_returns_404(client: TestClient) -> None:
response = client.get("/api/shares/inexistant-token")
assert response.status_code == 404
assert "existe pas" in response.json()["detail"]
def test_update_share_with_valid_management_token(client: TestClient, created_share: dict) -> None:
updated_map = build_map(node_count=6, name="Homelab v2")
response = client.put(
"/api/shares/" + created_share["share_token"],
json={"title": "Homelab v2", "map": updated_map},
headers={"X-Management-Token": created_share["management_token"]},
)
assert response.status_code == 200
assert response.json()["title"] == "Homelab v2"
public = client.get("/api/shares/" + created_share["share_token"]).json()
assert len(public["map"]["nodes"]) == 6
assert public["title"] == "Homelab v2"
def test_update_share_with_wrong_management_token_is_refused(
client: TestClient, created_share: dict
) -> None:
response = client.put(
"/api/shares/" + created_share["share_token"],
json={"title": "Pirate", "map": build_map()},
headers={"X-Management-Token": "mauvais-jeton"},
)
assert response.status_code == 403
public = client.get("/api/shares/" + created_share["share_token"]).json()
assert public["title"] == "Carte de test"
def test_update_share_without_management_token_is_refused(
client: TestClient, created_share: dict
) -> None:
response = client.put(
"/api/shares/" + created_share["share_token"],
json={"title": "Pirate", "map": build_map()},
)
assert response.status_code == 403
def test_share_token_alone_cannot_delete(client: TestClient, created_share: dict) -> None:
response = client.delete(
"/api/shares/" + created_share["share_token"],
headers={"X-Management-Token": created_share["share_token"]},
)
assert response.status_code == 403
assert client.get("/api/shares/" + created_share["share_token"]).status_code == 200
def test_delete_share_then_read_returns_404(client: TestClient, created_share: dict) -> None:
response = client.delete(
"/api/shares/" + created_share["share_token"],
headers={"X-Management-Token": created_share["management_token"]},
)
assert response.status_code == 204
assert client.get("/api/shares/" + created_share["share_token"]).status_code == 404
def test_delete_unknown_share_returns_404(client: TestClient) -> None:
response = client.delete(
"/api/shares/inexistant", headers={"X-Management-Token": "peu-importe"}
)
assert response.status_code == 404
def test_share_tokens_are_unique(client: TestClient) -> None:
tokens = set()
for _ in range(15):
body = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
tokens.add(body["share_token"])
assert len(tokens) == 15

22
backend/tests/test_spa.py Normal file
View File

@@ -0,0 +1,22 @@
"""Le routage cote client doit rester fonctionnel derriere FastAPI."""
from fastapi.testclient import TestClient
def test_share_route_falls_back_to_spa(client: TestClient) -> None:
response = client.get("/s/un-token-quelconque")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
def test_root_route_is_served(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
def test_path_traversal_is_blocked(client: TestClient) -> None:
response = client.get("/../../etc/passwd")
# Soit 404, soit repli SPA : dans tous les cas jamais le contenu du fichier.
assert response.status_code in (200, 404)
assert "root:" not in response.text

View File

@@ -0,0 +1,78 @@
"""Tests des garde-fous : payload invalide, limites de taille et de noeuds."""
from __future__ import annotations
import os
from fastapi.testclient import TestClient
from app.config import get_settings
from tests.conftest import build_map
def test_missing_map_returns_422(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "Sans carte"})
assert response.status_code == 422
assert "carte valide" in response.json()["detail"]
def test_map_without_version_returns_422(client: TestClient) -> None:
payload = build_map()
del payload["version"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
def test_node_without_id_returns_422(client: TestClient) -> None:
payload = build_map()
del payload["nodes"][0]["id"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
def test_unknown_top_level_field_is_rejected(client: TestClient) -> None:
response = client.post(
"/api/shares", json={"title": "x", "map": build_map(), "is_premium": True}
)
assert response.status_code == 422
def test_garbage_body_returns_422(client: TestClient) -> None:
response = client.post("/api/shares", json={"map": "ceci n est pas une carte"})
assert response.status_code == 422
def test_node_count_limit_is_enforced(client: TestClient) -> None:
os.environ["MAX_NODES"] = "10"
get_settings.cache_clear()
try:
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=25)})
assert response.status_code == 422
assert "10" in response.json()["detail"]
finally:
os.environ["MAX_NODES"] = "5000"
get_settings.cache_clear()
def test_map_size_limit_is_enforced(client: TestClient) -> None:
os.environ["MAX_MAP_SIZE_MB"] = "0.001"
get_settings.cache_clear()
try:
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=60)})
assert response.status_code == 413
assert "volumineuse" in response.json()["detail"] or "limite" in response.json()["detail"]
finally:
os.environ["MAX_MAP_SIZE_MB"] = "10"
get_settings.cache_clear()
def test_node_text_is_stored_as_plain_text(client: TestClient) -> None:
"""Le backend stocke le texte tel quel : il ne l interprete jamais comme du HTML."""
payload = build_map()
payload["nodes"][0]["text"] = "<script>alert(1)</script>"
created = client.post("/api/shares", json={"title": "x", "map": payload})
assert created.status_code == 201
public = client.get("/api/shares/" + created.json()["share_token"]).json()
assert public["map"]["nodes"][0]["text"] == "<script>alert(1)</script>"
assert public["map"]["nodes"][0]["type"] == "root"

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
openmind:
build:
context: .
dockerfile: Dockerfile
image: openmind:latest
container_name: openmind
restart: unless-stopped
env_file:
- .env
environment:
# Le conteneur ecoute toujours sur 8080 ; APP_PORT ne change que l'hote.
APP_NAME: ${APP_NAME:-OpenMind}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/openmind.db}
MAX_MAP_SIZE_MB: ${MAX_MAP_SIZE_MB:-10}
MAX_NODES: ${MAX_NODES:-5000}
CORS_ORIGINS: ${CORS_ORIGINS:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
STATIC_DIR: /app/static
ports:
- "${APP_PORT:-8080}:8080"
volumes:
# Bind mount explicite : les partages SQLite restent accessibles sur l'hote.
- ./data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3

34
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,34 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'coverage', 'node_modules', 'public'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node },
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-console': ['error', { allow: ['warn', 'error'] }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
},
},
);

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="fr" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="description" content="Cartes mentales libres : local-first, sans compte, sans abonnement." />
<meta name="theme-color" content="#4f46e5" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>OpenMind</title>
</head>
<body class="h-full">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5041
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "openmind-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@xyflow/react": "^12.3.6",
"dagre": "^0.8.5",
"dexie": "^4.0.10",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/dagre": "^0.7.52",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"fake-indexeddb": "^6.0.0",
"globals": "^15.12.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="OpenMind">
<rect width="512" height="512" rx="112" fill="#4f46e5"/>
<g stroke="#ffffff" stroke-width="18" stroke-linecap="round" fill="none" opacity="0.92">
<path d="M196 256 H286"/>
<path d="M196 256 C 240 256, 250 160, 300 160"/>
<path d="M196 256 C 240 256, 250 352, 300 352"/>
</g>
<circle cx="168" cy="256" r="46" fill="#ffffff"/>
<circle cx="336" cy="160" r="30" fill="#c7d2fe"/>
<circle cx="336" cy="256" r="30" fill="#a5b4fc"/>
<circle cx="336" cy="352" r="30" fill="#c7d2fe"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,39 @@
{
"name": "OpenMind",
"short_name": "OpenMind",
"description": "Cartes mentales libres : local-first, sans compte, sans abonnement.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f8fafc",
"theme_color": "#4f46e5",
"lang": "fr",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

71
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,71 @@
/*
* Service worker d'OpenMind.
*
* Objectif : permettre d'ouvrir et d'editer les cartes locales meme sans
* reseau, une fois l'application chargee au moins une fois. Les appels API
* (partage) ne sont jamais mis en cache : ils echouent proprement hors ligne.
*/
const CACHE_NAME = 'openmind-shell-v1';
const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/icons/icon.svg'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.catch(() => undefined)
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Les routes API restent toujours en reseau direct.
if (url.pathname.startsWith('/api/')) return;
// Navigation : reseau d'abord, repli sur la coquille applicative en cache.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy));
return response;
})
.catch(() => caches.match('/index.html').then((cached) => cached || Response.error())),
);
return;
}
// Assets : cache d'abord, rafraichi en arriere-plan.
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => cached || Response.error());
return cached || network;
}),
);
});

44
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { Button } from '@/components/Button';
import { Toasts } from '@/components/Toasts';
import { DashboardPage } from '@/features/maps/DashboardPage';
import { EditorPage } from '@/features/editor/EditorPage';
import { SharedMapPage } from '@/features/sharing/SharedMapPage';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate, useRoute } from '@/hooks/useRoute';
function NotFoundPage() {
const appName = useAppConfigStore((state) => state.name);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">Page introuvable</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Cette adresse ne correspond a aucune page de {appName}.
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Retour au tableau de bord
</Button>
</div>
);
}
export function App() {
const route = useRoute();
const loadConfig = useAppConfigStore((state) => state.load);
useEffect(() => {
void loadConfig();
}, [loadConfig]);
return (
<>
<main className="h-full">
{route.name === 'dashboard' ? <DashboardPage /> : null}
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
{route.name === 'notFound' ? <NotFoundPage /> : null}
</main>
<Toasts />
</>
);
}

View File

@@ -0,0 +1,46 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const VARIANTS: Record<Variant, string> = {
primary:
'bg-indigo-600 text-white hover:bg-indigo-500 disabled:hover:bg-indigo-600 shadow-sm border border-transparent',
secondary:
'bg-white text-zinc-700 border border-zinc-200 hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-200 dark:border-zinc-700 dark:hover:bg-zinc-800',
ghost:
'bg-transparent text-zinc-600 border border-transparent hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
danger:
'bg-red-600 text-white hover:bg-red-500 border border-transparent disabled:hover:bg-red-600',
};
const SIZES: Record<Size, string> = {
sm: 'h-8 px-2.5 text-xs gap-1.5',
md: 'h-9 px-3.5 text-sm gap-2',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'secondary', size = 'md', className, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
type={type}
className={cn(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
'disabled:cursor-not-allowed disabled:opacity-50',
VARIANTS[variant],
SIZES[size],
className,
)}
{...props}
/>
);
});

View File

@@ -0,0 +1,44 @@
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
export interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'default' | 'danger';
onConfirm: () => void;
onCancel: () => void;
}
/** Confirmation explicite : utilisee pour toute action destructrice. */
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirmer',
cancelLabel = 'Annuler',
tone = 'default',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
size="sm"
footer={
<>
<Button onClick={onCancel}>{cancelLabel}</Button>
<Button variant={tone === 'danger' ? 'danger' : 'primary'} onClick={onConfirm} autoFocus>
{confirmLabel}
</Button>
</>
}
>
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">{message}</p>
</Modal>
);
}

View File

@@ -0,0 +1,41 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/utils/cn';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Libelle accessible, egalement utilise comme infobulle. */
label: string;
icon: ReactNode;
active?: boolean;
tone?: 'default' | 'danger';
}
/**
* Bouton represente uniquement par une icone : il porte toujours un aria-label
* et une infobulle native (title).
*/
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ label, icon, active = false, tone = 'default', className, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
type={type}
aria-label={label}
title={label}
aria-pressed={active || undefined}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-transparent transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
tone === 'danger'
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
active && 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-300',
className,
)}
{...props}
>
{icon}
</button>
);
});

View File

@@ -0,0 +1,248 @@
/**
* Jeu d'icones minimal, dessine a la main (aucune dependance externe).
* Chaque icone est purement decorative : le libelle accessible est porte par
* le bouton qui la contient.
*/
interface IconProps {
className?: string;
}
const DEFAULT_CLASS = 'h-4 w-4';
function Svg({ className, children }: IconProps & { children: React.ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className ?? DEFAULT_CLASS}
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export const PlusIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 5v14M5 12h14" />
</Svg>
);
export const ImportIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</Svg>
);
export const DownloadIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3v12m0 0 4-4m-4 4-4-4M4 19h16" />
</Svg>
);
export const SearchIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</Svg>
);
export const TrashIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" />
</Svg>
);
export const CopyIcon = (p: IconProps) => (
<Svg {...p}>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h8" />
</Svg>
);
export const PencilIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 20h4L20 8l-4-4L4 16v4Z" />
</Svg>
);
export const UndoIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M9 7 4 12l5 5" />
<path d="M4 12h10a6 6 0 0 1 0 12h-1" />
</Svg>
);
export const RedoIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m15 7 5 5-5 5" />
<path d="M20 12H10a6 6 0 0 0 0 12h1" />
</Svg>
);
export const ZoomInIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="M11 8.5v5M8.5 11h5M16 16l4.5 4.5" />
</Svg>
);
export const ZoomOutIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="M8.5 11h5M16 16l4.5 4.5" />
</Svg>
);
export const FitIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</Svg>
);
export const LayoutIcon = (p: IconProps) => (
<Svg {...p}>
<rect x="3" y="10" width="5" height="4" rx="1" />
<rect x="16" y="4" width="5" height="4" rx="1" />
<rect x="16" y="16" width="5" height="4" rx="1" />
<path d="M8 12h4v-6h4M12 12v6h4" />
</Svg>
);
export const ShareIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="6" r="2.5" />
<circle cx="18" cy="18" r="2.5" />
<path d="m8.3 10.8 7.4-3.6M8.3 13.2l7.4 3.6" />
</Svg>
);
export const PrintIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M7 9V4h10v5" />
<rect x="4" y="9" width="16" height="7" rx="2" />
<path d="M7 14h10v6H7z" />
</Svg>
);
export const SunIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</Svg>
);
export const MoonIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" />
</Svg>
);
export const CloseIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M6 6l12 12M18 6L6 18" />
</Svg>
);
export const ChevronLeftIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m14 6-6 6 6 6" />
</Svg>
);
export const ChevronRightIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m10 6 6 6-6 6" />
</Svg>
);
export const ChevronDownIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m6 10 6 6 6-6" />
</Svg>
);
export const ArrowLeftIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
</Svg>
);
export const CheckIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m5 13 4 4L19 7" />
</Svg>
);
export const GridIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 9h16M4 15h16M9 4v16M15 4v16" />
</Svg>
);
export const PaletteIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6Z" />
<circle cx="8" cy="10" r="1" />
<circle cx="12" cy="7.5" r="1" />
<circle cx="16" cy="10" r="1" />
</Svg>
);
export const LinkIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M10 13a4 4 0 0 0 5.7 0l2.6-2.6a4 4 0 0 0-5.7-5.7L11.5 6" />
<path d="M14 11a4 4 0 0 0-5.7 0l-2.6 2.6a4 4 0 0 0 5.7 5.7L12.5 18" />
</Svg>
);
export const NoteIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M6 3h9l4 4v14H6z" />
<path d="M14 3v5h5M9 12h6M9 16h4" />
</Svg>
);
export const SlidersIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M5 6h14M5 12h14M5 18h14" />
<circle cx="9" cy="6" r="2" />
<circle cx="15" cy="12" r="2" />
<circle cx="8" cy="18" r="2" />
</Svg>
);
export const SaveIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M5 4h11l3 3v13H5z" />
<path d="M8 4v5h7V4M8 20v-6h8v6" />
</Svg>
);
export const MapIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="7" r="2.5" />
<circle cx="18" cy="17" r="2.5" />
<path d="M8.5 11 15.5 7.8M8.5 13l7 3.2" />
</Svg>
);
export const SpinnerIcon = ({ className }: IconProps) => (
<svg
viewBox="0 0 24 24"
className={`${className ?? DEFAULT_CLASS} animate-spin`}
aria-hidden="true"
focusable="false"
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" fill="none" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" />
</svg>
);

View File

@@ -0,0 +1,133 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/utils/cn';
export interface MenuProps {
label: string;
trigger: ReactNode;
children: (close: () => void) => ReactNode;
align?: 'left' | 'right';
className?: string;
disabled?: boolean;
}
/** Menu deroulant accessible : Echap ferme, clic exterieur ferme. */
export function Menu({
label,
trigger,
children,
align = 'left',
className,
disabled = false,
}: MenuProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return undefined;
const onPointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
aria-disabled={disabled}
onClick={() => !disabled && setOpen((value) => !value)}
onKeyDown={(event) => {
if (disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setOpen((value) => !value);
}
}}
>
{trigger}
</div>
{open ? (
<div
role="menu"
aria-label={label}
className={cn(
'panel absolute top-full z-40 mt-1.5 min-w-[13rem] animate-fade-in p-1',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{children(() => setOpen(false))}
</div>
) : null}
</div>
);
}
export interface MenuItemProps {
icon?: ReactNode;
label: string;
hint?: string;
onSelect: () => void;
disabled?: boolean;
tone?: 'default' | 'danger';
active?: boolean;
}
export function MenuItem({
icon,
label,
hint,
onSelect,
disabled = false,
tone = 'default',
active = false,
}: MenuItemProps) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onSelect}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
tone === 'danger'
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
active && 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300',
)}
>
{icon ? <span className="shrink-0 opacity-80">{icon}</span> : null}
<span className="flex-1">{label}</span>
{hint ? (
<span className="shrink-0 text-[11px] uppercase tracking-wide text-zinc-400">{hint}</span>
) : null}
</button>
);
}
export function MenuSeparator() {
return <div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800" role="separator" />;
}
export function MenuLabel({ children }: { children: ReactNode }) {
return (
<div className="px-2.5 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">
{children}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { CloseIcon } from '@/components/Icons';
import { IconButton } from '@/components/IconButton';
import { cn } from '@/utils/cn';
export interface ModalProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg';
}
const SIZES = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
};
/** Fenetre modale accessible : fermeture par Echap, focus initial, clic exterieur. */
export function Modal({
open,
title,
description,
onClose,
children,
footer,
size = 'md',
}: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', onKeyDown, true);
panelRef.current?.focus();
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4 backdrop-blur-[2px]"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className={cn(
'panel w-full animate-fade-in overflow-hidden outline-none',
SIZES[size],
)}
>
<header className="flex items-start justify-between gap-4 border-b border-zinc-100 px-5 py-4 dark:border-zinc-800">
<div>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{description}</p>
) : null}
</div>
<IconButton label="Fermer" icon={<CloseIcon />} onClick={onClose} />
</header>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<footer className="flex justify-end gap-2 border-t border-zinc-100 bg-zinc-50/60 px-5 py-3 dark:border-zinc-800 dark:bg-zinc-900/60">
{footer}
</footer>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useUiStore } from '@/stores/uiStore';
import { CloseIcon } from '@/components/Icons';
import { cn } from '@/utils/cn';
const TONE_CLASSES = {
info: 'border-zinc-200 bg-white text-zinc-800 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100',
success:
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100',
error:
'border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100',
};
/** Zone de notifications : messages courts, jamais d'erreur technique brute. */
export function Toasts() {
const toasts = useUiStore((state) => state.toasts);
const dismissToast = useUiStore((state) => state.dismissToast);
if (toasts.length === 0) return null;
return (
<div
className="no-print pointer-events-none fixed bottom-4 left-1/2 z-[60] flex w-full max-w-md -translate-x-1/2 flex-col gap-2 px-4"
role="status"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
'pointer-events-auto flex animate-fade-in items-start gap-3 rounded-xl border px-4 py-3 text-sm shadow-panel',
TONE_CLASSES[toast.tone],
)}
>
<span className="flex-1 leading-snug">{toast.message}</span>
<button
type="button"
onClick={() => dismissToast(toast.id)}
aria-label="Fermer la notification"
className="mt-0.5 opacity-60 transition-opacity hover:opacity-100"
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Configuration centrale de l'application.
*
* Le nom du produit n'est defini QU'ICI (et surchargeable a l'execution via
* APP_NAME cote backend, expose par /api/config). Aucun autre fichier ne doit
* ecrire le nom en dur.
*/
export const APP_CONFIG = {
/** Nom par defaut, utilise tant que /api/config n'a pas repondu. */
name: 'OpenMind',
tagline: 'Vos cartes mentales, dans votre navigateur. Sans compte, sans limite.',
/** Extension du format natif d'echange. */
fileExtension: '.mmap',
fileMimeType: 'application/json',
/** Limites par defaut, alignees sur celles du backend. */
maxNodes: 5000,
maxMapSizeMb: 10,
/** Delai d'inactivite avant sauvegarde automatique dans IndexedDB. */
autosaveDelayMs: 700,
/** Nombre maximum d'etats conserves pour undo/redo. */
historyLimit: 100,
/** Prefixe des liens publics de partage. */
sharePathPrefix: '/s/',
repositoryUrl: 'https://github.com/',
} as const;
export type AppConfig = typeof APP_CONFIG;

View File

@@ -0,0 +1,37 @@
import Dexie, { type Table } from 'dexie';
import { type MindMap, type ShareRecord } from '@/types/map';
/** Cle/valeur pour les drapeaux locaux (premier lancement, preferences...). */
export interface MetaEntry {
key: string;
value: unknown;
}
/**
* Base locale du navigateur.
*
* Trois tables seulement :
* - `maps` : les cartes privees, qui ne quittent jamais le navigateur ;
* - `shares` : les jetons de partage (dont le management token secret) ;
* - `meta` : quelques drapeaux applicatifs.
*/
export class OpenMindDatabase extends Dexie {
maps!: Table<MindMap, string>;
shares!: Table<ShareRecord, string>;
meta!: Table<MetaEntry, string>;
constructor(name = 'openmind') {
super(name);
this.version(1).stores({
maps: 'id, updatedAt, name',
shares: 'mapId, shareToken',
meta: 'key',
});
}
}
export const db = new OpenMindDatabase();
export const META_KEYS = {
sampleSeeded: 'sample-map-seeded',
} as const;

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import { Button } from '@/components/Button';
import { SpinnerIcon } from '@/components/Icons';
import { EditorToolbar } from '@/features/editor/EditorToolbar';
import { MindMapCanvas } from '@/features/editor/MindMapCanvas';
import { PropertiesPanel } from '@/features/editor/PropertiesPanel';
import { SearchPanel } from '@/features/editor/SearchPanel';
import { useAutosave } from '@/features/editor/useAutosave';
import { useEditorShortcuts } from '@/features/editor/useEditorShortcuts';
import { ShareDialog } from '@/features/sharing/ShareDialog';
import { getMap } from '@/services/mapRepository';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
export interface EditorPageProps {
mapId: string;
}
function EditorPageContent({ mapId }: EditorPageProps) {
const loadMap = useEditorStore((state) => state.loadMap);
const closeMap = useEditorStore((state) => state.closeMap);
const requestFit = useEditorStore((state) => state.requestFit);
const mapName = useEditorStore((state) => state.map?.name);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const searchPanelOpen = useUiStore((state) => state.searchPanelOpen);
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const [status, setStatus] = useState<'loading' | 'ready' | 'missing'>('loading');
const [shareOpen, setShareOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const { saveNow } = useAutosave(status === 'ready');
useEffect(() => {
let cancelled = false;
setStatus('loading');
void getMap(mapId).then((map) => {
if (cancelled) return;
if (!map) {
setStatus('missing');
return;
}
loadMap(map);
setStatus('ready');
});
return () => {
cancelled = true;
closeMap();
};
}, [mapId, loadMap, closeMap]);
const openSearch = useCallback(() => {
setSearchPanelOpen(true);
window.setTimeout(() => searchInputRef.current?.focus(), 30);
}, [setSearchPanelOpen]);
useEditorShortcuts(status === 'ready', {
onSave: () => {
void saveNow().then(() => pushToast('Carte sauvegardee localement.', 'success'));
},
onFit: requestFit,
onFocusSearch: openSearch,
});
if (status === 'loading') {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
<SpinnerIcon className="h-5 w-5" />
<span className="ml-2 text-sm">Ouverture de la carte...</span>
</div>
);
}
if (status === 'missing') {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Impossible de charger cette carte.
</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Elle a peut-etre ete supprimee, ou elle a ete creee dans un autre navigateur : les cartes
restent stockees localement.
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Retour au tableau de bord
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<EditorToolbar
onOpenShare={() => setShareOpen(true)}
onToggleSearch={() => (searchPanelOpen ? setSearchPanelOpen(false) : openSearch())}
/>
<div className="relative flex min-h-0 flex-1">
<div className="relative min-w-0 flex-1">
<h1 className="print-title px-2">{mapName}</h1>
<MindMapCanvas />
{searchPanelOpen ? <SearchPanel ref={searchInputRef} /> : null}
</div>
{propertiesPanelOpen ? <PropertiesPanel /> : null}
</div>
<ShareDialog open={shareOpen} onClose={() => setShareOpen(false)} />
</div>
);
}
export function EditorPage(props: EditorPageProps) {
return (
<ReactFlowProvider>
<EditorPageContent {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,249 @@
import { useEffect, useState } from 'react';
import { useReactFlow } from '@xyflow/react';
import { type BranchStyle } from '@/types/map';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { Menu, MenuItem, MenuLabel, MenuSeparator } from '@/components/Menu';
import {
ArrowLeftIcon,
ChevronDownIcon,
FitIcon,
GridIcon,
LayoutIcon,
MoonIcon,
PaletteIcon,
PlusIcon,
RedoIcon,
SearchIcon,
ShareIcon,
SlidersIcon,
SunIcon,
TrashIcon,
UndoIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@/components/Icons';
import { ExportMenu } from '@/features/export/ExportMenu';
import { LAYOUT_OPTIONS } from '@/features/editor/layout';
import { THEMES } from '@/features/editor/themes';
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
const BRANCH_STYLES: Array<{ id: BranchStyle; label: string }> = [
{ id: 'bezier', label: 'Bezier' },
{ id: 'curved', label: 'Courbe' },
{ id: 'straight', label: 'Droite' },
];
const SAVE_LABELS = {
idle: '',
dirty: 'Modifications non enregistrees',
saving: 'Sauvegarde...',
saved: 'Sauvegarde',
error: 'Sauvegarde impossible',
} as const;
export interface EditorToolbarProps {
onOpenShare: () => void;
onToggleSearch: () => void;
}
export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProps) {
const map = useEditorStore((state) => state.map);
const saveStatus = useEditorStore((state) => state.saveStatus);
const canUndo = useEditorStore(selectCanUndo);
const canRedo = useEditorStore(selectCanRedo);
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
const undo = useEditorStore((state) => state.undo);
const redo = useEditorStore((state) => state.redo);
const addChild = useEditorStore((state) => state.addChild);
const deleteNodes = useEditorStore((state) => state.deleteNodes);
const applyAutoLayout = useEditorStore((state) => state.applyAutoLayout);
const updateSettings = useEditorStore((state) => state.updateSettings);
const renameMap = useEditorStore((state) => state.renameMap);
const requestFit = useEditorStore((state) => state.requestFit);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const { zoomIn, zoomOut } = useReactFlow();
const [name, setName] = useState(map?.name ?? '');
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
if (!map) return null;
const commitName = () => {
const trimmed = name.trim();
if (!trimmed) {
setName(map.name);
return;
}
if (trimmed !== map.name) renameMap(trimmed);
};
return (
<header
className="no-print flex h-14 shrink-0 items-center gap-2 border-b border-zinc-200 bg-white px-3 dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Barre d'outils de l'editeur"
>
<IconButton
label="Revenir au tableau de bord"
icon={<ArrowLeftIcon />}
onClick={() => navigate('/')}
/>
<input
value={name}
onChange={(event) => setName(event.target.value)}
onBlur={commitName}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
aria-label="Nom de la carte"
className="min-w-0 max-w-[16rem] flex-1 truncate rounded-md border border-transparent bg-transparent px-2 py-1 text-sm font-semibold text-zinc-900 hover:border-zinc-200 focus:border-indigo-400 dark:text-zinc-50 dark:hover:border-zinc-700"
/>
<span
className={`hidden shrink-0 text-xs sm:inline ${
saveStatus === 'error' ? 'text-red-500' : 'text-zinc-400'
}`}
role="status"
aria-live="polite"
>
{SAVE_LABELS[saveStatus]}
</span>
<div className="mx-1 hidden h-6 w-px bg-zinc-200 dark:bg-zinc-700 sm:block" />
<IconButton label="Annuler (Ctrl+Z)" icon={<UndoIcon />} disabled={!canUndo} onClick={undo} />
<IconButton label="Retablir (Ctrl+Y)" icon={<RedoIcon />} disabled={!canRedo} onClick={redo} />
<IconButton
label="Ajouter un noeud enfant (Tab)"
icon={<PlusIcon />}
onClick={() => addChild()}
/>
<IconButton
label="Supprimer la selection (Suppr)"
icon={<TrashIcon />}
tone="danger"
disabled={selectedIds.length === 0}
onClick={() => deleteNodes(selectedIds)}
/>
<div className="mx-1 hidden h-6 w-px bg-zinc-200 dark:bg-zinc-700 sm:block" />
<Menu
label="Organisation automatique"
trigger={
<Button size="sm">
<LayoutIcon className="h-4 w-4" />
<span className="hidden md:inline">Organisation automatique</span>
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{(close) => (
<>
<MenuLabel>Disposition</MenuLabel>
{LAYOUT_OPTIONS.map((option) => (
<MenuItem
key={option.id}
label={option.label}
active={map.settings.layout === option.id}
onSelect={() => {
applyAutoLayout(option.id);
close();
}}
/>
))}
<MenuSeparator />
<MenuItem
label="Reorganiser avec la disposition actuelle"
onSelect={() => {
applyAutoLayout();
close();
}}
/>
</>
)}
</Menu>
<Menu
label="Theme et style"
trigger={
<Button size="sm">
<PaletteIcon className="h-4 w-4" />
<span className="hidden lg:inline">Theme</span>
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{() => (
<>
<MenuLabel>Theme de la carte</MenuLabel>
<div className="max-h-56 overflow-y-auto">
{THEMES.map((theme) => (
<MenuItem
key={theme.id}
label={theme.name}
active={map.settings.theme === theme.id}
onSelect={() => updateSettings({ theme: theme.id })}
/>
))}
</div>
<MenuSeparator />
<MenuLabel>Style des branches</MenuLabel>
{BRANCH_STYLES.map((branch) => (
<MenuItem
key={branch.id}
label={branch.label}
active={map.settings.branchStyle === branch.id}
onSelect={() => updateSettings({ branchStyle: branch.id })}
/>
))}
<MenuSeparator />
<MenuItem
icon={<GridIcon className="h-4 w-4" />}
label={map.settings.showGrid ? 'Masquer la grille' : 'Afficher la grille'}
onSelect={() => updateSettings({ showGrid: !map.settings.showGrid })}
/>
</>
)}
</Menu>
<div className="ml-auto flex items-center gap-1">
<IconButton label="Zoom avant" icon={<ZoomInIcon />} onClick={() => zoomIn()} />
<IconButton label="Zoom arriere" icon={<ZoomOutIcon />} onClick={() => zoomOut()} />
<IconButton label="Adapter a l'ecran (F)" icon={<FitIcon />} onClick={requestFit} />
<IconButton
label="Rechercher dans la carte (Ctrl+F)"
icon={<SearchIcon />}
onClick={onToggleSearch}
/>
<IconButton
label={propertiesPanelOpen ? 'Masquer les proprietes' : 'Afficher les proprietes'}
icon={<SlidersIcon />}
active={propertiesPanelOpen}
onClick={() => setPropertiesPanelOpen(!propertiesPanelOpen)}
/>
<IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
icon={colorMode === 'dark' ? <SunIcon /> : <MoonIcon />}
onClick={toggleColorMode}
/>
<div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" />
<Button size="sm" onClick={onOpenShare}>
<ShareIcon className="h-4 w-4" />
<span className="hidden md:inline">Partager</span>
</Button>
<ExportMenu />
</div>
</header>
);
}

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters';
import { MindNodeView } from '@/features/editor/MindNodeView';
import { getTheme } from '@/features/editor/themes';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { resolveNodeSize } from '@/utils/nodeSize';
const NODE_TYPES = { mind: MindNodeView };
export interface MindMapCanvasProps {
readOnly?: boolean;
showMiniMap?: boolean;
showControls?: boolean;
}
export function MindMapCanvas({
readOnly = false,
showMiniMap = true,
showControls = true,
}: MindMapCanvasProps) {
const map = useEditorStore((state) => state.map);
const selectedNodeIds = useEditorStore((state) => state.selectedNodeIds);
const searchResults = useEditorStore((state) => state.searchResults);
const editingNodeId = useEditorStore((state) => state.editingNodeId);
const focusRequest = useEditorStore((state) => state.focusRequest);
const fitRequest = useEditorStore((state) => state.fitRequest);
const sizeVersion = useEditorStore((state) => state.sizeVersion);
const pushToast = useUiStore((state) => state.pushToast);
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
const initialisedFor = useRef<string | null>(null);
const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]);
const adapterOptions = useMemo(
() => ({
theme,
selectedIds: selectedNodeIds,
searchResults,
editingNodeId,
readOnly,
measuredSizes: useEditorStore.getState().measuredSizes,
}),
// sizeVersion force le recalcul quand une taille de noeud a change.
// eslint-disable-next-line react-hooks/exhaustive-deps
[theme, selectedNodeIds, searchResults, editingNodeId, readOnly, sizeVersion],
);
const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]);
const edges = useMemo(() => (map ? toFlowEdges(map, adapterOptions) : []), [map, adapterOptions]);
// Cadrage initial a l'ouverture d'une carte.
useEffect(() => {
if (!map || initialisedFor.current === map.id) return undefined;
initialisedFor.current = map.id;
const timer = window.setTimeout(() => fitView({ padding: 0.25, duration: 0 }), 60);
return () => window.clearTimeout(timer);
}, [map, fitView]);
useEffect(() => {
if (fitRequest === 0) return undefined;
const timer = window.setTimeout(() => fitView({ padding: 0.25, duration: 300 }), 30);
return () => window.clearTimeout(timer);
}, [fitRequest, fitView]);
// Centrage sur un resultat de recherche.
useEffect(() => {
if (!focusRequest || !map) return;
const target = map.nodes.find((node) => node.id === focusRequest.nodeId);
if (!target) return;
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes);
setCenter(target.position.x + size.width / 2, target.position.y + size.height / 2, {
zoom: 1.15,
duration: 400,
});
}, [focusRequest, map, setCenter]);
const onNodesChange = useCallback(
(changes: NodeChange<MindFlowNode>[]) => {
const store = useEditorStore.getState();
const positions: Array<{ id: string; position: { x: number; y: number } }> = [];
let selection: Set<string> | null = null;
for (const change of changes) {
if (change.type === 'position' && change.position) {
positions.push({ id: change.id, position: change.position });
} else if (change.type === 'select') {
selection ??= new Set(store.selectedNodeIds);
if (change.selected) selection.add(change.id);
else selection.delete(change.id);
} else if (change.type === 'dimensions' && change.dimensions) {
store.setMeasuredSize(change.id, {
width: Math.round(change.dimensions.width),
height: Math.round(change.dimensions.height),
});
} else if (change.type === 'remove' && !readOnly) {
store.deleteNodes([change.id]);
}
}
if (positions.length > 0 && !readOnly) store.moveNodes(positions);
if (selection) store.setSelection([...selection]);
},
[readOnly],
);
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (readOnly) return;
const store = useEditorStore.getState();
for (const change of changes) {
if (change.type !== 'remove') continue;
const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
if (edge) store.detachNode(edge.target);
}
},
[readOnly],
);
const onConnect = useCallback(
(connection: Connection) => {
if (readOnly || !connection.source || !connection.target) return;
const linked = useEditorStore.getState().reparentNode(connection.target, connection.source);
if (!linked) {
pushToast('Impossible de relier ces noeuds : cela creerait une boucle.', 'error');
}
},
[readOnly, pushToast],
);
const handlePaneDoubleClick = useCallback(
(event: React.MouseEvent) => {
if (readOnly) return;
const target = event.target as HTMLElement;
if (!target.classList.contains('react-flow__pane')) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const store = useEditorStore.getState();
const created = store.addChild(null);
if (created) store.moveNodes([{ id: created, position }]);
},
[readOnly, screenToFlowPosition],
);
if (!map) return null;
return (
<div
className="openmind-canvas print-surface h-full w-full"
style={{ background: theme.canvasBackground }}
onDoubleClick={handlePaneDoubleClick}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStart={() => useEditorStore.getState().pushHistory()}
onMoveEnd={(_event, viewport) => useEditorStore.getState().setViewport(viewport)}
onPaneClick={() => {
const store = useEditorStore.getState();
store.setSelection([]);
store.setEditingNode(null);
}}
defaultViewport={map.viewport}
minZoom={0.1}
maxZoom={3}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode="Shift"
deleteKeyCode={null}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
elementsSelectable
edgesFocusable={false}
// React Flow donne sinon le focus au noeud selectionne, ce qui coupe
// l'edition en ligne ; Tab est deja utilise comme raccourci.
nodesFocusable={false}
panOnScroll
selectionOnDrag={!readOnly}
zoomOnDoubleClick={false}
aria-label="Canvas de la carte mentale"
>
{map.settings.showGrid ? (
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color={theme.gridColor} />
) : null}
{showControls ? (
<Controls className="no-print" showInteractive={!readOnly} position="bottom-left" />
) : null}
{showMiniMap ? (
<MiniMap
className="no-print"
pannable
zoomable
position="bottom-right"
nodeColor={(node) => (node.data as MindFlowNode['data']).appearance.background}
maskColor={theme.isDark ? 'rgba(15,23,42,0.6)' : 'rgba(148,163,184,0.25)'}
style={{ background: theme.isDark ? '#0f172a' : '#ffffff' }}
/>
) : null}
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Handle, type NodeProps } from '@xyflow/react';
import { type MindFlowNode } from '@/features/editor/flowAdapters';
import { useEditorStore } from '@/stores/editorStore';
import { LinkIcon, NoteIcon } from '@/components/Icons';
import { cn } from '@/utils/cn';
/**
* Rendu d'un noeud.
*
* Le texte est toujours rendu comme du texte : aucun HTML provenant d'une carte
* n'est jamais interprete.
*/
function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
const { node, appearance, childCount, hiddenCount, isSearchHit, readOnly, isEditing } = data;
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const setEditingNode = useEditorStore((state) => state.setEditingNode);
const toggleCollapse = useEditorStore((state) => state.toggleCollapse);
const setMeasuredSize = useEditorStore((state) => state.setMeasuredSize);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [draft, setDraft] = useState(node.text);
useEffect(() => {
if (isEditing) setDraft(node.text);
}, [isEditing, node.text]);
useEffect(() => {
if (!isEditing) return undefined;
const focusTextarea = () => {
const textarea = textareaRef.current;
if (!textarea || document.activeElement === textarea) return;
textarea.focus();
textarea.select();
};
focusTextarea();
// Seconde tentative apres le rendu du canvas, au cas ou React Flow
// reprendrait le focus sur le noeud selectionne.
const timer = window.setTimeout(focusTextarea, 0);
return () => window.clearTimeout(timer);
}, [isEditing]);
// Les tailles reelles alimentent l'auto-layout et les exports.
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
setMeasuredSize(id, {
width: Math.round(element.offsetWidth),
height: Math.round(element.offsetHeight),
});
}, [id, node.text, node.style, node.icon, setMeasuredSize, isEditing]);
const commitText = () => {
const value = draft.trim();
if (value !== node.text) updateNodeText(id, value);
setEditingNode(null);
};
const cancelEdit = () => {
setDraft(node.text);
setEditingNode(null);
};
const isRoot = node.type === 'root';
const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre');
return (
<div
ref={containerRef}
className={cn(
'mind-node relative select-none px-3.5 py-2',
isSearchHit && 'mind-node--search-hit',
)}
style={{
background: appearance.background,
color: appearance.color,
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
borderRadius: appearance.borderRadius,
boxShadow: appearance.boxShadow,
fontFamily: appearance.fontFamily,
fontSize: appearance.fontSize,
fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle,
textAlign: appearance.textAlign,
minWidth: appearance.circle ? 96 : 88,
maxWidth: appearance.width ?? 260,
width: appearance.width,
...(appearance.circle
? { aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }
: {}),
}}
aria-label={label}
onDoubleClick={(event) => {
if (readOnly) return;
event.stopPropagation();
setEditingNode(id);
}}
>
<Handle
type="target"
position={data.targetPosition}
isConnectable={!readOnly}
aria-label="Point de connexion entrant"
/>
<div className="flex items-start gap-2">
{node.icon ? (
<span aria-hidden="true" className="leading-5">
{node.icon}
</span>
) : null}
{isEditing ? (
<textarea
ref={textareaRef}
className="nodrag nowheel w-full resize-none bg-transparent leading-snug outline-none"
style={{ color: appearance.color, minWidth: 90 }}
rows={Math.min(6, Math.max(1, draft.split('\n').length))}
value={draft}
aria-label="Texte du noeud"
onChange={(event) => setDraft(event.target.value)}
onBlur={commitText}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitText();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
/>
) : (
<span className="whitespace-pre-wrap break-words leading-snug">
{node.text || <span className="opacity-50">Sans titre</span>}
</span>
)}
{node.note ? (
<span title="Ce noeud contient une note" className="mt-0.5 shrink-0 opacity-70">
<NoteIcon className="h-3.5 w-3.5" />
</span>
) : null}
{node.url ? (
<a
href={node.url}
target="_blank"
rel="noopener noreferrer nofollow"
title={`Ouvrir ${node.url}`}
aria-label={`Ouvrir le lien du noeud ${label}`}
className="nodrag mt-0.5 shrink-0 opacity-70 transition-opacity hover:opacity-100"
onClick={(event) => event.stopPropagation()}
>
<LinkIcon className="h-3.5 w-3.5" />
</a>
) : null}
</div>
{childCount > 0 ? (
<button
type="button"
className={cn(
'nodrag absolute -bottom-2.5 left-1/2 z-10 flex h-5 min-w-5 -translate-x-1/2 items-center justify-center',
'rounded-full border border-zinc-300 bg-white px-1 text-[10px] font-semibold text-zinc-600',
'shadow-sm transition-colors hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200',
)}
aria-label={
node.collapsed
? `Deplier la branche ${label} (${hiddenCount} elements masques)`
: `Replier la branche ${label}`
}
aria-expanded={!node.collapsed}
title={node.collapsed ? `${hiddenCount} elements masques` : 'Replier la branche'}
onClick={(event) => {
event.stopPropagation();
toggleCollapse(id);
}}
>
{node.collapsed ? hiddenCount : '-'}
</button>
) : null}
<Handle
type="source"
position={data.sourcePosition}
isConnectable={!readOnly}
aria-label="Point de connexion sortant"
/>
</div>
);
}
export const MindNodeView = memo(MindNodeViewComponent);

View File

@@ -0,0 +1,345 @@
import { useEffect, useState } from 'react';
import { type NodeShape, type TextAlign } from '@/types/map';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { CloseIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth } from '@/features/editor/themes';
import { selectSelectedNode, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
const SHAPES: Array<{ id: NodeShape; label: string }> = [
{ id: 'rectangle', label: 'Rectangle' },
{ id: 'rounded', label: 'Arrondi' },
{ id: 'capsule', label: 'Capsule' },
{ id: 'circle', label: 'Cercle' },
];
const ALIGNMENTS: Array<{ id: TextAlign; label: string }> = [
{ id: 'left', label: 'Gauche' },
{ id: 'center', label: 'Centre' },
{ id: 'right', label: 'Droite' },
];
const EMOJIS = ['', '💡', '✅', '📌', '⚠️', '🔒', '🌐', '🖥️', '📦', '📄', '🔗', '⭐', '🐳', '🗄️'];
/** Petit selecteur de couleur : palette native + valeur libre. */
function ColorField({
id,
label,
value,
fallback,
onChange,
}: {
id: string;
label: string;
value: string | undefined;
fallback: string;
onChange: (color: string | undefined) => void;
}) {
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={value ?? normaliseHex(fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{value ?? `theme (${fallback})`}
</span>
{value ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
function normaliseHex(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
}
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const node = useEditorStore(selectSelectedNode);
const selectedCount = useEditorStore((state) => state.selectedNodeIds.length);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const updateNodeStyle = useEditorStore((state) => state.updateNodeStyle);
const resetNodeStyle = useEditorStore((state) => state.resetNodeStyle);
const detachNode = useEditorStore((state) => state.detachNode);
const deleteNodes = useEditorStore((state) => state.deleteNodes);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
const [url, setUrl] = useState('');
const [note, setNote] = useState('');
useEffect(() => {
setText(node?.text ?? '');
setUrl(node?.url ?? '');
setNote(node?.note ?? '');
}, [node?.id, node?.text, node?.url, node?.note]);
const theme = getTheme(map?.settings.theme);
const depth = node && map ? (computeDepths(map.nodes).get(node.id) ?? 0) : 0;
const palette = paletteForDepth(theme, depth);
const style = node?.style ?? {};
const header = (
<header className="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">Proprietes</h2>
<IconButton
label="Masquer le panneau de proprietes"
icon={<CloseIcon />}
onClick={() => setPropertiesPanelOpen(false)}
/>
</header>
);
if (!node) {
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{selectedCount > 1
? `${selectedCount} noeuds selectionnes. Selectionnez un seul noeud pour modifier son apparence.`
: 'Selectionnez un noeud pour modifier son texte, ses couleurs, sa forme ou y ajouter une note.'}
</p>
</aside>
);
}
const commitUrl = () => {
const trimmed = url.trim();
if (!trimmed) {
updateNode(node.id, { url: undefined });
return;
}
const safe = sanitizeUrl(trimmed);
if (!safe) {
pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error');
setUrl(node.url ?? '');
return;
}
setUrl(safe);
updateNode(node.id, { url: safe });
};
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<div className="space-y-5 px-4 py-4">
<div>
<label className="field-label" htmlFor="node-text">
Texte
</label>
<textarea
id="node-text"
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== node.text && updateNodeText(node.id, text)}
/>
</div>
<div>
<span className="field-label">Icone</span>
<div className="flex flex-wrap gap-1">
{EMOJIS.map((emoji) => (
<button
key={emoji || 'none'}
type="button"
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
aria-pressed={(node.icon ?? '') === emoji}
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
(node.icon ?? '') === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji || '-'}
</button>
))}
</div>
</div>
<ColorField
id="node-bg"
label="Fond"
value={style.backgroundColor}
fallback={palette.background}
onChange={(color) => updateNodeStyle(node.id, { backgroundColor: color })}
/>
<ColorField
id="node-text-color"
label="Texte"
value={style.textColor}
fallback={palette.text}
onChange={(color) => updateNodeStyle(node.id, { textColor: color })}
/>
<ColorField
id="node-border"
label="Bordure"
value={style.borderColor}
fallback={palette.border}
onChange={(color) => updateNodeStyle(node.id, { borderColor: color })}
/>
<div>
<label className="field-label" htmlFor="node-shape">
Forme
</label>
<select
id="node-shape"
className="field-input"
value={style.shape ?? theme.nodeShape}
onChange={(event) => updateNodeStyle(node.id, { shape: event.target.value as NodeShape })}
>
{SHAPES.map((shape) => (
<option key={shape.id} value={shape.id}>
{shape.label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="node-size">
Taille du texte : {style.fontSize ?? (depth === 0 ? 16 : 14)} px
</label>
<input
id="node-size"
type="range"
min={10}
max={32}
step={1}
value={style.fontSize ?? (depth === 0 ? 16 : 14)}
onChange={(event) => updateNodeStyle(node.id, { fontSize: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={style.bold ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.bold)}
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
className="font-bold"
>
G
</Button>
<Button
size="sm"
variant={style.italic ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.italic)}
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
className="italic"
>
I
</Button>
<div className="ml-auto flex gap-1">
{ALIGNMENTS.map((alignment) => (
<Button
key={alignment.id}
size="sm"
variant={(style.textAlign ?? 'left') === alignment.id ? 'primary' : 'secondary'}
aria-pressed={(style.textAlign ?? 'left') === alignment.id}
aria-label={`Alignement ${alignment.label}`}
onClick={() => updateNodeStyle(node.id, { textAlign: alignment.id })}
>
{alignment.label.charAt(0)}
</Button>
))}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-url">
Lien
</label>
<div className="flex items-center gap-2">
<input
id="node-url"
type="url"
inputMode="url"
placeholder="https://exemple.fr"
className="field-input"
value={url}
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{node.url ? (
<a
href={node.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
aria-label="Ouvrir le lien dans un nouvel onglet"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LinkIcon />
</a>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-note">
Note
</label>
<textarea
id="node-note"
className="field-input min-h-[80px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note}
onChange={(event) => setNote(event.target.value)}
onBlur={() => note !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })}
/>
</div>
<div className="space-y-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<Button size="sm" className="w-full" onClick={() => resetNodeStyle(node.id)}>
Revenir au style du theme
</Button>
{node.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}>
Detacher de son parent
</Button>
) : null}
<Button
size="sm"
variant="danger"
className="w-full"
onClick={() => deleteNodes([node.id])}
>
<TrashIcon className="h-4 w-4" />
Supprimer ce noeud
</Button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,99 @@
import { forwardRef } from 'react';
import { IconButton } from '@/components/IconButton';
import { ChevronLeftIcon, ChevronRightIcon, CloseIcon, SearchIcon } from '@/components/Icons';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
/** Recherche dans la carte : navigation entre resultats et centrage du canvas. */
export const SearchPanel = forwardRef<HTMLInputElement>(function SearchPanel(_props, ref) {
const query = useEditorStore((state) => state.searchQuery);
const results = useEditorStore((state) => state.searchResults);
const index = useEditorStore((state) => state.searchIndex);
const setSearchQuery = useEditorStore((state) => state.setSearchQuery);
const nextResult = useEditorStore((state) => state.nextSearchResult);
const previousResult = useEditorStore((state) => state.previousSearchResult);
const map = useEditorStore((state) => state.map);
const gotoSearchResult = useEditorStore((state) => state.gotoSearchResult);
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
const close = () => {
setSearchQuery('');
setSearchPanelOpen(false);
};
const labelFor = (nodeId: string) =>
map?.nodes.find((node) => node.id === nodeId)?.text || 'Sans titre';
return (
<div
className="no-print panel absolute right-4 top-4 z-20 w-80 animate-fade-in p-3"
role="search"
aria-label="Rechercher dans la carte"
>
<div className="flex items-center gap-2">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
ref={ref}
type="search"
value={query}
placeholder="Rechercher un noeud..."
aria-label="Texte a rechercher"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) previousResult();
else nextResult();
}
if (event.key === 'Escape') {
event.preventDefault();
close();
}
}}
/>
<span className="shrink-0 text-xs tabular-nums text-zinc-400">
{results.length > 0 ? `${index + 1}/${results.length}` : query ? '0' : ''}
</span>
<IconButton
label="Resultat precedent"
icon={<ChevronLeftIcon className="h-4 w-4" />}
disabled={results.length === 0}
onClick={previousResult}
/>
<IconButton
label="Resultat suivant"
icon={<ChevronRightIcon className="h-4 w-4" />}
disabled={results.length === 0}
onClick={nextResult}
/>
<IconButton label="Fermer la recherche" icon={<CloseIcon />} onClick={close} />
</div>
{query && results.length === 0 ? (
<p className="mt-2 px-1 text-xs text-zinc-500">Aucun noeud ne correspond.</p>
) : null}
{results.length > 0 ? (
<ul className="mt-2 max-h-52 overflow-y-auto" aria-label="Resultats de recherche">
{results.map((nodeId, position) => (
<li key={nodeId}>
<button
type="button"
onClick={() => gotoSearchResult(position)}
aria-current={position === index}
className={`w-full truncate rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
position === index
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800'
}`}
>
{labelFor(nodeId)}
</button>
</li>
))}
</ul>
) : null}
</div>
);
});

View File

@@ -0,0 +1,182 @@
import { type Edge, type Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
import { type BranchStyle, type MindMap, type MindMapNode, type NodeShape } from '@/types/map';
import { type MapTheme, paletteForDepth } from '@/features/editor/themes';
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { buildChildrenMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
import { type NodeSize } from '@/utils/nodeSize';
/** Style CSS resolu d'un noeud : theme + personnalisations explicites. */
export interface NodeAppearance {
background: string;
color: string;
borderColor: string;
borderWidth: number;
borderRadius: string;
fontSize: number;
fontFamily: string;
fontWeight: number;
fontStyle: 'normal' | 'italic';
textAlign: 'left' | 'center' | 'right';
boxShadow: string;
width?: number;
circle: boolean;
}
export interface MindNodeData extends Record<string, unknown> {
node: MindMapNode;
depth: number;
appearance: NodeAppearance;
childCount: number;
hiddenCount: number;
isSearchHit: boolean;
readOnly: boolean;
isEditing: boolean;
sourcePosition: Position;
targetPosition: Position;
}
export type MindFlowNode = Node<MindNodeData, 'mind'>;
function radiusForShape(shape: NodeShape, themeRadius: number): string {
switch (shape) {
case 'rectangle':
return '3px';
case 'capsule':
return '999px';
case 'circle':
return '50%';
case 'rounded':
default:
return `${themeRadius}px`;
}
}
/**
* Fusionne le theme et les personnalisations du noeud.
* Une valeur definie explicitement sur le noeud gagne toujours : changer de
* theme ne detruit donc jamais une personnalisation.
*/
export function resolveNodeAppearance(
node: MindMapNode,
depth: number,
theme: MapTheme,
): NodeAppearance {
const palette = paletteForDepth(theme, depth);
const style = node.style ?? {};
const shape = style.shape ?? theme.nodeShape;
return {
background: style.backgroundColor ?? palette.background,
color: style.textColor ?? palette.text,
borderColor: style.borderColor ?? palette.border,
borderWidth: theme.borderWidth,
borderRadius: radiusForShape(shape, theme.nodeRadius),
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
fontFamily: theme.fontFamily,
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
fontStyle: style.italic ? 'italic' : 'normal',
textAlign: style.textAlign ?? 'left',
boxShadow: theme.shadow,
...(style.width ? { width: style.width } : {}),
circle: shape === 'circle',
};
}
function handlePositions(layout: MindMap['settings']['layout']): {
source: Position;
target: Position;
} {
if (!isHorizontalLayout(layout)) {
return { source: Position.Bottom, target: Position.Top };
}
return layoutDirection(layout) === -1
? { source: Position.Left, target: Position.Right }
: { source: Position.Right, target: Position.Left };
}
const EDGE_TYPE_BY_BRANCH: Record<BranchStyle, string> = {
bezier: 'default',
curved: 'smoothstep',
straight: 'straight',
};
export interface AdapterOptions {
theme: MapTheme;
selectedIds: string[];
searchResults: string[];
editingNodeId: string | null;
readOnly: boolean;
/** Tailles reelles mesurees dans le DOM, requises par la minimap. */
measuredSizes?: Map<string, NodeSize>;
}
export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode[] {
const depths = computeDepths(map.nodes);
const hidden = getHiddenNodeIds(map.nodes);
const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults);
const positions = handlePositions(map.settings.layout);
return map.nodes.map((node) => {
const depth = depths.get(node.id) ?? 0;
const childIds = children.get(node.id) ?? [];
const measured = options.measuredSizes?.get(node.id);
return {
id: node.id,
type: 'mind' as const,
position: node.position,
...(measured ? { measured } : {}),
selected: selected.has(node.id),
hidden: hidden.has(node.id),
draggable: !options.readOnly,
selectable: true,
connectable: !options.readOnly,
deletable: !options.readOnly,
data: {
node,
depth,
appearance: resolveNodeAppearance(node, depth, options.theme),
childCount: childIds.length,
hiddenCount: node.collapsed ? countBranch(children, node.id) : 0,
isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id,
sourcePosition: positions.source,
targetPosition: positions.target,
},
};
});
}
function countBranch(children: Map<string, string[]>, rootId: string): number {
let total = 0;
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
total += 1;
const next = children.get(current);
if (next) queue.push(...next);
}
return total;
}
export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes);
const edgeType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
return map.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edgeType,
hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly,
style: {
stroke: edge.color ?? options.theme.branchColor,
strokeWidth: options.theme.branchWidth,
},
}));
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { createSampleMap } from '@/services/mapFactory';
describe('auto-layout', () => {
it('positionne tous les noeuds', () => {
const map = createSampleMap();
const positions = computeLayout(map.nodes, 'mindmap-right');
expect(positions.size).toBe(map.nodes.length);
for (const node of map.nodes) {
const position = positions.get(node.id);
expect(Number.isFinite(position?.x)).toBe(true);
expect(Number.isFinite(position?.y)).toBe(true);
}
});
it('deploie vers la droite ou vers la gauche selon la disposition', () => {
const map = createSampleMap();
const root = map.nodes[0] as { id: string };
const child = map.nodes[1] as { id: string };
const right = computeLayout(map.nodes, 'mindmap-right');
const left = computeLayout(map.nodes, 'mindmap-left');
expect((right.get(child.id)?.x ?? 0) > (right.get(root.id)?.x ?? 0)).toBe(true);
expect((left.get(child.id)?.x ?? 0) < (left.get(root.id)?.x ?? 0)).toBe(true);
});
it('empile verticalement en mode arbre vertical', () => {
const map = createSampleMap();
const root = map.nodes[0] as { id: string };
const child = map.nodes[1] as { id: string };
const vertical = computeLayout(map.nodes, 'tree-vertical');
expect((vertical.get(child.id)?.y ?? 0) > (vertical.get(root.id)?.y ?? 0)).toBe(true);
});
it('expose l orientation de chaque disposition', () => {
expect(isHorizontalLayout('mindmap-right')).toBe(true);
expect(isHorizontalLayout('tree-vertical')).toBe(false);
expect(layoutDirection('mindmap-left')).toBe(-1);
expect(layoutDirection('mindmap-right')).toBe(1);
});
it('ne plante pas sur une carte vide', () => {
expect(computeLayout([], 'mindmap-right').size).toBe(0);
});
});

View File

@@ -0,0 +1,121 @@
import dagre from 'dagre';
import { type LayoutKind, type MindMapNode, type Position } from '@/types/map';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption {
id: LayoutKind;
label: string;
description: string;
}
/**
* Dispositions proposees dans l'interface de la V1.
* `mindmap-both`, `organization` et `freeform` existent dans le modele mais ne
* sont pas encore implementees : elles restent disponibles pour une V2.
*/
export const LAYOUT_OPTIONS: LayoutOption[] = [
{
id: 'mindmap-right',
label: 'Mind map droite',
description: 'Les branches se deploient vers la droite.',
},
{
id: 'mindmap-left',
label: 'Mind map gauche',
description: 'Les branches se deploient vers la gauche.',
},
{
id: 'tree-horizontal',
label: 'Arbre horizontal',
description: 'Hierarchie stricte, de gauche a droite.',
},
{
id: 'tree-vertical',
label: 'Arbre vertical',
description: 'Hierarchie stricte, de haut en bas.',
},
];
interface DagreParams {
rankdir: 'LR' | 'RL' | 'TB';
ranksep: number;
nodesep: number;
}
const LAYOUT_PARAMS: Record<string, DagreParams> = {
'mindmap-right': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
'mindmap-left': { rankdir: 'RL', ranksep: 90, nodesep: 22 },
'tree-horizontal': { rankdir: 'LR', ranksep: 130, nodesep: 44 },
'tree-vertical': { rankdir: 'TB', ranksep: 90, nodesep: 48 },
// Replis pour les dispositions reservees.
'mindmap-both': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
organization: { rankdir: 'TB', ranksep: 90, nodesep: 48 },
freeform: { rankdir: 'LR', ranksep: 90, nodesep: 22 },
};
/** Orientation d'un layout : sert a placer les poignees de connexion. */
export function isHorizontalLayout(layout: LayoutKind): boolean {
return LAYOUT_PARAMS[layout]?.rankdir !== 'TB';
}
/** Sens de deploiement : -1 vers la gauche/le haut, 1 vers la droite/le bas. */
export function layoutDirection(layout: LayoutKind): 1 | -1 {
return LAYOUT_PARAMS[layout]?.rankdir === 'RL' ? -1 : 1;
}
/**
* Calcule les positions via dagre.
*
* Le layout n'est jamais applique automatiquement : il est declenche seulement
* par le bouton "Organisation automatique", ce qui preserve les deplacements
* manuels de l'utilisateur.
*/
export function computeLayout(
nodes: MindMapNode[],
layout: LayoutKind,
measuredSizes?: Map<string, NodeSize>,
): Map<string, Position> {
const positions = new Map<string, Position>();
if (nodes.length === 0) return positions;
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
const graph = new dagre.graphlib.Graph();
graph.setGraph({
rankdir: params?.rankdir ?? 'LR',
ranksep: params?.ranksep ?? 90,
nodesep: params?.nodesep ?? 22,
marginx: 40,
marginy: 40,
});
graph.setDefaultEdgeLabel(() => ({}));
const known = new Set(nodes.map((node) => node.id));
const sizes = new Map<string, NodeSize>();
for (const node of nodes) {
const size = resolveNodeSize(node, measuredSizes);
sizes.set(node.id, size);
graph.setNode(node.id, { width: size.width, height: size.height });
}
for (const node of nodes) {
if (node.parentId && known.has(node.parentId)) {
graph.setEdge(node.parentId, node.id);
}
}
dagre.layout(graph);
for (const node of nodes) {
const laidOut = graph.node(node.id) as { x?: number; y?: number } | undefined;
if (!laidOut || laidOut.x === undefined || laidOut.y === undefined) continue;
const size = sizes.get(node.id) as NodeSize;
// dagre renvoie le centre du noeud ; React Flow attend le coin haut-gauche.
positions.set(node.id, {
x: Math.round(laidOut.x - size.width / 2),
y: Math.round(laidOut.y - size.height / 2),
});
}
return positions;
}

View File

@@ -0,0 +1,235 @@
import { type NodeShape } from '@/types/map';
export interface NodePalette {
background: string;
text: string;
border: string;
}
/**
* Un theme decrit l'apparence par defaut d'une carte.
* Il ne remplace jamais une personnalisation explicite d'un noeud : la fusion
* est faite dans `flowAdapters.resolveNodeAppearance`.
*/
export interface MapTheme {
id: string;
name: string;
isDark: boolean;
canvasBackground: string;
gridColor: string;
fontFamily: string;
nodeRadius: number;
nodeShape: NodeShape;
borderWidth: number;
shadow: string;
branchColor: string;
branchWidth: number;
root: NodePalette;
levels: NodePalette[];
}
const SANS = "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif";
const MONO = "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace";
const SOFT_SHADOW = '0 1px 2px rgba(15,23,42,0.06), 0 6px 16px -10px rgba(15,23,42,0.35)';
const FLAT_SHADOW = 'none';
const GLOW_SHADOW = '0 0 0 1px rgba(148,163,184,0.10), 0 8px 24px -12px rgba(56,189,248,0.55)';
export const THEMES: MapTheme[] = [
{
id: 'default',
name: 'Default',
isDark: false,
canvasBackground: '#f8fafc',
gridColor: '#cbd5e1',
fontFamily: SANS,
nodeRadius: 10,
nodeShape: 'rounded',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#94a3b8',
branchWidth: 2,
root: { background: '#4f46e5', text: '#ffffff', border: '#4338ca' },
levels: [
{ background: '#eef2ff', text: '#3730a3', border: '#c7d2fe' },
{ background: '#ecfeff', text: '#155e75', border: '#a5f3fc' },
{ background: '#f0fdf4', text: '#166534', border: '#bbf7d0' },
{ background: '#fff7ed', text: '#9a3412', border: '#fed7aa' },
{ background: '#fdf4ff', text: '#86198f', border: '#f5d0fe' },
],
},
{
id: 'minimal',
name: 'Minimal',
isDark: false,
canvasBackground: '#ffffff',
gridColor: '#e5e7eb',
fontFamily: SANS,
nodeRadius: 6,
nodeShape: 'rounded',
borderWidth: 1,
shadow: FLAT_SHADOW,
branchColor: '#9ca3af',
branchWidth: 1.5,
root: { background: '#ffffff', text: '#111827', border: '#111827' },
levels: [
{ background: '#ffffff', text: '#374151', border: '#9ca3af' },
{ background: '#ffffff', text: '#4b5563', border: '#d1d5db' },
{ background: '#fafafa', text: '#4b5563', border: '#e5e7eb' },
],
},
{
id: 'dark',
name: 'Dark',
isDark: true,
canvasBackground: '#111827',
gridColor: '#374151',
fontFamily: SANS,
nodeRadius: 10,
nodeShape: 'rounded',
borderWidth: 1,
shadow: '0 8px 24px -14px rgba(0,0,0,0.9)',
branchColor: '#64748b',
branchWidth: 2,
root: { background: '#6366f1', text: '#ffffff', border: '#818cf8' },
levels: [
{ background: '#1f2937', text: '#e5e7eb', border: '#4b5563' },
{ background: '#1e293b', text: '#cbd5e1', border: '#475569' },
{ background: '#172033', text: '#cbd5e1', border: '#334155' },
],
},
{
id: 'midnight',
name: 'Midnight',
isDark: true,
canvasBackground: '#0b1020',
gridColor: '#1e293b',
fontFamily: SANS,
nodeRadius: 12,
nodeShape: 'rounded',
borderWidth: 1,
shadow: GLOW_SHADOW,
branchColor: '#38bdf8',
branchWidth: 2,
root: { background: '#7c3aed', text: '#ffffff', border: '#a78bfa' },
levels: [
{ background: '#111a33', text: '#c4b5fd', border: '#4c1d95' },
{ background: '#0f2233', text: '#7dd3fc', border: '#0c4a6e' },
{ background: '#0f2a2a', text: '#5eead4', border: '#115e59' },
],
},
{
id: 'ocean',
name: 'Ocean',
isDark: false,
canvasBackground: '#f0f9ff',
gridColor: '#bae6fd',
fontFamily: SANS,
nodeRadius: 14,
nodeShape: 'rounded',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#38bdf8',
branchWidth: 2,
root: { background: '#0369a1', text: '#ffffff', border: '#0284c7' },
levels: [
{ background: '#e0f2fe', text: '#075985', border: '#7dd3fc' },
{ background: '#ccfbf1', text: '#115e59', border: '#5eead4' },
{ background: '#dbeafe', text: '#1e40af', border: '#93c5fd' },
],
},
{
id: 'forest',
name: 'Forest',
isDark: false,
canvasBackground: '#f6faf6',
gridColor: '#c7e0c7',
fontFamily: SANS,
nodeRadius: 10,
nodeShape: 'rounded',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#84a98c',
branchWidth: 2,
root: { background: '#166534', text: '#ffffff', border: '#14532d' },
levels: [
{ background: '#dcfce7', text: '#14532d', border: '#86efac' },
{ background: '#ecfccb', text: '#3f6212', border: '#bef264' },
{ background: '#fef3c7', text: '#78350f', border: '#fcd34d' },
],
},
{
id: 'pastel',
name: 'Pastel',
isDark: false,
canvasBackground: '#fffdf9',
gridColor: '#f1e7dc',
fontFamily: SANS,
nodeRadius: 18,
nodeShape: 'capsule',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#d6bcfa',
branchWidth: 2,
root: { background: '#c4b5fd', text: '#312e81', border: '#a78bfa' },
levels: [
{ background: '#fde2e4', text: '#9d174d', border: '#fbcfe8' },
{ background: '#dff5ea', text: '#065f46', border: '#a7f3d0' },
{ background: '#e0e7ff', text: '#3730a3', border: '#c7d2fe' },
{ background: '#fff1cc', text: '#92400e', border: '#fde68a' },
],
},
{
id: 'monochrome',
name: 'Monochrome',
isDark: false,
canvasBackground: '#fafafa',
gridColor: '#e4e4e7',
fontFamily: SANS,
nodeRadius: 4,
nodeShape: 'rectangle',
borderWidth: 1,
shadow: FLAT_SHADOW,
branchColor: '#71717a',
branchWidth: 1.5,
root: { background: '#18181b', text: '#ffffff', border: '#18181b' },
levels: [
{ background: '#ffffff', text: '#27272a', border: '#52525b' },
{ background: '#f4f4f5', text: '#3f3f46', border: '#a1a1aa' },
{ background: '#e4e4e7', text: '#3f3f46', border: '#d4d4d8' },
],
},
{
id: 'blueprint',
name: 'Blueprint',
isDark: true,
canvasBackground: '#0d2b45',
gridColor: '#1d4666',
fontFamily: MONO,
nodeRadius: 2,
nodeShape: 'rectangle',
borderWidth: 1.5,
shadow: FLAT_SHADOW,
branchColor: '#7fb3d5',
branchWidth: 1.5,
root: { background: '#0d2b45', text: '#e8f4fd', border: '#e8f4fd' },
levels: [
{ background: 'rgba(232,244,253,0.06)', text: '#dbeafe', border: '#93c5fd' },
{ background: 'rgba(232,244,253,0.04)', text: '#bfdbfe', border: '#60a5fa' },
{ background: 'rgba(232,244,253,0.02)', text: '#bfdbfe', border: '#3b82f6' },
],
},
];
export const DEFAULT_THEME_ID = 'default';
export function getTheme(themeId: string | undefined): MapTheme {
return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme);
}
/** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */
export function paletteForDepth(theme: MapTheme, depth: number): NodePalette {
if (depth <= 0) return theme.root;
const index = (depth - 1) % theme.levels.length;
return theme.levels[index] as NodePalette;
}

View File

@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef } from 'react';
import { APP_CONFIG } from '@/config/app';
import { saveMap } from '@/services/mapRepository';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { useDebouncedEffect } from '@/hooks/useDebouncedEffect';
/**
* Sauvegarde automatique dans IndexedDB.
*
* Volontairement independante de l'historique undo/redo : on ecrit apres une
* courte periode d'inactivite, sans jamais recharger l'application.
*/
export function useAutosave(enabled: boolean): { saveNow: () => Promise<void> } {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const skipFirstRun = useRef(true);
const latestMap = useRef(map);
latestMap.current = map;
const persist = useCallback(async () => {
const current = latestMap.current;
if (!current || !enabled) return;
const store = useEditorStore.getState();
store.setSaveStatus('saving');
try {
await saveMap(current);
store.markSaved();
} catch {
store.setSaveStatus('error');
pushToast(
"La sauvegarde locale a echoue. Exportez votre carte pour ne rien perdre.",
'error',
);
}
}, [enabled, pushToast]);
useDebouncedEffect(
() => {
if (!enabled) return;
if (skipFirstRun.current) {
skipFirstRun.current = false;
return;
}
void persist();
},
[map, enabled],
APP_CONFIG.autosaveDelayMs,
);
// Sauvegarde de securite en quittant l'editeur ou l'onglet.
useEffect(() => {
if (!enabled) return undefined;
const onHide = () => {
if (document.visibilityState === 'hidden') void persist();
};
document.addEventListener('visibilitychange', onHide);
return () => {
document.removeEventListener('visibilitychange', onHide);
void persist();
};
}, [enabled, persist]);
return { saveNow: persist };
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useRef } from 'react';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
/** Vrai si l'utilisateur est en train de saisir du texte. */
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true
);
}
export interface ShortcutHandlers {
onSave: () => void;
onFit: () => void;
onFocusSearch: () => void;
}
/**
* Raccourcis clavier de l'editeur.
* Ctrl et Cmd (macOS) sont traites de la meme maniere.
*/
export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers): void {
// Les handlers changent a chaque rendu : on les lit via une ref pour ne
// reinscrire l'ecouteur qu'une seule fois.
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
if (!enabled) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
const store = useEditorStore.getState();
const typing = isTypingTarget(event.target) || store.editingNodeId !== null;
const modifier = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (modifier && key === 's') {
event.preventDefault();
handlersRef.current.onSave();
return;
}
if (modifier && key === 'f') {
event.preventDefault();
handlersRef.current.onFocusSearch();
return;
}
if (typing) return;
if (modifier) {
switch (key) {
case 'z':
event.preventDefault();
if (event.shiftKey) store.redo();
else store.undo();
return;
case 'y':
event.preventDefault();
store.redo();
return;
case 'c':
if (store.copySelection() > 0) {
useUiStore.getState().pushToast('Selection copiee.', 'info');
}
return;
case 'v':
event.preventDefault();
store.paste();
return;
case 'd':
event.preventDefault();
store.duplicateSelection();
return;
case 'a':
event.preventDefault();
store.setSelection(store.map?.nodes.map((node) => node.id) ?? []);
return;
default:
return;
}
}
switch (event.key) {
case 'Tab':
event.preventDefault();
store.addChild();
break;
case 'Enter':
event.preventDefault();
store.addSibling();
break;
case 'Delete':
case 'Backspace':
if (store.selectedNodeIds.length > 0) {
event.preventDefault();
store.deleteNodes(store.selectedNodeIds);
}
break;
case 'f':
case 'F':
event.preventDefault();
handlersRef.current.onFit();
break;
case 'F2':
if (store.selectedNodeIds[0]) {
event.preventDefault();
store.setEditingNode(store.selectedNodeIds[0]);
}
break;
case 'Escape':
store.setEditingNode(null);
store.setSelection([]);
break;
default:
break;
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}

View File

@@ -0,0 +1,149 @@
import { useState } from 'react';
import { Menu, MenuItem, MenuLabel, MenuSeparator } from '@/components/Menu';
import { Button } from '@/components/Button';
import { ChevronDownIcon, DownloadIcon, PrintIcon } from '@/components/Icons';
import { APP_CONFIG } from '@/config/app';
import {
ExportError,
dataUrlToBlob,
renderMapToPng,
renderMapToSvg,
} from '@/features/export/exportImage';
import { PdfExportDialog } from '@/features/export/PdfExportDialog';
import { PrintDialog } from '@/features/export/PrintDialog';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { serializeMap } from '@/services/mapSerialization';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ExportMenuProps {
/** La vue publique n'expose pas l'export .mmap natif. */
allowNativeExport?: boolean;
}
export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const [pdfOpen, setPdfOpen] = useState(false);
const [printOpen, setPrintOpen] = useState(false);
const [busy, setBusy] = useState(false);
const exportNative = () => {
if (!map) return;
const blob = new Blob([serializeMap(map)], {
type: `${APP_CONFIG.fileMimeType};charset=utf-8`,
});
downloadBlob(blob, toSafeFilename(map.name, APP_CONFIG.fileExtension));
pushToast('Carte exportee.', 'success');
};
const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
if (!map || busy) return;
setBusy(true);
try {
const sizes = useEditorStore.getState().measuredSizes;
const dataUrl =
format === 'png'
? await renderMapToPng({ map, sizes, pixelRatio, transparent })
: await renderMapToSvg({ map, sizes, transparent });
const blob = dataUrlToBlob(dataUrl);
downloadBlob(blob, toSafeFilename(map.name, format === 'png' ? '.png' : '.svg'));
pushToast(`Export ${format.toUpperCase()} termine.`, 'success');
} catch (error) {
pushToast(
error instanceof ExportError ? error.message : "L'export n'a pas pu aboutir.",
'error',
);
} finally {
setBusy(false);
}
};
return (
<>
<Menu
label="Menu d'export"
align="right"
disabled={!map}
trigger={
<Button size="sm" disabled={!map}>
<DownloadIcon className="h-4 w-4" />
Exporter
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{(close) => (
<>
{allowNativeExport ? (
<>
<MenuLabel>Format natif</MenuLabel>
<MenuItem
label={`Exporter ${APP_CONFIG.fileExtension}`}
hint="JSON"
onSelect={() => {
exportNative();
close();
}}
/>
<MenuSeparator />
</>
) : null}
<MenuLabel>Image</MenuLabel>
<MenuItem
label="PNG - resolution normale"
onSelect={() => {
void exportImage('png', 1, false);
close();
}}
/>
<MenuItem
label="PNG - resolution x2"
onSelect={() => {
void exportImage('png', 2, false);
close();
}}
/>
<MenuItem
label="PNG - fond transparent"
onSelect={() => {
void exportImage('png', 2, true);
close();
}}
/>
<MenuItem
label="SVG (vectoriel)"
onSelect={() => {
void exportImage('svg', 1, false);
close();
}}
/>
<MenuSeparator />
<MenuLabel>Document</MenuLabel>
<MenuItem
label="PDF..."
onSelect={() => {
setPdfOpen(true);
close();
}}
/>
<MenuItem
icon={<PrintIcon className="h-4 w-4" />}
label="Imprimer..."
onSelect={() => {
setPrintOpen(true);
close();
}}
/>
</>
)}
</Menu>
<PdfExportDialog open={pdfOpen} onClose={() => setPdfOpen(false)} />
<PrintDialog open={printOpen} onClose={() => setPrintOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SpinnerIcon } from '@/components/Icons';
import {
DEFAULT_PDF_OPTIONS,
renderMapToPdf,
type PdfExportOptions,
} from '@/features/export/exportPdf';
import { ExportError } from '@/features/export/exportImage';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface PdfExportDialogProps {
open: boolean;
onClose: () => void;
}
const SEGMENT_CLASS =
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors border border-transparent';
export function PdfExportDialog({ open, onClose }: PdfExportDialogProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const [options, setOptions] = useState<PdfExportOptions>(DEFAULT_PDF_OPTIONS);
const [busy, setBusy] = useState(false);
const update = (patch: Partial<PdfExportOptions>) =>
setOptions((current) => ({ ...current, ...patch }));
const handleExport = async () => {
if (!map) return;
setBusy(true);
try {
const result = await renderMapToPdf(map, options, useEditorStore.getState().measuredSizes);
downloadBlob(result.blob, toSafeFilename(map.name, '.pdf'));
if (result.downscaled) {
pushToast(
'La carte etait plus grande que la page : elle a ete reduite pour tenir entierement.',
'info',
);
} else {
pushToast('PDF genere.', 'success');
}
onClose();
} catch (error) {
pushToast(
error instanceof ExportError ? error.message : "Le PDF n'a pas pu etre genere.",
'error',
);
} finally {
setBusy(false);
}
};
const segment = (active: boolean) =>
`${SEGMENT_CLASS} ${
active
? 'bg-white text-indigo-700 shadow-sm border-zinc-200 dark:bg-zinc-800 dark:text-indigo-300 dark:border-zinc-700'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
}`;
return (
<Modal
open={open}
title="Exporter en PDF"
description={`La carte "${map?.name ?? ''}" sera exportee entierement, centree dans la page.`}
onClose={onClose}
footer={
<>
<Button onClick={onClose} disabled={busy}>
Annuler
</Button>
<Button variant="primary" onClick={handleExport} disabled={busy || !map}>
{busy ? <SpinnerIcon className="h-4 w-4" /> : null}
{busy ? 'Generation...' : 'Exporter le PDF'}
</Button>
</>
}
>
<div className="space-y-5">
<fieldset>
<legend className="field-label">Papier</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(['a4', 'a3'] as const).map((paper) => (
<button
key={paper}
type="button"
className={segment(options.paper === paper)}
onClick={() => update({ paper })}
aria-pressed={options.paper === paper}
>
{paper.toUpperCase()}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="field-label">Orientation</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(
[
['portrait', 'Portrait'],
['landscape', 'Paysage'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={segment(options.orientation === value)}
onClick={() => update({ orientation: value })}
aria-pressed={options.orientation === value}
>
{label}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="field-label">Echelle</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(
[
['fit', 'Adapter a une page'],
['actual', 'Taille reelle'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={segment(options.scaleMode === value)}
onClick={() => update({ scaleMode: value })}
aria-pressed={options.scaleMode === value}
>
{label}
</button>
))}
</div>
</fieldset>
<div>
<label className="field-label" htmlFor="pdf-margin">
Marges : {options.marginMm} mm
</label>
<input
id="pdf-margin"
type="range"
min={0}
max={30}
step={1}
value={options.marginMm}
onChange={(event) => update({ marginMm: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<fieldset className="space-y-2">
<legend className="field-label">Afficher</legend>
{(
[
['showBackground', 'Le fond de la carte'],
['showTitle', 'Le titre'],
['showDate', 'La date de derniere modification'],
] as const
).map(([key, label]) => (
<label key={key} className="flex items-center gap-2.5 text-sm">
<input
type="checkbox"
checked={options[key]}
onChange={(event) => update({ [key]: event.target.checked })}
className="h-4 w-4 rounded border-zinc-300 accent-indigo-600"
/>
<span className="text-zinc-700 dark:text-zinc-200">{label}</span>
</label>
))}
</fieldset>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le PDF est genere entierement dans votre navigateur : aucune donnee n&apos;est envoyee au
serveur.
</p>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import {
DEFAULT_PRINT_OPTIONS,
printMap,
type PrintOptions,
} from '@/features/export/printMap';
import { useEditorStore } from '@/stores/editorStore';
export interface PrintDialogProps {
open: boolean;
onClose: () => void;
}
const SEGMENT_CLASS = 'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors';
export function PrintDialog({ open, onClose }: PrintDialogProps) {
const requestFit = useEditorStore((state) => state.requestFit);
const [options, setOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
const segment = (active: boolean) =>
`${SEGMENT_CLASS} ${
active
? 'bg-white text-indigo-700 shadow-sm dark:bg-zinc-800 dark:text-indigo-300'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
}`;
const handlePrint = () => {
// On recadre la carte avant d'ouvrir l'apercu pour qu'elle tienne dans la page.
requestFit();
onClose();
window.setTimeout(() => printMap(options), 420);
};
return (
<Modal
open={open}
title="Imprimer la carte"
description="L'interface est masquee a l'impression : seule la carte est envoyee a l'imprimante."
onClose={onClose}
size="sm"
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={handlePrint}>
Ouvrir l&apos;apercu
</Button>
</>
}
>
<div className="space-y-5">
<fieldset>
<legend className="field-label">Papier</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(['a4', 'a3'] as const).map((paper) => (
<button
key={paper}
type="button"
className={segment(options.paper === paper)}
onClick={() => setOptions((current) => ({ ...current, paper }))}
aria-pressed={options.paper === paper}
>
{paper.toUpperCase()}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="field-label">Orientation</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(
[
['portrait', 'Portrait'],
['landscape', 'Paysage'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={segment(options.orientation === value)}
onClick={() => setOptions((current) => ({ ...current, orientation: value }))}
aria-pressed={options.orientation === value}
>
{label}
</button>
))}
</div>
</fieldset>
<div>
<label className="field-label" htmlFor="print-margin">
Marges : {options.marginMm} mm
</label>
<input
id="print-margin"
type="range"
min={0}
max={25}
step={1}
value={options.marginMm}
onChange={(event) =>
setOptions((current) => ({ ...current, marginMm: Number(event.target.value) }))
}
className="w-full accent-indigo-600"
/>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,218 @@
import { toPng, toSvg } from 'html-to-image';
import { type MindMap } from '@/types/map';
import { getTheme } from '@/features/editor/themes';
import { getHiddenNodeIds } from '@/utils/tree';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
/** Erreur d'export presentee telle quelle a l'utilisateur. */
export class ExportError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExportError';
}
}
export const EXPORT_PADDING = 48;
/** Garde-fou : au-dela, les navigateurs echouent a produire le canvas. */
const MAX_IMAGE_DIMENSION = 12000;
export interface MapBounds {
x: number;
y: number;
width: number;
height: number;
}
/** Rectangle englobant tous les noeuds visibles, en coordonnees du canvas. */
export function computeMapBounds(map: MindMap, sizes?: Map<string, NodeSize>): MapBounds {
const hidden = getHiddenNodeIds(map.nodes);
const visible = map.nodes.filter((node) => !hidden.has(node.id));
if (visible.length === 0) return { x: 0, y: 0, width: 400, height: 300 };
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of visible) {
const size = resolveNodeSize(node, sizes);
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + size.width);
maxY = Math.max(maxY, node.position.y + size.height);
}
return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
}
function findViewportElement(): HTMLElement {
const element = document.querySelector<HTMLElement>('.openmind-canvas .react-flow__viewport');
if (!element) {
throw new ExportError("La carte n'est pas encore affichee : impossible de l'exporter.");
}
return element;
}
function findCanvasElement(): HTMLElement | null {
return document.querySelector<HTMLElement>('.openmind-canvas');
}
/** Ne capture ni la minimap, ni les controles, ni l'attribution. */
function exportFilter(node: HTMLElement): boolean {
const className = typeof node.className === 'string' ? node.className : '';
return !(
className.includes('react-flow__minimap') ||
className.includes('react-flow__controls') ||
className.includes('react-flow__attribution') ||
className.includes('no-print')
);
}
export interface RenderOptions {
map: MindMap;
sizes?: Map<string, NodeSize>;
pixelRatio?: number;
transparent?: boolean;
backgroundColor?: string;
}
interface RenderContext {
element: HTMLElement;
width: number;
height: number;
options: Parameters<typeof toPng>[1];
}
function prepare(options: RenderOptions): RenderContext {
const element = findViewportElement();
const bounds = computeMapBounds(options.map, options.sizes);
const width = Math.min(MAX_IMAGE_DIMENSION, Math.ceil(bounds.width + EXPORT_PADDING * 2));
const height = Math.min(MAX_IMAGE_DIMENSION, Math.ceil(bounds.height + EXPORT_PADDING * 2));
const theme = getTheme(options.map.settings.theme);
const background = options.transparent
? undefined
: (options.backgroundColor ?? theme.canvasBackground);
return {
element,
width,
height,
options: {
width,
height,
pixelRatio: options.pixelRatio ?? 1,
backgroundColor: background,
cacheBust: true,
skipFonts: true,
filter: exportFilter as (node: HTMLElement) => boolean,
style: {
width: `${width}px`,
height: `${height}px`,
// Toute la carte est cadree, pas seulement la zone visible a l'ecran.
transform: `translate(${EXPORT_PADDING - bounds.x}px, ${EXPORT_PADDING - bounds.y}px) scale(1)`,
transformOrigin: '0 0',
},
},
};
}
/**
* Garde-fou : la bibliotheque de rendu attend une frame d'animation, qui n'est
* jamais produite si l'onglet passe en arriere-plan pendant la generation.
* Sans ce delai, l'export resterait bloque sans message.
*/
const EXPORT_TIMEOUT_MS = 45000;
function withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
window.setTimeout(
() =>
reject(
new ExportError(
"L'export a ete interrompu : il a pris trop de temps. " +
'Gardez cet onglet au premier plan pendant la generation.',
),
),
EXPORT_TIMEOUT_MS,
);
}),
]);
}
async function withExportMode<T>(run: () => Promise<T>): Promise<T> {
const canvas = findCanvasElement();
canvas?.classList.add('openmind-exporting');
try {
return await run();
} finally {
canvas?.classList.remove('openmind-exporting');
}
}
/** Rendu PNG de la carte complete. */
export async function renderMapToPng(options: RenderOptions): Promise<string> {
const context = prepare(options);
return withExportMode(async () => {
try {
return await withTimeout(toPng(context.element, context.options));
} catch (error) {
if (error instanceof ExportError) throw error;
throw new ExportError("L'image n'a pas pu etre generee. Reessayez.");
}
});
}
/**
* Rendu SVG de la carte complete.
*
* Limitation connue : les noeuds sont encapsules dans un `foreignObject`
* (HTML embarque). Le fichier reste vectoriel et redimensionnable sans perte,
* les liens sont de vrais chemins SVG, mais certains editeurs vectoriels
* n'exploitent pas le contenu HTML des noeuds.
*/
export async function renderMapToSvg(options: RenderOptions): Promise<string> {
const context = prepare(options);
return withExportMode(async () => {
try {
return await withTimeout(toSvg(context.element, context.options));
} catch (error) {
if (error instanceof ExportError) throw error;
throw new ExportError("Le SVG n'a pas pu etre genere. Reessayez.");
}
});
}
/**
* Convertit une data URL en Blob.
*
* La conversion est faite a la main plutot qu'avec `fetch(dataUrl)` : la CSP de
* l'application n'autorise que `connect-src 'self'`, ce qui bloquerait une
* requete vers une URL `data:`.
*/
export function dataUrlToBlob(dataUrl: string): Blob {
const separator = dataUrl.indexOf(',');
if (!dataUrl.startsWith('data:') || separator === -1) {
throw new ExportError("Le fichier genere n'a pas pu etre prepare.");
}
const header = dataUrl.slice(5, separator);
const payload = dataUrl.slice(separator + 1);
const isBase64 = header.endsWith(';base64');
const mimeType = (isBase64 ? header.slice(0, -';base64'.length) : header).split(';')[0] || 'application/octet-stream';
if (!isBase64) {
// Cas des SVG : le contenu est encode en pourcentage, pas en base64.
return new Blob([decodeURIComponent(payload)], { type: `${mimeType};charset=utf-8` });
}
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}

View File

@@ -0,0 +1,145 @@
import { type MindMap } from '@/types/map';
import { getTheme } from '@/features/editor/themes';
import { ExportError, renderMapToPng } from '@/features/export/exportImage';
import { type NodeSize } from '@/utils/nodeSize';
import { formatDate } from '@/utils/date';
export type PaperFormat = 'a4' | 'a3';
export type PageOrientation = 'portrait' | 'landscape';
export type PdfScaleMode = 'fit' | 'actual';
export interface PdfExportOptions {
paper: PaperFormat;
orientation: PageOrientation;
/** Marge en millimetres appliquee sur les quatre cotes. */
marginMm: number;
scaleMode: PdfScaleMode;
showBackground: boolean;
showTitle: boolean;
showDate: boolean;
}
export const DEFAULT_PDF_OPTIONS: PdfExportOptions = {
paper: 'a4',
orientation: 'landscape',
marginMm: 10,
scaleMode: 'fit',
showBackground: true,
showTitle: true,
showDate: true,
};
/** 96 dpi : conversion pixels CSS -> millimetres. */
const MM_PER_PX = 25.4 / 96;
export interface PdfExportResult {
blob: Blob;
/** Vrai si la carte a du etre reduite alors que "taille reelle" etait demande. */
downscaled: boolean;
}
export async function renderMapToPdf(
map: MindMap,
options: PdfExportOptions,
sizes?: Map<string, NodeSize>,
): Promise<PdfExportResult> {
const theme = getTheme(map.settings.theme);
const dataUrl = await renderMapToPng({
map,
sizes,
pixelRatio: 2,
transparent: false,
backgroundColor: options.showBackground ? theme.canvasBackground : '#ffffff',
});
const image = await loadImage(dataUrl);
// jsPDF est charge a la demande : il ne pese pas sur le premier affichage.
const { jsPDF } = await import('jspdf');
const document_ = new jsPDF({
orientation: options.orientation,
unit: 'mm',
format: options.paper,
compress: true,
});
const pageWidth = document_.internal.pageSize.getWidth();
const pageHeight = document_.internal.pageSize.getHeight();
const margin = Math.max(0, Math.min(40, options.marginMm));
let contentTop = margin;
let contentBottom = pageHeight - margin;
if (options.showTitle || options.showDate) {
document_.setFont('helvetica', 'normal');
if (options.showTitle) {
document_.setFontSize(13);
document_.setTextColor(30, 30, 40);
document_.text(map.name, margin, contentTop + 4, { maxWidth: pageWidth - margin * 2 - 40 });
}
if (options.showDate) {
document_.setFontSize(9);
document_.setTextColor(120, 120, 130);
document_.text(formatDate(map.updatedAt), pageWidth - margin, contentTop + 4, {
align: 'right',
});
}
contentTop += 10;
}
contentBottom -= 2;
const usableWidth = pageWidth - margin * 2;
const usableHeight = contentBottom - contentTop;
if (usableWidth <= 5 || usableHeight <= 5) {
throw new ExportError('Les marges choisies ne laissent pas assez de place pour la carte.');
}
// L'image est rendue en x2 : on revient d'abord a la taille CSS.
const cssWidth = image.width / 2;
const cssHeight = image.height / 2;
let widthMm: number;
let heightMm: number;
let downscaled = false;
if (options.scaleMode === 'actual') {
widthMm = cssWidth * MM_PER_PX;
heightMm = cssHeight * MM_PER_PX;
if (widthMm > usableWidth || heightMm > usableHeight) {
// On preserve la lisibilite plutot que de couper la carte.
const ratio = Math.min(usableWidth / widthMm, usableHeight / heightMm);
widthMm *= ratio;
heightMm *= ratio;
downscaled = true;
}
} else {
const ratio = Math.min(usableWidth / cssWidth, usableHeight / cssHeight);
widthMm = cssWidth * ratio;
heightMm = cssHeight * ratio;
}
// Centrage dans la zone utile.
const x = margin + (usableWidth - widthMm) / 2;
const y = contentTop + (usableHeight - heightMm) / 2;
if (options.showBackground) {
document_.setFillColor(theme.canvasBackground);
document_.rect(margin, contentTop, usableWidth, usableHeight, 'F');
}
document_.addImage(dataUrl, 'PNG', x, y, widthMm, heightMm, undefined, 'FAST');
return { blob: document_.output('blob'), downscaled };
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new ExportError("L'image de la carte n'a pas pu etre preparee."));
image.src = dataUrl;
});
}

View File

@@ -0,0 +1,39 @@
import { type PageOrientation, type PaperFormat } from '@/features/export/exportPdf';
export interface PrintOptions {
paper: PaperFormat;
orientation: PageOrientation;
marginMm: number;
}
export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
paper: 'a4',
orientation: 'landscape',
marginMm: 10,
};
const STYLE_ELEMENT_ID = 'openmind-print-page-rule';
/**
* Impression native du navigateur.
* La regle @page est injectee dynamiquement pour honorer le format et
* l'orientation choisis ; le CSS `@media print` masque toute l'interface.
*/
export function printMap(options: PrintOptions = DEFAULT_PRINT_OPTIONS): void {
const existing = document.getElementById(STYLE_ELEMENT_ID);
existing?.remove();
const style = document.createElement('style');
style.id = STYLE_ELEMENT_ID;
style.textContent = `@page { size: ${options.paper.toUpperCase()} ${options.orientation}; margin: ${options.marginMm}mm; }`;
document.head.appendChild(style);
const cleanup = () => {
document.getElementById(STYLE_ELEMENT_ID)?.remove();
window.removeEventListener('afterprint', cleanup);
};
window.addEventListener('afterprint', cleanup);
// Laisse le temps au navigateur d'appliquer la regle avant d'ouvrir l'apercu.
window.setTimeout(() => window.print(), 60);
}

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type MapSummary } from '@/types/map';
import { APP_CONFIG } from '@/config/app';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Modal } from '@/components/Modal';
import { ImportIcon, MoonIcon, PlusIcon, SearchIcon, SunIcon } from '@/components/Icons';
import { MapCard } from '@/features/maps/MapCard';
import {
createMap,
deleteMap,
duplicateMap,
getMap,
importMap,
listMaps,
renameMap,
seedSampleMapOnce,
} from '@/services/mapRepository';
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useUiStore } from '@/stores/uiStore';
import { buildMapPath, navigate } from '@/hooks/useRoute';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() {
const appName = useAppConfigStore((state) => state.name);
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
const [maps, setMaps] = useState<MapSummary[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [renaming, setRenaming] = useState<MapSummary | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleting, setDeleting] = useState<MapSummary | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => {
setLoading(true);
// La carte d'exemple du premier lancement doit exister avant de lister.
await seedSampleMapOnce();
setMaps(await listMaps());
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return maps;
return maps.filter((map) => map.name.toLowerCase().includes(needle));
}, [maps, query]);
const handleCreate = async () => {
const map = await createMap('Nouvelle carte');
navigate(buildMapPath(map.id));
};
const handleImportFile = async (file: File) => {
try {
const parsed = await readMapFile(file, maxMapSizeMb);
const stored = await importMap(parsed);
pushToast(`Carte "${stored.name}" importee.`, 'success');
navigate(buildMapPath(stored.id));
} catch (error) {
pushToast(
error instanceof MapFormatError
? error.message
: "Le fichier selectionne n'est pas une carte valide.",
'error',
);
}
};
const handleExport = async (summary: MapSummary) => {
const map = await getMap(summary.id);
if (!map) {
pushToast('Impossible de charger cette carte.', 'error');
return;
}
const blob = new Blob([serializeMap(map)], {
type: `${APP_CONFIG.fileMimeType};charset=utf-8`,
});
downloadBlob(blob, toSafeFilename(map.name, APP_CONFIG.fileExtension));
};
const handleDuplicate = async (summary: MapSummary) => {
const copy = await duplicateMap(summary.id);
if (copy) {
pushToast(`Copie creee : ${copy.name}`, 'success');
await refresh();
}
};
const confirmRename = async () => {
if (!renaming) return;
const trimmed = renameValue.trim();
if (trimmed) {
await renameMap(renaming.id, trimmed);
await refresh();
}
setRenaming(null);
};
const confirmDelete = async () => {
if (!deleting) return;
await deleteMap(deleting.id);
setDeleting(null);
pushToast('Carte supprimee.', 'success');
await refresh();
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col px-5 py-8 sm:px-8">
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{appName}
</h1>
<p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
{APP_CONFIG.tagline}
</p>
</div>
<IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
icon={colorMode === 'dark' ? <SunIcon /> : <MoonIcon />}
onClick={toggleColorMode}
/>
</header>
<div className="mb-6 flex flex-wrap items-center gap-2">
<Button variant="primary" onClick={() => void handleCreate()}>
<PlusIcon className="h-4 w-4" />
Nouvelle carte
</Button>
<Button onClick={() => fileInputRef.current?.click()}>
<ImportIcon className="h-4 w-4" />
Importer
</Button>
<input
ref={fileInputRef}
type="file"
accept={`${APP_CONFIG.fileExtension},application/json`}
className="sr-only"
aria-label="Importer un fichier de carte"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportFile(file);
}}
/>
<div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher une carte"
aria-label="Rechercher une carte"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
</div>
{loading ? (
<p className="text-sm text-zinc-500">Chargement des cartes...</p>
) : filtered.length === 0 ? (
<div className="panel flex flex-col items-center gap-3 px-6 py-14 text-center">
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
{query ? 'Aucune carte ne correspond' : 'Aucune carte pour le moment'}
</h2>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
{query
? 'Essayez un autre terme de recherche.'
: `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`}
</p>
{!query ? (
<Button variant="primary" onClick={() => void handleCreate()}>
<PlusIcon className="h-4 w-4" />
Nouvelle carte
</Button>
) : null}
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((map) => (
<li key={map.id}>
<MapCard
map={map}
onOpen={() => navigate(buildMapPath(map.id))}
onRename={() => {
setRenaming(map);
setRenameValue(map.name);
}}
onDuplicate={() => void handleDuplicate(map)}
onExport={() => void handleExport(map)}
onDelete={() => setDeleting(map)}
/>
</li>
))}
</ul>
)}
<footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont
envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '}
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
</footer>
<Modal
open={renaming !== null}
title="Renommer la carte"
onClose={() => setRenaming(null)}
size="sm"
footer={
<>
<Button onClick={() => setRenaming(null)}>Annuler</Button>
<Button variant="primary" onClick={() => void confirmRename()}>
Renommer
</Button>
</>
}
>
<label className="field-label" htmlFor="rename-input">
Nouveau nom
</label>
<input
id="rename-input"
className="field-input"
value={renameValue}
autoFocus
onChange={(event) => setRenameValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void confirmRename();
}}
/>
</Modal>
<ConfirmDialog
open={deleting !== null}
title="Supprimer cette carte ?"
message={`"${deleting?.name ?? ''}" sera definitivement supprimee de ce navigateur. Cette action est irreversible : exportez-la d'abord si vous souhaitez la conserver.`}
confirmLabel="Supprimer"
tone="danger"
onConfirm={() => void confirmDelete()}
onCancel={() => setDeleting(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { type MapSummary } from '@/types/map';
import { Menu, MenuItem, MenuSeparator } from '@/components/Menu';
import { IconButton } from '@/components/IconButton';
import {
CopyIcon,
DownloadIcon,
MapIcon,
PencilIcon,
ShareIcon,
SlidersIcon,
TrashIcon,
} from '@/components/Icons';
import { formatRelative } from '@/utils/date';
export interface MapCardProps {
map: MapSummary;
onOpen: () => void;
onRename: () => void;
onDuplicate: () => void;
onExport: () => void;
onDelete: () => void;
}
export function MapCard({ map, onOpen, onRename, onDuplicate, onExport, onDelete }: MapCardProps) {
return (
<article className="panel group flex flex-col gap-3 p-4 transition-shadow hover:shadow-lg">
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={onOpen}
className="min-w-0 flex-1 text-left"
aria-label={`Ouvrir la carte ${map.name}`}
>
<span className="flex items-center gap-2">
<MapIcon className="h-4 w-4 shrink-0 text-indigo-500" />
<span className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{map.name}
</span>
</span>
</button>
<Menu
label={`Actions pour ${map.name}`}
align="right"
trigger={
<IconButton
label="Actions"
icon={<SlidersIcon />}
className="opacity-60 group-hover:opacity-100"
/>
}
>
{(close) => (
<>
<MenuItem
label="Ouvrir"
onSelect={() => {
onOpen();
close();
}}
/>
<MenuItem
icon={<PencilIcon className="h-4 w-4" />}
label="Renommer"
onSelect={() => {
onRename();
close();
}}
/>
<MenuItem
icon={<CopyIcon className="h-4 w-4" />}
label="Dupliquer"
onSelect={() => {
onDuplicate();
close();
}}
/>
<MenuItem
icon={<DownloadIcon className="h-4 w-4" />}
label="Exporter (.mmap)"
onSelect={() => {
onExport();
close();
}}
/>
<MenuSeparator />
<MenuItem
icon={<TrashIcon className="h-4 w-4" />}
label="Supprimer"
tone="danger"
onSelect={() => {
onDelete();
close();
}}
/>
</>
)}
</Menu>
</div>
<button
type="button"
onClick={onOpen}
className="flex items-center gap-3 text-left text-xs text-zinc-500 dark:text-zinc-400"
tabIndex={-1}
aria-hidden="true"
>
<span>{map.nodeCount} noeuds</span>
<span aria-hidden="true">.</span>
<span>Modifiee {formatRelative(map.updatedAt)}</span>
</button>
{map.isShared ? (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
<ShareIcon className="h-3 w-3" />
Partagee
</span>
) : null}
</article>
);
}

View File

@@ -0,0 +1,239 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CheckIcon, CopyIcon, SpinnerIcon } from '@/components/Icons';
import { type ShareRecord } from '@/types/map';
import { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
import {
deleteShareRecord,
getShareRecord,
saveShareRecord,
} from '@/services/shareRepository';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { buildShareUrl } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date';
export interface ShareDialogProps {
open: boolean;
onClose: () => void;
onShareStateChange?: (shared: boolean) => void;
}
export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const [record, setRecord] = useState<ShareRecord | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const refresh = useCallback(async (mapId: string) => {
setLoading(true);
const existing = await getShareRecord(mapId);
setRecord(existing ?? null);
setLoading(false);
}, []);
useEffect(() => {
if (!open || !map) return;
void refresh(map.id);
}, [open, map, refresh]);
const handleError = (error: unknown, fallback: string) => {
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
};
const handleCreate = async () => {
if (!map || busy) return;
setBusy(true);
try {
const response = await createShare(map, map.name);
const created: ShareRecord = {
mapId: map.id,
shareToken: response.shareToken,
managementToken: response.managementToken,
sharePath: response.sharePath,
title: response.title,
createdAt: response.createdAt,
updatedAt: response.updatedAt,
};
await saveShareRecord(created);
setRecord(created);
onShareStateChange?.(true);
pushToast('Lien de partage cree.', 'success');
} catch (error) {
handleError(error, "Le partage n'a pas pu etre cree.");
} finally {
setBusy(false);
}
};
const handleUpdate = async () => {
if (!map || !record || busy) return;
setBusy(true);
try {
const response = await updateShare(
record.shareToken,
record.managementToken,
map,
map.name,
);
const updated = { ...record, title: map.name, updatedAt: response.updatedAt };
await saveShareRecord(updated);
setRecord(updated);
pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
} catch (error) {
handleError(error, "La mise a jour du partage n'a pas abouti.");
} finally {
setBusy(false);
}
};
const handleDelete = async () => {
if (!map || !record || busy) return;
setConfirmDelete(false);
setBusy(true);
try {
await deleteShare(record.shareToken, record.managementToken);
} catch (error) {
// Deja supprime cote serveur : on nettoie quand meme la reference locale.
if (!(error instanceof ShareApiError) || error.status !== 404) {
handleError(error, "Le partage n'a pas pu etre supprime.");
setBusy(false);
return;
}
}
await deleteShareRecord(map.id);
setRecord(null);
onShareStateChange?.(false);
setBusy(false);
pushToast('Partage supprime : le lien public ne fonctionne plus.', 'success');
};
const handleCopy = async () => {
if (!record) return;
const url = buildShareUrl(record.shareToken);
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
pushToast('Copie impossible : selectionnez le lien puis copiez-le manuellement.', 'error');
}
};
const shareUrl = record ? buildShareUrl(record.shareToken) : '';
return (
<>
<Modal
open={open}
title={record ? 'Carte partagee' : 'Partager cette carte'}
onClose={onClose}
footer={
record ? (
<>
<Button
variant="danger"
onClick={() => setConfirmDelete(true)}
disabled={busy}
>
Supprimer le partage
</Button>
<Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}>
{busy ? <SpinnerIcon className="h-4 w-4" /> : null}
Mettre a jour le partage
</Button>
</>
) : (
<>
<Button onClick={onClose} disabled={busy}>
Annuler
</Button>
<Button variant="primary" onClick={() => void handleCreate()} disabled={busy || !map}>
{busy ? <SpinnerIcon className="h-4 w-4" /> : null}
Creer le lien de partage
</Button>
</>
)
}
>
{loading ? (
<p className="text-sm text-zinc-500">Chargement...</p>
) : record ? (
<div className="space-y-4">
<div>
<span className="field-label">Lien public (lecture seule)</span>
<div className="flex items-center gap-2">
<input
readOnly
value={shareUrl}
aria-label="Lien public de la carte"
className="field-input font-mono text-xs"
onFocus={(event) => event.target.select()}
/>
<Button onClick={() => void handleCopy()} aria-label="Copier le lien">
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
{copied ? 'Copie' : 'Copier'}
</Button>
</div>
</div>
<dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Publie le</dt>
<dd className="text-zinc-700 dark:text-zinc-200">
{formatDateTime(record.createdAt)}
</dd>
</div>
<div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Derniere publication</dt>
<dd className="text-zinc-700 dark:text-zinc-200">
{formatDateTime(record.updatedAt)}
</dd>
</div>
</dl>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le lien est <strong>non repertorie</strong> : il n&apos;apparait dans aucune liste
publique, mais toute personne qui le connait peut consulter la carte. Les
modifications faites ici ne sont visibles publiquement qu&apos;apres un
<em> Mettre a jour le partage</em>.
</p>
</div>
) : (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p>
Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour
generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base
de donnees.
</p>
<ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400">
<li>Le lien obtenu est non repertorie et en lecture seule.</li>
<li>Aucun compte n&apos;est necessaire pour le consulter.</li>
<li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li>
<li>
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
appareil, vous ne pourrez pas gerer ce partage.
</li>
</ul>
</div>
)}
</Modal>
<ConfirmDialog
open={confirmDelete}
title="Supprimer le partage ?"
message="Le lien public cessera immediatement de fonctionner et la copie serveur sera effacee. Votre carte locale n'est pas affectee."
confirmLabel="Supprimer le partage"
tone="danger"
onConfirm={() => void handleDelete()}
onCancel={() => setConfirmDelete(false)}
/>
</>
);
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import {
FitIcon,
MapIcon,
MoonIcon,
SpinnerIcon,
SunIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@/components/Icons';
import { ExportMenu } from '@/features/export/ExportMenu';
import { MindMapCanvas } from '@/features/editor/MindMapCanvas';
import { ShareApiError, fetchSharedMap } from '@/services/shareApi';
import { MapFormatError } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date';
export interface SharedMapPageProps {
token: string;
}
type Status =
| { kind: 'loading' }
| { kind: 'ready'; title: string; updatedAt: string }
| { kind: 'error'; message: string };
function SharedMapContent({ token }: SharedMapPageProps) {
const appName = useAppConfigStore((state) => state.name);
const loadMap = useEditorStore((state) => state.loadMap);
const closeMap = useEditorStore((state) => state.closeMap);
const requestFit = useEditorStore((state) => state.requestFit);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const { zoomIn, zoomOut } = useReactFlow();
const [status, setStatus] = useState<Status>({ kind: 'loading' });
useEffect(() => {
let cancelled = false;
setStatus({ kind: 'loading' });
fetchSharedMap(token)
.then((response) => {
if (cancelled) return;
loadMap(response.map, { readOnly: true });
setStatus({ kind: 'ready', title: response.title, updatedAt: response.updatedAt });
document.title = `${response.title} - ${appName}`;
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ShareApiError || error instanceof MapFormatError
? error.message
: 'Cette carte partagee est introuvable.';
setStatus({ kind: 'error', message });
});
return () => {
cancelled = true;
closeMap();
};
}, [token, loadMap, closeMap, appName]);
if (status.kind === 'loading') {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
<SpinnerIcon className="h-5 w-5" />
<span className="ml-2 text-sm">Chargement de la carte partagee...</span>
</div>
);
}
if (status.kind === 'error') {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Le lien a peut-etre ete supprime par son auteur, ou l&apos;adresse est incorrecte.
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Ouvrir {appName}
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<header className="no-print flex h-14 shrink-0 items-center gap-3 border-b border-zinc-200 bg-white px-4 dark:border-zinc-800 dark:bg-zinc-900">
<MapIcon className="h-5 w-5 shrink-0 text-indigo-500" />
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{status.title}
</h1>
<p className="truncate text-[11px] text-zinc-400">
Carte partagee - lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
</p>
</div>
<div className="ml-auto flex items-center gap-1">
<IconButton label="Zoom avant" icon={<ZoomInIcon />} onClick={() => zoomIn()} />
<IconButton label="Zoom arriere" icon={<ZoomOutIcon />} onClick={() => zoomOut()} />
<IconButton label="Adapter a l'ecran" icon={<FitIcon />} onClick={requestFit} />
<IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
icon={colorMode === 'dark' ? <SunIcon /> : <MoonIcon />}
onClick={toggleColorMode}
/>
<ExportMenu allowNativeExport={false} />
</div>
</header>
<div className="relative min-h-0 flex-1">
<h2 className="print-title px-2">{status.title}</h2>
<MindMapCanvas readOnly showMiniMap={false} />
</div>
<footer className="no-print flex h-8 shrink-0 items-center justify-center border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
Carte partagee via {appName} - aucune inscription necessaire
</footer>
</div>
);
}
export function SharedMapPage(props: SharedMapPageProps) {
return (
<ReactFlowProvider>
<SharedMapContent {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react';
/**
* Execute `effect` apres `delay` ms d'inactivite sur `deps`.
* Utilise pour l'autosave : on n'ecrit pas en base a chaque micro-mouvement.
*/
export function useDebouncedEffect(effect: () => void, deps: unknown[], delay: number): void {
const callbackRef = useRef(effect);
callbackRef.current = effect;
useEffect(() => {
const timer = window.setTimeout(() => callbackRef.current(), delay);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, delay]);
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { APP_CONFIG } from '@/config/app';
export type Route =
| { name: 'dashboard' }
| { name: 'editor'; mapId: string }
| { name: 'shared'; token: string }
| { name: 'notFound' };
const NAVIGATION_EVENT = 'openmind:navigate';
export function parseRoute(pathname: string): Route {
const clean = pathname.replace(/\/+$/, '') || '/';
if (clean === '/' || clean === '') return { name: 'dashboard' };
const editorMatch = clean.match(/^\/map\/([^/]+)$/);
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };
const sharePattern = new RegExp(`^${APP_CONFIG.sharePathPrefix}([^/]+)$`);
const shareMatch = clean.match(sharePattern);
if (shareMatch?.[1]) return { name: 'shared', token: decodeURIComponent(shareMatch[1]) };
return { name: 'notFound' };
}
/** Navigation cote client, sans dependance a un routeur externe. */
export function navigate(path: string, options: { replace?: boolean } = {}): void {
if (options.replace) {
window.history.replaceState({}, '', path);
} else {
window.history.pushState({}, '', path);
}
window.dispatchEvent(new Event(NAVIGATION_EVENT));
}
export function useRoute(): Route {
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname));
useEffect(() => {
const sync = () => setRoute(parseRoute(window.location.pathname));
window.addEventListener('popstate', sync);
window.addEventListener(NAVIGATION_EVENT, sync);
return () => {
window.removeEventListener('popstate', sync);
window.removeEventListener(NAVIGATION_EVENT, sync);
};
}, []);
return route;
}
export function buildMapPath(mapId: string): string {
return `/map/${encodeURIComponent(mapId)}`;
}
export function buildSharePath(token: string): string {
return `${APP_CONFIG.sharePathPrefix}${encodeURIComponent(token)}`;
}
export function buildShareUrl(token: string): string {
return `${window.location.origin}${buildSharePath(token)}`;
}

29
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@/App';
import { initializeColorMode } from '@/stores/uiStore';
import '@/styles/index.css';
import '@/styles/print.css';
initializeColorMode();
const container = document.getElementById('root');
if (!container) {
throw new Error('Element racine introuvable.');
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
// Service worker : permet d'ouvrir et d'editer les cartes locales hors ligne
// une fois l'application chargee au moins une fois.
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// L'echec d'enregistrement ne doit jamais empecher l'application de fonctionner.
});
});
}

View File

@@ -0,0 +1,131 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
export const DEFAULT_SETTINGS = {
theme: 'default',
layout: 'mindmap-right',
branchStyle: 'bezier',
showGrid: true,
} as const;
function nowIso(): string {
return new Date().toISOString();
}
export function createNode(partial: Partial<MindMapNode> & { text: string }): MindMapNode {
return {
id: partial.id ?? createId(),
type: partial.type ?? 'standard',
text: partial.text,
position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null,
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}),
...(partial.note ? { note: partial.note } : {}),
...(partial.style ? { style: partial.style } : {}),
};
}
export function createEmptyMap(name: string): MindMap {
const timestamp = nowIso();
const root = createNode({
type: 'root',
text: name || 'Nouvelle carte',
position: { x: 0, y: 0 },
});
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: name || 'Nouvelle carte',
createdAt: timestamp,
updatedAt: timestamp,
nodes: [root],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
}
/** Copie profonde avec nouveaux identifiants : aucune collision avec l'original. */
export function cloneMap(map: MindMap, name?: string): MindMap {
const timestamp = nowIso();
const idMapping = new Map<string, string>();
for (const node of map.nodes) idMapping.set(node.id, createId());
const nodes = map.nodes.map((node) => ({
...node,
id: idMapping.get(node.id) as string,
parentId: node.parentId ? (idMapping.get(node.parentId) ?? null) : null,
position: { ...node.position },
...(node.style ? { style: { ...node.style } } : {}),
}));
return {
...map,
id: createId(),
name: name ?? map.name,
createdAt: timestamp,
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
viewport: { ...map.viewport },
settings: { ...map.settings },
};
}
/**
* Carte d'exemple creee au premier lancement.
* Volontairement minimale : elle sert a comprendre l'outil en dix secondes.
*/
export function createSampleMap(): MindMap {
const timestamp = nowIso();
const root = createNode({
id: createId(),
type: 'root',
text: 'Mon projet',
position: { x: 0, y: 0 },
});
const branches: Array<{ text: string; icon: string; children?: string[] }> = [
{ text: 'Idees', icon: '💡', children: ['Brainstorming', 'Inspirations'] },
{ text: 'Taches', icon: '✅', children: ['A faire', 'En cours'] },
{ text: 'Documentation', icon: '📄' },
{ text: 'Ressources', icon: '🔗' },
];
const nodes: MindMapNode[] = [root];
branches.forEach((branch, index) => {
const node = createNode({
text: branch.text,
icon: branch.icon,
parentId: root.id,
position: { x: 260, y: index * 90 - 135 },
});
nodes.push(node);
branch.children?.forEach((childText, childIndex) => {
nodes.push(
createNode({
text: childText,
parentId: node.id,
position: { x: 500, y: index * 90 - 135 + childIndex * 46 - 23 },
}),
);
});
});
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: 'Exemple : Mon projet',
createdAt: timestamp,
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
}

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/db/database';
import {
createMap,
deleteMap,
duplicateMap,
getMap,
importMap,
listMaps,
renameMap,
seedSampleMapOnce,
} from '@/services/mapRepository';
import { saveShareRecord } from '@/services/shareRepository';
import { createSampleMap } from '@/services/mapFactory';
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('stockage IndexedDB des cartes', () => {
it('cree, relit et liste une carte', async () => {
const map = await createMap('Homelab');
const stored = await getMap(map.id);
expect(stored?.name).toBe('Homelab');
expect(stored?.nodes).toHaveLength(1);
const list = await listMaps();
expect(list).toHaveLength(1);
expect(list[0]?.nodeCount).toBe(1);
expect(list[0]?.isShared).toBe(false);
});
it('renomme une carte', async () => {
const map = await createMap('Avant');
await renameMap(map.id, 'Apres');
expect((await getMap(map.id))?.name).toBe('Apres');
});
it('duplique une carte avec de nouveaux identifiants', async () => {
const map = await createMap('Original');
const copy = await duplicateMap(map.id);
expect(copy?.id).not.toBe(map.id);
expect(copy?.name).toBe('Original (copie)');
expect(copy?.nodes[0]?.id).not.toBe(map.nodes[0]?.id);
expect(await listMaps()).toHaveLength(2);
});
it('supprime une carte et sa reference de partage locale', async () => {
const map = await createMap('A supprimer');
await saveShareRecord({
mapId: map.id,
shareToken: 'public-token',
managementToken: 'secret-token',
sharePath: '/s/public-token',
title: map.name,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect((await listMaps())[0]?.isShared).toBe(true);
await deleteMap(map.id);
expect(await getMap(map.id)).toBeUndefined();
expect(await db.shares.get(map.id)).toBeUndefined();
});
it('importe une carte sous un nouvel identifiant', async () => {
const source = createSampleMap();
const first = await importMap(source);
const second = await importMap(source);
expect(first.id).not.toBe(source.id);
expect(second.id).not.toBe(first.id);
expect(await listMaps()).toHaveLength(2);
});
it('ajoute la carte d exemple une seule fois', async () => {
await seedSampleMapOnce();
expect(await listMaps()).toHaveLength(1);
await seedSampleMapOnce();
expect(await listMaps()).toHaveLength(1);
});
it('n ajoute pas d exemple si des cartes existent deja', async () => {
await createMap('Deja la');
await seedSampleMapOnce();
const list = await listMaps();
expect(list).toHaveLength(1);
expect(list[0]?.name).toBe('Deja la');
});
it('conserve les cartes apres reouverture de la base', async () => {
const map = await createMap('Persistante');
db.close();
await db.open();
expect((await getMap(map.id))?.name).toBe('Persistante');
});
});

View File

@@ -0,0 +1,98 @@
import { db, META_KEYS } from '@/db/database';
import { type MindMap, type MapSummary } from '@/types/map';
import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository';
/** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */
export async function listMaps(): Promise<MapSummary[]> {
const [maps, shares] = await Promise.all([db.maps.toArray(), listShareRecords()]);
const sharedIds = new Set(shares.map((share) => share.mapId));
return maps
.map((map) => ({
id: map.id,
name: map.name,
createdAt: map.createdAt,
updatedAt: map.updatedAt,
nodeCount: map.nodes.length,
isShared: sharedIds.has(map.id),
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function getMap(id: string): Promise<MindMap | undefined> {
return db.maps.get(id);
}
export async function saveMap(map: MindMap): Promise<void> {
await db.maps.put(map);
}
export async function createMap(name: string): Promise<MindMap> {
const map = createEmptyMap(name);
await db.maps.put(map);
return map;
}
export async function renameMap(id: string, name: string): Promise<void> {
const map = await db.maps.get(id);
if (!map) return;
await db.maps.put({ ...map, name, updatedAt: new Date().toISOString() });
}
export async function duplicateMap(id: string): Promise<MindMap | undefined> {
const map = await db.maps.get(id);
if (!map) return undefined;
const copy = cloneMap(map, `${map.name} (copie)`);
await db.maps.put(copy);
return copy;
}
/** Supprime la carte locale. Le partage serveur eventuel n'est pas touche ici. */
export async function deleteMap(id: string): Promise<void> {
await db.maps.delete(id);
await deleteShareRecord(id);
}
export async function importMap(map: MindMap): Promise<MindMap> {
// Un import cree toujours une nouvelle carte : jamais d'ecrasement silencieux.
const stored: MindMap = {
...map,
id: createId(),
updatedAt: new Date().toISOString(),
};
await db.maps.put(stored);
return stored;
}
export async function isShared(mapId: string): Promise<boolean> {
return (await getShareRecord(mapId)) !== undefined;
}
/**
* Ajoute une carte d'exemple au tout premier lancement, pour que l'outil soit
* comprehensible immediatement. Ne s'execute qu'une seule fois.
*
* La promesse est memorisee : plusieurs appels concurrents (StrictMode, montage
* simultane de composants) partagent la meme execution et attendent son
* resultat, afin que la liste des cartes ne soit jamais lue trop tot.
*/
let seedingPromise: Promise<void> | null = null;
async function runSeed(): Promise<void> {
const flag = await db.meta.get(META_KEYS.sampleSeeded);
if (flag) return;
const count = await db.maps.count();
if (count === 0) {
await db.maps.put(createSampleMap());
}
await db.meta.put({ key: META_KEYS.sampleSeeded, value: true });
}
export function seedSampleMapOnce(): Promise<void> {
seedingPromise ??= runSeed();
return seedingPromise;
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION } from '@/types/map';
import { createSampleMap } from '@/services/mapFactory';
import {
MapFormatError,
parseMapDocument,
serializeMap,
} from '@/services/mapSerialization';
describe('serialisation des cartes', () => {
it('effectue un aller-retour sans perte', () => {
const original = createSampleMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original)));
expect(restored.name).toBe(original.name);
expect(restored.nodes).toHaveLength(original.nodes.length);
expect(restored.edges).toHaveLength(original.edges.length);
expect(restored.settings.theme).toBe(original.settings.theme);
expect(restored.nodes[0]?.type).toBe('root');
});
it('rejette un contenu qui n est pas un objet', () => {
expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => parseMapDocument(null)).toThrow(MapFormatError);
expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError);
});
it('rejette une carte sans version', () => {
const map = createSampleMap() as unknown as Record<string, unknown>;
delete map.version;
expect(() => parseMapDocument(map)).toThrow(/version/i);
});
it('rejette une version de format plus recente', () => {
const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 };
expect(() => parseMapDocument(map)).toThrow(/plus recente/i);
});
it('rejette une carte sans noeud exploitable', () => {
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
MapFormatError,
);
});
it('applique la limite de noeuds', () => {
const nodes = Array.from({ length: 12 }, (_, index) => ({
id: `n${index}`,
text: `Noeud ${index}`,
parentId: index === 0 ? null : 'n0',
}));
expect(() => parseMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
/limite/i,
);
});
it('neutralise les URL dangereuses', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
name: 'Test',
nodes: [
{ id: 'a', type: 'root', text: 'Racine', url: 'javascript:alert(1)' },
{ id: 'b', text: 'Lien', parentId: 'a', url: 'exemple.fr' },
{ id: 'c', text: 'Data', parentId: 'a', url: 'data:text/html,<script>' },
],
});
expect(parsed.nodes[0]?.url).toBeUndefined();
expect(parsed.nodes[1]?.url).toBe('https://exemple.fr/');
expect(parsed.nodes[2]?.url).toBeUndefined();
});
it('conserve le texte tel quel sans interpreter le HTML', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }],
});
expect(parsed.nodes[0]?.text).toBe('<img src=x onerror=alert(1)>');
});
it('reconstruit la hierarchie a partir des liens', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [
{ id: 'a', type: 'root', text: 'Racine' },
{ id: 'b', text: 'Enfant' },
],
edges: [{ id: 'e1', source: 'a', target: 'b' }],
});
expect(parsed.nodes.find((node) => node.id === 'b')?.parentId).toBe('a');
expect(parsed.edges).toHaveLength(1);
expect(parsed.edges[0]?.source).toBe('a');
});
it('ignore les liens pointant vers des noeuds inexistants', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: 'Racine' }],
edges: [{ id: 'e1', source: 'a', target: 'fantome' }],
});
expect(parsed.edges).toHaveLength(0);
});
it('ignore les styles invalides mais garde les styles valides', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
style: { backgroundColor: 'url(javascript:1)', textColor: '#ff0000', shape: 'triangle' },
},
],
});
expect(parsed.nodes[0]?.style?.backgroundColor).toBeUndefined();
expect(parsed.nodes[0]?.style?.textColor).toBe('#ff0000');
expect(parsed.nodes[0]?.style?.shape).toBeUndefined();
});
});

View File

@@ -0,0 +1,293 @@
import { APP_CONFIG } from '@/config/app';
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapNode,
type NodeKind,
type NodeShape,
type NodeStyle,
type TextAlign,
} from '@/types/map';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
export class MapFormatError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapFormatError';
}
}
const MAX_TEXT_LENGTH = 5000;
const MAX_NOTE_LENGTH = 20000;
const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
'tree-horizontal',
'tree-vertical',
'mindmap-both',
'organization',
'freeform',
];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.slice(0, maxLength);
}
function asFiniteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/** Une couleur CSS acceptee : hex, rgb(a) ou nom simple. Rien d'autre. */
function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim().slice(0, 40);
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
if (/^rgba?\(\s*[\d.\s,%/]+\)$/.test(trimmed)) return trimmed;
if (/^[a-zA-Z]{3,20}$/.test(trimmed)) return trimmed;
return undefined;
}
function parseStyle(value: unknown): NodeStyle | undefined {
if (!isRecord(value)) return undefined;
const style: NodeStyle = {};
const background = asColor(value.backgroundColor);
if (background) style.backgroundColor = background;
const text = asColor(value.textColor);
if (text) style.textColor = text;
const border = asColor(value.borderColor);
if (border) style.borderColor = border;
if (typeof value.shape === 'string' && NODE_SHAPES.includes(value.shape as NodeShape)) {
style.shape = value.shape as NodeShape;
}
if (typeof value.fontSize === 'number' && Number.isFinite(value.fontSize)) {
style.fontSize = Math.min(48, Math.max(10, Math.round(value.fontSize)));
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
style.width = Math.min(600, Math.max(80, Math.round(value.width)));
}
if (typeof value.bold === 'boolean') style.bold = value.bold;
if (typeof value.italic === 'boolean') style.italic = value.italic;
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign;
}
return Object.keys(style).length > 0 ? style : undefined;
}
function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null;
const id = asString(value.id, 128);
if (!id) return null;
const type =
typeof value.type === 'string' && NODE_KINDS.includes(value.type as NodeKind)
? (value.type as NodeKind)
: 'standard';
const rawPosition = isRecord(value.position) ? value.position : {};
const node: MindMapNode = {
id,
type,
text: asString(value.text, MAX_TEXT_LENGTH) ?? '',
position: {
x: asFiniteNumber(rawPosition.x, 0),
y: asFiniteNumber(rawPosition.y, 0),
},
parentId: asString(value.parentId, 128) ?? null,
};
if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8);
if (icon) node.icon = icon;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url;
const note = asString(value.note, MAX_NOTE_LENGTH);
if (note) node.note = note;
const style = parseStyle(value.style);
if (style) node.style = style;
if (isRecord(value.meta)) node.meta = value.meta;
return node;
}
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
if (typeof value.theme === 'string') settings.theme = value.theme.slice(0, 40);
if (typeof value.layout === 'string' && LAYOUTS.includes(value.layout as LayoutKind)) {
settings.layout = value.layout as LayoutKind;
}
if (
typeof value.branchStyle === 'string' &&
BRANCH_STYLES.includes(value.branchStyle as BranchStyle)
) {
settings.branchStyle = value.branchStyle as BranchStyle;
}
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
return settings;
}
/**
* Point d'extension des migrations de format.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
* recente, avant la normalisation finale.
*/
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
const document = raw;
const current = version;
// Exemple d'evolution future :
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
if (current !== MAP_FORMAT_VERSION) {
return { ...document, version: MAP_FORMAT_VERSION };
}
return document;
}
/**
* Valide et normalise un document carte provenant d'un fichier ou du serveur.
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue,
* ni injecte en HTML.
*/
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap {
if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide.");
}
const version = raw.version;
if (typeof version !== 'number' || !Number.isFinite(version) || version < 1) {
throw new MapFormatError(
"Ce fichier ne precise aucune version de format : il n'est pas reconnu comme une carte.",
);
}
if (version > MAP_FORMAT_VERSION) {
throw new MapFormatError(
'Cette carte a ete creee avec une version plus recente du format (v' +
version +
"). Mettez a jour l'application pour l'ouvrir.",
);
}
const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents).");
}
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
if (document.nodes.length > maxNodes) {
throw new MapFormatError(
'Cette carte contient ' +
document.nodes.length +
' noeuds : la limite est de ' +
maxNodes +
'.',
);
}
const seen = new Set<string>();
const nodes: MindMapNode[] = [];
for (const rawNode of document.nodes) {
const node = parseNode(rawNode);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
}
if (nodes.length === 0) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
}
const rawEdges = Array.isArray(document.edges) ? document.edges : [];
const parsedEdges = rawEdges
.filter(isRecord)
.map((edge) => ({
id: asString(edge.id, 256) ?? createId(),
source: asString(edge.source, 128) ?? '',
target: asString(edge.target, 128) ?? '',
}))
.filter((edge) => seen.has(edge.source) && seen.has(edge.target));
// La hierarchie fait autorite ; les liens en sont deduits.
const linked = rebuildHierarchyFromEdges(nodes, parsedEdges).map((node) => ({
...node,
parentId: node.parentId && seen.has(node.parentId) ? node.parentId : null,
}));
// Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null };
}
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString();
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked,
edges: rebuildEdges(linked),
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))),
},
settings: parseSettings(document.settings),
};
}
/** Lit un fichier .mmap choisi par l'utilisateur et retourne la carte validee. */
export async function readMapFile(
file: File,
maxSizeMb: number = APP_CONFIG.maxMapSizeMb,
): Promise<MindMap> {
if (file.size > maxSizeMb * 1024 * 1024) {
throw new MapFormatError('Ce fichier depasse la taille maximale de ' + maxSizeMb + ' Mo.');
}
const text = await file.text();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
}
return parseMapDocument(raw);
}

View File

@@ -0,0 +1,164 @@
import { type MindMap } from '@/types/map';
import { parseMapDocument } from '@/services/mapSerialization';
/** Erreur reseau/serveur deja traduite en message utilisateur. */
export class ShareApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = 'ShareApiError';
this.status = status;
}
}
export interface ShareCreatedResponse {
shareToken: string;
managementToken: string;
sharePath: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface SharedMapResponse {
shareToken: string;
title: string;
map: MindMap;
createdAt: string;
updatedAt: string;
}
const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
function messageForStatus(status: number, serverDetail?: string): string {
if (status === 404) return "Ce partage n'existe plus.";
if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur.";
if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.';
if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur.";
if (status >= 500) return 'Le serveur de partage est momentanement indisponible.';
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
});
} catch {
// Hors ligne, ou backend injoignable : l'application locale reste utilisable.
throw new ShareApiError('Le serveur de partage est momentanement indisponible.', 0);
}
if (response.status === 204) {
return undefined as T;
}
let payload: unknown = null;
const text = await response.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const detail =
payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: undefined;
throw new ShareApiError(messageForStatus(response.status, detail), response.status);
}
return payload as T;
}
interface RawShareCreated {
share_token: string;
management_token: string;
share_path: string;
title: string;
created_at: string;
updated_at: string;
}
interface RawSharePublic {
share_token: string;
title: string;
map_version: number;
map: unknown;
created_at: string;
updated_at: string;
}
/** Publie un snapshot de la carte. Appele uniquement sur action explicite. */
export async function createShare(map: MindMap, title: string): Promise<ShareCreatedResponse> {
const raw = await request<RawShareCreated>('/api/shares', {
method: 'POST',
body: JSON.stringify({ title, map }),
});
return {
shareToken: raw.share_token,
managementToken: raw.management_token,
sharePath: raw.share_path,
title: raw.title,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export async function updateShare(
shareToken: string,
managementToken: string,
map: MindMap,
title: string,
): Promise<{ updatedAt: string }> {
const raw = await request<{ updated_at: string }>(
`/api/shares/${encodeURIComponent(shareToken)}`,
{
method: 'PUT',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
body: JSON.stringify({ title, map }),
},
);
return { updatedAt: raw.updated_at };
}
export async function deleteShare(shareToken: string, managementToken: string): Promise<void> {
await request<void>(`/api/shares/${encodeURIComponent(shareToken)}`, {
method: 'DELETE',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
});
}
/** Lecture publique : le document recu est valide comme un fichier importe. */
export async function fetchSharedMap(shareToken: string): Promise<SharedMapResponse> {
const raw = await request<RawSharePublic>(`/api/shares/${encodeURIComponent(shareToken)}`);
return {
shareToken: raw.share_token,
title: raw.title,
map: parseMapDocument(raw.map),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export interface PublicAppConfig {
appName: string;
maxNodes: number;
maxMapSizeMb: number;
sharingEnabled: boolean;
}
export async function fetchAppConfig(): Promise<PublicAppConfig> {
return request<PublicAppConfig>('/api/config');
}

View File

@@ -0,0 +1,22 @@
import { db } from '@/db/database';
import { type ShareRecord } from '@/types/map';
/**
* Les jetons de partage vivent uniquement dans l'IndexedDB du proprietaire.
* Le `managementToken` n'est jamais place dans une URL ni exporte en .mmap.
*/
export async function getShareRecord(mapId: string): Promise<ShareRecord | undefined> {
return db.shares.get(mapId);
}
export async function listShareRecords(): Promise<ShareRecord[]> {
return db.shares.toArray();
}
export async function saveShareRecord(record: ShareRecord): Promise<void> {
await db.shares.put(record);
}
export async function deleteShareRecord(mapId: string): Promise<void> {
await db.shares.delete(mapId);
}

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { APP_CONFIG } from '@/config/app';
import { fetchAppConfig } from '@/services/shareApi';
interface AppConfigState {
name: string;
maxNodes: number;
maxMapSizeMb: number;
/** Faux tant que le backend n'a pas repondu : l'app reste utilisable hors ligne. */
serverReachable: boolean;
load: () => Promise<void>;
}
/**
* Le nom du produit vient de la configuration serveur (APP_NAME), avec repli
* sur `APP_CONFIG.name`. Aucun composant ne doit ecrire le nom en dur.
*/
export const useAppConfigStore = create<AppConfigState>((set) => ({
name: APP_CONFIG.name,
maxNodes: APP_CONFIG.maxNodes,
maxMapSizeMb: APP_CONFIG.maxMapSizeMb,
serverReachable: false,
load: async () => {
try {
const config = await fetchAppConfig();
set({
name: config.appName || APP_CONFIG.name,
maxNodes: config.maxNodes || APP_CONFIG.maxNodes,
maxMapSizeMb: config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
serverReachable: true,
});
if (typeof document !== 'undefined') {
document.title = config.appName || APP_CONFIG.name;
}
} catch {
// Hors ligne : on garde les valeurs par defaut, sans bloquer l'interface.
set({ serverReachable: false });
}
},
}));

View File

@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Carte de test'));
});
describe('store de l editeur', () => {
it('cree un enfant et le lien correspondant', () => {
const parent = rootId();
const childId = store().addChild(parent);
expect(childId).toBeTruthy();
expect(store().map?.nodes).toHaveLength(2);
expect(store().map?.edges).toHaveLength(1);
expect(store().map?.edges[0]?.source).toBe(parent);
expect(store().selectedNodeIds).toEqual([childId]);
expect(store().editingNodeId).toBe(childId);
});
it('cree un frere au meme niveau', () => {
const child = store().addChild(rootId()) as string;
const sibling = store().addSibling(child);
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(3);
expect(nodes.find((node) => node.id === sibling)?.parentId).toBe(rootId());
});
it('supprime la branche entiere', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().addChild(branch);
expect(store().map?.nodes).toHaveLength(4);
store().deleteNodes([branch]);
expect(store().map?.nodes).toHaveLength(1);
expect(store().map?.edges).toHaveLength(0);
});
it('annule et retablit une suppression', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
const before = store().map?.nodes.length;
store().deleteNodes([branch]);
expect(store().map?.nodes).toHaveLength(1);
store().undo();
expect(store().map?.nodes).toHaveLength(before as number);
store().redo();
expect(store().map?.nodes).toHaveLength(1);
});
it('ne cree pas d entree d historique pendant un deplacement', () => {
const child = store().addChild(rootId()) as string;
const historyLength = store().past.length;
store().moveNodes([{ id: child, position: { x: 10, y: 10 } }]);
store().moveNodes([{ id: child, position: { x: 20, y: 20 } }]);
store().moveNodes([{ id: child, position: { x: 30, y: 30 } }]);
expect(store().past.length).toBe(historyLength);
expect(store().map?.nodes.find((node) => node.id === child)?.position).toEqual({
x: 30,
y: 30,
});
});
it('regroupe un deplacement en une seule annulation', () => {
const child = store().addChild(rootId()) as string;
const originalPosition = store().map?.nodes.find((node) => node.id === child)?.position;
// Un glisser-deposer : une seule entree d'historique au debut du drag.
store().pushHistory();
store().moveNodes([{ id: child, position: { x: 10, y: 10 } }]);
store().moveNodes([{ id: child, position: { x: 50, y: 50 } }]);
store().undo();
expect(store().map?.nodes.find((node) => node.id === child)?.position).toEqual(
originalPosition,
);
});
it('copie et colle une branche avec de nouveaux identifiants', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().setSelection([branch]);
expect(store().copySelection()).toBe(2);
store().setSelection([rootId()]);
expect(store().paste()).toBe(2);
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(5);
expect(new Set(nodes.map((node) => node.id)).size).toBe(5);
});
it('duplique la selection au meme niveau', () => {
const branch = store().addChild(rootId()) as string;
store().setSelection([branch]);
expect(store().duplicateSelection()).toBe(1);
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(3);
expect(nodes.filter((node) => node.parentId === rootId())).toHaveLength(2);
});
it('replie une branche et compte les descendants masques', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().toggleCollapse(branch);
expect(store().map?.nodes.find((node) => node.id === branch)?.collapsed).toBe(true);
});
it('refuse un rattachement qui creerait une boucle', () => {
const parent = store().addChild(rootId()) as string;
const child = store().addChild(parent) as string;
expect(store().reparentNode(parent, child)).toBe(false);
expect(store().map?.nodes.find((node) => node.id === parent)?.parentId).toBe(rootId());
});
it('accepte un rattachement valide', () => {
const first = store().addChild(rootId()) as string;
const second = store().addChild(rootId()) as string;
expect(store().reparentNode(second, first)).toBe(true);
expect(store().map?.nodes.find((node) => node.id === second)?.parentId).toBe(first);
});
it('trouve les noeuds par recherche insensible aux accents', () => {
const child = store().addChild(rootId()) as string;
store().updateNodeText(child, 'Reseau prive');
store().setSearchQuery('reseau');
expect(store().searchResults).toContain(child);
store().setSearchQuery('inexistant');
expect(store().searchResults).toHaveLength(0);
});
it('applique une organisation automatique sans perdre de noeud', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().addChild(branch);
store().applyAutoLayout('tree-vertical');
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(4);
expect(store().map?.settings.layout).toBe('tree-vertical');
expect(new Set(nodes.map((node) => `${node.position.x}:${node.position.y}`)).size).toBe(4);
});
it('conserve les personnalisations de noeud quand le theme change', () => {
const child = store().addChild(rootId()) as string;
store().updateNodeStyle(child, { backgroundColor: '#123456' });
store().updateSettings({ theme: 'midnight' });
expect(store().map?.nodes.find((node) => node.id === child)?.style?.backgroundColor).toBe(
'#123456',
);
});
it('bloque toute modification en lecture seule', () => {
store().loadMap(createEmptyMap('Publique'), { readOnly: true });
const before = store().map?.nodes.length;
store().addChild(rootId());
expect(store().map?.nodes.length).toBe(before);
});
});

View File

@@ -0,0 +1,652 @@
import { create } from 'zustand';
import { APP_CONFIG } from '@/config/app';
import {
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapEdge,
type MindMapNode,
type NodeStyle,
type Position,
type Viewport,
} from '@/types/map';
import { createNode } from '@/services/mapFactory';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { createId } from '@/utils/id';
import { type NodeSize } from '@/utils/nodeSize';
import {
buildNodeMap,
rebuildEdges,
suggestChildPosition,
withDescendants,
wouldCreateCycle,
} from '@/utils/tree';
export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
interface HistorySnapshot {
nodes: MindMapNode[];
edges: MindMapEdge[];
}
interface ClipboardPayload {
nodes: MindMapNode[];
}
export interface FocusRequest {
nodeId: string;
nonce: number;
}
interface EditorState {
map: MindMap | null;
readOnly: boolean;
selectedNodeIds: string[];
editingNodeId: string | null;
past: HistorySnapshot[];
future: HistorySnapshot[];
clipboard: ClipboardPayload | null;
saveStatus: SaveStatus;
lastSavedAt: string | null;
searchQuery: string;
searchResults: string[];
searchIndex: number;
focusRequest: FocusRequest | null;
fitRequest: number;
/** Tailles mesurees dans le DOM, utilisees par l'auto-layout et les exports. */
measuredSizes: Map<string, NodeSize>;
/** Incremente uniquement quand une taille change reellement. */
sizeVersion: number;
loadMap: (map: MindMap, options?: { readOnly?: boolean }) => void;
closeMap: () => void;
setSelection: (ids: string[]) => void;
setEditingNode: (id: string | null) => void;
pushHistory: () => void;
undo: () => void;
redo: () => void;
addChild: (parentId?: string | null) => string | null;
addSibling: (nodeId?: string) => string | null;
deleteNodes: (ids: string[]) => void;
updateNodeText: (id: string, text: string) => void;
updateNode: (id: string, patch: Partial<Omit<MindMapNode, 'id'>>) => void;
updateNodeStyle: (id: string, patch: NodeStyle) => void;
resetNodeStyle: (id: string) => void;
moveNodes: (updates: Array<{ id: string; position: Position }>) => void;
reparentNode: (childId: string, parentId: string) => boolean;
detachNode: (childId: string) => void;
toggleCollapse: (id: string) => void;
expandAncestors: (id: string) => void;
copySelection: () => number;
paste: () => number;
duplicateSelection: () => number;
applyAutoLayout: (layout?: LayoutKind) => void;
updateSettings: (patch: Partial<MapSettings>) => void;
renameMap: (name: string) => void;
setViewport: (viewport: Viewport) => void;
setSaveStatus: (status: SaveStatus) => void;
markSaved: () => void;
setSearchQuery: (query: string) => void;
gotoSearchResult: (index: number) => void;
nextSearchResult: () => void;
previousSearchResult: () => void;
requestFit: () => void;
setMeasuredSize: (id: string, size: NodeSize) => void;
}
function snapshot(map: MindMap): HistorySnapshot {
return { nodes: map.nodes, edges: map.edges };
}
/** Normalisation pour la recherche : insensible a la casse et aux accents. */
function normalizeText(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
return { ...map, nodes, edges: rebuildEdges(nodes) };
}
/**
* Copie un ensemble de sous-arbres en regenerant tous les identifiants.
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
*/
function cloneSubtrees(
source: MindMapNode[],
offset: number,
parentOverride?: string | null,
): MindMapNode[] {
const idMapping = new Map<string, string>();
for (const node of source) idMapping.set(node.id, createId());
return source.map((node) => {
const mappedParent = node.parentId ? idMapping.get(node.parentId) : undefined;
const parentId =
mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null));
return {
...node,
id: idMapping.get(node.id) as string,
type: node.type === 'root' && parentId ? 'standard' : node.type,
parentId,
position: { x: node.position.x + offset, y: node.position.y + offset },
...(node.style ? { style: { ...node.style } } : {}),
};
});
}
export const useEditorStore = create<EditorState>((set, get) => {
/**
* Applique une transformation sur la carte.
* `history: false` regroupe les micro-modifications (glisser-deposer) pour
* eviter de saturer la pile d'annulation.
*/
const commit = (
updater: (map: MindMap) => MindMap,
options: { history?: boolean } = {},
): void => {
const state = get();
const current = state.map;
if (!current || state.readOnly) return;
const next = updater(current);
if (next === current) return;
const withHistory = options.history !== false;
set({
map: { ...next, updatedAt: new Date().toISOString() },
saveStatus: 'dirty',
...(withHistory
? {
past: [...state.past, snapshot(current)].slice(-APP_CONFIG.historyLimit),
future: [],
}
: {}),
});
};
const restore = (target: HistorySnapshot): void => {
const state = get();
const current = state.map;
if (!current) return;
const existing = new Set(target.nodes.map((node) => node.id));
set({
map: {
...current,
nodes: target.nodes,
edges: target.edges,
updatedAt: new Date().toISOString(),
},
selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)),
editingNodeId: null,
saveStatus: 'dirty',
});
};
return {
map: null,
readOnly: false,
selectedNodeIds: [],
editingNodeId: null,
past: [],
future: [],
clipboard: null,
saveStatus: 'idle',
lastSavedAt: null,
searchQuery: '',
searchResults: [],
searchIndex: 0,
focusRequest: null,
fitRequest: 0,
measuredSizes: new Map(),
sizeVersion: 0,
loadMap: (map, options) => {
set({
map,
readOnly: options?.readOnly ?? false,
selectedNodeIds: [],
editingNodeId: null,
past: [],
future: [],
clipboard: null,
saveStatus: 'saved',
lastSavedAt: map.updatedAt,
searchQuery: '',
searchResults: [],
searchIndex: 0,
focusRequest: null,
measuredSizes: new Map(),
sizeVersion: get().sizeVersion + 1,
});
},
closeMap: () => {
set({
map: null,
readOnly: false,
selectedNodeIds: [],
editingNodeId: null,
past: [],
future: [],
saveStatus: 'idle',
searchQuery: '',
searchResults: [],
measuredSizes: new Map(),
});
},
setSelection: (ids) => set({ selectedNodeIds: ids }),
setEditingNode: (id) => set({ editingNodeId: id }),
pushHistory: () => {
const state = get();
if (!state.map || state.readOnly) return;
set({
past: [...state.past, snapshot(state.map)].slice(-APP_CONFIG.historyLimit),
future: [],
});
},
undo: () => {
const state = get();
const previous = state.past[state.past.length - 1];
if (!previous || !state.map || state.readOnly) return;
set({
past: state.past.slice(0, -1),
future: [...state.future, snapshot(state.map)].slice(-APP_CONFIG.historyLimit),
});
restore(previous);
},
redo: () => {
const state = get();
const next = state.future[state.future.length - 1];
if (!next || !state.map || state.readOnly) return;
set({
future: state.future.slice(0, -1),
past: [...state.past, snapshot(state.map)].slice(-APP_CONFIG.historyLimit),
});
restore(next);
},
addChild: (parentId) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return null;
const targetId = parentId ?? state.selectedNodeIds[0] ?? null;
const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout);
const child = parent
? createNode({
text: '',
parentId: parent.id,
position: suggestChildPosition(map.nodes, parent, horizontal, direction),
})
: createNode({
text: '',
type: map.nodes.length === 0 ? 'root' : 'standard',
position: { x: 0, y: map.nodes.length * 70 },
});
commit((current) => {
const nodes = current.nodes.map((node) =>
parent && node.id === parent.id && node.collapsed ? { ...node, collapsed: false } : node,
);
return withEdges(current, [...nodes, child]);
});
set({ selectedNodeIds: [child.id], editingNodeId: child.id });
return child.id;
},
addSibling: (nodeId) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return null;
const referenceId = nodeId ?? state.selectedNodeIds[0];
const reference = referenceId
? map.nodes.find((node) => node.id === referenceId)
: undefined;
if (!reference) return get().addChild(null);
// Un frere de la racine devient une nouvelle racine detachee.
if (!reference.parentId) {
const sibling = createNode({
text: '',
position: { x: reference.position.x, y: reference.position.y + 120 },
});
commit((current) => withEdges(current, [...current.nodes, sibling]));
set({ selectedNodeIds: [sibling.id], editingNodeId: sibling.id });
return sibling.id;
}
const parent = map.nodes.find((node) => node.id === reference.parentId);
if (!parent) return null;
const horizontal = isHorizontalLayout(map.settings.layout);
const sibling = createNode({
text: '',
parentId: parent.id,
position: horizontal
? { x: reference.position.x, y: reference.position.y + 70 }
: { x: reference.position.x + 200, y: reference.position.y },
});
commit((current) => withEdges(current, [...current.nodes, sibling]));
set({ selectedNodeIds: [sibling.id], editingNodeId: sibling.id });
return sibling.id;
},
deleteNodes: (ids) => {
const state = get();
const map = state.map;
if (!map || state.readOnly || ids.length === 0) return;
// Supprimer un noeud supprime toute sa branche : c'est reversible via Ctrl+Z.
const doomed = new Set(withDescendants(map.nodes, ids));
const remaining = map.nodes.filter((node) => !doomed.has(node.id));
const firstParent = map.nodes.find((node) => node.id === ids[0])?.parentId ?? null;
commit((current) => withEdges(current, remaining));
set({
selectedNodeIds: firstParent && !doomed.has(firstParent) ? [firstParent] : [],
editingNodeId: null,
});
},
updateNodeText: (id, text) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => (node.id === id ? { ...node, text } : node)),
),
);
},
updateNode: (id, patch) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => (node.id === id ? { ...node, ...patch } : node)),
),
);
},
updateNodeStyle: (id, patch) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) =>
node.id === id ? { ...node, style: { ...node.style, ...patch } } : node,
),
),
);
},
resetNodeStyle: (id) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => {
if (node.id !== id) return node;
const { style: _style, ...rest } = node;
return rest as MindMapNode;
}),
),
);
},
moveNodes: (updates) => {
if (updates.length === 0) return;
const byId = new Map(updates.map((update) => [update.id, update.position]));
// Pas d'entree d'historique ici : `pushHistory` est appele au debut du drag.
commit(
(current) => ({
...current,
nodes: current.nodes.map((node) => {
const position = byId.get(node.id);
return position ? { ...node, position } : node;
}),
}),
{ history: false },
);
},
reparentNode: (childId, parentId) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return false;
if (wouldCreateCycle(map.nodes, childId, parentId)) return false;
commit((current) =>
withEdges(
current,
current.nodes.map((node) => {
if (node.id === childId) return { ...node, parentId, type: 'standard' };
if (node.id === parentId && node.collapsed) return { ...node, collapsed: false };
return node;
}),
),
);
return true;
},
detachNode: (childId) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => (node.id === childId ? { ...node, parentId: null } : node)),
),
);
},
toggleCollapse: (id) => {
const state = get();
const map = state.map;
if (!map) return;
const nodes = map.nodes.map((node) =>
node.id === id ? { ...node, collapsed: !node.collapsed } : node,
);
// Replier reste possible en lecture seule : c'est un confort de lecture
// sur une carte partagee, et rien n'est sauvegarde dans ce mode.
if (state.readOnly) {
set({ map: { ...map, nodes } });
return;
}
commit((current) => withEdges(current, nodes));
},
expandAncestors: (id) => {
const state = get();
const map = state.map;
if (!map) return;
const nodeMap = buildNodeMap(map.nodes);
const toExpand = new Set<string>();
let cursor = nodeMap.get(id)?.parentId ?? null;
let guard = 0;
while (cursor && guard < 1000) {
const parent = nodeMap.get(cursor);
if (!parent) break;
if (parent.collapsed) toExpand.add(parent.id);
cursor = parent.parentId;
guard += 1;
}
if (toExpand.size === 0) return;
commit(
(current) =>
withEdges(
current,
current.nodes.map((node) =>
toExpand.has(node.id) ? { ...node, collapsed: false } : node,
),
),
{ history: false },
);
},
copySelection: () => {
const state = get();
const map = state.map;
if (!map || state.selectedNodeIds.length === 0) return 0;
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const nodes = map.nodes.filter((node) => ids.has(node.id));
set({ clipboard: { nodes: nodes.map((node) => ({ ...node })) } });
return nodes.length;
},
paste: () => {
const state = get();
const map = state.map;
const clipboard = state.clipboard;
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
const target = state.selectedNodeIds[0] ?? null;
const clones = cloneSubtrees(clipboard.nodes, 40, target);
commit((current) => withEdges(current, [...current.nodes, ...clones]));
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
duplicateSelection: () => {
const state = get();
const map = state.map;
if (!map || state.readOnly || state.selectedNodeIds.length === 0) return 0;
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const source = map.nodes.filter((node) => ids.has(node.id));
// parentOverride non fourni : chaque copie reste au meme niveau que l'original.
const clones = cloneSubtrees(source, 40);
commit((current) => withEdges(current, [...current.nodes, ...clones]));
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
applyAutoLayout: (layout) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return;
const target = layout ?? map.settings.layout;
const positions = computeLayout(map.nodes, target, state.measuredSizes);
if (positions.size === 0) return;
commit((current) => ({
...current,
settings: { ...current.settings, layout: target },
nodes: current.nodes.map((node) => {
const position = positions.get(node.id);
return position ? { ...node, position } : node;
}),
}));
set({ fitRequest: get().fitRequest + 1 });
},
updateSettings: (patch) => {
commit((current) => ({ ...current, settings: { ...current.settings, ...patch } }));
},
renameMap: (name) => {
commit((current) => ({ ...current, name }));
},
setViewport: (viewport) => {
const state = get();
if (!state.map || state.readOnly) return;
// Le deplacement du canvas ne cree pas d'entree d'historique.
set({ map: { ...state.map, viewport } });
},
setSaveStatus: (status) => set({ saveStatus: status }),
markSaved: () => set({ saveStatus: 'saved', lastSavedAt: new Date().toISOString() }),
setSearchQuery: (query) => {
const state = get();
const map = state.map;
const trimmed = query.trim();
if (!map || trimmed.length === 0) {
set({ searchQuery: query, searchResults: [], searchIndex: 0 });
return;
}
const needle = normalizeText(trimmed);
const results = map.nodes
.filter(
(node) =>
normalizeText(node.text).includes(needle) ||
(node.note ? normalizeText(node.note).includes(needle) : false),
)
.map((node) => node.id);
set({ searchQuery: query, searchResults: results, searchIndex: 0 });
if (results.length > 0) get().gotoSearchResult(0);
},
gotoSearchResult: (index) => {
const state = get();
const results = state.searchResults;
if (results.length === 0) return;
const bounded = ((index % results.length) + results.length) % results.length;
const nodeId = results[bounded] as string;
get().expandAncestors(nodeId);
set({
searchIndex: bounded,
selectedNodeIds: [nodeId],
focusRequest: { nodeId, nonce: (state.focusRequest?.nonce ?? 0) + 1 },
});
},
nextSearchResult: () => get().gotoSearchResult(get().searchIndex + 1),
previousSearchResult: () => get().gotoSearchResult(get().searchIndex - 1),
requestFit: () => set({ fitRequest: get().fitRequest + 1 }),
setMeasuredSize: (id, size) => {
const state = get();
const known = state.measuredSizes.get(id);
if (known && known.width === size.width && known.height === size.height) return;
state.measuredSizes.set(id, size);
// React Flow a besoin des dimensions sur les noeuds controles (minimap,
// cadrage). On ne redessine que lorsqu'une taille a reellement change.
set({ sizeVersion: state.sizeVersion + 1 });
},
};
});
/** Selecteurs utilitaires, pour eviter de recalculer dans les composants. */
export const selectCanUndo = (state: EditorState): boolean => state.past.length > 0;
export const selectCanRedo = (state: EditorState): boolean => state.future.length > 0;
export const selectSelectedNode = (state: EditorState): MindMapNode | null => {
if (!state.map || state.selectedNodeIds.length !== 1) return null;
return state.map.nodes.find((node) => node.id === state.selectedNodeIds[0]) ?? null;
};

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand';
import { createId } from '@/utils/id';
export type ColorMode = 'light' | 'dark';
export type ToastTone = 'info' | 'success' | 'error';
export interface Toast {
id: string;
message: string;
tone: ToastTone;
}
interface UiState {
colorMode: ColorMode;
propertiesPanelOpen: boolean;
searchPanelOpen: boolean;
toasts: Toast[];
setColorMode: (mode: ColorMode) => void;
toggleColorMode: () => void;
setPropertiesPanelOpen: (open: boolean) => void;
setSearchPanelOpen: (open: boolean) => void;
pushToast: (message: string, tone?: ToastTone) => void;
dismissToast: (id: string) => void;
}
const COLOR_MODE_KEY = 'openmind:color-mode';
function readInitialColorMode(): ColorMode {
if (typeof window === 'undefined') return 'light';
try {
const stored = window.localStorage.getItem(COLOR_MODE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
} catch {
// Stockage indisponible (navigation privee stricte) : on ignore.
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyColorMode(mode: ColorMode): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', mode === 'dark');
try {
window.localStorage.setItem(COLOR_MODE_KEY, mode);
} catch {
// Ignore : la preference restera celle du systeme.
}
}
export const useUiStore = create<UiState>((set, get) => ({
colorMode: readInitialColorMode(),
propertiesPanelOpen: true,
searchPanelOpen: false,
toasts: [],
setColorMode: (mode) => {
applyColorMode(mode);
set({ colorMode: mode });
},
toggleColorMode: () => {
const next = get().colorMode === 'dark' ? 'light' : 'dark';
get().setColorMode(next);
},
setPropertiesPanelOpen: (open) => set({ propertiesPanelOpen: open }),
setSearchPanelOpen: (open) => set({ searchPanelOpen: open }),
pushToast: (message, tone = 'info') => {
const toast: Toast = { id: createId(), message, tone };
set({ toasts: [...get().toasts, toast] });
window.setTimeout(() => get().dismissToast(toast.id), tone === 'error' ? 7000 : 3800);
},
dismissToast: (id) => set({ toasts: get().toasts.filter((toast) => toast.id !== id) }),
}));
/** Applique le mode couleur au chargement de l'application. */
export function initializeColorMode(): void {
applyColorMode(useUiStore.getState().colorMode);
}

View File

@@ -0,0 +1,97 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
font-family: theme('fontFamily.sans');
}
/* Focus visible homogene et lisible sur les deux themes. */
:focus-visible {
@apply outline-none ring-2 ring-indigo-500 ring-offset-2 ring-offset-white dark:ring-offset-zinc-950;
}
}
@layer components {
.panel {
@apply rounded-xl border border-zinc-200 bg-white shadow-panel dark:border-zinc-800 dark:bg-zinc-900;
}
.field-label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400;
}
.field-input {
@apply w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500;
}
}
/* ---------------------------------------------------------------- Canvas */
/* L'attribution React Flow reste visible a l'ecran ; elle est retiree
uniquement des exports et de l'impression. */
.openmind-exporting .react-flow__attribution {
display: none;
}
.openmind-canvas .react-flow__handle {
width: 9px;
height: 9px;
border: 2px solid rgba(255, 255, 255, 0.9);
background: rgba(99, 102, 241, 0.9);
opacity: 0;
transition: opacity 120ms ease-out;
}
.openmind-canvas .react-flow__node:hover .react-flow__handle,
.openmind-canvas .react-flow__node.selected .react-flow__handle {
opacity: 1;
}
.openmind-canvas .react-flow__node.selected .mind-node {
box-shadow: 0 0 0 2px #6366f1, 0 0 0 5px rgba(99, 102, 241, 0.22);
}
.mind-node {
transition:
box-shadow 120ms ease-out,
transform 120ms ease-out;
}
.mind-node--search-hit {
animation: mind-node-pulse 1.6s ease-out 2;
}
@keyframes mind-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
35% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.mind-node,
.mind-node--search-hit {
transition: none;
animation: none;
}
}

View File

@@ -0,0 +1,46 @@
/* Mode impression : seule la carte reste visible. */
@media print {
.no-print,
.no-print * {
display: none !important;
}
html,
body,
#root {
height: auto !important;
background: #ffffff !important;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print-surface {
position: static !important;
width: 100% !important;
height: 100vh !important;
border: 0 !important;
box-shadow: none !important;
overflow: visible !important;
}
.react-flow__minimap,
.react-flow__controls,
.react-flow__background,
.react-flow__attribution {
display: none !important;
}
.print-title {
display: block !important;
margin-bottom: 6mm;
font-size: 14pt;
font-weight: 600;
}
}
.print-title {
display: none;
}

View File

@@ -0,0 +1,2 @@
// IndexedDB en memoire pour tester la couche Dexie hors navigateur.
import 'fake-indexeddb/auto';

Some files were not shown because too many files have changed in this diff Show More