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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:12:00 +02:00
parent 2afbf637ce
commit e3c530348e
33 changed files with 4151 additions and 104 deletions

112
README.md
View File

@@ -227,10 +227,36 @@ double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
Bezier, branches repliables avec compteur d'elements masques. Bezier, branches repliables avec compteur d'elements masques.
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche, **Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
arbre horizontal, arbre vertical. L'espacement est configurable (distance mind map bilaterale, arbre horizontal, arbre vertical. L'espacement est
parent/enfant et espacement entre branches), avec les presets Compact, Normal configurable (distance parent/enfant et espacement entre branches), avec les
et Aere. Modifier l'espacement ne deplace rien tant que l'organisation n'est pas presets Compact, Normal et Aere. Choisir une disposition ou modifier
relancee. 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, **Proprietes du noeud**, regroupees en sections repliables (Contenu,
Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ; 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 et mise en evidence temporaire du noeud, depliage automatique des branches
repliees. 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, (A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond,
titre, date), impression via `@media print`. titre, date), impression via `@media print`.
@@ -326,14 +352,20 @@ Le format est en **version 3**.
| v1 | format initial | `style.width` devient `layout.width` | | 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` | | 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 | | 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 Les fichiers v1, v2 et v3 restent lisibles, et les cartes deja enregistrees dans
IndexedDB s'ouvrent sans modification. Le passage en v3 est justifie : l'ordre IndexedDB s'ouvrent sans modification. Le passage en v3 etait justifie : l'ordre
des freres fait desormais partie de la semantique du document, et un lecteur v2 des freres fait partie de la semantique du document, et un lecteur v2
l'ignorerait en reordonnant silencieusement la carte. Les noeuds sont malgre 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 tout ecrits en parcours de profondeur, freres dans l'ordre, ce qui donne le bon
resultat meme a un lecteur qui ignore `order`. 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, Toute carte qui entre dans l'editeur passe par le meme point d'entree,
`normalizeMapDocument()`, quelle que soit sa provenance : `normalizeMapDocument()`, quelle que soit sa provenance :
@@ -362,6 +394,8 @@ vous la modifiez.
"text": "Infrastructure", "text": "Infrastructure",
"position": { "x": 0, "y": 0 }, "position": { "x": 0, "y": 0 },
"parentId": null, "parentId": null,
"order": 0,
"side": "right",
"collapsed": false, "collapsed": false,
"icon": "\ud83d\udda5\ufe0f", "icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr", "url": "https://exemple.fr",
@@ -389,6 +423,19 @@ vous la modifiez.
} }
], ],
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }], "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 }, "viewport": { "x": 0, "y": 0, "zoom": 1 },
"settings": { "settings": {
"theme": "default", "theme": "default",
@@ -398,11 +445,21 @@ vous la modifiez.
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 }, "layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
"snapToGrid": false, "snapToGrid": false,
"snapGrid": 20, "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 A l'import, le fichier est **valide et normalise** : structure, version, champs
essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:` essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:`
sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni 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 Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et
suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
jeton, partage inexistant, payload invalide, limites de taille et de noeuds, 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 Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
d'import, migrations, store Zustand (creation, suppression de branche, d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
undo/redo, groupement du glisser-deposer, copier/coller, duplication, detection store Zustand (creation, suppression de branche, undo/redo, groupement du
de boucle, recherche, auto-layout, lecture seule), assainissement des URL. 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 ├── hooks/ # routeur minimal, debounce
├── components/ # briques d'interface reutilisables ├── components/ # briques d'interface reutilisables
├── features/ ├── 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 │ ├── maps/ # tableau de bord
│ ├── export/ # PNG, SVG, PDF, impression │ ├── export/ # PNG, SVG, PDF, impression
│ └── sharing/ # dialogue de partage, page publique │ └── 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 - **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 navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis
a jour ni supprime. a jour ni supprime.
- **Dispositions reservees** : `mindmap-both`, `organization` et `freeform` - **Dispositions reservees** : `organization` et `freeform` existent dans le
existent dans le modele de donnees mais ne sont pas proposees dans modele de donnees mais ne sont pas proposees dans l'interface.
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 - **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
acceptes par le format, mais la V1 ne produit que `root` et `standard`. acceptes par le format, mais la V1 ne produit que `root` et `standard`.
- **Migrations de schema SQLite** : la V1 initialise le schema avec - **Migrations de schema SQLite** : la V1 initialise le schema avec

View File

@@ -31,6 +31,29 @@ class MapEdgeIn(BaseModel):
target: str = Field(min_length=1, max_length=128) 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): class MapDocumentIn(BaseModel):
"""Document carte complet, tel que produit par le frontend.""" """Document carte complet, tel que produit par le frontend."""
@@ -41,6 +64,7 @@ class MapDocumentIn(BaseModel):
name: str = Field(default="", max_length=255) name: str = Field(default="", max_length=255)
nodes: list[MapNodeIn] = Field(default_factory=list) nodes: list[MapNodeIn] = Field(default_factory=list)
edges: list[MapEdgeIn] = 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) viewport: dict[str, Any] = Field(default_factory=dict)
settings: 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 max_edges = get_settings().max_nodes * 4
if len(self.edges) > max_edges: if len(self.edges) > max_edges:
raise ValueError(f"La carte depasse la limite de {max_edges} liens.") 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 return self

View File

@@ -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

View File

@@ -71,9 +71,11 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const requestFit = useEditorStore((state) => state.requestFit); const requestFit = useEditorStore((state) => state.requestFit);
const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed); const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed);
const collapseToLevel = useEditorStore((state) => state.collapseToLevel); const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
const balanceSides = useEditorStore((state) => state.balanceSides);
const colorMode = useUiStore((state) => state.colorMode); const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode); const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen); const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen); const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
@@ -158,17 +160,22 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
{(close) => ( {(close) => (
<> <>
<MenuLabel>Disposition</MenuLabel> <MenuLabel>Disposition</MenuLabel>
{/*
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) => ( {LAYOUT_OPTIONS.map((option) => (
<MenuItem <MenuItem
key={option.id} key={option.id}
label={option.label} label={option.label}
active={map.settings.layout === option.id} active={map.settings.layout === option.id}
onSelect={() => { onSelect={() => updateSettings({ layout: option.id })}
applyAutoLayout(option.id);
close();
}}
/> />
))} ))}
<p className="px-2.5 pb-2 pt-1 text-[11px] leading-snug text-zinc-400">
Choisir une disposition ne deplace rien : utilisez Reorganiser.
</p>
<MenuSeparator /> <MenuSeparator />
<MenuLabel>Espacement</MenuLabel> <MenuLabel>Espacement</MenuLabel>
<div className="flex gap-1 px-2.5 pb-2"> <div className="flex gap-1 px-2.5 pb-2">
@@ -235,6 +242,20 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
</div> </div>
<MenuSeparator /> <MenuSeparator />
<MenuItem
label="Repartir les branches sans cote"
hint="bilaterale"
onSelect={() => {
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();
}}
/>
<MenuItem <MenuItem
label="Reorganiser avec la disposition actuelle" label="Reorganiser avec la disposition actuelle"
onSelect={() => { onSelect={() => {

View File

@@ -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 (
<ul
className={
depth === 0
? 'space-y-0.5'
: 'space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-700'
}
>
{nodes.map((node, index) => (
<li key={`${depth}-${index}-${node.text}`}>
<span className="text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
{node.note ? (
<span className="ml-1 text-[10px] uppercase tracking-wide text-indigo-500">note</span>
) : null}
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
</li>
))}
</ul>
);
}
/**
* 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<HTMLInputElement>(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 (
<Modal
open={open}
title="Importer du Markdown"
description={
parentName
? `Le contenu deviendra un sous-arbre de "${parentName}".`
: 'Le contenu sera ajoute a la carte.'
}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
{analysis.count > 0 ? `Importer ${analysis.count} noeud(s)` : 'Importer'}
</Button>
</>
}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => fileInputRef.current?.click()}>
Choisir un fichier .md
</Button>
<span className="text-[11px] text-zinc-400">ou collez le contenu ci-dessous</span>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Choisir un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void readFile(file);
}}
/>
</div>
<textarea
id="markdown-import-text"
autoFocus
spellCheck={false}
className="field-input min-h-[200px] resize-y font-mono text-xs leading-relaxed"
placeholder={'# Infrastructure\n\n## Reseau\n- VLAN\n- VPN\n\n## Virtualisation\n- Proxmox\n - VM'}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
}}
aria-label="Contenu Markdown"
/>
<p className="text-[11px] text-zinc-400">
Titres (#, ##, ###) et listes construisent la hierarchie. Les paragraphes et les blocs de
code deviennent la note du noeud au-dessus. Ctrl+Entree valide.
</p>
{analysis.error ? (
<p
role="alert"
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300"
>
{analysis.error}
</p>
) : null}
{canSubmit ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/60">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-zinc-500">
Apercu - {analysis.count} noeud(s) seront crees
</p>
<div className="max-h-48 overflow-y-auto">
<PreviewTree nodes={analysis.roots} />
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -12,7 +12,12 @@ import {
type NodeChange, type NodeChange,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; 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 { MindNodeView } from '@/features/editor/MindNodeView';
import { getTheme } from '@/features/editor/themes'; import { getTheme } from '@/features/editor/themes';
import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer'; import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer';
@@ -27,6 +32,8 @@ import {
} from '@/features/editor/NodeContextMenu'; } from '@/features/editor/NodeContextMenu';
import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog'; import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog';
import { MoveToDialog } from '@/features/editor/MoveToDialog'; 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 { SelectionToolbar } from '@/features/editor/SelectionToolbar';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
@@ -52,6 +59,7 @@ export function MindMapCanvas({
const focusRequest = useEditorStore((state) => state.focusRequest); const focusRequest = useEditorStore((state) => state.focusRequest);
const fitRequest = useEditorStore((state) => state.fitRequest); const fitRequest = useEditorStore((state) => state.fitRequest);
const sizeVersion = useEditorStore((state) => state.sizeVersion); const sizeVersion = useEditorStore((state) => state.sizeVersion);
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
const { fitView, setCenter, screenToFlowPosition } = useReactFlow(); const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
@@ -68,6 +76,8 @@ export function MindMapCanvas({
null, null,
); );
const [moveTarget, setMoveTarget] = useState<string | null>(null); const [moveTarget, setMoveTarget] = useState<string | null>(null);
const [relationSource, setRelationSource] = useState<string | null>(null);
const [markdownParent, setMarkdownParent] = useState<string | null>(null);
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest); const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest); const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
@@ -87,15 +97,35 @@ export function MindMapCanvas({
searchResults, searchResults,
editingNodeId, editingNodeId,
readOnly, readOnly,
selectedRelationId,
measuredSizes: useEditorStore.getState().measuredSizes, measuredSizes: useEditorStore.getState().measuredSizes,
}), }),
// sizeVersion force le recalcul quand une taille de noeud a change. // sizeVersion force le recalcul quand une taille de noeud a change.
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 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. // Cadrage initial a l'ouverture d'une carte.
useEffect(() => { useEffect(() => {
@@ -156,10 +186,22 @@ export function MindMapCanvas({
const onEdgesChange = useCallback( const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => { (changes: EdgeChange<Edge>[]) => {
if (readOnly) return;
const store = useEditorStore.getState(); const store = useEditorStore.getState();
for (const change of changes) { 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); const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
if (edge) store.detachNode(edge.target); if (edge) store.detachNode(edge.target);
} }
@@ -264,6 +306,7 @@ export function MindMapCanvas({
const store = useEditorStore.getState(); const store = useEditorStore.getState();
store.setSelection([]); store.setSelection([]);
store.setEditingNode(null); store.setEditingNode(null);
store.setSelectedRelation(null);
setContextMenu(null); setContextMenu(null);
}} }}
defaultViewport={map.viewport} defaultViewport={map.viewport}
@@ -313,6 +356,8 @@ export function MindMapCanvas({
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })} onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
onMoveTo={(nodeId) => setMoveTarget(nodeId)} onMoveTo={(nodeId) => setMoveTarget(nodeId)}
onAddRelation={(nodeId) => setRelationSource(nodeId)}
onImportMarkdown={(nodeId) => setMarkdownParent(nodeId)}
/> />
) : null} ) : null}
@@ -328,6 +373,18 @@ export function MindMapCanvas({
nodeId={moveTarget} nodeId={moveTarget}
onClose={() => setMoveTarget(null)} onClose={() => setMoveTarget(null)}
/> />
<RelationDialog
open={relationSource !== null}
sourceId={relationSource}
onClose={() => setRelationSource(null)}
/>
<MarkdownImportDialog
open={markdownParent !== null}
parentId={markdownParent}
onClose={() => setMarkdownParent(null)}
/>
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { type MindFlowNode } from '@/features/editor/flowAdapters';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver'; import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph'; import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph';
@@ -42,6 +42,22 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [draft, setDraft] = useState(node.text); 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(() => { useEffect(() => {
if (isEditing) setDraft(node.text); if (isEditing) setDraft(node.text);
}, [isEditing, node.text]); }, [isEditing, node.text]);
@@ -204,7 +220,8 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
<Handle <Handle
type="target" type="target"
position={data.targetPosition} id={data.targetHandle.id}
position={data.targetHandle.position}
isConnectable={!readOnly} isConnectable={!readOnly}
aria-label="Point de connexion entrant" aria-label="Point de connexion entrant"
/> />
@@ -304,12 +321,18 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
</button> </button>
) : null} ) : null}
{/* En mind map bilaterale, la racine porte deux poignees sortantes :
ses branches partent a gauche comme a droite. */}
{data.sourceHandles.map((handle) => (
<Handle <Handle
key={handle.id}
type="source" type="source"
position={data.sourcePosition} id={handle.id}
position={handle.position}
isConnectable={!readOnly} isConnectable={!readOnly}
aria-label="Point de connexion sortant" aria-label="Point de connexion sortant"
/> />
))}
</div> </div>
); );
} }

View File

@@ -15,6 +15,8 @@ export interface NodeContextMenuProps {
onClose: () => void; onClose: () => void;
onAddChildren: (parentId: string) => void; onAddChildren: (parentId: string) => void;
onMoveTo: (nodeId: string) => void; onMoveTo: (nodeId: string) => void;
onAddRelation: (nodeId: string) => void;
onImportMarkdown: (nodeId: string) => void;
} }
interface MenuEntry { interface MenuEntry {
@@ -34,7 +36,14 @@ interface MenuHeading {
type MenuLine = MenuEntry | MenuHeading; type MenuLine = MenuEntry | MenuHeading;
/** Menu contextuel d'un noeud, organise par famille d'action. */ /** 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 map = useEditorStore((store) => store.map);
const selectedIds = useEditorStore((store) => store.selectedNodeIds); const selectedIds = useEditorStore((store) => store.selectedNodeIds);
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null); 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 hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id);
const notify = (message: string) => pushToast(message, 'success'); 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 [ return [
{ kind: 'heading', label: 'Ajouter' }, { kind: 'heading', label: 'Ajouter' },
{ kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) }, { 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, disabled: !node.parentId,
run: () => store.addSiblingAt(node.id, 'after'), 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' }, { kind: 'heading', label: 'Branche' },
{ {
@@ -125,6 +154,28 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
run: () => store.setBranchCollapsed(node.id, !node.collapsed), 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: 'heading', label: 'Ordre' },
{ {
kind: 'item', kind: 'item',
@@ -204,6 +255,8 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
hasNodeClipboard, hasNodeClipboard,
onAddChildren, onAddChildren,
onMoveTo, onMoveTo,
onAddRelation,
onImportMarkdown,
pushToast, pushToast,
]); ]);

View File

@@ -28,6 +28,7 @@ import {
} from '@/features/editor/nodeStyleResolver'; } from '@/features/editor/nodeStyleResolver';
import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons'; import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons';
import { PanelSection } from '@/features/editor/panel/PanelSection'; import { PanelSection } from '@/features/editor/panel/PanelSection';
import { RelationProperties } from '@/features/editor/RelationProperties';
import { import {
ColorField, ColorField,
NumberField, NumberField,
@@ -90,6 +91,7 @@ const ICON_SIZES = [16, 20, 24, 32];
export function PropertiesPanel() { export function PropertiesPanel() {
const map = useEditorStore((state) => state.map); const map = useEditorStore((state) => state.map);
const selectedIds = useEditorStore((state) => state.selectedNodeIds); const selectedIds = useEditorStore((state) => state.selectedNodeIds);
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const updateNode = useEditorStore((state) => state.updateNode); const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText); const updateNodeText = useEditorStore((state) => state.updateNodeText);
@@ -135,6 +137,8 @@ export function PropertiesPanel() {
const palette = paletteForDepth(theme, referenceDepth); const palette = paletteForDepth(theme, referenceDepth);
const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined; const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined;
const relation = map?.relations.find((candidate) => candidate.id === selectedRelationId);
const header = ( const header = (
<header className="flex items-center justify-between gap-2 border-b border-zinc-100 px-4 py-3 dark:border-zinc-800"> <header className="flex items-center justify-between gap-2 border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50"> <h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
@@ -153,6 +157,20 @@ export function PropertiesPanel() {
</header> </header>
); );
// 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 (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<RelationProperties relationId={relation.id} />
</aside>
);
}
if (nodes.length === 0) { if (nodes.length === 0) {
return ( return (
<aside <aside
@@ -162,7 +180,8 @@ export function PropertiesPanel() {
{header} {header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400"> <p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
Selectionnez un noeud pour modifier son contenu, sa typographie, son apparence et sa mise 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.
</p> </p>
</aside> </aside>
); );

View File

@@ -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 (
<Modal
open={open}
title="Ajouter une relation"
description={
source
? `Relation visuelle depuis "${source.text || 'Sans titre'}". Elle ne modifie ni le parent ni les enfants.`
: ''
}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<div>
<label className="field-label" htmlFor="relation-label">
Libelle (facultatif)
</label>
<input
id="relation-label"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise, protege..."
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
autoFocus
type="search"
value={query}
onChange={(event) => 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"
/>
</div>
{candidates.length === 0 ? (
<p className="px-1 py-3 text-sm text-zinc-500">Aucun noeud ne correspond.</p>
) : (
<ul className="max-h-64 overflow-y-auto" aria-label="Cibles possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => 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"
>
<span
aria-hidden="true"
className="shrink-0 font-mono text-[11px] text-zinc-400"
style={{ paddingLeft: depth * 10 }}
>
{depth === 0 ? '\u25cf' : '\u2514'}
</span>
<span className="truncate">{candidate.text || 'Sans titre'}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}

View File

@@ -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 (
<>
<PanelSection id="relation-content" title="Relation">
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{sourceText || 'Sans titre'}
</span>
{' → '}
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{targetText || 'Sans titre'}
</span>
</p>
<p className="text-[11px] leading-snug text-zinc-400">
Connexion visuelle uniquement : la hierarchie de ces deux noeuds est inchangee.
</p>
<div>
<label className="field-label" htmlFor="relation-label-field">
Libelle
</label>
<input
id="relation-label-field"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise..."
onChange={(event) => setLabel(event.target.value)}
onBlur={() => {
const trimmed = label.trim();
if (trimmed !== (relation.label ?? '')) {
updateRelation(relation.id, trimmed ? { label: trimmed } : { label: undefined });
}
}}
/>
</div>
</PanelSection>
<PanelSection id="relation-style" title="Apparence">
<SegmentedControl
legend="Trace"
selected={relation.style ?? RELATION_DEFAULTS.style}
options={LINE_STYLES}
onSelect={(value) => updateRelation(relation.id, { style: value })}
/>
<ColorField
id="relation-color"
label="Couleur"
shared={{ value: relation.color, mixed: false }}
fallback={relationColor(theme)}
onChange={(value) => updateRelation(relation.id, { color: value })}
/>
<NumberField
id="relation-width"
label="Epaisseur"
value={relation.width ?? RELATION_DEFAULTS.width}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
step={0.5}
onChange={(value) => updateRelation(relation.id, { width: value })}
/>
<ToggleRow
id="relation-dashed"
label="Trait pointille"
checked={relation.dashed ?? RELATION_DEFAULTS.dashed}
onChange={(checked) => updateRelation(relation.id, { dashed: checked })}
/>
<SegmentedControl
legend="Extremite d arrivee"
selected={relation.markerEnd ?? RELATION_DEFAULTS.markerEnd}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerEnd: value })}
/>
<SegmentedControl
legend="Extremite de depart"
selected={relation.markerStart ?? RELATION_DEFAULTS.markerStart}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerStart: value })}
/>
</PanelSection>
<div className="px-4 py-3">
<Button size="sm" variant="danger" onClick={() => deleteRelation(relation.id)}>
<TrashIcon className="h-3.5 w-3.5" />
Supprimer la relation
</Button>
</div>
</>
);
}

View File

@@ -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> = {},
): 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<string, number> = { 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);
}
});
});

View File

@@ -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<string, string[]>, 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<string, NodeSide> {
const result = new Map<string, NodeSide>();
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<string, NodeSide> {
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<string, NodeSide>();
// 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<string>();
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;
}

View File

@@ -1,7 +1,13 @@
import { type Edge, type Node } from '@xyflow/react'; import { type Edge, type Node } from '@xyflow/react';
import { Position } from '@xyflow/react'; import { MarkerType, Position } from '@xyflow/react';
import { type BranchStyle, type MindMap, type MindMapNode } from '@/types/map'; import {
import { type MapTheme } from '@/features/editor/themes'; 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 { import {
resolveNodeAppearance, resolveNodeAppearance,
type NodeAppearance, type NodeAppearance,
@@ -22,8 +28,19 @@ export interface MindNodeData extends Record<string, unknown> {
isSearchHit: boolean; isSearchHit: boolean;
readOnly: boolean; readOnly: boolean;
isEditing: 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<MindNodeData, 'mind'>; export type MindFlowNode = Node<MindNodeData, 'mind'>;
@@ -40,6 +57,81 @@ function handlePositions(layout: MindMap['settings']['layout']): {
: { source: Position.Right, target: Position.Left }; : { 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<NodeSide, HandleSpec>;
}
const SIDE_POSITION: Record<NodeSide, Position> = {
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<string, HandlePlan> {
const plans = new Map<string, HandlePlan>();
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<NodeSide>();
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<NodeSide, HandleSpec>();
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<BranchStyle, string> = { const EDGE_TYPE_BY_BRANCH: Record<BranchStyle, string> = {
bezier: 'default', bezier: 'default',
curved: 'smoothstep', curved: 'smoothstep',
@@ -62,12 +154,18 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
const children = buildChildrenMap(map.nodes); const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds); const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults); 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) => { return map.nodes.map((node) => {
const depth = depths.get(node.id) ?? 0; const depth = depths.get(node.id) ?? 0;
const childIds = children.get(node.id) ?? []; const childIds = children.get(node.id) ?? [];
const measured = options.measuredSizes?.get(node.id); const measured = options.measuredSizes?.get(node.id);
const plan = plans.get(node.id) ?? fallback;
return { return {
id: node.id, id: node.id,
@@ -89,8 +187,8 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
isSearchHit: searchHits.has(node.id), isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly, readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id, isEditing: options.editingNodeId === node.id,
sourcePosition: positions.source, sourceHandles: plan.sources,
targetPosition: positions.target, targetHandle: plan.target,
}, },
}; };
}); });
@@ -116,16 +214,21 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes); const hidden = getHiddenNodeIds(map.nodes);
const nodeMap = buildNodeMap(map.nodes); const nodeMap = buildNodeMap(map.nodes);
const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default'; 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) => { return map.edges.map((edge) => {
const target = nodeMap.get(edge.target); const target = nodeMap.get(edge.target);
const branch = target?.branch ?? {}; const branch = target?.branch ?? {};
const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType; const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType;
const childSide: NodeSide = sides?.get(edge.target) ?? 'right';
return { return {
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
sourceHandle: plans.get(edge.source)?.sourceBySide.get(childSide)?.id,
targetHandle: plans.get(edge.target)?.target.id,
type, type,
hidden: hidden.has(edge.source) || hidden.has(edge.target), hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly, 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-<parent>-<enfant>`. */
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);
}

