From e3c530348e0f963ff68bce3a2ae53fa1d8230ff9 Mon Sep 17 00:00:00 2001
From: snoopy
Date: Thu, 10 Sep 2026 08:12:00 +0200
Subject: [PATCH] Phase 2 : mind map bilaterale, relations, templates et
Markdown
Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et
`MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les
cartes v1/v2/v3 restent ouvrables telles quelles.
Une relation est une connexion purement visuelle : elle ne touche jamais
parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue
de deriver les liens de parentId et se contente de retirer les relations dont
une extremite a disparu.
Mind map bilaterale : repartition gloutonne et deterministe des branches selon
la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre
recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul
Reorganiser applique.
Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou
texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant,
et l'export Markdown de la hierarchie.
Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles
changent de cote, ce qui faisait disparaitre les liens concernes.
Co-Authored-By: Claude Opus 5
---
README.md | 112 ++++-
backend/app/schemas/map.py | 28 ++
backend/tests/test_relation_payload.py | 149 +++++++
.../src/features/editor/EditorToolbar.tsx | 29 +-
.../features/editor/MarkdownImportDialog.tsx | 183 ++++++++
.../src/features/editor/MindMapCanvas.tsx | 67 ++-
frontend/src/features/editor/MindNodeView.tsx | 39 +-
.../src/features/editor/NodeContextMenu.tsx | 55 ++-
.../src/features/editor/PropertiesPanel.tsx | 21 +-
.../src/features/editor/RelationDialog.tsx | 139 ++++++
.../features/editor/RelationProperties.tsx | 138 ++++++
.../src/features/editor/bilateral.test.ts | 221 ++++++++++
frontend/src/features/editor/bilateral.ts | 152 +++++++
frontend/src/features/editor/flowAdapters.ts | 211 +++++++++-
frontend/src/features/editor/layout.ts | 75 +++-
frontend/src/features/editor/themes.ts | 15 +
.../src/features/editor/useEditorShortcuts.ts | 8 +-
frontend/src/features/export/ExportMenu.tsx | 31 ++
frontend/src/features/maps/DashboardPage.tsx | 101 ++++-
.../features/templates/TemplateGallery.tsx | 68 +++
.../src/features/templates/templates.test.ts | 122 ++++++
frontend/src/features/templates/templates.ts | 395 ++++++++++++++++++
frontend/src/services/mapFactory.ts | 7 +
.../services/mapRelationSerialization.test.ts | 255 +++++++++++
frontend/src/services/mapSerialization.ts | 105 +++++
frontend/src/services/markdown.test.ts | 262 ++++++++++++
frontend/src/services/markdown.ts | 319 ++++++++++++++
frontend/src/services/outlineParser.ts | 17 +-
frontend/src/services/relations.ts | 105 +++++
frontend/src/stores/editorRelations.test.ts | 267 ++++++++++++
frontend/src/stores/editorSides.test.ts | 182 ++++++++
frontend/src/stores/editorStore.ts | 321 ++++++++++++--
frontend/src/types/map.ts | 56 ++-
33 files changed, 4151 insertions(+), 104 deletions(-)
create mode 100644 backend/tests/test_relation_payload.py
create mode 100644 frontend/src/features/editor/MarkdownImportDialog.tsx
create mode 100644 frontend/src/features/editor/RelationDialog.tsx
create mode 100644 frontend/src/features/editor/RelationProperties.tsx
create mode 100644 frontend/src/features/editor/bilateral.test.ts
create mode 100644 frontend/src/features/editor/bilateral.ts
create mode 100644 frontend/src/features/templates/TemplateGallery.tsx
create mode 100644 frontend/src/features/templates/templates.test.ts
create mode 100644 frontend/src/features/templates/templates.ts
create mode 100644 frontend/src/services/mapRelationSerialization.test.ts
create mode 100644 frontend/src/services/markdown.test.ts
create mode 100644 frontend/src/services/markdown.ts
create mode 100644 frontend/src/services/relations.ts
create mode 100644 frontend/src/stores/editorRelations.test.ts
create mode 100644 frontend/src/stores/editorSides.test.ts
diff --git a/README.md b/README.md
index f7cf422..d9cccde 100644
--- a/README.md
+++ b/README.md
@@ -227,10 +227,36 @@ double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
Bezier, branches repliables avec compteur d'elements masques.
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
-arbre horizontal, arbre vertical. L'espacement est configurable (distance
-parent/enfant et espacement entre branches), avec les presets Compact, Normal
-et Aere. Modifier l'espacement ne deplace rien tant que l'organisation n'est pas
-relancee.
+mind map bilaterale, arbre horizontal, arbre vertical. L'espacement est
+configurable (distance parent/enfant et espacement entre branches), avec les
+presets Compact, Normal et Aere. Choisir une disposition ou modifier
+l'espacement ne deplace rien : c'est *Reorganiser* qui applique, ce qui protege
+les positions placees a la main.
+
+**Mind map bilaterale** : la racine reste au centre, les branches de niveau 1 se
+repartissent a gauche et a droite, et tous les descendants heritent du cote de
+leur branche. La repartition automatique equilibre les deux cotes selon la
+taille des sous-arbres ; le cote se change ensuite a la main (*Placer la branche
+a gauche / a droite*).
+
+**Relations secondaires** : une connexion visuelle entre deux noeuds
+quelconques, independante de la hierarchie. Elle ne modifie ni le parent, ni
+l'ordre, ni le repli, et l'auto-layout l'ignore totalement. Libelle libre,
+trace (Bezier, courbe, droite), couleur, epaisseur, trait plein ou pointille,
+fleches aux deux extremites. Une relation dont une extremite est masquee par un
+repli disparait de l'affichage et revient au deploiement.
+
+**Modeles** : galerie locale de huit modeles (carte vide, brainstorming, gestion
+de projet, architecture IT, reunion, SWOT, plan d'etude, planification
+personnelle). Choisir un modele en cree une copie : l'original n'est jamais
+modifie, et la carte obtenue est une carte comme les autres.
+
+**Markdown** : import d'un fichier `.md` ou d'un texte colle, comme nouvelle
+carte depuis le tableau de bord ou comme sous-arbre d'un noeud existant. Les
+titres et les listes construisent la hierarchie ; les paragraphes et les blocs
+de code rejoignent la note du noeud, en texte brut. Export inverse en `.md` :
+contenu et hierarchie uniquement, sans positions ni couleurs, avec un
+avertissement si la carte contient des relations secondaires.
**Proprietes du noeud**, regroupees en sections repliables (Contenu,
Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ;
@@ -274,7 +300,7 @@ theme ne supprime jamais une personnalisation explicite d'un noeud.
et mise en evidence temporaire du noeud, depliage automatique des branches
repliees.
-**Exports** : `.mmap` (format natif), PNG (x1, x2, fond transparent), SVG, PDF
+**Exports** : `.mmap` (format natif), Markdown (`.md`), 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`.
@@ -326,14 +352,20 @@ Le format est en **version 3**.
| v1 | format initial | `style.width` devient `layout.width` |
| v2 | mise en page des noeuds (`layout`, `iconStyle`, `branch`) | l'ordre des freres est materialise depuis leur position dans `nodes` |
| v3 | ordre explicite des freres (`node.order`) | aucune |
+| v4 | cote des branches (`node.side`) et relations secondaires (`relations`) | aucune |
-Les fichiers v1 et v2 restent lisibles, et les cartes deja enregistrees dans
-IndexedDB s'ouvrent sans modification. Le passage en v3 est justifie : l'ordre
-des freres fait desormais partie de la semantique du document, et un lecteur v2
+Les fichiers v1, v2 et v3 restent lisibles, et les cartes deja enregistrees dans
+IndexedDB s'ouvrent sans modification. Le passage en v3 etait justifie : l'ordre
+des freres fait partie de la semantique du document, et un lecteur v2
l'ignorerait en reordonnant silencieusement la carte. Les noeuds sont malgre
tout ecrits en parcours de profondeur, freres dans l'ordre, ce qui donne le bon
resultat meme a un lecteur qui ignore `order`.
+Le passage en v4 l'est aussi : un lecteur v3 ignorerait `relations` et perdrait
+donc une partie du document. La migration v3 -> v4 n'a en revanche rien a
+inventer : aucun cote defini signifie "a repartir automatiquement", aucune
+relation signifie "aucune". Une carte v3 s'affiche exactement comme avant.
+
Toute carte qui entre dans l'editeur passe par le meme point d'entree,
`normalizeMapDocument()`, quelle que soit sa provenance :
@@ -362,6 +394,8 @@ vous la modifiez.
"text": "Infrastructure",
"position": { "x": 0, "y": 0 },
"parentId": null,
+ "order": 0,
+ "side": "right",
"collapsed": false,
"icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr",
@@ -389,6 +423,19 @@ vous la modifiez.
}
],
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }],
+ "relations": [
+ {
+ "id": "r-uuid",
+ "sourceId": "a",
+ "targetId": "b",
+ "label": "depend de",
+ "style": "bezier",
+ "color": "#a855f7",
+ "width": 2,
+ "dashed": true,
+ "markerEnd": "arrow"
+ }
+ ],
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"settings": {
"theme": "default",
@@ -398,11 +445,21 @@ vous la modifiez.
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
"snapToGrid": false,
"snapGrid": 20,
- "alignmentGuides": true
+ "alignmentGuides": true,
+ "dragMovesBranch": false
}
}
```
+Deux precisions sur ce document :
+
+- `edges` est **derive** de `parentId` : il est recalcule a chaque modification
+ et n'est jamais une source de verite. `relations`, au contraire, est une
+ donnee a part entiere, que rien ne permet de deduire de la hierarchie.
+- `side` n'a de sens que sur un enfant direct d'une racine. Tous ses descendants
+ heritent du cote de leur branche, ce qui evite d'entretenir la meme
+ information sur chaque noeud.
+
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
@@ -488,12 +545,17 @@ docker run --rm -v "$PWD/frontend:/app" -w /app node:20-alpine sh -c "npm ci &&
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.
+non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et
+v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente.
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.
+d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
+store Zustand (creation, suppression de branche, undo/redo, groupement du
+glisser-deposer, copier/coller, duplication, detection de boucle, recherche,
+auto-layout, lecture seule), repartition et heritage des cotes, auto-layout
+bilateral, relations secondaires (creation, refus, style, repli, duplication,
+independance vis-a-vis de la hierarchie), analyse et export Markdown, galerie de
+modeles, assainissement des URL.
---
@@ -535,7 +597,8 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
├── hooks/ # routeur minimal, debounce
├── components/ # briques d'interface reutilisables
├── features/
- │ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, themes
+ │ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes
+ │ ├── templates/ # galerie et definitions des modeles
│ ├── maps/ # tableau de bord
│ ├── export/ # PNG, SVG, PDF, impression
│ └── sharing/ # dialogue de partage, page publique
@@ -583,9 +646,24 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
- **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.
+- **Dispositions reservees** : `organization` et `freeform` existent dans le
+ modele de donnees mais ne sont pas proposees dans l'interface.
+- **Relations et export Markdown** : l'export `.md` decrit le contenu et la
+ hierarchie. Les relations secondaires n'y ont pas de representation fidele et
+ ne sont pas inventees ; un message le signale au moment de l'export.
+- **Relations et minimap** : la minimap n'affiche que les noeuds. Les relations
+ y sont invisibles, comme les branches hierarchiques.
+- **Doublon de relation** : deux relations identiques (meme source vers meme
+ cible) se superposeraient, la seconde serait donc invisible et impossible a
+ selectionner. Une seule est acceptee par couple ; le sens inverse, lui, reste
+ autorise.
+- **Relation dupliquee** : dupliquer ou coller une branche reporte les relations
+ dont les deux extremites sont dans la branche. Une relation qui sort vers un
+ noeud exterieur n'est pas recreee : rien ne dit que la copie doit viser la
+ meme cible, et le deviner creerait un lien non demande.
+- **Bilaterale et racines multiples** : le recalage des deux cotes se fait sur
+ la premiere racine. Une carte a plusieurs racines reste affichee, mais les
+ racines suivantes ne sont pas recentrees individuellement.
- **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
diff --git a/backend/app/schemas/map.py b/backend/app/schemas/map.py
index 8b78309..a430731 100644
--- a/backend/app/schemas/map.py
+++ b/backend/app/schemas/map.py
@@ -31,6 +31,29 @@ class MapEdgeIn(BaseModel):
target: str = Field(min_length=1, max_length=128)
+class MapRelationIn(BaseModel):
+ """Relation secondaire (format v4).
+
+ Purement visuelle : le backend ne l'interprete pas, il verifie seulement
+ qu'elle est bien formee. Une relation qui boucle sur elle-meme est refusee,
+ comme cote frontend : les deux validations disent la meme chose.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ id: str = Field(min_length=1, max_length=128)
+ # Les noms suivent le document JSON du frontend, d'ou le camelCase.
+ sourceId: str = Field(min_length=1, max_length=128)
+ targetId: str = Field(min_length=1, max_length=128)
+ label: str | None = Field(default=None, max_length=120)
+
+ @model_validator(mode="after")
+ def _check_endpoints(self) -> MapRelationIn:
+ if self.sourceId == self.targetId:
+ raise ValueError("Une relation doit relier deux noeuds differents.")
+ return self
+
+
class MapDocumentIn(BaseModel):
"""Document carte complet, tel que produit par le frontend."""
@@ -41,6 +64,7 @@ class MapDocumentIn(BaseModel):
name: str = Field(default="", max_length=255)
nodes: list[MapNodeIn] = Field(default_factory=list)
edges: list[MapEdgeIn] = Field(default_factory=list)
+ relations: list[MapRelationIn] = Field(default_factory=list)
viewport: dict[str, Any] = Field(default_factory=dict)
settings: dict[str, Any] = Field(default_factory=dict)
@@ -59,4 +83,8 @@ class MapDocumentIn(BaseModel):
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.")
+
+ max_relations = get_settings().max_nodes * 2
+ if len(self.relations) > max_relations:
+ raise ValueError(f"La carte depasse la limite de {max_relations} relations.")
return self
diff --git a/backend/tests/test_relation_payload.py b/backend/tests/test_relation_payload.py
new file mode 100644
index 0000000..eafb030
--- /dev/null
+++ b/backend/tests/test_relation_payload.py
@@ -0,0 +1,149 @@
+"""Le backend doit accepter le format v4 : cotes de branche et relations."""
+
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+from tests.conftest import build_map
+
+
+def build_v4_map() -> dict:
+ payload = build_map(node_count=1, name="Infrastructure")
+ payload["version"] = 4
+ payload["nodes"] = [
+ {
+ "id": "root",
+ "type": "root",
+ "text": "Infrastructure",
+ "position": {"x": 0, "y": 0},
+ "parentId": None,
+ "order": 0,
+ },
+ {
+ "id": "reseau",
+ "type": "standard",
+ "text": "Reseau",
+ "position": {"x": 240, "y": 0},
+ "parentId": "root",
+ "order": 0,
+ "side": "right",
+ },
+ {
+ "id": "firewall",
+ "type": "standard",
+ "text": "Firewall",
+ "position": {"x": 460, "y": 0},
+ "parentId": "reseau",
+ "order": 0,
+ },
+ {
+ "id": "services",
+ "type": "standard",
+ "text": "Services",
+ "position": {"x": -240, "y": 0},
+ "parentId": "root",
+ "order": 1,
+ "side": "left",
+ },
+ {
+ "id": "web",
+ "type": "standard",
+ "text": "Web",
+ "position": {"x": -460, "y": 0},
+ "parentId": "services",
+ "order": 0,
+ },
+ ]
+ payload["edges"] = [
+ {"id": "e-root-reseau", "source": "root", "target": "reseau"},
+ {"id": "e-reseau-firewall", "source": "reseau", "target": "firewall"},
+ {"id": "e-root-services", "source": "root", "target": "services"},
+ {"id": "e-services-web", "source": "services", "target": "web"},
+ ]
+ payload["relations"] = [
+ {
+ "id": "r-1",
+ "sourceId": "firewall",
+ "targetId": "web",
+ "label": "protege",
+ "style": "bezier",
+ "color": "#a855f7",
+ "width": 2,
+ "dashed": True,
+ "markerEnd": "arrow",
+ }
+ ]
+ payload["settings"]["layout"] = "mindmap-both"
+ return payload
+
+
+def test_share_accepts_v4_map(client: TestClient) -> None:
+ response = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
+ assert response.status_code == 201, response.text
+
+
+def test_sides_and_relations_are_returned_unchanged(client: TestClient) -> None:
+ created = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
+ token = created.json()["share_token"]
+
+ public = client.get("/api/shares/" + token).json()
+ assert public["map_version"] == 4
+ assert public["map"]["settings"]["layout"] == "mindmap-both"
+
+ sides = {node["id"]: node.get("side") for node in public["map"]["nodes"]}
+ assert sides["reseau"] == "right"
+ assert sides["services"] == "left"
+ # Le cote n'est porte que par les branches de niveau 1.
+ assert sides["firewall"] is None
+
+ relations = public["map"]["relations"]
+ assert len(relations) == 1
+ assert relations[0]["sourceId"] == "firewall"
+ assert relations[0]["targetId"] == "web"
+ assert relations[0]["label"] == "protege"
+ assert relations[0]["dashed"] is True
+
+
+def test_relation_does_not_alter_hierarchy(client: TestClient) -> None:
+ """Une relation ne doit jamais devenir un lien parent / enfant."""
+ created = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
+ public = client.get("/api/shares/" + created.json()["share_token"]).json()
+
+ parents = {node["id"]: node.get("parentId") for node in public["map"]["nodes"]}
+ assert parents["firewall"] == "reseau"
+ assert parents["web"] == "services"
+
+ edge_pairs = {(edge["source"], edge["target"]) for edge in public["map"]["edges"]}
+ assert ("firewall", "web") not in edge_pairs
+
+
+def test_self_relation_is_rejected(client: TestClient) -> None:
+ payload = build_v4_map()
+ payload["relations"][0]["targetId"] = payload["relations"][0]["sourceId"]
+
+ response = client.post("/api/shares", json={"title": "x", "map": payload})
+ assert response.status_code == 422
+
+
+def test_relation_without_endpoints_is_rejected(client: TestClient) -> None:
+ payload = build_v4_map()
+ del payload["relations"][0]["targetId"]
+
+ response = client.post("/api/shares", json={"title": "x", "map": payload})
+ assert response.status_code == 422
+
+
+def test_map_without_relations_is_still_accepted(client: TestClient) -> None:
+ payload = build_v4_map()
+ del payload["relations"]
+
+ response = client.post("/api/shares", json={"title": "x", "map": payload})
+ assert response.status_code == 201, response.text
+
+
+def test_older_versions_are_still_accepted(client: TestClient) -> None:
+ for version in (1, 2, 3):
+ payload = build_map()
+ payload["version"] = version
+ response = client.post("/api/shares", json={"title": f"v{version}", "map": payload})
+ assert response.status_code == 201, response.text
diff --git a/frontend/src/features/editor/EditorToolbar.tsx b/frontend/src/features/editor/EditorToolbar.tsx
index 6ecfd53..c5f4ae3 100644
--- a/frontend/src/features/editor/EditorToolbar.tsx
+++ b/frontend/src/features/editor/EditorToolbar.tsx
@@ -71,9 +71,11 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const requestFit = useEditorStore((state) => state.requestFit);
const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed);
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
+ const balanceSides = useEditorStore((state) => state.balanceSides);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
+ const pushToast = useUiStore((state) => state.pushToast);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
@@ -158,17 +160,22 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
{(close) => (
<>
Disposition
+ {/*
+ Choisir une disposition ne deplace rien : c'est "Reorganiser" qui
+ applique. Les positions placees a la main ne bougent donc jamais
+ sans que l'utilisateur le demande.
+ */}
{LAYOUT_OPTIONS.map((option) => (
{
- applyAutoLayout(option.id);
- close();
- }}
+ onSelect={() => updateSettings({ layout: option.id })}
/>
))}
+
+ Choisir une disposition ne deplace rien : utilisez Reorganiser.
+
Espacement
@@ -235,6 +242,20 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
+ {
+ const changed = balanceSides();
+ pushToast(
+ changed > 0
+ ? `${changed} branche(s) reparties. Relancez l'organisation pour les deplacer.`
+ : 'Toutes les branches ont deja un cote.',
+ changed > 0 ? 'success' : 'info',
+ );
+ close();
+ }}
+ />
{
diff --git a/frontend/src/features/editor/MarkdownImportDialog.tsx b/frontend/src/features/editor/MarkdownImportDialog.tsx
new file mode 100644
index 0000000..b799376
--- /dev/null
+++ b/frontend/src/features/editor/MarkdownImportDialog.tsx
@@ -0,0 +1,183 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Button } from '@/components/Button';
+import { Modal } from '@/components/Modal';
+import { MarkdownError, parseMarkdown } from '@/services/markdown';
+import { type OutlineNode } from '@/services/outlineParser';
+import { useAppConfigStore } from '@/stores/appConfigStore';
+import { useEditorStore } from '@/stores/editorStore';
+import { useUiStore } from '@/stores/uiStore';
+
+export interface MarkdownImportDialogProps {
+ open: boolean;
+ /** Noeud sous lequel importer. `null` = a la racine de la carte. */
+ parentId: string | null;
+ onClose: () => void;
+}
+
+interface Analysis {
+ roots: OutlineNode[];
+ count: number;
+ error: string | null;
+}
+
+function analyse(text: string, maxNodes: number): Analysis {
+ if (text.trim().length === 0) return { roots: [], count: 0, error: null };
+ try {
+ const result = parseMarkdown(text, maxNodes);
+ return { roots: result.roots, count: result.count, error: null };
+ } catch (error) {
+ return {
+ roots: [],
+ count: 0,
+ error:
+ error instanceof MarkdownError
+ ? error.message
+ : "Ce document n'a pas pu etre analyse.",
+ };
+ }
+}
+
+function PreviewTree({ nodes, depth = 0 }: { nodes: OutlineNode[]; depth?: number }) {
+ return (
+
+ {nodes.map((node, index) => (
+
+ {node.text}
+ {node.note ? (
+ note
+ ) : null}
+ {node.children.length > 0 ? : null}
+
+ ))}
+
+ );
+}
+
+/**
+ * Import Markdown dans une carte ouverte.
+ *
+ * Le contenu devient un sous-arbre du noeud vise, en une seule entree
+ * d'historique. Les paragraphes rejoignent la note du noeud correspondant, les
+ * blocs de code aussi : rien n'est execute, rien n'est rendu en HTML.
+ */
+export function MarkdownImportDialog({ open, parentId, onClose }: MarkdownImportDialogProps) {
+ const insertOutline = useEditorStore((state) => state.insertOutline);
+ const parentName = useEditorStore(
+ (state) => state.map?.nodes.find((node) => node.id === parentId)?.text,
+ );
+ const maxNodes = useAppConfigStore((state) => state.maxNodes);
+ const pushToast = useUiStore((state) => state.pushToast);
+
+ const [text, setText] = useState('');
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ if (open) setText('');
+ }, [open]);
+
+ const analysis = useMemo(() => analyse(text, maxNodes), [text, maxNodes]);
+ const canSubmit = analysis.count > 0 && analysis.error === null;
+
+ const submit = () => {
+ if (!canSubmit) return;
+ const created = insertOutline(parentId, analysis.roots);
+ if (created > 0) {
+ pushToast(`${created} noeud(s) importe(s) depuis le Markdown.`, 'success');
+ onClose();
+ }
+ };
+
+ const readFile = async (file: File) => {
+ const content = await file.text();
+ setText(content);
+ };
+
+ return (
+
+ Annuler
+
+ {analysis.count > 0 ? `Importer ${analysis.count} noeud(s)` : 'Importer'}
+
+ >
+ }
+ >
+
+
+ fileInputRef.current?.click()}>
+ Choisir un fichier .md
+
+ ou collez le contenu ci-dessous
+ {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (file) void readFile(file);
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/editor/MindMapCanvas.tsx b/frontend/src/features/editor/MindMapCanvas.tsx
index be240ab..7fc009a 100644
--- a/frontend/src/features/editor/MindMapCanvas.tsx
+++ b/frontend/src/features/editor/MindMapCanvas.tsx
@@ -12,7 +12,12 @@ import {
type NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
-import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters';
+import {
+ type MindFlowNode,
+ toFlowEdges,
+ toFlowNodes,
+ toFlowRelationEdges,
+} from '@/features/editor/flowAdapters';
import { MindNodeView } from '@/features/editor/MindNodeView';
import { getTheme } from '@/features/editor/themes';
import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer';
@@ -27,6 +32,8 @@ import {
} from '@/features/editor/NodeContextMenu';
import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog';
import { MoveToDialog } from '@/features/editor/MoveToDialog';
+import { RelationDialog } from '@/features/editor/RelationDialog';
+import { MarkdownImportDialog } from '@/features/editor/MarkdownImportDialog';
import { SelectionToolbar } from '@/features/editor/SelectionToolbar';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
@@ -52,6 +59,7 @@ export function MindMapCanvas({
const focusRequest = useEditorStore((state) => state.focusRequest);
const fitRequest = useEditorStore((state) => state.fitRequest);
const sizeVersion = useEditorStore((state) => state.sizeVersion);
+ const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const pushToast = useUiStore((state) => state.pushToast);
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
@@ -68,6 +76,8 @@ export function MindMapCanvas({
null,
);
const [moveTarget, setMoveTarget] = useState(null);
+ const [relationSource, setRelationSource] = useState(null);
+ const [markdownParent, setMarkdownParent] = useState(null);
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
@@ -87,15 +97,35 @@ export function MindMapCanvas({
searchResults,
editingNodeId,
readOnly,
+ selectedRelationId,
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],
+ [
+ theme,
+ selectedNodeIds,
+ searchResults,
+ editingNodeId,
+ readOnly,
+ sizeVersion,
+ selectedRelationId,
+ ],
);
const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]);
- const edges = useMemo(() => (map ? toFlowEdges(map, adapterOptions) : []), [map, adapterOptions]);
+
+ /*
+ * Les aretes affichees sont l'union de deux choses de nature differente : les
+ * liens hierarchiques, derives de `parentId`, et les relations secondaires,
+ * qui sont une donnee du document. Elles ne se melangent qu'ici, a
+ * l'affichage : le modele, lui, les garde separees.
+ */
+ const edges = useMemo(
+ () =>
+ map ? [...toFlowEdges(map, adapterOptions), ...toFlowRelationEdges(map, adapterOptions)] : [],
+ [map, adapterOptions],
+ );
// Cadrage initial a l'ouverture d'une carte.
useEffect(() => {
@@ -156,10 +186,22 @@ export function MindMapCanvas({
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
- if (readOnly) return;
const store = useEditorStore.getState();
for (const change of changes) {
- if (change.type !== 'remove') continue;
+ if (change.type === 'select') {
+ // Une relation se selectionne ; un lien hierarchique, non : il n'a
+ // pas de proprietes propres, tout se regle sur le noeud enfant.
+ const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
+ if (relation) store.setSelectedRelation(change.selected ? relation.id : null);
+ continue;
+ }
+ if (change.type !== 'remove' || readOnly) continue;
+
+ const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
+ if (relation) {
+ store.deleteRelation(relation.id);
+ continue;
+ }
const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
if (edge) store.detachNode(edge.target);
}
@@ -264,6 +306,7 @@ export function MindMapCanvas({
const store = useEditorStore.getState();
store.setSelection([]);
store.setEditingNode(null);
+ store.setSelectedRelation(null);
setContextMenu(null);
}}
defaultViewport={map.viewport}
@@ -313,6 +356,8 @@ export function MindMapCanvas({
onClose={() => setContextMenu(null)}
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
onMoveTo={(nodeId) => setMoveTarget(nodeId)}
+ onAddRelation={(nodeId) => setRelationSource(nodeId)}
+ onImportMarkdown={(nodeId) => setMarkdownParent(nodeId)}
/>
) : null}
@@ -328,6 +373,18 @@ export function MindMapCanvas({
nodeId={moveTarget}
onClose={() => setMoveTarget(null)}
/>
+
+ setRelationSource(null)}
+ />
+
+ setMarkdownParent(null)}
+ />
);
}
diff --git a/frontend/src/features/editor/MindNodeView.tsx b/frontend/src/features/editor/MindNodeView.tsx
index 8616790..6cbf8fa 100644
--- a/frontend/src/features/editor/MindNodeView.tsx
+++ b/frontend/src/features/editor/MindNodeView.tsx
@@ -1,5 +1,5 @@
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { Handle, NodeResizer, type NodeProps } from '@xyflow/react';
+import { Handle, NodeResizer, useUpdateNodeInternals, type NodeProps } from '@xyflow/react';
import { type MindFlowNode } from '@/features/editor/flowAdapters';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph';
@@ -42,6 +42,22 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps)
const textareaRef = useRef(null);
const [draft, setDraft] = useState(node.text);
+ /*
+ * React Flow mesure les poignees une seule fois, a leur apparition. Quand une
+ * branche change de cote, ou que la carte passe en bilaterale, les poignees
+ * changent d'identifiant et de bord : sans cette notification, React Flow
+ * garde les anciennes mesures et cesse simplement de dessiner les liens
+ * concernes.
+ */
+ const updateNodeInternals = useUpdateNodeInternals();
+ const handleSignature = `${data.targetHandle.id}|${data.sourceHandles
+ .map((handle) => handle.id)
+ .join(',')}`;
+
+ useEffect(() => {
+ updateNodeInternals(id);
+ }, [id, handleSignature, updateNodeInternals]);
+
useEffect(() => {
if (isEditing) setDraft(node.text);
}, [isEditing, node.text]);
@@ -204,7 +220,8 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps)
@@ -304,12 +321,18 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps)
) : null}
-
+ {/* En mind map bilaterale, la racine porte deux poignees sortantes :
+ ses branches partent a gauche comme a droite. */}
+ {data.sourceHandles.map((handle) => (
+
+ ))}
);
}
diff --git a/frontend/src/features/editor/NodeContextMenu.tsx b/frontend/src/features/editor/NodeContextMenu.tsx
index 2d7e159..c035b56 100644
--- a/frontend/src/features/editor/NodeContextMenu.tsx
+++ b/frontend/src/features/editor/NodeContextMenu.tsx
@@ -15,6 +15,8 @@ export interface NodeContextMenuProps {
onClose: () => void;
onAddChildren: (parentId: string) => void;
onMoveTo: (nodeId: string) => void;
+ onAddRelation: (nodeId: string) => void;
+ onImportMarkdown: (nodeId: string) => void;
}
interface MenuEntry {
@@ -34,7 +36,14 @@ interface MenuHeading {
type MenuLine = MenuEntry | MenuHeading;
/** Menu contextuel d'un noeud, organise par famille d'action. */
-export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: NodeContextMenuProps) {
+export function NodeContextMenu({
+ state,
+ onClose,
+ onAddChildren,
+ onMoveTo,
+ onAddRelation,
+ onImportMarkdown,
+}: NodeContextMenuProps) {
const map = useEditorStore((store) => store.map);
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
@@ -72,6 +81,14 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
const hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id);
const notify = (message: string) => pushToast(message, 'success');
+ // Le cote ne se regle que sur une branche de niveau 1 : ailleurs, il est
+ // herite de la branche, il n'y a donc rien a choisir.
+ const parent = node.parentId
+ ? map.nodes.find((candidate) => candidate.id === node.parentId)
+ : undefined;
+ const isBranchRoot = Boolean(parent && !parent.parentId);
+ const bilateral = map.settings.layout === 'mindmap-both';
+
return [
{ kind: 'heading', label: 'Ajouter' },
{ kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) },
@@ -94,6 +111,18 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
disabled: !node.parentId,
run: () => store.addSiblingAt(node.id, 'after'),
},
+ {
+ kind: 'item',
+ label: 'Importer Markdown ici...',
+ run: () => onImportMarkdown(node.id),
+ },
+
+ { kind: 'heading', label: 'Relation' },
+ {
+ kind: 'item',
+ label: 'Ajouter une relation...',
+ run: () => onAddRelation(node.id),
+ },
{ kind: 'heading', label: 'Branche' },
{
@@ -125,6 +154,28 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
run: () => store.setBranchCollapsed(node.id, !node.collapsed),
},
+ { kind: 'heading', label: 'Cote' },
+ {
+ kind: 'item',
+ label: 'Placer la branche a gauche',
+ hint: bilateral ? undefined : 'bilaterale',
+ disabled: !isBranchRoot || node.side === 'left',
+ run: () => {
+ store.setNodeSide(node.id, 'left');
+ notify('Branche placee a gauche. Relancez l organisation pour la deplacer.');
+ },
+ },
+ {
+ kind: 'item',
+ label: 'Placer la branche a droite',
+ hint: bilateral ? undefined : 'bilaterale',
+ disabled: !isBranchRoot || node.side === 'right',
+ run: () => {
+ store.setNodeSide(node.id, 'right');
+ notify('Branche placee a droite. Relancez l organisation pour la deplacer.');
+ },
+ },
+
{ kind: 'heading', label: 'Ordre' },
{
kind: 'item',
@@ -204,6 +255,8 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
hasNodeClipboard,
onAddChildren,
onMoveTo,
+ onAddRelation,
+ onImportMarkdown,
pushToast,
]);
diff --git a/frontend/src/features/editor/PropertiesPanel.tsx b/frontend/src/features/editor/PropertiesPanel.tsx
index 770c8c5..9426ea2 100644
--- a/frontend/src/features/editor/PropertiesPanel.tsx
+++ b/frontend/src/features/editor/PropertiesPanel.tsx
@@ -28,6 +28,7 @@ import {
} from '@/features/editor/nodeStyleResolver';
import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
+import { RelationProperties } from '@/features/editor/RelationProperties';
import {
ColorField,
NumberField,
@@ -90,6 +91,7 @@ const ICON_SIZES = [16, 20, 24, 32];
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
+ const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
@@ -135,6 +137,8 @@ export function PropertiesPanel() {
const palette = paletteForDepth(theme, referenceDepth);
const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined;
+ const relation = map?.relations.find((candidate) => candidate.id === selectedRelationId);
+
const header = (
@@ -153,6 +157,20 @@ export function PropertiesPanel() {
);
+ // Une relation selectionnee prend la place du panneau de noeud : les deux
+ // selections s'excluent, le panneau ne montre donc jamais les deux a la fois.
+ if (nodes.length === 0 && relation) {
+ return (
+
+ );
+ }
+
if (nodes.length === 0) {
return (
Selectionnez un noeud pour modifier son contenu, sa typographie, son apparence et sa mise
- en page. Selectionnez-en plusieurs pour appliquer un reglage a tout le lot.
+ en page. Selectionnez-en plusieurs pour appliquer un reglage a tout le lot. Cliquez une
+ relation pour en modifier le libelle et le trace.
);
diff --git a/frontend/src/features/editor/RelationDialog.tsx b/frontend/src/features/editor/RelationDialog.tsx
new file mode 100644
index 0000000..6257dd7
--- /dev/null
+++ b/frontend/src/features/editor/RelationDialog.tsx
@@ -0,0 +1,139 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Button } from '@/components/Button';
+import { Modal } from '@/components/Modal';
+import { SearchIcon } from '@/components/Icons';
+import { RELATION_REJECTION_MESSAGE } from '@/services/relations';
+import { useEditorStore } from '@/stores/editorStore';
+import { useUiStore } from '@/stores/uiStore';
+import { computeDepths } from '@/utils/tree';
+
+export interface RelationDialogProps {
+ open: boolean;
+ sourceId: string | null;
+ onClose: () => void;
+}
+
+function normalize(value: string): string {
+ return value
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase();
+}
+
+/**
+ * Creation d'une relation secondaire.
+ *
+ * Le choix passe par une recherche explicite, et non par un glissement depuis
+ * une poignee : tirer un lien depuis une poignee cree deja un rattachement
+ * parent / enfant. Deux gestes differents pour deux natures differentes, il n'y
+ * a donc aucune ambiguite sur ce que l'on est en train de creer.
+ */
+export function RelationDialog({ open, sourceId, onClose }: RelationDialogProps) {
+ const map = useEditorStore((state) => state.map);
+ const addRelation = useEditorStore((state) => state.addRelation);
+ const pushToast = useUiStore((state) => state.pushToast);
+
+ const [query, setQuery] = useState('');
+ const [label, setLabel] = useState('');
+
+ useEffect(() => {
+ if (open) {
+ setQuery('');
+ setLabel('');
+ }
+ }, [open]);
+
+ const source = map?.nodes.find((candidate) => candidate.id === sourceId);
+
+ const candidates = useMemo(() => {
+ if (!map || !source) return [];
+ const depths = computeDepths(map.nodes);
+ const needle = normalize(query.trim());
+
+ return map.nodes
+ .filter((candidate) => candidate.id !== source.id)
+ .filter((candidate) => !needle || normalize(candidate.text).includes(needle))
+ .map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
+ .slice(0, 60);
+ }, [map, source, query]);
+
+ const create = (targetId: string) => {
+ if (!source) return;
+ const trimmed = label.trim();
+ const rejection = addRelation(source.id, targetId, trimmed ? { label: trimmed } : undefined);
+ if (rejection) {
+ pushToast(RELATION_REJECTION_MESSAGE[rejection], 'error');
+ return;
+ }
+ pushToast('Relation creee. La hierarchie est inchangee.', 'success');
+ onClose();
+ };
+
+ return (
+ Annuler}
+ >
+
+
+
+ Libelle (facultatif)
+
+ setLabel(event.target.value)}
+ />
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Rechercher le noeud cible"
+ aria-label="Rechercher le noeud cible"
+ className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
+ />
+
+
+ {candidates.length === 0 ? (
+
Aucun noeud ne correspond.
+ ) : (
+
+ {candidates.map(({ node: candidate, depth }) => (
+
+ create(candidate.id)}
+ className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800"
+ >
+
+ {depth === 0 ? '\u25cf' : '\u2514'}
+
+ {candidate.text || 'Sans titre'}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/features/editor/RelationProperties.tsx b/frontend/src/features/editor/RelationProperties.tsx
new file mode 100644
index 0000000..aeb3c53
--- /dev/null
+++ b/frontend/src/features/editor/RelationProperties.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useState } from 'react';
+import { type BranchStyle, type RelationMarker } from '@/types/map';
+import { Button } from '@/components/Button';
+import { TrashIcon } from '@/components/Icons';
+import { PanelSection } from '@/features/editor/panel/PanelSection';
+import {
+ ColorField,
+ NumberField,
+ SegmentedControl,
+ ToggleRow,
+} from '@/features/editor/panel/controls';
+import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
+import { RELATION_DEFAULTS } from '@/features/editor/flowAdapters';
+import { getTheme, relationColor } from '@/features/editor/themes';
+import { useEditorStore } from '@/stores/editorStore';
+
+const LINE_STYLES: Array<{ value: BranchStyle; label: string }> = [
+ { value: 'bezier', label: 'Bezier' },
+ { value: 'curved', label: 'Courbe' },
+ { value: 'straight', label: 'Droite' },
+];
+
+const MARKERS: Array<{ value: RelationMarker; label: string }> = [
+ { value: 'none', label: 'Aucune' },
+ { value: 'arrow', label: 'Fleche' },
+];
+
+/**
+ * Proprietes d'une relation secondaire.
+ *
+ * Volontairement limite a ce qui concerne le trait : une relation n'a ni
+ * parent, ni enfants, ni ordre. La supprimer ici ne supprime aucun noeud.
+ */
+export function RelationProperties({ relationId }: { relationId: string }) {
+ const map = useEditorStore((state) => state.map);
+ const updateRelation = useEditorStore((state) => state.updateRelation);
+ const deleteRelation = useEditorStore((state) => state.deleteRelation);
+
+ const relation = map?.relations.find((candidate) => candidate.id === relationId);
+ const [label, setLabel] = useState('');
+
+ useEffect(() => setLabel(relation?.label ?? ''), [relation?.id, relation?.label]);
+
+ if (!map || !relation) return null;
+
+ const theme = getTheme(map.settings.theme);
+ const sourceText = map.nodes.find((node) => node.id === relation.sourceId)?.text ?? '';
+ const targetText = map.nodes.find((node) => node.id === relation.targetId)?.text ?? '';
+
+ return (
+ <>
+
+
+
+ {sourceText || 'Sans titre'}
+
+ {' → '}
+
+ {targetText || 'Sans titre'}
+
+
+
+ Connexion visuelle uniquement : la hierarchie de ces deux noeuds est inchangee.
+
+
+
+
+ Libelle
+
+ setLabel(event.target.value)}
+ onBlur={() => {
+ const trimmed = label.trim();
+ if (trimmed !== (relation.label ?? '')) {
+ updateRelation(relation.id, trimmed ? { label: trimmed } : { label: undefined });
+ }
+ }}
+ />
+
+
+
+
+ updateRelation(relation.id, { style: value })}
+ />
+ updateRelation(relation.id, { color: value })}
+ />
+ updateRelation(relation.id, { width: value })}
+ />
+ updateRelation(relation.id, { dashed: checked })}
+ />
+ updateRelation(relation.id, { markerEnd: value })}
+ />
+ updateRelation(relation.id, { markerStart: value })}
+ />
+
+
+
+ deleteRelation(relation.id)}>
+
+ Supprimer la relation
+
+
+ >
+ );
+}
diff --git a/frontend/src/features/editor/bilateral.test.ts b/frontend/src/features/editor/bilateral.test.ts
new file mode 100644
index 0000000..21fa37f
--- /dev/null
+++ b/frontend/src/features/editor/bilateral.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, it } from 'vitest';
+import { type MindMapNode } from '@/types/map';
+import {
+ assignSides,
+ findBranchRoot,
+ getLevelOneBranches,
+ materializeSides,
+ resolveNodeSides,
+} from '@/features/editor/bilateral';
+import { computeLayout } from '@/features/editor/layout';
+
+function node(
+ id: string,
+ parentId: string | null,
+ order: number,
+ extra: Partial = {},
+): MindMapNode {
+ return {
+ id,
+ type: parentId === null ? 'root' : 'standard',
+ text: id,
+ position: { x: 0, y: 0 },
+ parentId,
+ order,
+ ...extra,
+ };
+}
+
+/**
+ * Racine avec quatre branches de poids tres differents :
+ * A = 25 noeuds, B = 3, C = 12, D = 5.
+ */
+function unbalancedMap(): MindMapNode[] {
+ const nodes: MindMapNode[] = [node('root', null, 0)];
+ const weights: Array<[string, number]> = [
+ ['A', 24],
+ ['B', 2],
+ ['C', 11],
+ ['D', 4],
+ ];
+ weights.forEach(([branch, children], index) => {
+ nodes.push(node(branch, 'root', index));
+ for (let i = 0; i < children; i += 1) nodes.push(node(`${branch}${i}`, branch, i));
+ });
+ return nodes;
+}
+
+describe('attribution automatique des cotes', () => {
+ it('repartit les branches sans cote de part et d autre', () => {
+ const sides = resolveNodeSides(unbalancedMap());
+ const branches = ['A', 'B', 'C', 'D'].map((id) => sides.get(id));
+ expect(branches).toContain('left');
+ expect(branches).toContain('right');
+ });
+
+ it('equilibre les deux cotes en tenant compte de la taille des sous-arbres', () => {
+ const nodes = unbalancedMap();
+ const sides = resolveNodeSides(nodes);
+ const weights: Record = { A: 25, B: 3, C: 12, D: 5 };
+
+ let left = 0;
+ let right = 0;
+ for (const [branch, weight] of Object.entries(weights)) {
+ if (sides.get(branch) === 'left') left += weight;
+ else right += weight;
+ }
+ // 25 d'un cote, 3 + 12 + 5 = 20 de l'autre : l'ecart reste faible.
+ expect(Math.abs(left - right)).toBeLessThanOrEqual(5);
+ });
+
+ it('est deterministe : deux appels donnent le meme resultat', () => {
+ const first = resolveNodeSides(unbalancedMap());
+ const second = resolveNodeSides(unbalancedMap());
+ for (const id of ['A', 'B', 'C', 'D']) expect(second.get(id)).toBe(first.get(id));
+ });
+
+ it('respecte un cote choisi a la main et equilibre le reste autour', () => {
+ const nodes = unbalancedMap().map((candidate) =>
+ candidate.id === 'A' ? { ...candidate, side: 'left' as const } : candidate,
+ );
+ const sides = resolveNodeSides(nodes);
+ expect(sides.get('A')).toBe('left');
+ // 25 a gauche : les trois autres branches vont a droite.
+ expect(sides.get('B')).toBe('right');
+ expect(sides.get('C')).toBe('right');
+ expect(sides.get('D')).toBe('right');
+ });
+
+ it('departage a poids egal par l ordre de fratrie', () => {
+ const result = assignSides([
+ { id: 'x', weight: 4, order: 0 },
+ { id: 'y', weight: 4, order: 1 },
+ ]);
+ expect(result.get('x')).toBe('right');
+ expect(result.get('y')).toBe('left');
+ });
+});
+
+describe('heritage du cote', () => {
+ it('donne a tous les descendants le cote de leur branche de niveau 1', () => {
+ const nodes = unbalancedMap();
+ const sides = resolveNodeSides(nodes);
+ for (const branch of ['A', 'B', 'C', 'D']) {
+ const expected = sides.get(branch);
+ for (const candidate of nodes.filter((item) => item.parentId === branch)) {
+ expect(sides.get(candidate.id)).toBe(expected);
+ }
+ }
+ });
+
+ it('identifie la branche de niveau 1 dont depend un noeud profond', () => {
+ const nodes = unbalancedMap();
+ expect(findBranchRoot(nodes, 'A3')?.id).toBe('A');
+ expect(findBranchRoot(nodes, 'A')?.id).toBe('A');
+ expect(findBranchRoot(nodes, 'root')).toBeNull();
+ });
+
+ it('ne liste comme branches que les enfants directs des racines', () => {
+ const branches = getLevelOneBranches(unbalancedMap()).map((branch) => branch.id);
+ expect(branches).toEqual(['A', 'B', 'C', 'D']);
+ });
+});
+
+describe('materialisation des cotes', () => {
+ it('ecrit un cote uniquement sur les branches de niveau 1', () => {
+ const materialized = materializeSides(unbalancedMap());
+ for (const candidate of materialized) {
+ const isBranch = ['A', 'B', 'C', 'D'].includes(candidate.id);
+ expect(candidate.side !== undefined).toBe(isBranch);
+ }
+ });
+
+ it('ne modifie pas le tableau si tous les cotes sont deja definis', () => {
+ const once = materializeSides(unbalancedMap());
+ expect(materializeSides(once)).toBe(once);
+ });
+
+ it('n ecrase jamais un cote choisi a la main', () => {
+ const nodes = unbalancedMap().map((candidate) =>
+ candidate.id === 'B' ? { ...candidate, side: 'left' as const } : candidate,
+ );
+ const materialized = materializeSides(nodes);
+ expect(materialized.find((candidate) => candidate.id === 'B')?.side).toBe('left');
+ });
+});
+
+describe('auto-layout bilateral', () => {
+ it('place la racine entre les deux cotes', () => {
+ const nodes = materializeSides(unbalancedMap());
+ const sides = resolveNodeSides(nodes);
+ const positions = computeLayout(nodes, 'mindmap-both');
+
+ const rootX = positions.get('root')?.x as number;
+ const lefts = nodes
+ .filter((candidate) => sides.get(candidate.id) === 'left' && candidate.parentId === 'root')
+ .map((candidate) => positions.get(candidate.id)?.x as number);
+ const rights = nodes
+ .filter((candidate) => sides.get(candidate.id) === 'right' && candidate.parentId === 'root')
+ .map((candidate) => positions.get(candidate.id)?.x as number);
+
+ expect(lefts.length).toBeGreaterThan(0);
+ expect(rights.length).toBeGreaterThan(0);
+ for (const x of lefts) expect(x).toBeLessThan(rootX);
+ for (const x of rights) expect(x).toBeGreaterThan(rootX);
+ });
+
+ it('fait progresser chaque cote en s eloignant de la racine', () => {
+ const nodes = materializeSides(unbalancedMap());
+ const sides = resolveNodeSides(nodes);
+ const positions = computeLayout(nodes, 'mindmap-both');
+ const rootX = positions.get('root')?.x as number;
+
+ for (const branch of ['A', 'B', 'C', 'D']) {
+ const branchX = positions.get(branch)?.x as number;
+ const child = nodes.find((candidate) => candidate.parentId === branch);
+ if (!child) continue;
+ const childX = positions.get(child.id)?.x as number;
+ if (sides.get(branch) === 'left') {
+ expect(childX).toBeLessThan(branchX);
+ expect(branchX).toBeLessThan(rootX);
+ } else {
+ expect(childX).toBeGreaterThan(branchX);
+ expect(branchX).toBeGreaterThan(rootX);
+ }
+ }
+ });
+
+ it('respecte l ordre des freres a l interieur d un cote', () => {
+ const nodes: MindMapNode[] = [
+ node('root', null, 0),
+ node('a', 'root', 0, { side: 'right' }),
+ node('b', 'root', 1, { side: 'right' }),
+ node('c', 'root', 2, { side: 'right' }),
+ ];
+ const positions = computeLayout(nodes, 'mindmap-both');
+ const y = (id: string) => positions.get(id)?.y as number;
+ expect(y('a')).toBeLessThan(y('b'));
+ expect(y('b')).toBeLessThan(y('c'));
+ });
+
+ it('deploie vers la droite ou vers la gauche selon la disposition classique', () => {
+ const nodes: MindMapNode[] = [node('root', null, 0), node('a', 'root', 0)];
+
+ const right = computeLayout(nodes, 'mindmap-right');
+ expect((right.get('a')?.x as number)).toBeGreaterThan(right.get('root')?.x as number);
+
+ const left = computeLayout(nodes, 'mindmap-left');
+ expect((left.get('a')?.x as number)).toBeLessThan(left.get('root')?.x as number);
+ });
+
+ it('ignore les relations secondaires : elles ne sont pas dans le graphe', () => {
+ // computeLayout ne recoit que des noeuds : aucune relation ne peut
+ // influencer dagre, par construction de sa signature.
+ const nodes = materializeSides(unbalancedMap());
+ const withoutRelations = computeLayout(nodes, 'mindmap-both');
+ const again = computeLayout(nodes, 'mindmap-both');
+ for (const [id, position] of withoutRelations) {
+ expect(again.get(id)).toEqual(position);
+ }
+ });
+});
diff --git a/frontend/src/features/editor/bilateral.ts b/frontend/src/features/editor/bilateral.ts
new file mode 100644
index 0000000..9d03f6f
--- /dev/null
+++ b/frontend/src/features/editor/bilateral.ts
@@ -0,0 +1,152 @@
+import { type MindMapNode, type NodeSide } from '@/types/map';
+import { buildChildrenMap, buildNodeMap, getOrderedChildren, getRootNodes } from '@/utils/tree';
+
+/**
+ * Cotes gauche / droite d'une mind map bilaterale.
+ *
+ * Le modele est volontairement minimal : seul un enfant direct d'une racine
+ * porte un `side`. Tous ses descendants heritent du cote de leur branche de
+ * niveau 1, ce qui evite d'entretenir la meme information sur chaque noeud et
+ * garantit qu'une branche ne peut pas se retrouver a cheval sur les deux cotes.
+ *
+ * `order` reste la seule source de verite de l'ordre : on filtre les enfants
+ * par cote, puis on respecte leur ordre existant. Il n'y a jamais de
+ * `leftOrder` / `rightOrder` a maintenir en parallele.
+ */
+
+/** Poids d'une branche : elle-meme plus tous ses descendants. */
+function branchWeight(children: Map, rootId: string): number {
+ let total = 1;
+ 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;
+}
+
+/**
+ * Repartition gloutonne et deterministe.
+ *
+ * Les branches sans cote sont traitees de la plus lourde a la plus legere et
+ * placees du cote le moins charge. C'est suffisant pour equilibrer visuellement
+ * une carte reelle, sans construire un solveur : a poids egal, l'ordre de
+ * fratrie tranche, donc le resultat est reproductible.
+ */
+export function assignSides(
+ branches: Array<{ id: string; weight: number; order: number; side?: NodeSide }>,
+): Map {
+ const result = new Map();
+ let leftLoad = 0;
+ let rightLoad = 0;
+
+ for (const branch of branches) {
+ if (!branch.side) continue;
+ result.set(branch.id, branch.side);
+ if (branch.side === 'left') leftLoad += branch.weight;
+ else rightLoad += branch.weight;
+ }
+
+ const pending = branches
+ .filter((branch) => !branch.side)
+ .sort((a, b) => (b.weight !== a.weight ? b.weight - a.weight : a.order - b.order));
+
+ for (const branch of pending) {
+ // A charge egale, la droite d'abord : c'est le cote de lecture naturel.
+ const side: NodeSide = rightLoad <= leftLoad ? 'right' : 'left';
+ result.set(branch.id, side);
+ if (side === 'left') leftLoad += branch.weight;
+ else rightLoad += branch.weight;
+ }
+
+ return result;
+}
+
+/** Branches de niveau 1 : les enfants directs des racines, dans l'ordre. */
+export function getLevelOneBranches(nodes: MindMapNode[]): MindMapNode[] {
+ const branches: MindMapNode[] = [];
+ for (const root of getRootNodes(nodes)) {
+ branches.push(...getOrderedChildren(nodes, root.id));
+ }
+ return branches;
+}
+
+/**
+ * Cote effectif de chaque noeud.
+ *
+ * Fonction pure : elle ne modifie pas le document. Une branche sans cote defini
+ * en recoit un ici aussi, par le meme algorithme que l'attribution explicite :
+ * une branche ajoutee apres coup se place donc immediatement du bon cote, sans
+ * qu'il faille reecrire la carte.
+ */
+export function resolveNodeSides(nodes: MindMapNode[]): Map {
+ const children = buildChildrenMap(nodes);
+ const branches = getLevelOneBranches(nodes).map((node) => ({
+ id: node.id,
+ weight: branchWeight(children, node.id),
+ order: node.order ?? 0,
+ side: node.side,
+ }));
+
+ const byBranch = assignSides(branches);
+ const sides = new Map();
+
+ // Les racines restent au centre : leur cote ne sert qu'aux cas degenres.
+ for (const root of getRootNodes(nodes)) sides.set(root.id, 'right');
+
+ for (const [branchId, side] of byBranch) {
+ sides.set(branchId, side);
+ const queue = [...(children.get(branchId) ?? [])];
+ while (queue.length > 0) {
+ const current = queue.shift() as string;
+ sides.set(current, side);
+ const next = children.get(current);
+ if (next) queue.push(...next);
+ }
+ }
+
+ // Securite : un noeud orphelin d'une donnee incoherente reste positionnable.
+ for (const node of nodes) if (!sides.has(node.id)) sides.set(node.id, 'right');
+ return sides;
+}
+
+/**
+ * Materialise les cotes manquants dans le document.
+ *
+ * Seules les branches de niveau 1 sans cote sont completees : un cote choisi a
+ * la main n'est jamais ecrase. Retourne le tableau d'origine si rien ne change.
+ */
+export function materializeSides(nodes: MindMapNode[]): MindMapNode[] {
+ const branches = getLevelOneBranches(nodes);
+ if (branches.every((branch) => branch.side)) return nodes;
+
+ const resolved = resolveNodeSides(nodes);
+ const branchIds = new Set(branches.map((branch) => branch.id));
+
+ return nodes.map((node) =>
+ branchIds.has(node.id) && !node.side
+ ? { ...node, side: resolved.get(node.id) ?? 'right' }
+ : node,
+ );
+}
+
+/**
+ * Branche de niveau 1 dont depend un noeud, c'est-a-dire le noeud dont le
+ * `side` fait foi pour lui. `null` pour une racine.
+ */
+export function findBranchRoot(nodes: MindMapNode[], nodeId: string): MindMapNode | null {
+ const byId = buildNodeMap(nodes);
+ let current = byId.get(nodeId);
+ if (!current) return null;
+
+ const guard = new Set();
+ while (current.parentId && byId.has(current.parentId) && !guard.has(current.id)) {
+ guard.add(current.id);
+ const parent = byId.get(current.parentId) as MindMapNode;
+ if (!parent.parentId || !byId.has(parent.parentId)) return current;
+ current = parent;
+ }
+ return null;
+}
diff --git a/frontend/src/features/editor/flowAdapters.ts b/frontend/src/features/editor/flowAdapters.ts
index 55d5a27..9585f98 100644
--- a/frontend/src/features/editor/flowAdapters.ts
+++ b/frontend/src/features/editor/flowAdapters.ts
@@ -1,7 +1,13 @@
import { type Edge, type Node } from '@xyflow/react';
-import { Position } from '@xyflow/react';
-import { type BranchStyle, type MindMap, type MindMapNode } from '@/types/map';
-import { type MapTheme } from '@/features/editor/themes';
+import { MarkerType, Position } from '@xyflow/react';
+import {
+ type BranchStyle,
+ type MindMap,
+ type MindMapNode,
+ type NodeSide,
+} from '@/types/map';
+import { resolveNodeSides } from '@/features/editor/bilateral';
+import { relationColor, type MapTheme } from '@/features/editor/themes';
import {
resolveNodeAppearance,
type NodeAppearance,
@@ -22,8 +28,19 @@ export interface MindNodeData extends Record {
isSearchHit: boolean;
readOnly: boolean;
isEditing: boolean;
- sourcePosition: Position;
- targetPosition: Position;
+ /**
+ * Poignees sortantes. Une seule dans les dispositions classiques ; en mind map
+ * bilaterale, la racine en porte deux puisque ses branches partent des deux
+ * cotes.
+ */
+ sourceHandles: HandleSpec[];
+ targetHandle: HandleSpec;
+}
+
+/** Une poignee de connexion : identifiant stable et cote d'ancrage. */
+export interface HandleSpec {
+ id: string;
+ position: Position;
}
export type MindFlowNode = Node;
@@ -40,6 +57,81 @@ function handlePositions(layout: MindMap['settings']['layout']): {
: { source: Position.Right, target: Position.Left };
}
+function handleSpec(type: 's' | 't', position: Position): HandleSpec {
+ return { id: `${type}-${position}`, position };
+}
+
+/** Poignees d'un noeud : une entrante, une ou deux sortantes. */
+export interface HandlePlan {
+ sources: HandleSpec[];
+ target: HandleSpec;
+ /** Poignee sortante a utiliser pour rejoindre un enfant, par cote. */
+ sourceBySide: Map;
+}
+
+const SIDE_POSITION: Record = {
+ left: Position.Left,
+ right: Position.Right,
+};
+
+/**
+ * Repartition des poignees, calculee une fois par carte.
+ *
+ * Hors bilaterale, tous les noeuds partagent la meme orientation, comme avant.
+ * En bilaterale, chaque noeud regarde vers le centre et la racine porte les
+ * deux poignees sortantes : c'est ce qui permet aux branches de partir a gauche
+ * comme a droite sans que le trait fasse un detour.
+ */
+export function buildHandlePlans(map: MindMap): Map {
+ const plans = new Map();
+ const bilateral = map.settings.layout === 'mindmap-both';
+ const positions = handlePositions(map.settings.layout);
+
+ if (!bilateral) {
+ const source = handleSpec('s', positions.source);
+ const target = handleSpec('t', positions.target);
+ const plan: HandlePlan = {
+ sources: [source],
+ target,
+ sourceBySide: new Map([
+ ['left', source],
+ ['right', source],
+ ]),
+ };
+ for (const node of map.nodes) plans.set(node.id, plan);
+ return plans;
+ }
+
+ const sides = resolveNodeSides(map.nodes);
+ const children = buildChildrenMap(map.nodes);
+
+ for (const node of map.nodes) {
+ const own = sides.get(node.id) ?? 'right';
+ // Le parent est toujours du cote du centre : l'entree lui fait face.
+ const target = handleSpec('t', own === 'left' ? Position.Right : Position.Left);
+
+ const childSides = new Set();
+ for (const childId of children.get(node.id) ?? []) {
+ childSides.add(sides.get(childId) ?? 'right');
+ }
+ // Un noeud sans enfant garde une poignee : on doit pouvoir tirer un lien.
+ if (childSides.size === 0) childSides.add(own);
+
+ const sources = (['left', 'right'] as NodeSide[])
+ .filter((side) => childSides.has(side))
+ .map((side) => handleSpec('s', SIDE_POSITION[side]));
+
+ const sourceBySide = new Map();
+ for (const side of ['left', 'right'] as NodeSide[]) {
+ const match = sources.find((handle) => handle.position === SIDE_POSITION[side]);
+ sourceBySide.set(side, match ?? (sources[0] as HandleSpec));
+ }
+
+ plans.set(node.id, { sources, target, sourceBySide });
+ }
+ return plans;
+}
+
const EDGE_TYPE_BY_BRANCH: Record = {
bezier: 'default',
curved: 'smoothstep',
@@ -62,12 +154,18 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults);
- const positions = handlePositions(map.settings.layout);
+ const plans = buildHandlePlans(map);
+ const fallback: HandlePlan = {
+ sources: [handleSpec('s', Position.Right)],
+ target: handleSpec('t', Position.Left),
+ sourceBySide: new Map(),
+ };
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);
+ const plan = plans.get(node.id) ?? fallback;
return {
id: node.id,
@@ -89,8 +187,8 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id,
- sourcePosition: positions.source,
- targetPosition: positions.target,
+ sourceHandles: plan.sources,
+ targetHandle: plan.target,
},
};
});
@@ -116,16 +214,21 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes);
const nodeMap = buildNodeMap(map.nodes);
const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
+ const plans = buildHandlePlans(map);
+ const sides = map.settings.layout === 'mindmap-both' ? resolveNodeSides(map.nodes) : null;
return map.edges.map((edge) => {
const target = nodeMap.get(edge.target);
const branch = target?.branch ?? {};
const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType;
+ const childSide: NodeSide = sides?.get(edge.target) ?? 'right';
return {
id: edge.id,
source: edge.source,
target: edge.target,
+ sourceHandle: plans.get(edge.source)?.sourceBySide.get(childSide)?.id,
+ targetHandle: plans.get(edge.target)?.target.id,
type,
hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly,
@@ -136,3 +239,95 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
};
});
}
+
+/** Apparence par defaut d'une relation : pointille flechee, pour la distinguer. */
+export const RELATION_DEFAULTS = {
+ style: 'bezier' as BranchStyle,
+ width: 2,
+ dashed: true,
+ markerEnd: 'arrow' as const,
+ markerStart: 'none' as const,
+};
+
+/** Prefixe des identifiants de relation : ils ne peuvent pas etre confondus
+ * avec un lien hierarchique, dont l'id est toujours `e--`. */
+export const RELATION_ID_PREFIX = 'r-';
+
+/**
+ * Relations secondaires converties en aretes React Flow.
+ *
+ * Elles ne sont jamais melangees a `map.edges` dans le document : les liens
+ * hierarchiques restent derives de `parentId`, les relations restent une donnee
+ * propre. C'est seulement a l'affichage que les deux se rejoignent.
+ *
+ * Une relation dont une extremite est masquee par un repli est masquee elle
+ * aussi : on ne trace pas un trait vers une position qui n'existe pas. Elle
+ * reste dans le document et reapparait au deploiement.
+ */
+export function toFlowRelationEdges(
+ map: MindMap,
+ options: AdapterOptions & { selectedRelationId?: string | null },
+): Edge[] {
+ if (map.relations.length === 0) return [];
+
+ const hidden = getHiddenNodeIds(map.nodes);
+ const nodeMap = buildNodeMap(map.nodes);
+ const plans = buildHandlePlans(map);
+
+ return map.relations
+ .filter((relation) => nodeMap.has(relation.sourceId) && nodeMap.has(relation.targetId))
+ .map((relation) => {
+ const source = nodeMap.get(relation.sourceId) as MindMapNode;
+ const target = nodeMap.get(relation.targetId) as MindMapNode;
+ const type =
+ EDGE_TYPE_BY_BRANCH[relation.style ?? RELATION_DEFAULTS.style] ?? 'default';
+ const color = relation.color ?? relationColor(options.theme);
+ const width = relation.width ?? RELATION_DEFAULTS.width;
+ const dashed = relation.dashed ?? RELATION_DEFAULTS.dashed;
+ // La poignee sortante la plus proche de la cible evite un trait qui
+ // contourne le noeud avant de repartir.
+ const towards: NodeSide = target.position.x >= source.position.x ? 'right' : 'left';
+ const sourcePlan = plans.get(relation.sourceId);
+ const sourceHandle =
+ sourcePlan?.sources.find(
+ (handle) => handle.position === SIDE_POSITION[towards],
+ )?.id ?? sourcePlan?.sources[0]?.id;
+
+ return {
+ id: relation.id,
+ source: relation.sourceId,
+ target: relation.targetId,
+ sourceHandle,
+ targetHandle: plans.get(relation.targetId)?.target.id,
+ type,
+ selected: options.selectedRelationId === relation.id,
+ hidden: hidden.has(relation.sourceId) || hidden.has(relation.targetId),
+ deletable: !options.readOnly,
+ selectable: true,
+ ...(relation.label ? { label: relation.label } : {}),
+ labelStyle: { fill: color, fontSize: 11, fontWeight: 500 },
+ labelBgStyle: { fill: options.theme.canvasBackground, fillOpacity: 0.85 },
+ labelBgPadding: [4, 2] as [number, number],
+ labelBgBorderRadius: 4,
+ markerEnd:
+ (relation.markerEnd ?? RELATION_DEFAULTS.markerEnd) === 'arrow'
+ ? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
+ : undefined,
+ markerStart:
+ (relation.markerStart ?? RELATION_DEFAULTS.markerStart) === 'arrow'
+ ? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
+ : undefined,
+ style: {
+ stroke: color,
+ strokeWidth: width,
+ ...(dashed ? { strokeDasharray: '6 5' } : {}),
+ },
+ data: { relation },
+ };
+ });
+}
+
+/** Vrai si cette arete affichee est une relation secondaire. */
+export function isRelationEdgeId(map: MindMap, edgeId: string): boolean {
+ return map.relations.some((relation) => relation.id === edgeId);
+}
diff --git a/frontend/src/features/editor/layout.ts b/frontend/src/features/editor/layout.ts
index 1b78188..a87e575 100644
--- a/frontend/src/features/editor/layout.ts
+++ b/frontend/src/features/editor/layout.ts
@@ -1,6 +1,8 @@
import dagre from 'dagre';
import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
import { type MapTheme } from '@/features/editor/themes';
+import { resolveNodeSides } from '@/features/editor/bilateral';
+import { getRootNodes } from '@/utils/tree';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption {
@@ -10,9 +12,9 @@ export interface LayoutOption {
}
/**
- * 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.
+ * Dispositions proposees dans l'interface.
+ * `organization` et `freeform` existent dans le modele mais ne sont pas encore
+ * implementees : elles restent disponibles pour une version ulterieure.
*/
export const LAYOUT_OPTIONS: LayoutOption[] = [
{
@@ -25,6 +27,11 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
label: 'Mind map gauche',
description: 'Les branches se deploient vers la gauche.',
},
+ {
+ id: 'mindmap-both',
+ label: 'Mind map bilaterale',
+ description: 'Racine centree, branches reparties a gauche et a droite.',
+ },
{
id: 'tree-horizontal',
label: 'Arbre horizontal',
@@ -70,8 +77,10 @@ const LAYOUT_PARAMS: Record = {
'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
- // Replis pour les dispositions reservees.
+ // La bilaterale ne passe pas par dagre directement : elle combine les deux
+ // dispositions ci-dessus. L'entree sert a l'orientation des poignees.
'mindmap-both': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
+ // Replis pour les dispositions reservees.
organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
};
@@ -111,11 +120,67 @@ export function computeLayout(
nodes: MindMapNode[],
layout: LayoutKind,
options: LayoutOptions = {},
+): Map {
+ if (nodes.length === 0) return new Map();
+ if (layout === 'mindmap-both') return computeBilateralLayout(nodes, options);
+
+ const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
+ return runDagre(nodes, params, options);
+}
+
+/**
+ * Mind map bilaterale : deux passes dagre, une par cote, puis recalage.
+ *
+ * Chaque passe contient les racines et les branches de son cote ; on aligne
+ * ensuite le graphe gauche sur le graphe droit en superposant la racine. La
+ * racine se retrouve donc au centre, avec ses branches de part et d'autre, et
+ * chaque cote reste centre verticalement sur elle.
+ *
+ * Les relations secondaires n'entrent jamais dans ces graphes : seul `parentId`
+ * construit les aretes, ici comme dans les autres dispositions.
+ */
+function computeBilateralLayout(
+ nodes: MindMapNode[],
+ options: LayoutOptions,
+): Map {
+ const sides = resolveNodeSides(nodes);
+ const roots = getRootNodes(nodes);
+ const rootIds = new Set(roots.map((node) => node.id));
+
+ const rightNodes = nodes.filter(
+ (node) => rootIds.has(node.id) || sides.get(node.id) === 'right',
+ );
+ const leftNodes = nodes.filter((node) => rootIds.has(node.id) || sides.get(node.id) === 'left');
+
+ const rightParams = LAYOUT_PARAMS['mindmap-right'] as DagreParams;
+ const leftParams = LAYOUT_PARAMS['mindmap-left'] as DagreParams;
+
+ const right = runDagre(rightNodes, rightParams, options);
+ const left = runDagre(leftNodes, leftParams, options);
+
+ const anchor = roots[0];
+ const anchorRight = anchor ? right.get(anchor.id) : undefined;
+ const anchorLeft = anchor ? left.get(anchor.id) : undefined;
+ const shiftX = anchorRight && anchorLeft ? anchorRight.x - anchorLeft.x : 0;
+ const shiftY = anchorRight && anchorLeft ? anchorRight.y - anchorLeft.y : 0;
+
+ const positions = new Map(right);
+ for (const [id, position] of left) {
+ if (rootIds.has(id)) continue; // la racine garde la position du graphe droit
+ positions.set(id, { x: Math.round(position.x + shiftX), y: Math.round(position.y + shiftY) });
+ }
+ return positions;
+}
+
+/** Une passe dagre sur un sous-ensemble de noeuds. */
+function runDagre(
+ nodes: MindMapNode[],
+ params: DagreParams,
+ options: LayoutOptions,
): Map {
const positions = new Map();
if (nodes.length === 0) return positions;
- const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
const spacing = resolveSpacing(options.spacing);
const graph = new dagre.graphlib.Graph();
diff --git a/frontend/src/features/editor/themes.ts b/frontend/src/features/editor/themes.ts
index f460987..8287ffa 100644
--- a/frontend/src/features/editor/themes.ts
+++ b/frontend/src/features/editor/themes.ts
@@ -24,6 +24,12 @@ export interface MapTheme {
shadow: string;
branchColor: string;
branchWidth: number;
+ /**
+ * Couleur par defaut des relations secondaires (v4). Optionnelle : sans
+ * valeur, `relationColor()` choisit une teinte volontairement distincte des
+ * branches, pour qu'une relation ne se confonde jamais avec la hierarchie.
+ */
+ relationColor?: string;
root: NodePalette;
levels: NodePalette[];
@@ -276,6 +282,15 @@ export function getTheme(themeId: string | undefined): MapTheme {
return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme);
}
+/**
+ * Couleur d'une relation secondaire.
+ * Volontairement differente des branches : le lecteur doit voir au premier
+ * coup d'oeil qu'un trait n'appartient pas a la hierarchie.
+ */
+export function relationColor(theme: MapTheme): string {
+ return theme.relationColor ?? (theme.isDark ? '#f0abfc' : '#a855f7');
+}
+
/** 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;
diff --git a/frontend/src/features/editor/useEditorShortcuts.ts b/frontend/src/features/editor/useEditorShortcuts.ts
index 69e2d89..ca86a19 100644
--- a/frontend/src/features/editor/useEditorShortcuts.ts
+++ b/frontend/src/features/editor/useEditorShortcuts.ts
@@ -135,7 +135,12 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
break;
case 'Delete':
case 'Backspace':
- if (store.selectedNodeIds.length > 0) {
+ // Une relation selectionnee se supprime seule : aucun noeud n'est
+ // touche. C'est la contrepartie du fait qu'elle n'est pas structurelle.
+ if (store.selectedRelationId) {
+ event.preventDefault();
+ store.deleteRelation(store.selectedRelationId);
+ } else if (store.selectedNodeIds.length > 0) {
event.preventDefault();
store.deleteNodes(store.selectedNodeIds);
}
@@ -154,6 +159,7 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
case 'Escape':
store.setEditingNode(null);
store.setSelection([]);
+ store.setSelectedRelation(null);
break;
default:
break;
diff --git a/frontend/src/features/export/ExportMenu.tsx b/frontend/src/features/export/ExportMenu.tsx
index d81cb8b..402c8f1 100644
--- a/frontend/src/features/export/ExportMenu.tsx
+++ b/frontend/src/features/export/ExportMenu.tsx
@@ -14,6 +14,7 @@ import { PrintDialog } from '@/features/export/PrintDialog';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { serializeMap } from '@/services/mapSerialization';
+import { exportMarkdown, hasUnexportableRelations } from '@/services/markdown';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ExportMenuProps {
@@ -37,6 +38,25 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
pushToast('Carte exportee.', 'success');
};
+ /**
+ * Export Markdown : contenu et hierarchie.
+ * Les relations secondaires n'y ont pas de representation fidele ; plutot que
+ * d'inventer une hierarchie, on previent l'utilisateur et on exporte le reste.
+ */
+ const exportMarkdownFile = () => {
+ if (!map) return;
+ const blob = new Blob([exportMarkdown(map)], { type: 'text/markdown;charset=utf-8' });
+ downloadBlob(blob, toSafeFilename(map.name, '.md'));
+ if (hasUnexportableRelations(map)) {
+ pushToast(
+ 'Export Markdown termine. Les relations visuelles secondaires n y sont pas representees.',
+ 'info',
+ );
+ } else {
+ pushToast('Export Markdown termine.', 'success');
+ }
+ };
+
const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
if (!map || busy) return;
setBusy(true);
@@ -91,6 +111,17 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
>
) : null}
+ Texte
+ {
+ exportMarkdownFile();
+ close();
+ }}
+ />
+
+
Image
state.name);
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
+ const maxNodes = useAppConfigStore((state) => state.maxNodes);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
@@ -36,7 +47,9 @@ export function DashboardPage() {
const [renaming, setRenaming] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [deleting, setDeleting] = useState(null);
+ const [galleryOpen, setGalleryOpen] = useState(false);
const fileInputRef = useRef(null);
+ const markdownInputRef = useRef(null);
const refresh = useCallback(async () => {
setLoading(true);
@@ -56,9 +69,28 @@ export function DashboardPage() {
return maps.filter((map) => map.name.toLowerCase().includes(needle));
}, [maps, query]);
- const handleCreate = async () => {
- const map = await createMap('Nouvelle carte');
- navigate(buildMapPath(map.id));
+ /** Un modele est toujours copie : l'original reste une constante du code. */
+ const handleChooseTemplate = async (template: MapTemplate) => {
+ setGalleryOpen(false);
+ const stored = await importMap(buildTemplateMap(template));
+ navigate(buildMapPath(stored.id));
+ };
+
+ const handleImportMarkdown = async (file: File) => {
+ try {
+ const name = file.name.replace(/\.(md|markdown|txt)$/i, '');
+ const map = buildMapFromMarkdown(await file.text(), name || 'Carte importee', maxNodes);
+ const stored = await importMap(map);
+ pushToast(`Carte "${stored.name}" creee depuis le Markdown.`, 'success');
+ navigate(buildMapPath(stored.id));
+ } catch (error) {
+ pushToast(
+ error instanceof MarkdownError
+ ? error.message
+ : "Ce fichier Markdown n'a pas pu etre lu.",
+ 'error',
+ );
+ }
};
const handleImportFile = async (file: File) => {
@@ -134,14 +166,43 @@ export function DashboardPage() {
-
void handleCreate()}>
+ setGalleryOpen(true)}>
Nouvelle carte
- fileInputRef.current?.click()}>
-
- Importer
-
+
+
+
+ Importer
+
+
+ }
+ >
+ {(close) => (
+ <>
+
Depuis un fichier
+
{
+ fileInputRef.current?.click();
+ close();
+ }}
+ />
+ {
+ markdownInputRef.current?.click();
+ close();
+ }}
+ />
+ >
+ )}
+
+
+ {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (file) void handleImportMarkdown(file);
+ }}
+ />
@@ -181,7 +254,7 @@ export function DashboardPage() {
: `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`}
{!query ? (
-
void handleCreate()}>
+ setGalleryOpen(true)}>
Nouvelle carte
@@ -213,6 +286,12 @@ export function DashboardPage() {
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
+ setGalleryOpen(false)}
+ onSelect={(template) => void handleChooseTemplate(template)}
+ />
+
void;
+ onSelect: (template: MapTemplate) => void;
+}
+
+/** Apercu textuel : les premieres branches du modele, sans aucune image. */
+function outline(node: TemplateNode): string {
+ const branches = (node.children ?? []).slice(0, 5).map((child) => child.text);
+ if (branches.length === 0) return 'Une racine, rien d autre.';
+ const extra = (node.children ?? []).length - branches.length;
+ return branches.join(' - ') + (extra > 0 ? ` - +${extra}` : '');
+}
+
+function countNodes(node: TemplateNode): number {
+ return 1 + (node.children ?? []).reduce((total, child) => total + countNodes(child), 0);
+}
+
+/**
+ * Galerie de modeles.
+ *
+ * Tout est local : aucun appel reseau, aucune image distante. Choisir un modele
+ * cree une copie ; le modele lui-meme n'est jamais modifie.
+ */
+export function TemplateGallery({ open, onClose, onSelect }: TemplateGalleryProps) {
+ return (
+ Annuler }
+ >
+
+ {MAP_TEMPLATES.map((template) => (
+
+ onSelect(template)}
+ className="flex h-full w-full flex-col gap-1 rounded-lg border border-zinc-200 p-3 text-left transition-colors hover:border-indigo-300 hover:bg-indigo-50/50 dark:border-zinc-700 dark:hover:border-indigo-700 dark:hover:bg-indigo-950/30"
+ >
+
+
+ {template.icon}
+
+
+ {template.name}
+
+
+ {countNodes(template.root)} noeuds
+
+
+
+ {template.description}
+
+ {outline(template.root)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/features/templates/templates.test.ts b/frontend/src/features/templates/templates.test.ts
new file mode 100644
index 0000000..143ed23
--- /dev/null
+++ b/frontend/src/features/templates/templates.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest';
+import { MAP_TEMPLATES, buildTemplateMap, getTemplate } from '@/features/templates/templates';
+import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
+import { getLevelOneBranches } from '@/features/editor/bilateral';
+import { getOrderedChildren } from '@/utils/tree';
+
+describe('galerie de modeles', () => {
+ it('propose les huit modeles annonces', () => {
+ expect(MAP_TEMPLATES.map((template) => template.id)).toEqual([
+ 'blank',
+ 'brainstorming',
+ 'project',
+ 'it-architecture',
+ 'meeting',
+ 'swot',
+ 'study',
+ 'personal',
+ ]);
+ });
+
+ it('donne a chaque modele un nom et une description', () => {
+ for (const template of MAP_TEMPLATES) {
+ expect(template.name.length).toBeGreaterThan(0);
+ expect(template.description.length).toBeGreaterThan(0);
+ expect(template.icon.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('retrouve un modele par son identifiant', () => {
+ expect(getTemplate('swot')?.name).toBe('SWOT');
+ expect(getTemplate('inexistant')).toBeUndefined();
+ });
+});
+
+describe('creation depuis un modele', () => {
+ it('produit une carte valide pour chaque modele', () => {
+ for (const template of MAP_TEMPLATES) {
+ const map = buildTemplateMap(template);
+ expect(map.nodes.length).toBeGreaterThan(0);
+ expect(map.nodes[0]?.parentId).toBeNull();
+ expect(map.nodes[0]?.type).toBe('root');
+ expect(map.settings.theme).toBe(template.theme);
+ expect(map.settings.layout).toBe(template.layout);
+ expect(map.relations).toEqual([]);
+ }
+ });
+
+ it('regenere des identifiants uniques a chaque creation', () => {
+ const template = getTemplate('it-architecture');
+ const first = buildTemplateMap(template as never);
+ const second = buildTemplateMap(template as never);
+
+ const firstIds = new Set(first.nodes.map((node) => node.id));
+ expect(firstIds.size).toBe(first.nodes.length);
+ for (const node of second.nodes) expect(firstIds.has(node.id)).toBe(false);
+ expect(first.id).not.toBe(second.id);
+ });
+
+ it('ne modifie jamais le modele source', () => {
+ const template = getTemplate('it-architecture') as never as {
+ root: { text: string; children?: unknown[] };
+ };
+ const before = JSON.stringify(template);
+ const map = buildTemplateMap(template as never);
+ const root = map.nodes[0];
+ if (root) root.text = 'Modifie';
+ expect(JSON.stringify(template)).toBe(before);
+ });
+
+ it("reproduit l'arborescence du modele Architecture IT", () => {
+ const map = buildTemplateMap(getTemplate('it-architecture') as never);
+ const root = map.nodes[0];
+ expect(root?.text).toBe('Architecture IT');
+
+ const branches = getOrderedChildren(map.nodes, root?.id as string).map((node) => node.text);
+ expect(branches).toEqual(['Reseau', 'Compute', 'Stockage', 'Services', 'Supervision']);
+
+ const reseau = map.nodes.find((node) => node.text === 'Reseau');
+ expect(getOrderedChildren(map.nodes, reseau?.id as string).map((node) => node.text)).toEqual([
+ 'Firewall',
+ 'Switch',
+ 'VLAN',
+ 'VPN',
+ ]);
+ expect(map.nodes).toHaveLength(20);
+ });
+
+ it('n ecrit un cote que sur les branches de niveau 1', () => {
+ const map = buildTemplateMap(getTemplate('it-architecture') as never);
+ const branchIds = new Set(getLevelOneBranches(map.nodes).map((node) => node.id));
+ for (const node of map.nodes) {
+ if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
+ }
+ expect(branchIds.size).toBe(5);
+ });
+
+ it('positionne les noeuds sans les empiler', () => {
+ const map = buildTemplateMap(getTemplate('project') as never);
+ const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
+ expect(distinct.size).toBe(map.nodes.length);
+ });
+});
+
+describe('modele et format de fichier', () => {
+ it('survit a un aller-retour de serialisation', () => {
+ const map = buildTemplateMap(getTemplate('swot') as never);
+ const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
+
+ expect(again.nodes).toHaveLength(map.nodes.length);
+ expect(again.settings.layout).toBe(map.settings.layout);
+
+ const sides = new Map(again.nodes.map((node) => [node.text, node.side]));
+ for (const node of map.nodes) {
+ expect(sides.get(node.text)).toBe(node.side);
+ }
+ });
+
+ it('est ecrit au format courant', () => {
+ const map = buildTemplateMap(getTemplate('blank') as never);
+ expect(JSON.parse(serializeMap(map)).version).toBe(4);
+ });
+});
diff --git a/frontend/src/features/templates/templates.ts b/frontend/src/features/templates/templates.ts
new file mode 100644
index 0000000..592d65b
--- /dev/null
+++ b/frontend/src/features/templates/templates.ts
@@ -0,0 +1,395 @@
+import {
+ MAP_FORMAT_VERSION,
+ type BranchStyle,
+ type LayoutKind,
+ type MindMap,
+ type MindMapNode,
+ type NodeSide,
+} from '@/types/map';
+import { computeLayout } from '@/features/editor/layout';
+import { getTheme } from '@/features/editor/themes';
+import { createNode, DEFAULT_SETTINGS } from '@/services/mapFactory';
+import { createId } from '@/utils/id';
+import { rebuildEdges } from '@/utils/tree';
+
+/**
+ * Galerie de modeles, entierement locale.
+ *
+ * Un modele est une description figee : `buildTemplateMap` en fabrique une
+ * carte neuve, avec de nouveaux identifiants, a chaque appel. L'original n'est
+ * jamais modifie, et la carte creee est ensuite une carte comme les autres.
+ * Aucune ressource distante n'est chargee : tout est ecrit ici.
+ */
+
+export interface TemplateNode {
+ text: string;
+ icon?: string;
+ note?: string;
+ /** Cote impose pour une branche de niveau 1, en disposition bilaterale. */
+ side?: NodeSide;
+ children?: TemplateNode[];
+}
+
+export interface MapTemplate {
+ id: string;
+ name: string;
+ description: string;
+ /** Emoji d'illustration : aucune image distante. */
+ icon: string;
+ theme: string;
+ layout: LayoutKind;
+ branchStyle?: BranchStyle;
+ root: TemplateNode;
+}
+
+export const MAP_TEMPLATES: MapTemplate[] = [
+ {
+ id: 'blank',
+ name: 'Carte vide',
+ description: 'Une seule racine, a vous de construire.',
+ icon: '⬜',
+ theme: 'default',
+ layout: 'mindmap-right',
+ root: { text: 'Nouvelle carte' },
+ },
+ {
+ id: 'brainstorming',
+ name: 'Brainstorming',
+ description: 'Collecter des idees, les trier, decider.',
+ icon: '💡',
+ theme: 'sunset',
+ layout: 'mindmap-both',
+ root: {
+ text: 'Brainstorming',
+ icon: '💡',
+ children: [
+ {
+ text: 'Question de depart',
+ side: 'left',
+ children: [{ text: 'Contexte' }, { text: 'Contraintes' }],
+ },
+ {
+ text: 'Idees brutes',
+ side: 'right',
+ children: [{ text: 'Idee 1' }, { text: 'Idee 2' }, { text: 'Idee 3' }],
+ },
+ {
+ text: 'A creuser',
+ side: 'right',
+ children: [{ text: 'Pistes prometteuses' }, { text: 'Questions ouvertes' }],
+ },
+ {
+ text: 'Ecartees',
+ side: 'left',
+ children: [{ text: 'Trop couteux' }, { text: 'Hors sujet' }],
+ },
+ { text: 'Prochaines etapes', side: 'right', children: [{ text: 'Qui fait quoi' }] },
+ ],
+ },
+ },
+ {
+ id: 'project',
+ name: 'Gestion de projet',
+ description: 'Objectifs, lots de travail, jalons et risques.',
+ icon: '📋',
+ theme: 'ocean',
+ layout: 'mindmap-both',
+ root: {
+ text: 'Projet',
+ icon: '📋',
+ children: [
+ {
+ text: 'Objectifs',
+ icon: '🎯',
+ side: 'left',
+ children: [{ text: 'Resultat attendu' }, { text: 'Criteres de reussite' }],
+ },
+ {
+ text: 'Perimetre',
+ side: 'left',
+ children: [{ text: 'Inclus' }, { text: 'Exclu' }],
+ },
+ {
+ text: 'Lots de travail',
+ icon: '🧩',
+ side: 'right',
+ children: [
+ { text: 'Cadrage' },
+ { text: 'Realisation' },
+ { text: 'Recette' },
+ { text: 'Mise en production' },
+ ],
+ },
+ {
+ text: 'Jalons',
+ icon: '📅',
+ side: 'right',
+ children: [{ text: 'Lancement' }, { text: 'Livraison' }],
+ },
+ {
+ text: 'Equipe',
+ side: 'left',
+ children: [{ text: 'Sponsor' }, { text: 'Chef de projet' }, { text: 'Contributeurs' }],
+ },
+ {
+ text: 'Risques',
+ icon: '⚠️',
+ side: 'right',
+ children: [{ text: 'Identifies' }, { text: 'Plan de repli' }],
+ },
+ ],
+ },
+ },
+ {
+ id: 'it-architecture',
+ name: 'Architecture IT',
+ description: 'Reseau, compute, stockage, services et supervision.',
+ icon: '🖧',
+ theme: 'midnight',
+ layout: 'mindmap-both',
+ branchStyle: 'curved',
+ root: {
+ text: 'Architecture IT',
+ icon: '🖧',
+ note: "Vue d'ensemble d'un systeme d'information : adaptez chaque branche a votre contexte.",
+ children: [
+ {
+ text: 'Reseau',
+ icon: '🌐',
+ side: 'left',
+ children: [
+ { text: 'Firewall', note: "Filtrage entrant et sortant, regles par zone." },
+ { text: 'Switch' },
+ { text: 'VLAN' },
+ { text: 'VPN', note: "Acces distant des utilisateurs aux ressources internes." },
+ ],
+ },
+ {
+ text: 'Compute',
+ icon: '🖥️',
+ side: 'right',
+ children: [
+ { text: 'Hyperviseurs' },
+ { text: 'VM' },
+ { text: 'Conteneurs' },
+ ],
+ },
+ {
+ text: 'Stockage',
+ icon: '💾',
+ side: 'left',
+ children: [{ text: 'NAS' }, { text: 'Sauvegarde' }],
+ },
+ {
+ text: 'Services',
+ icon: '⚙️',
+ side: 'right',
+ children: [{ text: 'DNS' }, { text: 'DHCP' }, { text: 'Applications' }],
+ },
+ {
+ text: 'Supervision',
+ icon: '📈',
+ side: 'left',
+ children: [{ text: 'Monitoring' }, { text: 'Logs' }],
+ },
+ ],
+ },
+ },
+ {
+ id: 'meeting',
+ name: 'Reunion / prise de notes',
+ description: 'Ordre du jour, decisions, actions a suivre.',
+ icon: '📝',
+ theme: 'paper',
+ layout: 'mindmap-right',
+ root: {
+ text: 'Reunion',
+ icon: '📝',
+ children: [
+ { text: 'Contexte', children: [{ text: 'Date' }, { text: 'Participants' }] },
+ {
+ text: 'Ordre du jour',
+ children: [{ text: 'Point 1' }, { text: 'Point 2' }, { text: 'Point 3' }],
+ },
+ { text: 'Decisions', icon: '✅', children: [{ text: 'Decision 1' }] },
+ {
+ text: 'Actions',
+ icon: '➡️',
+ children: [{ text: 'Qui' }, { text: 'Quoi' }, { text: 'Pour quand' }],
+ },
+ { text: 'Points en suspens' },
+ ],
+ },
+ },
+ {
+ id: 'swot',
+ name: 'SWOT',
+ description: 'Forces, faiblesses, opportunites, menaces.',
+ icon: '🧭',
+ theme: 'forest',
+ layout: 'mindmap-both',
+ root: {
+ text: 'Analyse SWOT',
+ icon: '🧭',
+ children: [
+ {
+ text: 'Forces',
+ icon: '💪',
+ side: 'left',
+ children: [{ text: 'Interne, positif' }],
+ },
+ {
+ text: 'Faiblesses',
+ icon: '🪫',
+ side: 'left',
+ children: [{ text: 'Interne, a ameliorer' }],
+ },
+ {
+ text: 'Opportunites',
+ icon: '🚀',
+ side: 'right',
+ children: [{ text: 'Externe, favorable' }],
+ },
+ {
+ text: 'Menaces',
+ icon: '⛈️',
+ side: 'right',
+ children: [{ text: 'Externe, a surveiller' }],
+ },
+ ],
+ },
+ },
+ {
+ id: 'study',
+ name: "Plan d'etude",
+ description: 'Programme de revision par matiere et par echeance.',
+ icon: '🎓',
+ theme: 'lavender',
+ layout: 'mindmap-right',
+ root: {
+ text: 'Plan de revision',
+ icon: '🎓',
+ children: [
+ {
+ text: 'Matieres',
+ children: [{ text: 'Matiere 1' }, { text: 'Matiere 2' }, { text: 'Matiere 3' }],
+ },
+ {
+ text: 'Chapitres a revoir',
+ children: [{ text: 'A maitriser' }, { text: 'A consolider' }],
+ },
+ {
+ text: 'Methodes',
+ children: [{ text: 'Fiches' }, { text: 'Exercices' }, { text: 'Annales' }],
+ },
+ {
+ text: 'Planning',
+ icon: '📅',
+ children: [{ text: 'Cette semaine' }, { text: 'Avant l examen' }],
+ },
+ { text: 'Evaluation', children: [{ text: 'Points acquis' }, { text: 'Points fragiles' }] },
+ ],
+ },
+ },
+ {
+ id: 'personal',
+ name: 'Planification personnelle',
+ description: 'Priorites, habitudes et projets personnels.',
+ icon: '🌱',
+ theme: 'default',
+ layout: 'mindmap-both',
+ root: {
+ text: 'Mon organisation',
+ icon: '🌱',
+ children: [
+ {
+ text: 'Priorites',
+ icon: '⭐',
+ side: 'right',
+ children: [{ text: 'Cette semaine' }, { text: 'Ce mois-ci' }],
+ },
+ {
+ text: 'Sante',
+ side: 'left',
+ children: [{ text: 'Sport' }, { text: 'Sommeil' }, { text: 'Alimentation' }],
+ },
+ {
+ text: 'Projets',
+ side: 'right',
+ children: [{ text: 'En cours' }, { text: 'A demarrer' }],
+ },
+ {
+ text: 'Habitudes',
+ side: 'left',
+ children: [{ text: 'A garder' }, { text: 'A changer' }],
+ },
+ { text: 'Envies', icon: '✨', side: 'right', children: [{ text: 'A explorer' }] },
+ ],
+ },
+ },
+];
+
+export function getTemplate(id: string): MapTemplate | undefined {
+ return MAP_TEMPLATES.find((template) => template.id === id);
+}
+
+/**
+ * Fabrique une carte a partir d'un modele.
+ *
+ * Chaque appel regenere tous les identifiants et recopie les objets : le modele
+ * d'origine, qui est une constante du module, n'est jamais modifie. La carte
+ * produite est ensuite une carte ordinaire, modifiable et supprimable comme
+ * n'importe quelle autre.
+ *
+ * Les positions initiales viennent de l'auto-layout, calcule sur les tailles
+ * estimees : le modele s'ouvre deja bien dispose, sans attendre une mesure du
+ * DOM qui n'existe pas encore.
+ */
+export function buildTemplateMap(template: MapTemplate, name?: string): MindMap {
+ const timestamp = new Date().toISOString();
+ const nodes: MindMapNode[] = [];
+
+ const walk = (spec: TemplateNode, parentId: string | null, order: number, depth: number): void => {
+ const node = createNode({
+ text: spec.text,
+ type: parentId === null ? 'root' : 'standard',
+ parentId,
+ order,
+ ...(spec.icon ? { icon: spec.icon } : {}),
+ ...(spec.note ? { note: spec.note } : {}),
+ // Le cote n'a de sens que sur une branche de niveau 1.
+ ...(depth === 1 && spec.side ? { side: spec.side } : {}),
+ position: { x: 0, y: 0 },
+ });
+ nodes.push(node);
+ (spec.children ?? []).forEach((child, index) => walk(child, node.id, index, depth + 1));
+ };
+
+ walk(template.root, null, 0, 0);
+
+ const settings = {
+ ...DEFAULT_SETTINGS,
+ theme: template.theme,
+ layout: template.layout,
+ ...(template.branchStyle ? { branchStyle: template.branchStyle } : {}),
+ };
+
+ const positions = computeLayout(nodes, template.layout, { theme: getTheme(template.theme) });
+ const placed = nodes.map((node) => ({
+ ...node,
+ position: positions.get(node.id) ?? node.position,
+ }));
+
+ return {
+ version: MAP_FORMAT_VERSION,
+ id: createId(),
+ name: name?.trim() || template.root.text || template.name,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ nodes: placed,
+ edges: rebuildEdges(placed),
+ relations: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ settings,
+ };
+}
diff --git a/frontend/src/services/mapFactory.ts b/frontend/src/services/mapFactory.ts
index bc285e1..66d00fd 100644
--- a/frontend/src/services/mapFactory.ts
+++ b/frontend/src/services/mapFactory.ts
@@ -1,4 +1,5 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
+import { remapRelations } from '@/services/relations';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
@@ -21,6 +22,7 @@ export function createNode(partial: Partial
& { text: string }): Mi
position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null,
...(partial.order !== undefined ? { order: partial.order } : {}),
+ ...(partial.side !== undefined ? { side: partial.side } : {}),
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}),
@@ -45,6 +47,7 @@ export function createEmptyMap(name: string): MindMap {
updatedAt: timestamp,
nodes: [root],
edges: [],
+ relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
@@ -72,6 +75,9 @@ export function cloneMap(map: MindMap, name?: string): MindMap {
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
+ // Les relations suivent la copie : leurs extremites pointent vers les
+ // nouveaux identifiants, jamais vers ceux de la carte d'origine.
+ relations: remapRelations(map.relations ?? [], idMapping),
viewport: { ...map.viewport },
settings: { ...map.settings },
};
@@ -126,6 +132,7 @@ export function createSampleMap(): MindMap {
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
+ relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
diff --git a/frontend/src/services/mapRelationSerialization.test.ts b/frontend/src/services/mapRelationSerialization.test.ts
new file mode 100644
index 0000000..5c58046
--- /dev/null
+++ b/frontend/src/services/mapRelationSerialization.test.ts
@@ -0,0 +1,255 @@
+import { describe, expect, it } from 'vitest';
+import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
+import { MapFormatError, normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
+
+/** Document v4 minimal : deux branches opposees et une relation entre feuilles. */
+function documentV4(): Record {
+ return {
+ version: 4,
+ id: 'map-1',
+ name: 'Infrastructure',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ nodes: [
+ { id: 'root', type: 'root', text: 'Infrastructure', position: { x: 0, y: 0 }, parentId: null },
+ {
+ id: 'reseau',
+ type: 'standard',
+ text: 'Reseau',
+ position: { x: 240, y: 0 },
+ parentId: 'root',
+ order: 0,
+ side: 'right',
+ },
+ {
+ id: 'firewall',
+ type: 'standard',
+ text: 'Firewall',
+ position: { x: 480, y: 0 },
+ parentId: 'reseau',
+ order: 0,
+ },
+ {
+ id: 'services',
+ type: 'standard',
+ text: 'Services',
+ position: { x: -240, y: 0 },
+ parentId: 'root',
+ order: 1,
+ side: 'left',
+ },
+ {
+ id: 'web',
+ type: 'standard',
+ text: 'Web',
+ position: { x: -480, y: 0 },
+ parentId: 'services',
+ order: 0,
+ },
+ ],
+ edges: [],
+ relations: [
+ {
+ id: 'r-1',
+ sourceId: 'firewall',
+ targetId: 'web',
+ label: 'protege',
+ style: 'bezier',
+ color: '#a855f7',
+ width: 2,
+ dashed: true,
+ markerEnd: 'arrow',
+ },
+ ],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ settings: { theme: 'default', layout: 'mindmap-both', branchStyle: 'bezier', showGrid: true },
+ };
+}
+
+describe('lecture d un document v4', () => {
+ it('conserve les cotes et les relations', () => {
+ const map = normalizeMapDocument(documentV4(), { source: 'file' });
+ expect(map.version).toBe(MAP_FORMAT_VERSION);
+
+ const sides = new Map(map.nodes.map((node) => [node.id, node.side]));
+ expect(sides.get('reseau')).toBe('right');
+ expect(sides.get('services')).toBe('left');
+
+ expect(map.relations).toHaveLength(1);
+ expect(map.relations[0]?.label).toBe('protege');
+ expect(map.relations[0]?.dashed).toBe(true);
+ expect(map.relations[0]?.markerEnd).toBe('arrow');
+ expect(map.settings.layout).toBe('mindmap-both');
+ });
+
+ it('ne modifie pas le document fourni', () => {
+ const raw = documentV4();
+ const before = JSON.stringify(raw);
+ normalizeMapDocument(raw, { source: 'file' });
+ expect(JSON.stringify(raw)).toBe(before);
+ });
+
+ it('est idempotent : renormaliser ne change plus rien', () => {
+ const once = normalizeMapDocument(documentV4(), { source: 'file' });
+ const twice = normalizeMapDocument(JSON.parse(JSON.stringify(once)), { source: 'file' });
+ expect(twice.nodes).toEqual(once.nodes);
+ expect(twice.relations).toEqual(once.relations);
+ });
+
+ it('survit a un aller-retour complet', () => {
+ const map = normalizeMapDocument(documentV4(), { source: 'file' });
+ const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
+ expect(again.relations).toEqual(map.relations);
+ expect(again.nodes.map((node) => [node.id, node.side])).toEqual(
+ map.nodes.map((node) => [node.id, node.side]),
+ );
+ });
+});
+
+describe('relations incoherentes', () => {
+ it('retire une relation dont la source n existe pas', () => {
+ const raw = documentV4();
+ (raw.relations as Array>)[0]!.sourceId = 'fantome';
+ expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
+ });
+
+ it('retire une relation dont la cible n existe pas', () => {
+ const raw = documentV4();
+ (raw.relations as Array>)[0]!.targetId = 'fantome';
+ expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
+ });
+
+ it('retire une relation bouclant sur elle-meme', () => {
+ const raw = documentV4();
+ (raw.relations as Array>)[0]!.targetId = 'firewall';
+ expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
+ });
+
+ it('ne garde qu une relation par couple source / cible', () => {
+ const raw = documentV4();
+ const relations = raw.relations as Array>;
+ relations.push({ ...relations[0], id: 'r-2' });
+ expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(1);
+ });
+
+ it('ignore les champs de style invalides sans perdre la relation', () => {
+ const raw = documentV4();
+ const relation = (raw.relations as Array>)[0] as Record;
+ relation.style = 'zigzag';
+ relation.color = 'javascript:alert(1)';
+ relation.markerEnd = 'etoile';
+
+ const parsed = normalizeMapDocument(raw, { source: 'file' }).relations[0];
+ expect(parsed?.style).toBeUndefined();
+ expect(parsed?.color).toBeUndefined();
+ expect(parsed?.markerEnd).toBeUndefined();
+ expect(parsed?.label).toBe('protege');
+ });
+
+ it('ignore un cote invalide', () => {
+ const raw = documentV4();
+ (raw.nodes as Array>)[1]!.side = 'haut';
+ const map = normalizeMapDocument(raw, { source: 'file' });
+ expect(map.nodes.find((node) => node.id === 'reseau')?.side).toBeUndefined();
+ });
+
+ it('refuse un document depassant la limite de relations', () => {
+ const raw = documentV4();
+ raw.relations = Array.from({ length: 30 }, (_item, index) => ({
+ id: `r-${index}`,
+ sourceId: 'firewall',
+ targetId: 'web',
+ }));
+ expect(() => normalizeMapDocument(raw, { source: 'file', maxNodes: 10 })).toThrow(
+ MapFormatError,
+ );
+ });
+});
+
+describe('migrations vers la v4', () => {
+ function legacyDocument(version: number): Record {
+ return {
+ version,
+ id: `map-v${version}`,
+ name: `Carte v${version}`,
+ createdAt: '2025-01-01T00:00:00.000Z',
+ updatedAt: '2025-01-01T00:00:00.000Z',
+ nodes: [
+ {
+ id: 'root',
+ type: 'root',
+ text: 'Racine',
+ position: { x: 0, y: 0 },
+ parentId: null,
+ style: version === 1 ? { width: 220, backgroundColor: '#ffffff' } : undefined,
+ },
+ {
+ id: 'a',
+ type: 'standard',
+ text: 'A',
+ position: { x: 200, y: 0 },
+ parentId: 'root',
+ },
+ {
+ id: 'b',
+ type: 'standard',
+ text: 'B',
+ position: { x: 200, y: 80 },
+ parentId: 'root',
+ },
+ ],
+ edges: [
+ { id: 'e-root-a', source: 'root', target: 'a' },
+ { id: 'e-root-b', source: 'root', target: 'b' },
+ ],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier' },
+ };
+ }
+
+ it('ouvre une carte v1 et la ramene au format courant', () => {
+ const map = normalizeMapDocument(legacyDocument(1), { source: 'file' });
+ expect(map.version).toBe(4);
+ expect(map.relations).toEqual([]);
+ // La largeur fixe de la v1 a rejoint layout.width.
+ const root = map.nodes.find((node) => node.id === 'root');
+ expect(root?.layout?.width).toBe(220);
+ expect(root?.layout?.widthMode).toBe('fixed');
+ expect(root?.style?.backgroundColor).toBe('#ffffff');
+ });
+
+ it('ouvre une carte v2 en materialisant l ordre puis la v4', () => {
+ const map = normalizeMapDocument(legacyDocument(2), { source: 'file' });
+ expect(map.version).toBe(4);
+ expect(map.nodes.find((node) => node.id === 'a')?.order).toBe(0);
+ expect(map.nodes.find((node) => node.id === 'b')?.order).toBe(1);
+ expect(map.relations).toEqual([]);
+ });
+
+ it('ouvre une carte v3 sans rien inventer', () => {
+ const map = normalizeMapDocument(legacyDocument(3), { source: 'file' });
+ expect(map.version).toBe(4);
+ expect(map.relations).toEqual([]);
+ // Aucun cote : la carte reste exactement telle qu'elle etait.
+ for (const node of map.nodes) expect(node.side).toBeUndefined();
+ });
+
+ it('accepte une carte historique lue depuis IndexedDB', () => {
+ const stored = legacyDocument(2) as unknown as MindMap;
+ const map = normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
+ expect(map.version).toBe(4);
+ expect(map.nodes).toHaveLength(3);
+ expect(map.relations).toEqual([]);
+ });
+
+ it('refuse un document plus recent que le format connu', () => {
+ const raw = { ...documentV4(), version: 99 };
+ expect(() => normalizeMapDocument(raw, { source: 'file' })).toThrow(MapFormatError);
+ });
+
+ it('accepte une carte v4 sans champ relations', () => {
+ const raw = documentV4();
+ delete raw.relations;
+ expect(normalizeMapDocument(raw, { source: 'file' }).relations).toEqual([]);
+ });
+});
diff --git a/frontend/src/services/mapSerialization.ts b/frontend/src/services/mapSerialization.ts
index 39951fa..309afe8 100644
--- a/frontend/src/services/mapSerialization.ts
+++ b/frontend/src/services/mapSerialization.ts
@@ -8,12 +8,15 @@ import {
type MapSettings,
type MindMap,
type MindMapNode,
+ type MindMapRelation,
type NodeBranchStyle,
type NodeIconStyle,
type NodeKind,
type NodeLayout,
type NodeShape,
+ type NodeSide,
type NodeStyle,
+ type RelationMarker,
type ShadowStyle,
type SizeMode,
type TextAlign,
@@ -22,6 +25,7 @@ import {
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
import { SPACING_LIMITS } from '@/features/editor/layout';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
+import { createRelationId, pruneRelations } from '@/services/relations';
import { createId } from '@/utils/id';
import {
flattenInOrder,
@@ -53,6 +57,9 @@ const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounde
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
const ICON_KINDS = ['emoji', 'builtin'] as const;
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
+const NODE_SIDES: NodeSide[] = ['left', 'right'];
+const RELATION_MARKERS: RelationMarker[] = ['none', 'arrow'];
+const MAX_LABEL_LENGTH = 120;
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
@@ -251,6 +258,10 @@ function parseNode(value: unknown): MindMapNode | null {
node.order = Math.max(0, Math.round(value.order));
}
+ if (typeof value.side === 'string' && NODE_SIDES.includes(value.side as NodeSide)) {
+ node.side = value.side as NodeSide;
+ }
+
if (value.collapsed === true) node.collapsed = true;
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
@@ -281,6 +292,54 @@ function parseNode(value: unknown): MindMapNode | null {
return node;
}
+/**
+ * Relation secondaire (v4).
+ *
+ * Validee comme le reste du document : identifiants bornes, couleur filtree,
+ * enumerations verifiees. Une relation incoherente est ignoree plutot que
+ * corrigee au jugement.
+ */
+function parseRelation(value: unknown): MindMapRelation | null {
+ if (!isRecord(value)) return null;
+
+ const sourceId = asString(value.sourceId, 128);
+ const targetId = asString(value.targetId, 128);
+ if (!sourceId || !targetId || sourceId === targetId) return null;
+
+ const relation: MindMapRelation = {
+ id: asString(value.id, 128) ?? createRelationId(),
+ sourceId,
+ targetId,
+ };
+
+ const label = asString(value.label, MAX_LABEL_LENGTH)?.trim();
+ if (label) relation.label = label;
+
+ if (typeof value.style === 'string' && BRANCH_STYLES.includes(value.style as BranchStyle)) {
+ relation.style = value.style as BranchStyle;
+ }
+ const color = asColor(value.color);
+ if (color) relation.color = color;
+ if (typeof value.width === 'number' && Number.isFinite(value.width)) {
+ relation.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
+ }
+ if (typeof value.dashed === 'boolean') relation.dashed = value.dashed;
+ if (
+ typeof value.markerStart === 'string' &&
+ RELATION_MARKERS.includes(value.markerStart as RelationMarker)
+ ) {
+ relation.markerStart = value.markerStart as RelationMarker;
+ }
+ if (
+ typeof value.markerEnd === 'string' &&
+ RELATION_MARKERS.includes(value.markerEnd as RelationMarker)
+ ) {
+ relation.markerEnd = value.markerEnd as RelationMarker;
+ }
+
+ return relation;
+}
+
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
@@ -367,6 +426,18 @@ function migrateV2ToV3(document: Record): Record v4 : rien a reecrire.
+ *
+ * Une carte v3 n'a ni cote ni relation. Absence de cote signifie "a repartir
+ * automatiquement", et absence de relation signifie "aucune" : la carte
+ * s'affiche donc exactement comme avant tant que l'utilisateur ne demande pas
+ * la disposition bilaterale. La migration se contente d'estampiller la version.
+ */
+function migrateV3ToV4(document: Record): Record {
+ return { ...document, version: 4 };
+}
+
function migrate(raw: Record, version: number): Record {
let document = raw;
let current = version;
@@ -379,6 +450,10 @@ function migrate(raw: Record, version: number): Record maxNodes * 2) {
+ throw new MapFormatError(
+ 'Cette carte contient ' +
+ rawRelations.length +
+ ' relations : la limite est de ' +
+ maxNodes * 2 +
+ '.',
+ );
+ }
+
+ const seenRelations = new Set();
+ const relations = pruneRelations(
+ rawRelations
+ .map(parseRelation)
+ .filter((relation): relation is MindMapRelation => relation !== null)
+ .filter((relation) => {
+ // Un doublon exact serait invisible : le second trait se superposerait.
+ const key = `${relation.sourceId}>${relation.targetId}`;
+ if (seenRelations.has(key)) return false;
+ seenRelations.add(key);
+ return true;
+ }),
+ normalised,
+ );
+
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
@@ -511,6 +615,7 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: normalised,
edges: rebuildEdges(normalised),
+ relations,
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
diff --git a/frontend/src/services/markdown.test.ts b/frontend/src/services/markdown.test.ts
new file mode 100644
index 0000000..5ac85ba
--- /dev/null
+++ b/frontend/src/services/markdown.test.ts
@@ -0,0 +1,262 @@
+import { describe, expect, it } from 'vitest';
+import {
+ MarkdownError,
+ buildMapFromMarkdown,
+ exportMarkdown,
+ parseMarkdown,
+} from '@/services/markdown';
+import { createEmptyMap } from '@/services/mapFactory';
+import { useEditorStore } from '@/stores/editorStore';
+import { getOrderedChildren } from '@/utils/tree';
+
+const SAMPLE = `# Infrastructure
+
+## Reseau
+- VLAN
+- VPN
+- Firewall
+
+## Virtualisation
+- Proxmox
+ - VM
+ - LXC
+- Docker
+`;
+
+function texts(nodes: Array<{ text: string }>): string[] {
+ return nodes.map((node) => node.text);
+}
+
+describe('analyse Markdown', () => {
+ it('construit la hierarchie a partir des titres et des listes', () => {
+ const result = parseMarkdown(SAMPLE);
+ expect(result.title).toBe('Infrastructure');
+ expect(result.roots).toHaveLength(1);
+
+ const root = result.roots[0];
+ expect(texts(root?.children ?? [])).toEqual(['Reseau', 'Virtualisation']);
+
+ const reseau = root?.children[0];
+ expect(texts(reseau?.children ?? [])).toEqual(['VLAN', 'VPN', 'Firewall']);
+
+ const virtualisation = root?.children[1];
+ expect(texts(virtualisation?.children ?? [])).toEqual(['Proxmox', 'Docker']);
+ expect(texts(virtualisation?.children[0]?.children ?? [])).toEqual(['VM', 'LXC']);
+ expect(result.count).toBe(10);
+ });
+
+ it('gere trois niveaux de titres', () => {
+ const result = parseMarkdown('# A\n## B\n### C\n');
+ expect(result.roots[0]?.children[0]?.children[0]?.text).toBe('C');
+ });
+
+ it('accepte un niveau de titre saute', () => {
+ // Un H3 directement sous un H1 se rattache au titre ouvert le plus proche.
+ const result = parseMarkdown('# A\n### C\n');
+ expect(result.roots[0]?.children[0]?.text).toBe('C');
+ });
+
+ it('reprend un titre de niveau superieur apres un plus profond', () => {
+ const result = parseMarkdown('# A\n### C\n## B\n');
+ expect(texts(result.roots[0]?.children ?? [])).toEqual(['C', 'B']);
+ });
+
+ it('lit les listes numerotees', () => {
+ const result = parseMarkdown('# A\n1. un\n2. deux\n');
+ expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
+ });
+
+ it('accepte les puces * et +', () => {
+ const result = parseMarkdown('# A\n* un\n+ deux\n');
+ expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
+ });
+
+ it('accepte un document fait uniquement de listes', () => {
+ const result = parseMarkdown('- un\n - un.a\n- deux\n');
+ expect(texts(result.roots)).toEqual(['un', 'deux']);
+ expect(texts(result.roots[0]?.children ?? [])).toEqual(['un.a']);
+ expect(result.title).toBeUndefined();
+ });
+
+ it('conserve les caracteres Unicode', () => {
+ const result = parseMarkdown('# Sécurité réseau\n- Pare-feu «actif»\n- 日本語\n');
+ expect(result.roots[0]?.text).toBe('Sécurité réseau');
+ expect(texts(result.roots[0]?.children ?? [])).toEqual(['Pare-feu «actif»', '日本語']);
+ });
+});
+
+describe('paragraphes et blocs de code', () => {
+ it('met un paragraphe dans la note du titre au-dessus', () => {
+ const result = parseMarkdown(
+ '# A\n\n## VPN\n\nLe VPN permet aux utilisateurs distants\nd acceder aux ressources internes.\n',
+ );
+ const vpn = result.roots[0]?.children[0];
+ expect(vpn?.text).toBe('VPN');
+ expect(vpn?.note).toContain('Le VPN permet aux utilisateurs distants');
+ expect(vpn?.note).toContain('ressources internes');
+ });
+
+ it('met un bloc de code dans la note, sans creer de noeuds', () => {
+ const result = parseMarkdown('# A\n\n## Docker\n\n```bash\ndocker compose up -d\n```\n');
+ const docker = result.roots[0]?.children[0];
+ expect(docker?.note).toContain('docker compose up -d');
+ expect(docker?.children).toHaveLength(0);
+ expect(result.count).toBe(2);
+ });
+
+ it('n interprete jamais le contenu : la note reste du texte', () => {
+ const result = parseMarkdown('# A\n\n\n');
+ expect(result.roots[0]?.note).toBe('');
+ });
+
+ it('attache un paragraphe qui suit une liste au dernier element cree', () => {
+ const result = parseMarkdown('# A\n- un\n\nDetail de un.\n');
+ expect(result.roots[0]?.children[0]?.note).toBe('Detail de un.');
+ });
+});
+
+describe('documents refuses', () => {
+ it('refuse un document vide', () => {
+ expect(() => parseMarkdown('')).toThrow(MarkdownError);
+ });
+
+ it('refuse un document sans titre ni liste', () => {
+ expect(() => parseMarkdown('juste du texte\net encore du texte')).toThrow(MarkdownError);
+ });
+
+ it('signale la ligne exacte d une indentation incoherente', () => {
+ const text = '# A\n- un\n - trop profond\n - incoherent\n';
+ try {
+ parseMarkdown(text);
+ throw new Error('aurait du echouer');
+ } catch (error) {
+ expect(error).toBeInstanceOf(MarkdownError);
+ expect((error as MarkdownError).line).toBe(4);
+ expect((error as MarkdownError).message).toContain('Ligne 4');
+ }
+ });
+
+ it('refuse un document au-dela de la limite de noeuds', () => {
+ const many = Array.from({ length: 12 }, (_item, index) => `- item ${index}`).join('\n');
+ expect(() => parseMarkdown(`# A\n${many}`, 5)).toThrow(MarkdownError);
+ });
+});
+
+describe('import comme nouvelle carte', () => {
+ it('fait du titre H1 la racine et le nom de la carte', () => {
+ const map = buildMapFromMarkdown(SAMPLE, 'fichier');
+ expect(map.name).toBe('Infrastructure');
+ expect(map.nodes[0]?.text).toBe('Infrastructure');
+ expect(map.nodes).toHaveLength(10);
+ expect(map.relations).toEqual([]);
+ });
+
+ it('utilise le nom du fichier quand il n y a pas de H1 unique', () => {
+ const map = buildMapFromMarkdown('- un\n- deux\n', 'mes notes');
+ expect(map.name).toBe('mes notes');
+ expect(texts(getOrderedChildren(map.nodes, map.nodes[0]?.id as string))).toEqual([
+ 'un',
+ 'deux',
+ ]);
+ });
+
+ it('reconstruit la hierarchie et positionne les noeuds', () => {
+ const map = buildMapFromMarkdown(SAMPLE, 'fichier');
+ const root = map.nodes[0];
+ const branches = getOrderedChildren(map.nodes, root?.id as string);
+ expect(texts(branches)).toEqual(['Reseau', 'Virtualisation']);
+
+ const proxmox = map.nodes.find((node) => node.text === 'Proxmox');
+ expect(texts(getOrderedChildren(map.nodes, proxmox?.id as string))).toEqual(['VM', 'LXC']);
+
+ // Les positions viennent de l'auto-layout : rien n'est empile a l'origine.
+ const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
+ expect(distinct.size).toBe(map.nodes.length);
+ });
+});
+
+describe('import comme sous-arbre', () => {
+ it('insere le contenu sous le noeud vise en une seule entree d historique', () => {
+ const store = useEditorStore.getState();
+ store.loadMap(createEmptyMap('Carte'));
+ const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
+
+ const parsed = parseMarkdown(SAMPLE);
+ const created = useEditorStore.getState().insertOutline(rootId, parsed.roots);
+ expect(created).toBe(10);
+ expect(useEditorStore.getState().map?.nodes).toHaveLength(11);
+
+ useEditorStore.getState().undo();
+ expect(useEditorStore.getState().map?.nodes).toHaveLength(1);
+ });
+
+ it('reporte les notes des paragraphes sur les noeuds crees', () => {
+ const store = useEditorStore.getState();
+ store.loadMap(createEmptyMap('Carte'));
+ const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
+
+ const parsed = parseMarkdown('## VPN\n\nAcces distant securise.\n');
+ useEditorStore.getState().insertOutline(rootId, parsed.roots);
+
+ const vpn = useEditorStore.getState().map?.nodes.find((node) => node.text === 'VPN');
+ expect(vpn?.note).toBe('Acces distant securise.');
+ });
+});
+
+describe('export Markdown', () => {
+ it('ecrit les trois premiers niveaux en titres et la suite en listes', () => {
+ const map = buildMapFromMarkdown(SAMPLE, 'fichier');
+ const markdown = exportMarkdown(map);
+
+ expect(markdown).toContain('# Infrastructure');
+ expect(markdown).toContain('## Reseau');
+ expect(markdown).toContain('### VLAN');
+ expect(markdown).toContain('### Proxmox');
+ // VM et LXC sont freres sous Proxmox : meme niveau de liste.
+ expect(markdown).toContain('- VM');
+ expect(markdown).toContain('- LXC');
+ // Un titre est toujours precede d'une ligne vide.
+ expect(markdown).toContain('\n\n### Docker');
+ });
+
+ it('ecrit les notes sous leur titre', () => {
+ const map = buildMapFromMarkdown('# A\n\n## VPN\n\nAcces distant.\n', 'fichier');
+ expect(exportMarkdown(map)).toContain('Acces distant.');
+ });
+
+ it('ne represente pas les relations secondaires', () => {
+ const map = buildMapFromMarkdown(SAMPLE, 'fichier');
+ const firewall = map.nodes.find((node) => node.text === 'Firewall') as { id: string };
+ const docker = map.nodes.find((node) => node.text === 'Docker') as { id: string };
+
+ const withRelation = {
+ ...map,
+ relations: [
+ { id: 'r-1', sourceId: firewall.id, targetId: docker.id, label: 'protege' },
+ ],
+ };
+ const markdown = exportMarkdown(withRelation);
+ expect(markdown).not.toContain('protege');
+ expect(markdown).toContain('# Infrastructure');
+ });
+
+ it('conserve la hierarchie lors d un aller-retour', () => {
+ const map = buildMapFromMarkdown(SAMPLE, 'fichier');
+ const again = buildMapFromMarkdown(exportMarkdown(map), 'fichier');
+
+ const flatten = (source: typeof map, parentId: string | null, depth: number): string[] =>
+ getOrderedChildren(source.nodes, parentId).flatMap((node) => [
+ `${depth}:${node.text}`,
+ ...flatten(source, node.id, depth + 1),
+ ]);
+
+ expect(flatten(again, null, 0)).toEqual(flatten(map, null, 0));
+ });
+
+ it('respecte l ordre des freres', () => {
+ const map = buildMapFromMarkdown('# A\n- premier\n- deuxieme\n- troisieme\n', 'fichier');
+ const markdown = exportMarkdown(map);
+ expect(markdown.indexOf('premier')).toBeLessThan(markdown.indexOf('deuxieme'));
+ expect(markdown.indexOf('deuxieme')).toBeLessThan(markdown.indexOf('troisieme'));
+ });
+});
diff --git a/frontend/src/services/markdown.ts b/frontend/src/services/markdown.ts
new file mode 100644
index 0000000..41d65b6
--- /dev/null
+++ b/frontend/src/services/markdown.ts
@@ -0,0 +1,319 @@
+import { APP_CONFIG } from '@/config/app';
+import { type MindMap, type MindMapNode } from '@/types/map';
+import {
+ OutlineError,
+ isListItem,
+ parseOutline,
+ type OutlineNode,
+} from '@/services/outlineParser';
+import { computeLayout } from '@/features/editor/layout';
+import { getTheme } from '@/features/editor/themes';
+import { createEmptyMap, createNode } from '@/services/mapFactory';
+import { getOrderedChildren, rebuildEdges } from '@/utils/tree';
+
+/**
+ * Markdown <-> mind map.
+ *
+ * L'import ne cherche pas a etre un lecteur Markdown complet : il retient ce
+ * qui porte une hierarchie (titres et listes) et met le reste (paragraphes,
+ * blocs de code) dans la note du noeud courant. Rien n'est interprete comme du
+ * HTML, rien n'est execute : le texte reste du texte.
+ *
+ * Les listes sont deleguees a `parseOutline`, deja utilise par l'import
+ * d'outline : il n'y a qu'un seul analyseur d'indentation dans le projet.
+ */
+
+const MAX_NOTE_LENGTH = 20000;
+const MAX_HEADING_DEPTH = 6;
+
+export class MarkdownError extends Error {
+ readonly line: number;
+
+ constructor(message: string, line: number) {
+ super(message);
+ this.name = 'MarkdownError';
+ this.line = line;
+ }
+}
+
+export interface MarkdownResult {
+ roots: OutlineNode[];
+ /** Nombre total de noeuds produits. */
+ count: number;
+ /** Texte du premier titre de niveau 1, s'il existe. */
+ title?: string;
+}
+
+const HEADING = /^(#{1,6})\s+(.*)$/;
+const FENCE = /^\s*(```|~~~)/;
+
+function countNodes(nodes: OutlineNode[]): number {
+ let total = 0;
+ for (const node of nodes) total += 1 + countNodes(node.children);
+ return total;
+}
+
+/** Dernier noeud cree en profondeur : celui auquel rattacher la suite. */
+function lastLeaf(nodes: OutlineNode[]): OutlineNode | null {
+ const last = nodes[nodes.length - 1];
+ if (!last) return null;
+ return lastLeaf(last.children) ?? last;
+}
+
+function appendNote(node: OutlineNode | null, block: string): void {
+ if (!node) return;
+ const text = block.trim();
+ if (!text) return;
+ const merged = node.note ? `${node.note}\n\n${text}` : text;
+ node.note = merged.slice(0, MAX_NOTE_LENGTH);
+}
+
+/**
+ * Analyse un document Markdown.
+ *
+ * Titres et listes construisent la hierarchie ; un titre de niveau saute est
+ * accepte et rattache au titre ouvert le plus proche, ce qui evite de rejeter
+ * un document par ailleurs parfaitement lisible.
+ */
+export function parseMarkdown(
+ text: string,
+ maxNodes: number = APP_CONFIG.maxNodes,
+): MarkdownResult {
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
+
+ const roots: OutlineNode[] = [];
+ const headings: Array<{ level: number; node: OutlineNode }> = [];
+ let title: string | undefined;
+ let current: OutlineNode | null = null;
+
+ let paragraph: string[] = [];
+ let listBlock: Array<{ raw: string; line: number }> = [];
+ let fence: string | null = null;
+ let fenced: string[] = [];
+
+ const flushParagraph = (): void => {
+ if (paragraph.length === 0) return;
+ appendNote(current, paragraph.join('\n'));
+ paragraph = [];
+ };
+
+ const flushList = (): void => {
+ if (listBlock.length === 0) return;
+ const offset = (listBlock[0] as { line: number }).line - 1;
+ let parsed;
+ try {
+ parsed = parseOutline(listBlock.map((entry) => entry.raw).join('\n'), maxNodes);
+ } catch (error) {
+ if (error instanceof OutlineError) {
+ // On repositionne le numero de ligne sur le document complet.
+ throw new MarkdownError(
+ error.message.replace(/^Ligne \d+/, `Ligne ${error.line + offset}`),
+ error.line + offset,
+ );
+ }
+ throw error;
+ }
+ listBlock = [];
+
+ const parent = headings[headings.length - 1]?.node;
+ if (parent) parent.children.push(...parsed.roots);
+ else roots.push(...parsed.roots);
+ current = lastLeaf(parsed.roots) ?? current;
+ };
+
+ lines.forEach((raw, index) => {
+ const lineNumber = index + 1;
+
+ // Bloc de code : recopie telle quelle dans la note, jamais en noeuds.
+ if (fence !== null) {
+ fenced.push(raw);
+ if (raw.trimStart().startsWith(fence)) {
+ flushList();
+ appendNote(current, fenced.join('\n'));
+ fenced = [];
+ fence = null;
+ }
+ return;
+ }
+ const fenceMatch = FENCE.exec(raw);
+ if (fenceMatch) {
+ flushParagraph();
+ fence = fenceMatch[1] as string;
+ fenced = [raw];
+ return;
+ }
+
+ if (raw.trim().length === 0) {
+ flushParagraph();
+ return;
+ }
+
+ const heading = HEADING.exec(raw.trim());
+ if (heading) {
+ flushParagraph();
+ flushList();
+
+ const level = Math.min(MAX_HEADING_DEPTH, (heading[1] as string).length);
+ const label = (heading[2] as string).trim();
+ const node: OutlineNode = { text: label, children: [] };
+
+ while (headings.length > 0 && (headings[headings.length - 1] as { level: number }).level >= level) {
+ headings.pop();
+ }
+ const parent = headings[headings.length - 1]?.node;
+ if (parent) parent.children.push(node);
+ else roots.push(node);
+
+ headings.push({ level, node });
+ current = node;
+ if (level === 1 && !title) title = label;
+ return;
+ }
+
+ if (isListItem(raw)) {
+ flushParagraph();
+ listBlock.push({ raw, line: lineNumber });
+ return;
+ }
+
+ // Toute autre ligne est du texte : elle rejoint la note du noeud courant.
+ flushList();
+ paragraph.push(raw.trim());
+ });
+
+ if (fence !== null) appendNote(current, fenced.join('\n'));
+ flushList();
+ flushParagraph();
+
+ const count = countNodes(roots);
+ if (count === 0) {
+ throw new MarkdownError('Ce document Markdown ne contient aucun titre ni aucune liste.', 0);
+ }
+ if (count > maxNodes) {
+ throw new MarkdownError(
+ `Ce document produirait ${count} noeuds : la limite est de ${maxNodes}.`,
+ 0,
+ );
+ }
+
+ return { roots, count, ...(title ? { title } : {}) };
+}
+
+const HEADING_DEPTH = 3;
+
+/** Titre d'un noeud sur une seule ligne : un titre Markdown ne se coupe pas. */
+function singleLine(text: string): string {
+ return text.replace(/\s*\n+\s*/g, ' ').trim();
+}
+
+function writeNode(
+ node: MindMapNode,
+ depth: number,
+ nodes: MindMapNode[],
+ out: string[],
+): void {
+ const label = singleLine(node.text) || '(sans titre)';
+
+ if (depth < HEADING_DEPTH) {
+ out.push(`${'#'.repeat(depth + 1)} ${label}`, '');
+ } else {
+ out.push(`${' '.repeat(depth - HEADING_DEPTH)}- ${label}`);
+ }
+
+ if (node.note) {
+ // La note est ecrite sous son titre, separee par une ligne vide : elle
+ // revient au meme endroit lors d'un reimport.
+ if (depth >= HEADING_DEPTH) out.push('');
+ out.push(node.note.trim(), '');
+ }
+
+ for (const child of getOrderedChildren(nodes, node.id)) {
+ writeNode(child, depth + 1, nodes, out);
+ }
+
+ if (depth >= HEADING_DEPTH && node.note) out.push('');
+}
+
+/**
+ * Export Markdown : contenu et hierarchie uniquement.
+ *
+ * Ni positions, ni couleurs, ni dimensions, ni relations secondaires : ce
+ * format decrit ce que la carte dit, pas a quoi elle ressemble. Les trois
+ * premiers niveaux deviennent des titres, les suivants des listes imbriquees,
+ * ce qui redonne exactement la meme arborescence a la relecture.
+ */
+export function exportMarkdown(map: MindMap): string {
+ const out: string[] = [];
+ // `getOrderedChildren(nodes, null)` rend les racines dans l'ordre du document.
+ for (const root of getOrderedChildren(map.nodes, null)) {
+ writeNode(root, 0, map.nodes, out);
+ }
+ // Un titre est toujours precede d'une ligne vide : c'est ce qui separe une
+ // liste du titre suivant, et ce qui garde le fichier relisable tel quel.
+ const spaced: string[] = [];
+ for (const line of out) {
+ if (line.startsWith('#') && spaced.length > 0 && spaced[spaced.length - 1] !== '') {
+ spaced.push('');
+ }
+ spaced.push(line);
+ }
+
+ return `${spaced.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
+}
+
+/** Vrai si la carte contient des relations que l'export Markdown ne peut pas rendre. */
+export function hasUnexportableRelations(map: MindMap): boolean {
+ return map.relations.length > 0;
+}
+
+/**
+ * Cree une carte complete a partir d'un document Markdown.
+ *
+ * Si le document commence par un unique titre de niveau 1, il devient la racine
+ * et le nom de la carte. Sinon, une racine est creee avec le nom fourni (celui
+ * du fichier) : rien n'est invente au-dela.
+ */
+export function buildMapFromMarkdown(
+ text: string,
+ fallbackName: string,
+ maxNodes: number = APP_CONFIG.maxNodes,
+): MindMap {
+ const parsed = parseMarkdown(text, maxNodes);
+ const single = parsed.roots.length === 1 ? (parsed.roots[0] as OutlineNode) : null;
+
+ const rootSpec: OutlineNode = single ?? {
+ text: fallbackName,
+ children: parsed.roots,
+ };
+
+ const map = createEmptyMap(rootSpec.text || fallbackName);
+ const root = map.nodes[0] as MindMapNode;
+ const nodes: MindMapNode[] = [
+ { ...root, text: rootSpec.text || fallbackName, ...(rootSpec.note ? { note: rootSpec.note } : {}) },
+ ];
+
+ const walk = (children: OutlineNode[], parentId: string): void => {
+ children.forEach((child, index) => {
+ const node = createNode({
+ text: child.text,
+ parentId,
+ order: index,
+ ...(child.note ? { note: child.note } : {}),
+ position: { x: 0, y: 0 },
+ });
+ nodes.push(node);
+ walk(child.children, node.id);
+ });
+ };
+ walk(rootSpec.children, root.id);
+
+ const positions = computeLayout(nodes, map.settings.layout, {
+ theme: getTheme(map.settings.theme),
+ });
+ const placed = nodes.map((node) => ({
+ ...node,
+ position: positions.get(node.id) ?? node.position,
+ }));
+
+ return { ...map, nodes: placed, edges: rebuildEdges(placed) };
+}
diff --git a/frontend/src/services/outlineParser.ts b/frontend/src/services/outlineParser.ts
index 390ab7a..f7141c9 100644
--- a/frontend/src/services/outlineParser.ts
+++ b/frontend/src/services/outlineParser.ts
@@ -12,6 +12,11 @@ import { APP_CONFIG } from '@/config/app';
export interface OutlineNode {
text: string;
children: OutlineNode[];
+ /**
+ * Note attachee au noeud. Toujours du texte brut : elle n'est jamais
+ * interpretee comme du HTML. Alimentee par l'import Markdown.
+ */
+ note?: string;
}
export interface OutlineResult {
@@ -39,7 +44,14 @@ const MAX_TEXT_LENGTH = 500;
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
const NUMBERED = /^\d{1,3}[.)]\s+/;
-function indentWidth(line: string): number {
+/** Vrai si la ligne est un element de liste : puce ou numerotation. */
+export function isListItem(line: string): boolean {
+ const trimmed = line.trim();
+ return BULLET.test(trimmed) || NUMBERED.test(trimmed);
+}
+
+/** Largeur d'indentation d'une ligne, tabulation comptee pour TAB_WIDTH. */
+export function indentWidth(line: string): number {
let width = 0;
for (const character of line) {
if (character === ' ') width += 1;
@@ -49,7 +61,8 @@ function indentWidth(line: string): number {
return width;
}
-function cleanText(line: string): string {
+/** Texte utile d'une ligne : sans indentation, sans puce ni numerotation. */
+export function cleanText(line: string): string {
return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
}
diff --git a/frontend/src/services/relations.ts b/frontend/src/services/relations.ts
new file mode 100644
index 0000000..42d8911
--- /dev/null
+++ b/frontend/src/services/relations.ts
@@ -0,0 +1,105 @@
+import { type MindMapNode, type MindMapRelation } from '@/types/map';
+import { createId } from '@/utils/id';
+
+/**
+ * Regles des relations secondaires.
+ *
+ * Une relation est une connexion visuelle entre deux noeuds. Elle ne participe
+ * jamais a la structure : ni `parentId`, ni `order`, ni le repli, ni
+ * l'auto-layout ne la consultent. Ce module rassemble les seules regles qui la
+ * gouvernent, pour qu'elles ne soient pas reecrites a chaque appelant.
+ */
+
+/** Prefixe distinctif : un identifiant de relation ne ressemble a rien d'autre. */
+export function createRelationId(): string {
+ return `r-${createId()}`;
+}
+
+export type RelationRejection = 'self' | 'duplicate' | 'unknown';
+
+export const RELATION_REJECTION_MESSAGE: Record = {
+ self: "Une relation relie deux noeuds differents : elle ne peut pas boucler sur elle-meme.",
+ duplicate: 'Ces deux noeuds sont deja relies dans ce sens.',
+ unknown: "L'un des deux noeuds n'existe plus dans cette carte.",
+};
+
+/**
+ * Verifie qu'une relation peut etre creee.
+ *
+ * Deux refus volontaires :
+ * - source == target : un trait qui revient sur le meme noeud n'apporte rien
+ * et se dessine mal ;
+ * - doublon exact (meme source vers meme cible) : deux traits identiques se
+ * superposent, donc le second serait invisible et impossible a selectionner.
+ * Le sens inverse, lui, reste autorise : il porte une autre information.
+ */
+export function checkRelation(
+ nodes: MindMapNode[],
+ relations: MindMapRelation[],
+ sourceId: string,
+ targetId: string,
+): RelationRejection | null {
+ if (sourceId === targetId) return 'self';
+ const known = new Set(nodes.map((node) => node.id));
+ if (!known.has(sourceId) || !known.has(targetId)) return 'unknown';
+ const exists = relations.some(
+ (relation) => relation.sourceId === sourceId && relation.targetId === targetId,
+ );
+ return exists ? 'duplicate' : null;
+}
+
+/**
+ * Retire les relations devenues invalides.
+ *
+ * Appelee a chaque modification structurelle : supprimer un noeud ne doit pas
+ * laisser derriere lui un trait vers le vide. C'est la contrepartie du fait que
+ * les relations ne sont pas derivees de la hierarchie : rien ne les nettoie
+ * automatiquement.
+ */
+export function pruneRelations(
+ relations: MindMapRelation[],
+ nodes: MindMapNode[],
+): MindMapRelation[] {
+ if (relations.length === 0) return relations;
+ const known = new Set(nodes.map((node) => node.id));
+ const kept = relations.filter(
+ (relation) =>
+ relation.sourceId !== relation.targetId &&
+ known.has(relation.sourceId) &&
+ known.has(relation.targetId),
+ );
+ return kept.length === relations.length ? relations : kept;
+}
+
+/**
+ * Reporte des relations sur des identifiants renumerotes.
+ *
+ * Seules les relations dont les DEUX extremites font partie de la copie sont
+ * reportees. Une relation qui sort du sous-arbre copie n'est pas dupliquee :
+ * rien ne dit que la copie doit pointer vers la meme cible externe que
+ * l'original, et le deviner creerait des liens que l'utilisateur n'a pas
+ * demandes. Il reste libre de la recreer explicitement.
+ */
+export function remapRelations(
+ relations: MindMapRelation[],
+ idMapping: Map,
+): MindMapRelation[] {
+ const result: MindMapRelation[] = [];
+ for (const relation of relations) {
+ const source = idMapping.get(relation.sourceId);
+ const target = idMapping.get(relation.targetId);
+ if (!source || !target) continue;
+ result.push({ ...relation, id: createRelationId(), sourceId: source, targetId: target });
+ }
+ return result;
+}
+
+/** Relations entierement contenues dans un ensemble de noeuds. */
+export function relationsWithin(
+ relations: MindMapRelation[],
+ nodeIds: Set,
+): MindMapRelation[] {
+ return relations.filter(
+ (relation) => nodeIds.has(relation.sourceId) && nodeIds.has(relation.targetId),
+ );
+}
diff --git a/frontend/src/stores/editorRelations.test.ts b/frontend/src/stores/editorRelations.test.ts
new file mode 100644
index 0000000..763b33e
--- /dev/null
+++ b/frontend/src/stores/editorRelations.test.ts
@@ -0,0 +1,267 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { createEmptyMap } from '@/services/mapFactory';
+import { useEditorStore } from '@/stores/editorStore';
+import { computeLayout } from '@/features/editor/layout';
+import { getHiddenNodeIds } from '@/utils/tree';
+
+function store() {
+ return useEditorStore.getState();
+}
+
+function rootId(): string {
+ return store().map?.nodes[0]?.id as string;
+}
+
+function idOf(text: string): string {
+ return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
+}
+
+function relations() {
+ return store().map?.relations ?? [];
+}
+
+/** Infrastructure > (Reseau > Firewall, Services > Web). */
+beforeEach(() => {
+ useEditorStore.getState().loadMap(createEmptyMap('Infrastructure'));
+ const initial = useEditorStore.getState();
+ initial.addChildren(rootId(), ['Reseau', 'Services']);
+ initial.addChildren(idOf('Reseau'), ['Firewall', 'Switch']);
+ initial.addChildren(idOf('Services'), ['Web']);
+});
+
+describe('creation d une relation', () => {
+ it('cree une relation entre deux noeuds', () => {
+ expect(store().addRelation(idOf('Firewall'), idOf('Web'))).toBeNull();
+ expect(relations()).toHaveLength(1);
+ expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
+ expect(relations()[0]?.targetId).toBe(idOf('Web'));
+ });
+
+ it('accepte un libelle libre', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
+ expect(relations()[0]?.label).toBe('protege');
+ });
+
+ it('accepte un style, une couleur, une epaisseur et des extremites', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'), {
+ style: 'straight',
+ color: '#ff0000',
+ width: 3,
+ dashed: false,
+ markerEnd: 'none',
+ });
+ const relation = relations()[0];
+ expect(relation?.style).toBe('straight');
+ expect(relation?.color).toBe('#ff0000');
+ expect(relation?.width).toBe(3);
+ expect(relation?.dashed).toBe(false);
+ expect(relation?.markerEnd).toBe('none');
+ });
+
+ it('refuse une relation d un noeud vers lui-meme', () => {
+ expect(store().addRelation(idOf('Firewall'), idOf('Firewall'))).toBe('self');
+ expect(relations()).toHaveLength(0);
+ });
+
+ it('refuse une source inexistante', () => {
+ expect(store().addRelation('inconnu', idOf('Web'))).toBe('unknown');
+ expect(relations()).toHaveLength(0);
+ });
+
+ it('refuse une cible inexistante', () => {
+ expect(store().addRelation(idOf('Web'), 'inconnu')).toBe('unknown');
+ expect(relations()).toHaveLength(0);
+ });
+
+ it('refuse un doublon exact mais accepte le sens inverse', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ expect(store().addRelation(idOf('Firewall'), idOf('Web'))).toBe('duplicate');
+ expect(store().addRelation(idOf('Web'), idOf('Firewall'))).toBeNull();
+ expect(relations()).toHaveLength(2);
+ });
+});
+
+describe('independance vis-a-vis de la hierarchie', () => {
+ it('ne modifie ni parentId ni order ni les enfants', () => {
+ const before = (store().map?.nodes ?? []).map((node) => ({
+ id: node.id,
+ parentId: node.parentId,
+ order: node.order,
+ }));
+
+ store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
+
+ const after = (store().map?.nodes ?? []).map((node) => ({
+ id: node.id,
+ parentId: node.parentId,
+ order: node.order,
+ }));
+ expect(after).toEqual(before);
+ });
+
+ it('ne cree aucun lien hierarchique', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ const pairs = (store().map?.edges ?? []).map((edge) => `${edge.source}>${edge.target}`);
+ expect(pairs).not.toContain(`${idOf('Firewall')}>${idOf('Web')}`);
+ });
+
+ it('n influence pas l auto-layout', () => {
+ const nodes = store().map?.nodes ?? [];
+ const before = computeLayout(nodes, 'mindmap-right');
+
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ const after = computeLayout(store().map?.nodes ?? [], 'mindmap-right');
+
+ for (const [id, position] of before) expect(after.get(id)).toEqual(position);
+ });
+});
+
+describe('modification et suppression', () => {
+ it('modifie le libelle et le style sans toucher aux extremites', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ const relation = relations()[0];
+ store().updateRelation(relation?.id as string, { label: 'utilise', width: 4 });
+
+ const updated = relations()[0];
+ expect(updated?.label).toBe('utilise');
+ expect(updated?.width).toBe(4);
+ expect(updated?.sourceId).toBe(relation?.sourceId);
+ expect(updated?.targetId).toBe(relation?.targetId);
+ });
+
+ it('ne laisse pas deplacer une extremite par une mise a jour', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ const relation = relations()[0];
+ store().updateRelation(relation?.id as string, {
+ sourceId: idOf('Switch'),
+ } as { sourceId: string });
+ expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
+ });
+
+ it('supprime une relation sans supprimer de noeud', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ const count = store().map?.nodes.length as number;
+
+ store().deleteRelation(relations()[0]?.id as string);
+ expect(relations()).toHaveLength(0);
+ expect(store().map?.nodes).toHaveLength(count);
+ });
+
+ it('annule et retablit une creation en une seule entree', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
+ expect(relations()).toHaveLength(1);
+
+ store().undo();
+ expect(relations()).toHaveLength(0);
+
+ store().redo();
+ expect(relations()).toHaveLength(1);
+ expect(relations()[0]?.label).toBe('protege');
+ });
+
+ it('annule une suppression de relation', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ store().deleteRelation(relations()[0]?.id as string);
+ store().undo();
+ expect(relations()).toHaveLength(1);
+ });
+});
+
+describe('relations et structure', () => {
+ it('disparait quand un de ses noeuds est supprime', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ store().deleteNodes([idOf('Web')]);
+ expect(relations()).toHaveLength(0);
+ });
+
+ it('revient si la suppression est annulee', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ store().deleteNodes([idOf('Web')]);
+ store().undo();
+ expect(relations()).toHaveLength(1);
+ });
+
+ it('reste dans le document quand une extremite est masquee par un repli', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ store().setBranchCollapsed(idOf('Services'), true);
+
+ // La relation est toujours la : seul l'affichage la masque.
+ expect(relations()).toHaveLength(1);
+ expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('Web'))).toBe(true);
+
+ store().setBranchCollapsed(idOf('Services'), false);
+ expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('Web'))).toBe(false);
+ expect(relations()).toHaveLength(1);
+ });
+
+ it('survit a un changement de parent des deux extremites', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ store().moveNodeToParent(idOf('Firewall'), idOf('Services'));
+ expect(relations()).toHaveLength(1);
+ expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
+ });
+});
+
+describe('duplication de branche', () => {
+ it('duplique une relation interne avec de nouveaux identifiants', () => {
+ store().addRelation(idOf('Firewall'), idOf('Switch'), { label: 'secours' });
+ store().duplicateBranch(idOf('Reseau'));
+
+ expect(relations()).toHaveLength(2);
+ const [original, copy] = relations();
+ expect(copy?.id).not.toBe(original?.id);
+ expect(copy?.sourceId).not.toBe(original?.sourceId);
+ expect(copy?.targetId).not.toBe(original?.targetId);
+ expect(copy?.label).toBe('secours');
+
+ // Les nouvelles extremites appartiennent bien a la copie.
+ const ids = new Set((store().map?.nodes ?? []).map((node) => node.id));
+ expect(ids.has(copy?.sourceId as string)).toBe(true);
+ expect(ids.has(copy?.targetId as string)).toBe(true);
+ });
+
+ it('ne duplique pas une relation sortant de la branche', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'));
+ store().duplicateBranch(idOf('Reseau'));
+ expect(relations()).toHaveLength(1);
+ });
+
+ it('reporte les relations internes lors d un copier / coller de branche', () => {
+ store().addRelation(idOf('Firewall'), idOf('Switch'));
+ store().copyBranch(idOf('Reseau'));
+ store().pasteAsChild(idOf('Services'));
+
+ expect(relations()).toHaveLength(2);
+ const copy = relations()[1];
+ expect(copy?.sourceId).not.toBe(idOf('Firewall'));
+ });
+
+ it('compte la duplication comme une seule entree d historique', () => {
+ store().addRelation(idOf('Firewall'), idOf('Switch'));
+ const nodeCount = store().map?.nodes.length as number;
+
+ store().duplicateBranch(idOf('Reseau'));
+ expect(relations()).toHaveLength(2);
+
+ store().undo();
+ expect(relations()).toHaveLength(1);
+ expect(store().map?.nodes).toHaveLength(nodeCount);
+ });
+});
+
+describe('recherche', () => {
+ it('retrouve les noeuds relies par un libelle de relation', () => {
+ store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
+ store().setSearchQuery('protege');
+
+ const results = new Set(store().searchResults);
+ expect(results.has(idOf('Firewall'))).toBe(true);
+ expect(results.has(idOf('Web'))).toBe(true);
+ expect(results.has(idOf('Switch'))).toBe(false);
+ });
+
+ it('continue de chercher dans le texte des noeuds', () => {
+ store().setSearchQuery('Firewall');
+ expect(store().searchResults).toEqual([idOf('Firewall')]);
+ });
+});
diff --git a/frontend/src/stores/editorSides.test.ts b/frontend/src/stores/editorSides.test.ts
new file mode 100644
index 0000000..afeca49
--- /dev/null
+++ b/frontend/src/stores/editorSides.test.ts
@@ -0,0 +1,182 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { createEmptyMap } from '@/services/mapFactory';
+import { useEditorStore } from '@/stores/editorStore';
+import { getLevelOneBranches, resolveNodeSides } from '@/features/editor/bilateral';
+
+function store() {
+ return useEditorStore.getState();
+}
+
+function rootId(): string {
+ return store().map?.nodes[0]?.id as string;
+}
+
+function idOf(text: string): string {
+ return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
+}
+
+function sideOf(text: string) {
+ return store().map?.nodes.find((node) => node.text === text)?.side;
+}
+
+/** Infrastructure > (Reseau > Firewall, Services, Stockage, Supervision). */
+beforeEach(() => {
+ useEditorStore.getState().loadMap(createEmptyMap('Infrastructure'));
+ const initial = useEditorStore.getState();
+ initial.addChildren(rootId(), ['Reseau', 'Services', 'Stockage', 'Supervision']);
+ initial.addChildren(idOf('Reseau'), ['Firewall', 'Switch']);
+});
+
+describe('cote manuel', () => {
+ it('place une branche de niveau 1 a gauche', () => {
+ expect(store().setNodeSide(idOf('Services'), 'left')).toBe(true);
+ expect(sideOf('Services')).toBe('left');
+ });
+
+ it('refuse de poser un cote sur un noeud plus profond', () => {
+ expect(store().setNodeSide(idOf('Firewall'), 'left')).toBe(false);
+ expect(sideOf('Firewall')).toBeUndefined();
+ });
+
+ it('refuse de poser un cote sur la racine', () => {
+ expect(store().setNodeSide(rootId(), 'left')).toBe(false);
+ });
+
+ it('ne deplace aucun noeud', () => {
+ const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
+ store().setNodeSide(idOf('Services'), 'left');
+ const after = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
+ expect(after).toEqual(before);
+ });
+
+ it('compte pour une seule entree d historique', () => {
+ store().setNodeSide(idOf('Services'), 'left');
+ store().undo();
+ expect(sideOf('Services')).toBeUndefined();
+ store().redo();
+ expect(sideOf('Services')).toBe('left');
+ });
+
+ it('fait suivre tous les descendants de la branche', () => {
+ store().setNodeSide(idOf('Reseau'), 'left');
+ const sides = resolveNodeSides(store().map?.nodes ?? []);
+ expect(sides.get(idOf('Firewall'))).toBe('left');
+ expect(sides.get(idOf('Switch'))).toBe('left');
+ });
+});
+
+describe('repartition automatique', () => {
+ it('materialise les cotes manquants en une seule entree', () => {
+ const changed = store().balanceSides();
+ expect(changed).toBe(4);
+
+ for (const branch of ['Reseau', 'Services', 'Stockage', 'Supervision']) {
+ expect(sideOf(branch)).toBeDefined();
+ }
+ store().undo();
+ expect(sideOf('Reseau')).toBeUndefined();
+ });
+
+ it('ne fait rien si tout est deja reparti', () => {
+ store().balanceSides();
+ expect(store().balanceSides()).toBe(0);
+ });
+
+ it('respecte un cote deja choisi a la main', () => {
+ store().setNodeSide(idOf('Supervision'), 'left');
+ store().balanceSides();
+ expect(sideOf('Supervision')).toBe('left');
+ });
+
+ it('n ecrit un cote que sur les branches de niveau 1', () => {
+ store().balanceSides();
+ const branchIds = new Set(getLevelOneBranches(store().map?.nodes ?? []).map((n) => n.id));
+ for (const node of store().map?.nodes ?? []) {
+ if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
+ }
+ });
+});
+
+describe('passage en disposition bilaterale', () => {
+ it('repartit les branches et deplace la carte en une seule entree', () => {
+ const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
+
+ store().applyAutoLayout('mindmap-both');
+ expect(store().map?.settings.layout).toBe('mindmap-both');
+ for (const branch of ['Reseau', 'Services', 'Stockage', 'Supervision']) {
+ expect(sideOf(branch)).toBeDefined();
+ }
+
+ const after = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
+ expect(after).not.toEqual(before);
+
+ store().undo();
+ expect(sideOf('Reseau')).toBeUndefined();
+ expect((store().map?.nodes ?? []).map((node) => ({ ...node.position }))).toEqual(before);
+ });
+
+ it('place la racine entre les deux cotes', () => {
+ store().applyAutoLayout('mindmap-both');
+ const nodes = store().map?.nodes ?? [];
+ const sides = resolveNodeSides(nodes);
+ const rootX = nodes.find((node) => node.id === rootId())?.position.x as number;
+
+ for (const branch of getLevelOneBranches(nodes)) {
+ if (sides.get(branch.id) === 'left') expect(branch.position.x).toBeLessThan(rootX);
+ else expect(branch.position.x).toBeGreaterThan(rootX);
+ }
+ });
+
+ it('choisir une disposition sans reorganiser ne deplace rien', () => {
+ const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
+ store().updateSettings({ layout: 'mindmap-both' });
+ expect(store().map?.settings.layout).toBe('mindmap-both');
+ expect((store().map?.nodes ?? []).map((node) => ({ ...node.position }))).toEqual(before);
+ });
+});
+
+describe('cote et duplication', () => {
+ it('conserve le cote quand une branche de niveau 1 est dupliquee', () => {
+ store().setNodeSide(idOf('Reseau'), 'left');
+ store().duplicateBranch(idOf('Reseau'));
+
+ const copy = (store().map?.nodes ?? []).find((node) => node.text.endsWith('copie'));
+ expect(copy?.side).toBe('left');
+ expect(copy?.parentId).toBe(rootId());
+ });
+
+ it('conserve le cote lors d une serialisation en memoire', () => {
+ store().balanceSides();
+ const sides = (store().map?.nodes ?? []).map((node) => [node.text, node.side]);
+ const reloaded = JSON.parse(JSON.stringify(store().map)) as { nodes: typeof sides };
+ expect((reloaded.nodes as unknown as Array<{ text: string; side?: string }>).map((n) => [n.text, n.side])).toEqual(sides);
+ });
+});
+
+describe('creation du cote gauche', () => {
+ it('fait naitre les enfants vers la gauche sur une branche de gauche', () => {
+ store().setNodeSide(idOf('Reseau'), 'left');
+ store().setNodeSide(idOf('Services'), 'right');
+ store().updateSettings({ layout: 'mindmap-both' });
+
+ const reseauX = store().map?.nodes.find((n) => n.id === idOf('Reseau'))?.position.x as number;
+ const servicesX = store().map?.nodes.find((n) => n.id === idOf('Services'))?.position
+ .x as number;
+
+ store().addChildren(idOf('Reseau'), ['Routeur']);
+ store().addChildren(idOf('Services'), ['LDAP']);
+
+ const routeurX = store().map?.nodes.find((n) => n.text === 'Routeur')?.position.x as number;
+ const ldapX = store().map?.nodes.find((n) => n.text === 'LDAP')?.position.x as number;
+
+ expect(routeurX).toBeLessThan(reseauX);
+ expect(ldapX).toBeGreaterThan(servicesX);
+ });
+
+ it('garde le comportement d origine dans les dispositions classiques', () => {
+ const reseauX = store().map?.nodes.find((n) => n.id === idOf('Reseau'))?.position.x as number;
+ store().addChildren(idOf('Reseau'), ['Routeur']);
+ const routeurX = store().map?.nodes.find((n) => n.text === 'Routeur')?.position.x as number;
+ expect(routeurX).toBeGreaterThan(reseauX);
+ });
+});
diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts
index 116da66..53affb4 100644
--- a/frontend/src/stores/editorStore.ts
+++ b/frontend/src/stores/editorStore.ts
@@ -7,14 +7,25 @@ import {
type MindMap,
type MindMapEdge,
type MindMapNode,
+ type MindMapRelation,
type NodeBranchStyle,
type NodeIconStyle,
type NodeLayout,
+ type NodeSide,
type NodeStyle,
type Position,
type Viewport,
} from '@/types/map';
import { createNode } from '@/services/mapFactory';
+import {
+ checkRelation,
+ createRelationId,
+ pruneRelations,
+ relationsWithin,
+ remapRelations,
+ type RelationRejection,
+} from '@/services/relations';
+import { materializeSides, resolveNodeSides } from '@/features/editor/bilateral';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { getTheme, type MapTheme } from '@/features/editor/themes';
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
@@ -41,10 +52,14 @@ export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
interface HistorySnapshot {
nodes: MindMapNode[];
edges: MindMapEdge[];
+ /** Les relations font partie du document : elles suivent l'annulation. */
+ relations: MindMapRelation[];
}
interface ClipboardPayload {
nodes: MindMapNode[];
+ /** Relations internes au lot copie, reportees au collage. */
+ relations: MindMapRelation[];
}
/**
@@ -83,6 +98,12 @@ interface EditorState {
map: MindMap | null;
readOnly: boolean;
selectedNodeIds: string[];
+ /**
+ * Relation secondaire selectionnee. Distincte de la selection de noeuds :
+ * une relation n'est pas un noeud, et la supprimer ne doit jamais supprimer
+ * un noeud.
+ */
+ selectedRelationId: string | null;
editingNodeId: string | null;
past: HistorySnapshot[];
future: HistorySnapshot[];
@@ -106,6 +127,21 @@ interface EditorState {
setSelection: (ids: string[]) => void;
setEditingNode: (id: string | null) => void;
+ setSelectedRelation: (id: string | null) => void;
+
+ /** Cree une relation secondaire. Ne touche jamais a `parentId` ni a `order`. */
+ addRelation: (
+ sourceId: string,
+ targetId: string,
+ patch?: Partial>,
+ ) => RelationRejection | null;
+ updateRelation: (id: string, patch: Partial>) => void;
+ deleteRelation: (id: string) => void;
+
+ /** Cote d'une branche de niveau 1 en mind map bilaterale. */
+ setNodeSide: (nodeId: string, side: NodeSide) => boolean;
+ /** Repartit les branches sans cote. Une seule entree d'historique. */
+ balanceSides: () => number;
pushHistory: () => void;
undo: () => void;
@@ -183,7 +219,7 @@ interface EditorState {
}
function snapshot(map: MindMap): HistorySnapshot {
- return { nodes: map.nodes, edges: map.edges };
+ return { nodes: map.nodes, edges: map.edges, relations: map.relations };
}
/** Normalisation pour la recherche : insensible a la casse et aux accents. */
@@ -201,9 +237,33 @@ function normalizeText(value: string): string {
* depuis `parentId`. Les anciennes cartes recoivent ainsi un ordre explicite
* conforme a leur affichage, sans traitement special ailleurs.
*/
-function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
+function withEdges(
+ map: MindMap,
+ nodes: MindMapNode[],
+ relations?: MindMapRelation[],
+): MindMap {
const ordered = normalizeChildOrders(nodes);
- return { ...map, nodes: ordered, edges: rebuildEdges(ordered) };
+ /*
+ * Les relations ne sont jamais recalculees ici : elles ne se deduisent pas de
+ * `parentId`. On les preserve telles quelles et on retire seulement celles
+ * dont une extremite vient de disparaitre. `withEdges` ne doit sous aucun
+ * pretexte devenir un endroit ou une relation se perd.
+ */
+ const kept = pruneRelations(relations ?? map.relations, ordered);
+ return { ...map, nodes: ordered, edges: rebuildEdges(ordered), relations: kept };
+}
+
+/**
+ * Sens dans lequel placer les nouveaux enfants d'un parent.
+ *
+ * En mind map bilaterale, une branche de gauche fait naitre ses enfants vers la
+ * gauche : sans cela, tout ce qui est cree sur le cote gauche partirait vers la
+ * droite et traverserait la carte.
+ */
+function childDirection(map: MindMap, parent?: MindMapNode): 1 | -1 {
+ const base = layoutDirection(map.settings.layout);
+ if (map.settings.layout !== 'mindmap-both' || !parent) return base;
+ return resolveNodeSides(map.nodes).get(parent.id) === 'left' ? -1 : 1;
}
/**
@@ -230,6 +290,12 @@ function pruneEmpty(value: T | undefined): T | undefined {
return entries.length > 0 ? (Object.fromEntries(entries) as T) : undefined;
}
+interface ClonedSubtrees {
+ nodes: MindMapNode[];
+ /** Ancien identifiant -> nouveau. Sert a reporter les relations internes. */
+ idMapping: Map;
+}
+
/**
* Copie un ensemble de sous-arbres en regenerant tous les identifiants.
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
@@ -238,11 +304,11 @@ function cloneSubtrees(
source: MindMapNode[],
offset: number,
parentOverride?: string | null,
-): MindMapNode[] {
+): ClonedSubtrees {
const idMapping = new Map();
for (const node of source) idMapping.set(node.id, createId());
- return source.map((node) => {
+ const nodes = source.map((node) => {
const mappedParent = node.parentId ? idMapping.get(node.parentId) : undefined;
const parentId =
mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null));
@@ -256,6 +322,8 @@ function cloneSubtrees(
...(node.style ? { style: { ...node.style } } : {}),
};
});
+
+ return { nodes, idMapping };
}
export const useEditorStore = create((set, get) => {
@@ -301,6 +369,7 @@ export const useEditorStore = create((set, get) => {
...current,
nodes: target.nodes,
edges: target.edges,
+ relations: target.relations,
updatedAt: new Date().toISOString(),
},
selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)),
@@ -347,6 +416,7 @@ export const useEditorStore = create((set, get) => {
map: null,
readOnly: false,
selectedNodeIds: [],
+ selectedRelationId: null,
editingNodeId: null,
past: [],
future: [],
@@ -367,6 +437,7 @@ export const useEditorStore = create((set, get) => {
map,
readOnly: options?.readOnly ?? false,
selectedNodeIds: [],
+ selectedRelationId: null,
editingNodeId: null,
past: [],
future: [],
@@ -388,6 +459,7 @@ export const useEditorStore = create((set, get) => {
map: null,
readOnly: false,
selectedNodeIds: [],
+ selectedRelationId: null,
editingNodeId: null,
past: [],
future: [],
@@ -400,8 +472,122 @@ export const useEditorStore = create((set, get) => {
});
},
- setSelection: (ids) => set({ selectedNodeIds: ids }),
+ // Selectionner un noeud abandonne la relation selectionnee, et inversement :
+ // il n'y a jamais deux selections concurrentes a l'ecran.
+ setSelection: (ids) =>
+ set({ selectedNodeIds: ids, ...(ids.length > 0 ? { selectedRelationId: null } : {}) }),
setEditingNode: (id) => set({ editingNodeId: id }),
+ setSelectedRelation: (id) =>
+ set({ selectedRelationId: id, ...(id ? { selectedNodeIds: [], editingNodeId: null } : {}) }),
+
+
+ /**
+ * Cree une relation secondaire entre deux noeuds.
+ *
+ * Aucune ecriture sur `parentId`, `order`, `collapsed` ni sur les enfants :
+ * c'est la garantie qu'une relation ne devient jamais une deuxieme
+ * hierarchie. Retourne la raison du refus, ou `null` si la relation a bien
+ * ete creee.
+ */
+ addRelation: (sourceId, targetId, patch) => {
+ const state = get();
+ const map = state.map;
+ if (!map || state.readOnly) return 'unknown';
+
+ const rejection = checkRelation(map.nodes, map.relations, sourceId, targetId);
+ if (rejection) return rejection;
+
+ const relation: MindMapRelation = {
+ id: createRelationId(),
+ sourceId,
+ targetId,
+ ...(patch ?? {}),
+ };
+
+ commit((current) => ({ ...current, relations: [...current.relations, relation] }));
+ set({ selectedRelationId: relation.id, selectedNodeIds: [], editingNodeId: null });
+ return null;
+ },
+
+ updateRelation: (id, patch) => {
+ commit((current) => {
+ const index = current.relations.findIndex((relation) => relation.id === id);
+ if (index === -1) return current;
+ const relations = [...current.relations];
+ // `id`, `sourceId` et `targetId` restent maitres : on ne peut pas
+ // deplacer une extremite par une simple mise a jour de style.
+ const existing = relations[index] as MindMapRelation;
+ relations[index] = {
+ ...existing,
+ ...patch,
+ id: existing.id,
+ sourceId: existing.sourceId,
+ targetId: existing.targetId,
+ };
+ return { ...current, relations };
+ });
+ },
+
+ deleteRelation: (id) => {
+ const state = get();
+ if (!state.map || state.readOnly) return;
+ if (!state.map.relations.some((relation) => relation.id === id)) return;
+
+ // Supprimer une relation ne touche a aucun noeud.
+ commit((current) => ({
+ ...current,
+ relations: current.relations.filter((relation) => relation.id !== id),
+ }));
+ set({ selectedRelationId: null });
+ },
+
+ /**
+ * Change le cote d'une branche de niveau 1.
+ *
+ * Rien ne bouge a l'ecran : conformement au reste de l'auto-layout, les
+ * positions manuelles ne sont jamais ecrasees sans que l'utilisateur
+ * demande explicitement une reorganisation.
+ */
+ setNodeSide: (nodeId, side) => {
+ const state = get();
+ const map = state.map;
+ if (!map || state.readOnly) return false;
+
+ const node = map.nodes.find((candidate) => candidate.id === nodeId);
+ if (!node || !node.parentId) return false;
+ const parent = map.nodes.find((candidate) => candidate.id === node.parentId);
+ // Seule une branche de niveau 1 porte un cote : ailleurs il est herite.
+ if (!parent || parent.parentId) return false;
+ if (node.side === side) return true;
+
+ commit((current) =>
+ withEdges(
+ current,
+ current.nodes.map((candidate) =>
+ candidate.id === nodeId ? { ...candidate, side } : candidate,
+ ),
+ ),
+ );
+ return true;
+ },
+
+ /** Materialise les cotes manquants. Retourne le nombre de branches traitees. */
+ balanceSides: () => {
+ const state = get();
+ const map = state.map;
+ if (!map || state.readOnly) return 0;
+
+ const nodes = materializeSides(map.nodes);
+ if (nodes === map.nodes) return 0;
+
+ let changed = 0;
+ for (let index = 0; index < nodes.length; index += 1) {
+ if (nodes[index] !== map.nodes[index]) changed += 1;
+ }
+
+ commit((current) => withEdges(current, nodes));
+ return changed;
+ },
pushHistory: () => {
const state = get();
@@ -443,7 +629,7 @@ export const useEditorStore = create((set, get) => {
const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
- const direction = layoutDirection(map.settings.layout);
+ const direction = childDirection(map, parent);
const child = parent
? createNode({
@@ -523,7 +709,7 @@ export const useEditorStore = create((set, get) => {
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
- const direction = layoutDirection(map.settings.layout);
+ const direction = childDirection(map, parent);
const startOrder = nextSiblingOrder(map.nodes, parent?.id ?? null);
const positions = parent
@@ -561,7 +747,7 @@ export const useEditorStore = create((set, get) => {
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
- const direction = layoutDirection(map.settings.layout);
+ const direction = childDirection(map, parent);
const created: MindMapNode[] = [];
const insert = (
@@ -585,6 +771,8 @@ export const useEditorStore = create((set, get) => {
type: !anchorNode && map.nodes.length === 0 && created.length === 0 ? 'root' : 'standard',
order: baseOrder + index,
position,
+ // L'import Markdown range les paragraphes dans la note du noeud.
+ ...(outline.note ? { note: outline.note } : {}),
});
created.push(node);
if (outline.children.length > 0) insert(outline.children, node, depth + 1, 0);
@@ -844,22 +1032,33 @@ export const useEditorStore = create((set, get) => {
const branchIds = new Set(withDescendants(map.nodes, [id]));
const source = map.nodes.filter((node) => branchIds.has(node.id));
- const clones = cloneSubtrees(source, 40);
+ const { nodes: clones, idMapping } = cloneSubtrees(source, 40);
const cloneRoot = clones[0] as MindMapNode;
+ /*
+ * Les relations entierement contenues dans la branche sont dupliquees
+ * avec elle : la copie est autonome. Une relation qui sort de la branche
+ * ne l'est pas, faute de savoir si la copie doit viser la meme cible.
+ */
+ const internal = remapRelations(relationsWithin(map.relations, branchIds), idMapping);
+
commit((current) =>
- withEdges(current, [
- ...current.nodes,
- ...clones.map((node) =>
- node.id === cloneRoot.id
- ? {
- ...node,
- text: node.text ? `${node.text} copie` : node.text,
- order: nextSiblingOrder(current.nodes, root.parentId),
- }
- : node,
- ),
- ]),
+ withEdges(
+ current,
+ [
+ ...current.nodes,
+ ...clones.map((node) =>
+ node.id === cloneRoot.id
+ ? {
+ ...node,
+ text: node.text ? `${node.text} copie` : node.text,
+ order: nextSiblingOrder(current.nodes, root.parentId),
+ }
+ : node,
+ ),
+ ],
+ [...current.relations, ...internal],
+ ),
);
set({ selectedNodeIds: [cloneRoot.id], editingNodeId: null });
return clones.length;
@@ -873,7 +1072,7 @@ export const useEditorStore = create((set, get) => {
const branchIds = new Set(withDescendants(map.nodes, [id]));
const nodes = map.nodes.filter((node) => branchIds.has(node.id)).map((node) => ({ ...node }));
- set({ clipboard: { nodes } });
+ set({ clipboard: { nodes, relations: relationsWithin(map.relations, branchIds) } });
return nodes.length;
},
@@ -885,18 +1084,23 @@ export const useEditorStore = create((set, get) => {
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
if (!map.nodes.some((node) => node.id === parentId)) return 0;
- const clones = cloneSubtrees(clipboard.nodes, 40, parentId);
+ const { nodes: clones, idMapping } = cloneSubtrees(clipboard.nodes, 40, parentId);
+ const internal = remapRelations(clipboard.relations, idMapping);
let order = nextSiblingOrder(map.nodes, parentId);
commit((current) =>
- withEdges(current, [
- ...current.nodes.map((node) =>
- node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node,
- ),
- ...clones.map((node) =>
- node.parentId === parentId ? { ...node, order: order++ } : node,
- ),
- ]),
+ withEdges(
+ current,
+ [
+ ...current.nodes.map((node) =>
+ node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node,
+ ),
+ ...clones.map((node) =>
+ node.parentId === parentId ? { ...node, order: order++ } : node,
+ ),
+ ],
+ [...current.relations, ...internal],
+ ),
);
set({ selectedNodeIds: [clones[0]?.id as string], editingNodeId: null });
return clones.length;
@@ -1407,7 +1611,12 @@ export const useEditorStore = create((set, get) => {
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 })) } });
+ set({
+ clipboard: {
+ nodes: nodes.map((node) => ({ ...node })),
+ relations: relationsWithin(map.relations, ids),
+ },
+ });
return nodes.length;
},
@@ -1418,9 +1627,12 @@ export const useEditorStore = create((set, get) => {
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
const target = state.selectedNodeIds[0] ?? null;
- const clones = cloneSubtrees(clipboard.nodes, 40, target);
+ const { nodes: clones, idMapping } = cloneSubtrees(clipboard.nodes, 40, target);
+ const internal = remapRelations(clipboard.relations, idMapping);
- commit((current) => withEdges(current, [...current.nodes, ...clones]));
+ commit((current) =>
+ withEdges(current, [...current.nodes, ...clones], [...current.relations, ...internal]),
+ );
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
@@ -1433,9 +1645,12 @@ export const useEditorStore = create((set, get) => {
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);
+ const { nodes: clones, idMapping } = cloneSubtrees(source, 40);
+ const internal = remapRelations(relationsWithin(map.relations, ids), idMapping);
- commit((current) => withEdges(current, [...current.nodes, ...clones]));
+ commit((current) =>
+ withEdges(current, [...current.nodes, ...clones], [...current.relations, ...internal]),
+ );
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
@@ -1447,13 +1662,23 @@ export const useEditorStore = create((set, get) => {
const target = layout ?? map.settings.layout;
const effectiveSpacing = spacing ?? map.settings.layoutSpacing;
- const positions = computeLayout(map.nodes, target, {
+
+ /*
+ * Passage en bilaterale : les branches qui n'ont pas encore de cote en
+ * recoivent un ici, dans la meme entree d'historique que le deplacement.
+ * Un cote choisi a la main n'est jamais ecrase.
+ */
+ const nodes = target === 'mindmap-both' ? materializeSides(map.nodes) : map.nodes;
+
+ const positions = computeLayout(nodes, target, {
measuredSizes: state.measuredSizes,
spacing: effectiveSpacing,
theme: getTheme(map.settings.theme),
});
if (positions.size === 0) return;
+ const byId = new Map(nodes.map((node) => [node.id, node]));
+
commit((current) => ({
...current,
settings: {
@@ -1462,8 +1687,9 @@ export const useEditorStore = create((set, get) => {
...(spacing ? { layoutSpacing: spacing } : {}),
},
nodes: current.nodes.map((node) => {
+ const withSide = byId.get(node.id) ?? node;
const position = positions.get(node.id);
- return position ? { ...node, position } : node;
+ return position ? { ...withSide, position } : withSide;
}),
}));
set({ fitRequest: get().fitRequest + 1 });
@@ -1499,11 +1725,26 @@ export const useEditorStore = create((set, get) => {
}
const needle = normalizeText(trimmed);
+ /*
+ * Le libelle d'une relation est aussi du contenu : chercher "protege"
+ * doit ramener les deux noeuds qu'elle relie. La recherche continue de ne
+ * renvoyer que des noeuds, donc la navigation entre resultats, le
+ * deploiement des branches et le centrage restent inchanges.
+ */
+ const fromRelations = new Set();
+ for (const relation of map.relations) {
+ if (relation.label && normalizeText(relation.label).includes(needle)) {
+ fromRelations.add(relation.sourceId);
+ fromRelations.add(relation.targetId);
+ }
+ }
+
const results = map.nodes
.filter(
(node) =>
normalizeText(node.text).includes(needle) ||
- (node.note ? normalizeText(node.note).includes(needle) : false),
+ (node.note ? normalizeText(node.note).includes(needle) : false) ||
+ fromRelations.has(node.id),
)
.map((node) => node.id);
diff --git a/frontend/src/types/map.ts b/frontend/src/types/map.ts
index 49e1346..e93d1d4 100644
--- a/frontend/src/types/map.ts
+++ b/frontend/src/types/map.ts
@@ -17,8 +17,13 @@
* lecteur v2 l'ignorerait silencieusement en reordonnant la carte.
* La migration v2 -> v3 materialise l'ordre depuis la position dans le
* tableau `nodes`, ce qui reproduit exactement l'affichage precedent.
+ * v4 : mind map bilaterale (`MindMapNode.side`) et relations secondaires
+ * (`MindMap.relations`). Le bump est justifie : un lecteur v3 ignorerait
+ * les relations, donc perdrait silencieusement une partie du document.
+ * La migration v3 -> v4 n'a rien a inventer : aucun cote defini signifie
+ * "reparti automatiquement", et aucune relation signifie "aucune".
*/
-export const MAP_FORMAT_VERSION = 3;
+export const MAP_FORMAT_VERSION = 4;
/**
* Types de noeuds.
@@ -33,6 +38,9 @@ export const EDITABLE_NODE_KINDS: NodeKind[] = ['root', 'standard'];
export type NodeShape = 'rectangle' | 'rounded' | 'capsule' | 'circle';
+/** Cote d'une branche de niveau 1 en mind map bilaterale. */
+export type NodeSide = 'left' | 'right';
+
export type TextAlign = 'left' | 'center' | 'right';
export type VerticalAlign = 'top' | 'middle' | 'bottom';
@@ -117,6 +125,15 @@ export interface MindMapNode {
* position : il n'y a jamais deux sources contradictoires.
*/
order?: number;
+ /**
+ * Cote d'une branche en mind map bilaterale (v4).
+ *
+ * N'a de sens que sur un enfant direct d'une racine : c'est la branche de
+ * niveau 1 qui choisit son cote, tous ses descendants en heritent. Le champ
+ * n'est donc jamais ecrit ailleurs, et il est ignore s'il s'y trouve.
+ * Absent = a repartir automatiquement.
+ */
+ side?: NodeSide;
/** Branche repliee : les descendants sont masques. */
collapsed?: boolean;
/** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */
@@ -150,6 +167,34 @@ export interface MindMapEdge {
export type BranchStyle = 'straight' | 'curved' | 'bezier';
+/** Extremite d'une relation secondaire. */
+export type RelationMarker = 'none' | 'arrow';
+
+/**
+ * Relation secondaire entre deux noeuds (v4).
+ *
+ * C'est une connexion purement visuelle : elle n'a aucun effet sur `parentId`,
+ * `order`, les enfants, le repli ou l'auto-layout. La hierarchie reste la seule
+ * structure de la carte ; une relation ne doit jamais devenir une deuxieme
+ * hierarchie parallele.
+ */
+export interface MindMapRelation {
+ id: string;
+ sourceId: string;
+ targetId: string;
+ /** Texte libre affiche au milieu du trait. Aucun vocabulaire impose. */
+ label?: string;
+ /** Trace du trait. Absent = bezier. */
+ style?: BranchStyle;
+ color?: string;
+ width?: number;
+ /** Trait discontinu. Absent = pointille : c'est le defaut d'une relation. */
+ dashed?: boolean;
+ markerStart?: RelationMarker;
+ /** Absent = fleche : une relation se lit par defaut de la source vers la cible. */
+ markerEnd?: RelationMarker;
+}
+
/**
* Dispositions d'auto-layout.
* `mindmap-both`, `organization` et `freeform` sont reserves : ils sont
@@ -206,7 +251,16 @@ export interface MindMap {
createdAt: string;
updatedAt: string;
nodes: MindMapNode[];
+ /**
+ * Liens hierarchiques, entierement derives de `parentId`. Ils sont recalcules
+ * a chaque modification : ce ne sont pas une source de verite.
+ */
edges: MindMapEdge[];
+ /**
+ * Relations secondaires (v4). Elles sont, elles, une donnee a part entiere :
+ * rien ne permet de les deduire de la hierarchie.
+ */
+ relations: MindMapRelation[];
viewport: Viewport;
settings: MapSettings;
}