View File

@@ -1,6 +1,8 @@
import dagre from 'dagre'; import dagre from 'dagre';
import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map'; import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
import { type MapTheme } from '@/features/editor/themes'; 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'; import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption { export interface LayoutOption {
@@ -10,9 +12,9 @@ export interface LayoutOption {
} }
/** /**
* Dispositions proposees dans l'interface de la V1. * Dispositions proposees dans l'interface.
* `mindmap-both`, `organization` et `freeform` existent dans le modele mais ne * `organization` et `freeform` existent dans le modele mais ne sont pas encore
* sont pas encore implementees : elles restent disponibles pour une V2. * implementees : elles restent disponibles pour une version ulterieure.
*/ */
export const LAYOUT_OPTIONS: LayoutOption[] = [ export const LAYOUT_OPTIONS: LayoutOption[] = [
{ {
@@ -25,6 +27,11 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
label: 'Mind map gauche', label: 'Mind map gauche',
description: 'Les branches se deploient vers la 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', id: 'tree-horizontal',
label: 'Arbre horizontal', label: 'Arbre horizontal',
@@ -70,8 +77,10 @@ const LAYOUT_PARAMS: Record<string, DagreParams> = {
'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 }, 'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 }, 'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 }, '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 }, 'mindmap-both': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
// Replis pour les dispositions reservees.
organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 }, organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 }, freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
}; };
@@ -111,11 +120,67 @@ export function computeLayout(
nodes: MindMapNode[], nodes: MindMapNode[],
layout: LayoutKind, layout: LayoutKind,
options: LayoutOptions = {}, options: LayoutOptions = {},
): Map<string, Position> {
if (nodes.length === 0) return new Map<string, Position>();
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<string, Position> {
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<string, Position>(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<string, Position> { ): Map<string, Position> {
const positions = new Map<string, Position>(); const positions = new Map<string, Position>();
if (nodes.length === 0) return positions; if (nodes.length === 0) return positions;
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
const spacing = resolveSpacing(options.spacing); const spacing = resolveSpacing(options.spacing);
const graph = new dagre.graphlib.Graph(); const graph = new dagre.graphlib.Graph();

View File

@@ -24,6 +24,12 @@ export interface MapTheme {
shadow: string; shadow: string;
branchColor: string; branchColor: string;
branchWidth: number; 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; root: NodePalette;
levels: 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); 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). */ /** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */
export function paletteForDepth(theme: MapTheme, depth: number): NodePalette { export function paletteForDepth(theme: MapTheme, depth: number): NodePalette {
if (depth <= 0) return theme.root; if (depth <= 0) return theme.root;

View File

@@ -135,7 +135,12 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
break; break;
case 'Delete': case 'Delete':
case 'Backspace': 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(); event.preventDefault();
store.deleteNodes(store.selectedNodeIds); store.deleteNodes(store.selectedNodeIds);
} }
@@ -154,6 +159,7 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
case 'Escape': case 'Escape':
store.setEditingNode(null); store.setEditingNode(null);
store.setSelection([]); store.setSelection([]);
store.setSelectedRelation(null);
break; break;
default: default:
break; break;

View File

@@ -14,6 +14,7 @@ import { PrintDialog } from '@/features/export/PrintDialog';
import { useEditorStore } from '@/stores/editorStore'; import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { serializeMap } from '@/services/mapSerialization'; import { serializeMap } from '@/services/mapSerialization';
import { exportMarkdown, hasUnexportableRelations } from '@/services/markdown';
import { downloadBlob, toSafeFilename } from '@/utils/download'; import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ExportMenuProps { export interface ExportMenuProps {
@@ -37,6 +38,25 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
pushToast('Carte exportee.', 'success'); 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) => { const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
if (!map || busy) return; if (!map || busy) return;
setBusy(true); setBusy(true);
@@ -91,6 +111,17 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
</> </>
) : null} ) : null}
<MenuLabel>Texte</MenuLabel>
<MenuItem
label="Markdown (.md)"
hint="hierarchie"
onSelect={() => {
exportMarkdownFile();
close();
}}
/>
<MenuSeparator />
<MenuLabel>Image</MenuLabel> <MenuLabel>Image</MenuLabel>
<MenuItem <MenuItem
label="PNG - resolution normale" label="PNG - resolution normale"

View File

@@ -5,10 +5,20 @@ import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Modal } from '@/components/Modal'; import { Modal } from '@/components/Modal';
import { ImportIcon, MoonIcon, PlusIcon, SearchIcon, SunIcon } from '@/components/Icons'; import {
ChevronDownIcon,
ImportIcon,
MoonIcon,
PlusIcon,
SearchIcon,
SunIcon,
} from '@/components/Icons';
import { Menu, MenuItem, MenuLabel } from '@/components/Menu';
import { TemplateGallery } from '@/features/templates/TemplateGallery';
import { buildTemplateMap, type MapTemplate } from '@/features/templates/templates';
import { MarkdownError, buildMapFromMarkdown } from '@/services/markdown';
import { MapCard } from '@/features/maps/MapCard'; import { MapCard } from '@/features/maps/MapCard';
import { import {
createMap,
deleteMap, deleteMap,
duplicateMap, duplicateMap,
getMap, getMap,
@@ -26,6 +36,7 @@ import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() { export function DashboardPage() {
const appName = useAppConfigStore((state) => state.name); const appName = useAppConfigStore((state) => state.name);
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb); const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const colorMode = useUiStore((state) => state.colorMode); const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode); const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast); const pushToast = useUiStore((state) => state.pushToast);
@@ -36,7 +47,9 @@ export function DashboardPage() {
const [renaming, setRenaming] = useState<MapSummary | null>(null); const [renaming, setRenaming] = useState<MapSummary | null>(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const [deleting, setDeleting] = useState<MapSummary | null>(null); const [deleting, setDeleting] = useState<MapSummary | null>(null);
const [galleryOpen, setGalleryOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const markdownInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -56,9 +69,28 @@ export function DashboardPage() {
return maps.filter((map) => map.name.toLowerCase().includes(needle)); return maps.filter((map) => map.name.toLowerCase().includes(needle));
}, [maps, query]); }, [maps, query]);
const handleCreate = async () => { /** Un modele est toujours copie : l'original reste une constante du code. */
const map = await createMap('Nouvelle carte'); const handleChooseTemplate = async (template: MapTemplate) => {
navigate(buildMapPath(map.id)); 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) => { const handleImportFile = async (file: File) => {
@@ -134,14 +166,43 @@ export function DashboardPage() {
</header> </header>
<div className="mb-6 flex flex-wrap items-center gap-2"> <div className="mb-6 flex flex-wrap items-center gap-2">
<Button variant="primary" onClick={() => void handleCreate()}> <Button variant="primary" onClick={() => setGalleryOpen(true)}>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Nouvelle carte Nouvelle carte
</Button> </Button>
<Button onClick={() => fileInputRef.current?.click()}>
<Menu
label="Menu d'import"
trigger={
<Button>
<ImportIcon className="h-4 w-4" /> <ImportIcon className="h-4 w-4" />
Importer Importer
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button> </Button>
}
>
{(close) => (
<>
<MenuLabel>Depuis un fichier</MenuLabel>
<MenuItem
label={`Carte ${APP_CONFIG.fileExtension}`}
onSelect={() => {
fileInputRef.current?.click();
close();
}}
/>
<MenuItem
label="Markdown (.md)"
hint="nouvelle carte"
onSelect={() => {
markdownInputRef.current?.click();
close();
}}
/>
</>
)}
</Menu>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -154,6 +215,18 @@ export function DashboardPage() {
if (file) void handleImportFile(file); if (file) void handleImportFile(file);
}} }}
/> />
<input
ref={markdownInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Importer un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportMarkdown(file);
}}
/>
<div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900"> <div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" /> <SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
@@ -181,7 +254,7 @@ export function DashboardPage() {
: `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`} : `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`}
</p> </p>
{!query ? ( {!query ? (
<Button variant="primary" onClick={() => void handleCreate()}> <Button variant="primary" onClick={() => setGalleryOpen(true)}>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Nouvelle carte Nouvelle carte
</Button> </Button>
@@ -213,6 +286,12 @@ export function DashboardPage() {
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable. {APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
</footer> </footer>
<TemplateGallery
open={galleryOpen}
onClose={() => setGalleryOpen(false)}
onSelect={(template) => void handleChooseTemplate(template)}
/>
<Modal <Modal
open={renaming !== null} open={renaming !== null}
title="Renommer la carte" title="Renommer la carte"

View File

@@ -0,0 +1,68 @@
import { Modal } from '@/components/Modal';
import { Button } from '@/components/Button';
import { MAP_TEMPLATES, type MapTemplate, type TemplateNode } from '@/features/templates/templates';
export interface TemplateGalleryProps {
open: boolean;
onClose: () => 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 (
<Modal
open={open}
title="Nouvelle carte"
description="Partez d'une carte vide ou d'un modele. Le modele est copie : vous le modifiez librement."
onClose={onClose}
size="lg"
footer={<Button onClick={onClose}>Annuler</Button>}
>
<ul className="grid gap-2 sm:grid-cols-2" aria-label="Modeles disponibles">
{MAP_TEMPLATES.map((template) => (
<li key={template.id}>
<button
type="button"
onClick={() => 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"
>
<span className="flex items-center gap-2">
<span aria-hidden="true" className="text-lg">
{template.icon}
</span>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{template.name}
</span>
<span className="ml-auto shrink-0 text-[11px] text-zinc-400">
{countNodes(template.root)} noeuds
</span>
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{template.description}
</span>
<span className="truncate text-[11px] text-zinc-400">{outline(template.root)}</span>
</button>
</li>
))}
</ul>
</Modal>
);
}

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -1,4 +1,5 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map'; import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { remapRelations } from '@/services/relations';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree'; import { rebuildEdges } from '@/utils/tree';
@@ -21,6 +22,7 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
position: partial.position ?? { x: 0, y: 0 }, position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null, parentId: partial.parentId ?? null,
...(partial.order !== undefined ? { order: partial.order } : {}), ...(partial.order !== undefined ? { order: partial.order } : {}),
...(partial.side !== undefined ? { side: partial.side } : {}),
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}), ...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}), ...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}), ...(partial.url ? { url: partial.url } : {}),
@@ -45,6 +47,7 @@ export function createEmptyMap(name: string): MindMap {
updatedAt: timestamp, updatedAt: timestamp,
nodes: [root], nodes: [root],
edges: [], edges: [],
relations: [],
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS }, settings: { ...DEFAULT_SETTINGS },
}; };
@@ -72,6 +75,9 @@ export function cloneMap(map: MindMap, name?: string): MindMap {
updatedAt: timestamp, updatedAt: timestamp,
nodes, nodes,
edges: rebuildEdges(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 }, viewport: { ...map.viewport },
settings: { ...map.settings }, settings: { ...map.settings },
}; };
@@ -126,6 +132,7 @@ export function createSampleMap(): MindMap {
updatedAt: timestamp, updatedAt: timestamp,
nodes, nodes,
edges: rebuildEdges(nodes), edges: rebuildEdges(nodes),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS }, settings: { ...DEFAULT_SETTINGS },
}; };

View File

@@ -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<string, unknown> {
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<Record<string, unknown>>)[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<Record<string, unknown>>)[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<Record<string, unknown>>)[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<Record<string, unknown>>;
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<Record<string, unknown>>)[0] as Record<string, unknown>;
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<Record<string, unknown>>)[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<string, unknown> {
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([]);
});
});

View File

@@ -8,12 +8,15 @@ import {
type MapSettings, type MapSettings,
type MindMap, type MindMap,
type MindMapNode, type MindMapNode,
type MindMapRelation,
type NodeBranchStyle, type NodeBranchStyle,
type NodeIconStyle, type NodeIconStyle,
type NodeKind, type NodeKind,
type NodeLayout, type NodeLayout,
type NodeShape, type NodeShape,
type NodeSide,
type NodeStyle, type NodeStyle,
type RelationMarker,
type ShadowStyle, type ShadowStyle,
type SizeMode, type SizeMode,
type TextAlign, type TextAlign,
@@ -22,6 +25,7 @@ import {
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver'; import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
import { SPACING_LIMITS } from '@/features/editor/layout'; import { SPACING_LIMITS } from '@/features/editor/layout';
import { DEFAULT_SETTINGS } from '@/services/mapFactory'; import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createRelationId, pruneRelations } from '@/services/relations';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { import {
flattenInOrder, flattenInOrder,
@@ -53,6 +57,9 @@ const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounde
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top']; const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
const ICON_KINDS = ['emoji', 'builtin'] as const; const ICON_KINDS = ['emoji', 'builtin'] as const;
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier']; 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[] = [ const LAYOUTS: LayoutKind[] = [
'mindmap-right', 'mindmap-right',
'mindmap-left', 'mindmap-left',
@@ -251,6 +258,10 @@ function parseNode(value: unknown): MindMapNode | null {
node.order = Math.max(0, Math.round(value.order)); 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; if (value.collapsed === true) node.collapsed = true;
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree. // 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; 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 { function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS }; const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings; if (!isRecord(value)) return settings;
@@ -367,6 +426,18 @@ function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknow
return { ...document, nodes, version: 3 }; return { ...document, nodes, version: 3 };
} }
/**
* v3 -> 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<string, unknown>): Record<string, unknown> {
return { ...document, version: 4 };
}
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> { function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
let document = raw; let document = raw;
let current = version; let current = version;
@@ -379,6 +450,10 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
document = migrateV2ToV3(document); document = migrateV2ToV3(document);
current = 3; current = 3;
} }
if (current === 3) {
document = migrateV3ToV4(document);
current = 4;
}
if (current !== MAP_FORMAT_VERSION) { if (current !== MAP_FORMAT_VERSION) {
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee. // Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
@@ -503,6 +578,35 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
// est toujours dans la meme forme canonique, quelle que soit sa provenance. // est toujours dans la meme forme canonique, quelle que soit sa provenance.
const normalised = flattenInOrder(normalizeChildOrders(linked)); const normalised = flattenInOrder(normalizeChildOrders(linked));
// Les relations sont une donnee propre : rien ne les deduit de la hierarchie,
// et rien dans la hierarchie ne doit dependre d'elles. On se contente de
// retirer celles dont une extremite n'existe plus.
const rawRelations = Array.isArray(document.relations) ? document.relations : [];
if (rawRelations.length > maxNodes * 2) {
throw new MapFormatError(
'Cette carte contient ' +
rawRelations.length +
' relations : la limite est de ' +
maxNodes * 2 +
'.',
);
}
const seenRelations = new Set<string>();
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 { return {
version: MAP_FORMAT_VERSION, version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(), id: asString(document.id, 128) ?? createId(),
@@ -511,6 +615,7 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
updatedAt: asString(document.updatedAt, 40) ?? timestamp, updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: normalised, nodes: normalised,
edges: rebuildEdges(normalised), edges: rebuildEdges(normalised),
relations,
viewport: { viewport: {
x: asFiniteNumber(rawViewport.x, 0), x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0), y: asFiniteNumber(rawViewport.y, 0),

View File

@@ -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<script>alert(1)</script>\n');
expect(result.roots[0]?.note).toBe('<script>alert(1)</script>');
});
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'));
});
});

View File

@@ -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) };
}

View File

@@ -12,6 +12,11 @@ import { APP_CONFIG } from '@/config/app';
export interface OutlineNode { export interface OutlineNode {
text: string; text: string;
children: OutlineNode[]; 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 { export interface OutlineResult {
@@ -39,7 +44,14 @@ const MAX_TEXT_LENGTH = 500;
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/; const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
const NUMBERED = /^\d{1,3}[.)]\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; let width = 0;
for (const character of line) { for (const character of line) {
if (character === ' ') width += 1; if (character === ' ') width += 1;
@@ -49,7 +61,8 @@ function indentWidth(line: string): number {
return width; 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); return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
} }

View File

@@ -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<RelationRejection, string> = {
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<string, string>,
): 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<string>,
): MindMapRelation[] {
return relations.filter(
(relation) => nodeIds.has(relation.sourceId) && nodeIds.has(relation.targetId),
);
}

View File

@@ -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')]);
});
});

View File

@@ -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);
});
});

View File

@@ -7,14 +7,25 @@ import {
type MindMap, type MindMap,
type MindMapEdge, type MindMapEdge,
type MindMapNode, type MindMapNode,
type MindMapRelation,
type NodeBranchStyle, type NodeBranchStyle,
type NodeIconStyle, type NodeIconStyle,
type NodeLayout, type NodeLayout,
type NodeSide,
type NodeStyle, type NodeStyle,
type Position, type Position,
type Viewport, type Viewport,
} from '@/types/map'; } from '@/types/map';
import { createNode } from '@/services/mapFactory'; 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 { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { getTheme, type MapTheme } from '@/features/editor/themes'; import { getTheme, type MapTheme } from '@/features/editor/themes';
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver'; import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
@@ -41,10 +52,14 @@ export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
interface HistorySnapshot { interface HistorySnapshot {
nodes: MindMapNode[]; nodes: MindMapNode[];
edges: MindMapEdge[]; edges: MindMapEdge[];
/** Les relations font partie du document : elles suivent l'annulation. */
relations: MindMapRelation[];
} }
interface ClipboardPayload { interface ClipboardPayload {
nodes: MindMapNode[]; nodes: MindMapNode[];
/** Relations internes au lot copie, reportees au collage. */
relations: MindMapRelation[];
} }
/** /**
@@ -83,6 +98,12 @@ interface EditorState {
map: MindMap | null; map: MindMap | null;
readOnly: boolean; readOnly: boolean;
selectedNodeIds: string[]; 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; editingNodeId: string | null;
past: HistorySnapshot[]; past: HistorySnapshot[];
future: HistorySnapshot[]; future: HistorySnapshot[];
@@ -106,6 +127,21 @@ interface EditorState {
setSelection: (ids: string[]) => void; setSelection: (ids: string[]) => void;
setEditingNode: (id: string | null) => 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<Omit<MindMapRelation, 'id' | 'sourceId' | 'targetId'>>,
) => RelationRejection | null;
updateRelation: (id: string, patch: Partial<Omit<MindMapRelation, 'id'>>) => 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; pushHistory: () => void;
undo: () => void; undo: () => void;
@@ -183,7 +219,7 @@ interface EditorState {
} }
function snapshot(map: MindMap): HistorySnapshot { 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. */ /** 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 * depuis `parentId`. Les anciennes cartes recoivent ainsi un ordre explicite
* conforme a leur affichage, sans traitement special ailleurs. * 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); 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<T extends object>(value: T | undefined): T | undefined {
return entries.length > 0 ? (Object.fromEntries(entries) as 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<string, string>;
}
/** /**
* Copie un ensemble de sous-arbres en regenerant tous les identifiants. * Copie un ensemble de sous-arbres en regenerant tous les identifiants.
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`. * Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
@@ -238,11 +304,11 @@ function cloneSubtrees(
source: MindMapNode[], source: MindMapNode[],
offset: number, offset: number,
parentOverride?: string | null, parentOverride?: string | null,
): MindMapNode[] { ): ClonedSubtrees {
const idMapping = new Map<string, string>(); const idMapping = new Map<string, string>();
for (const node of source) idMapping.set(node.id, createId()); 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 mappedParent = node.parentId ? idMapping.get(node.parentId) : undefined;
const parentId = const parentId =
mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null)); mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null));
@@ -256,6 +322,8 @@ function cloneSubtrees(
...(node.style ? { style: { ...node.style } } : {}), ...(node.style ? { style: { ...node.style } } : {}),
}; };
}); });
return { nodes, idMapping };
} }
export const useEditorStore = create<EditorState>((set, get) => { export const useEditorStore = create<EditorState>((set, get) => {
@@ -301,6 +369,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
...current, ...current,
nodes: target.nodes, nodes: target.nodes,
edges: target.edges, edges: target.edges,
relations: target.relations,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}, },
selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)), selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)),
@@ -347,6 +416,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
map: null, map: null,
readOnly: false, readOnly: false,
selectedNodeIds: [], selectedNodeIds: [],
selectedRelationId: null,
editingNodeId: null, editingNodeId: null,
past: [], past: [],
future: [], future: [],
@@ -367,6 +437,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
map, map,
readOnly: options?.readOnly ?? false, readOnly: options?.readOnly ?? false,
selectedNodeIds: [], selectedNodeIds: [],
selectedRelationId: null,
editingNodeId: null, editingNodeId: null,
past: [], past: [],
future: [], future: [],
@@ -388,6 +459,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
map: null, map: null,
readOnly: false, readOnly: false,
selectedNodeIds: [], selectedNodeIds: [],
selectedRelationId: null,
editingNodeId: null, editingNodeId: null,
past: [], past: [],
future: [], future: [],
@@ -400,8 +472,122 @@ export const useEditorStore = create<EditorState>((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 }), 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: () => { pushHistory: () => {
const state = get(); const state = get();
@@ -443,7 +629,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined; const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout); const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout); const direction = childDirection(map, parent);
const child = parent const child = parent
? createNode({ ? createNode({
@@ -523,7 +709,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined; const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout); 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 startOrder = nextSiblingOrder(map.nodes, parent?.id ?? null);
const positions = parent const positions = parent
@@ -561,7 +747,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined; const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout); const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout); const direction = childDirection(map, parent);
const created: MindMapNode[] = []; const created: MindMapNode[] = [];
const insert = ( const insert = (
@@ -585,6 +771,8 @@ export const useEditorStore = create<EditorState>((set, get) => {
type: !anchorNode && map.nodes.length === 0 && created.length === 0 ? 'root' : 'standard', type: !anchorNode && map.nodes.length === 0 && created.length === 0 ? 'root' : 'standard',
order: baseOrder + index, order: baseOrder + index,
position, position,
// L'import Markdown range les paragraphes dans la note du noeud.
...(outline.note ? { note: outline.note } : {}),
}); });
created.push(node); created.push(node);
if (outline.children.length > 0) insert(outline.children, node, depth + 1, 0); if (outline.children.length > 0) insert(outline.children, node, depth + 1, 0);
@@ -844,11 +1032,20 @@ export const useEditorStore = create<EditorState>((set, get) => {
const branchIds = new Set(withDescendants(map.nodes, [id])); const branchIds = new Set(withDescendants(map.nodes, [id]));
const source = map.nodes.filter((node) => branchIds.has(node.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; 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) => commit((current) =>
withEdges(current, [ withEdges(
current,
[
...current.nodes, ...current.nodes,
...clones.map((node) => ...clones.map((node) =>
node.id === cloneRoot.id node.id === cloneRoot.id
@@ -859,7 +1056,9 @@ export const useEditorStore = create<EditorState>((set, get) => {
} }
: node, : node,
), ),
]), ],
[...current.relations, ...internal],
),
); );
set({ selectedNodeIds: [cloneRoot.id], editingNodeId: null }); set({ selectedNodeIds: [cloneRoot.id], editingNodeId: null });
return clones.length; return clones.length;
@@ -873,7 +1072,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const branchIds = new Set(withDescendants(map.nodes, [id])); const branchIds = new Set(withDescendants(map.nodes, [id]));
const nodes = map.nodes.filter((node) => branchIds.has(node.id)).map((node) => ({ ...node })); 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; return nodes.length;
}, },
@@ -885,18 +1084,23 @@ export const useEditorStore = create<EditorState>((set, get) => {
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0; if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
if (!map.nodes.some((node) => node.id === parentId)) 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); let order = nextSiblingOrder(map.nodes, parentId);
commit((current) => commit((current) =>
withEdges(current, [ withEdges(
current,
[
...current.nodes.map((node) => ...current.nodes.map((node) =>
node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node, node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node,
), ),
...clones.map((node) => ...clones.map((node) =>
node.parentId === parentId ? { ...node, order: order++ } : node, node.parentId === parentId ? { ...node, order: order++ } : node,
), ),
]), ],
[...current.relations, ...internal],
),
); );
set({ selectedNodeIds: [clones[0]?.id as string], editingNodeId: null }); set({ selectedNodeIds: [clones[0]?.id as string], editingNodeId: null });
return clones.length; return clones.length;
@@ -1407,7 +1611,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds)); const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const nodes = map.nodes.filter((node) => ids.has(node.id)); 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; return nodes.length;
}, },
@@ -1418,9 +1627,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0; if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
const target = state.selectedNodeIds[0] ?? null; 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] : [] }); set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length; return clones.length;
}, },
@@ -1433,9 +1645,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds)); const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const source = map.nodes.filter((node) => ids.has(node.id)); const source = map.nodes.filter((node) => ids.has(node.id));
// parentOverride non fourni : chaque copie reste au meme niveau que l'original. // 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] : [] }); set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length; return clones.length;
}, },
@@ -1447,13 +1662,23 @@ export const useEditorStore = create<EditorState>((set, get) => {
const target = layout ?? map.settings.layout; const target = layout ?? map.settings.layout;
const effectiveSpacing = spacing ?? map.settings.layoutSpacing; 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, measuredSizes: state.measuredSizes,
spacing: effectiveSpacing, spacing: effectiveSpacing,
theme: getTheme(map.settings.theme), theme: getTheme(map.settings.theme),
}); });
if (positions.size === 0) return; if (positions.size === 0) return;
const byId = new Map(nodes.map((node) => [node.id, node]));
commit((current) => ({ commit((current) => ({
...current, ...current,
settings: { settings: {
@@ -1462,8 +1687,9 @@ export const useEditorStore = create<EditorState>((set, get) => {
...(spacing ? { layoutSpacing: spacing } : {}), ...(spacing ? { layoutSpacing: spacing } : {}),
}, },
nodes: current.nodes.map((node) => { nodes: current.nodes.map((node) => {
const withSide = byId.get(node.id) ?? node;
const position = positions.get(node.id); const position = positions.get(node.id);
return position ? { ...node, position } : node; return position ? { ...withSide, position } : withSide;
}), }),
})); }));
set({ fitRequest: get().fitRequest + 1 }); set({ fitRequest: get().fitRequest + 1 });
@@ -1499,11 +1725,26 @@ export const useEditorStore = create<EditorState>((set, get) => {
} }
const needle = normalizeText(trimmed); 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<string>();
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 const results = map.nodes
.filter( .filter(
(node) => (node) =>
normalizeText(node.text).includes(needle) || 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); .map((node) => node.id);

View File

@@ -17,8 +17,13 @@
* lecteur v2 l'ignorerait silencieusement en reordonnant la carte. * lecteur v2 l'ignorerait silencieusement en reordonnant la carte.
* La migration v2 -> v3 materialise l'ordre depuis la position dans le * La migration v2 -> v3 materialise l'ordre depuis la position dans le
* tableau `nodes`, ce qui reproduit exactement l'affichage precedent. * 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. * Types de noeuds.
@@ -33,6 +38,9 @@ export const EDITABLE_NODE_KINDS: NodeKind[] = ['root', 'standard'];
export type NodeShape = 'rectangle' | 'rounded' | 'capsule' | 'circle'; 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 TextAlign = 'left' | 'center' | 'right';
export type VerticalAlign = 'top' | 'middle' | 'bottom'; export type VerticalAlign = 'top' | 'middle' | 'bottom';
@@ -117,6 +125,15 @@ export interface MindMapNode {
* position : il n'y a jamais deux sources contradictoires. * position : il n'y a jamais deux sources contradictoires.
*/ */
order?: number; 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. */ /** Branche repliee : les descendants sont masques. */
collapsed?: boolean; collapsed?: boolean;
/** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */ /** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */
@@ -150,6 +167,34 @@ export interface MindMapEdge {
export type BranchStyle = 'straight' | 'curved' | 'bezier'; 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. * Dispositions d'auto-layout.
* `mindmap-both`, `organization` et `freeform` sont reserves : ils sont * `mindmap-both`, `organization` et `freeform` sont reserves : ils sont
@@ -206,7 +251,16 @@ export interface MindMap {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
nodes: MindMapNode[]; nodes: MindMapNode[];
/**
* Liens hierarchiques, entierement derives de `parentId`. Ils sont recalcules
* a chaque modification : ce ne sont pas une source de verite.
*/
edges: MindMapEdge[]; 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; viewport: Viewport;
settings: MapSettings; settings: MapSettings;
} }