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:
112
README.md
112
README.md
@@ -227,10 +227,36 @@ double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
|
||||
Bezier, branches repliables avec compteur d'elements masques.
|
||||
|
||||
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
|
||||
arbre horizontal, arbre vertical. L'espacement est configurable (distance
|
||||
parent/enfant et espacement entre branches), avec les presets Compact, Normal
|
||||
et Aere. Modifier l'espacement ne deplace rien tant que l'organisation n'est pas
|
||||
relancee.
|
||||
mind map bilaterale, arbre horizontal, arbre vertical. L'espacement est
|
||||
configurable (distance parent/enfant et espacement entre branches), avec les
|
||||
presets Compact, Normal et Aere. Choisir une disposition ou modifier
|
||||
l'espacement ne deplace rien : c'est *Reorganiser* qui applique, ce qui protege
|
||||
les positions placees a la main.
|
||||
|
||||
**Mind map bilaterale** : la racine reste au centre, les branches de niveau 1 se
|
||||
repartissent a gauche et a droite, et tous les descendants heritent du cote de
|
||||
leur branche. La repartition automatique equilibre les deux cotes selon la
|
||||
taille des sous-arbres ; le cote se change ensuite a la main (*Placer la branche
|
||||
a gauche / a droite*).
|
||||
|
||||
**Relations secondaires** : une connexion visuelle entre deux noeuds
|
||||
quelconques, independante de la hierarchie. Elle ne modifie ni le parent, ni
|
||||
l'ordre, ni le repli, et l'auto-layout l'ignore totalement. Libelle libre,
|
||||
trace (Bezier, courbe, droite), couleur, epaisseur, trait plein ou pointille,
|
||||
fleches aux deux extremites. Une relation dont une extremite est masquee par un
|
||||
repli disparait de l'affichage et revient au deploiement.
|
||||
|
||||
**Modeles** : galerie locale de huit modeles (carte vide, brainstorming, gestion
|
||||
de projet, architecture IT, reunion, SWOT, plan d'etude, planification
|
||||
personnelle). Choisir un modele en cree une copie : l'original n'est jamais
|
||||
modifie, et la carte obtenue est une carte comme les autres.
|
||||
|
||||
**Markdown** : import d'un fichier `.md` ou d'un texte colle, comme nouvelle
|
||||
carte depuis le tableau de bord ou comme sous-arbre d'un noeud existant. Les
|
||||
titres et les listes construisent la hierarchie ; les paragraphes et les blocs
|
||||
de code rejoignent la note du noeud, en texte brut. Export inverse en `.md` :
|
||||
contenu et hierarchie uniquement, sans positions ni couleurs, avec un
|
||||
avertissement si la carte contient des relations secondaires.
|
||||
|
||||
**Proprietes du noeud**, regroupees en sections repliables (Contenu,
|
||||
Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ;
|
||||
@@ -274,7 +300,7 @@ theme ne supprime jamais une personnalisation explicite d'un noeud.
|
||||
et mise en evidence temporaire du noeud, depliage automatique des branches
|
||||
repliees.
|
||||
|
||||
**Exports** : `.mmap` (format natif), PNG (x1, x2, fond transparent), SVG, PDF
|
||||
**Exports** : `.mmap` (format natif), Markdown (`.md`), PNG (x1, x2, fond transparent), SVG, PDF
|
||||
(A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond,
|
||||
titre, date), impression via `@media print`.
|
||||
|
||||
@@ -326,14 +352,20 @@ Le format est en **version 3**.
|
||||
| v1 | format initial | `style.width` devient `layout.width` |
|
||||
| v2 | mise en page des noeuds (`layout`, `iconStyle`, `branch`) | l'ordre des freres est materialise depuis leur position dans `nodes` |
|
||||
| v3 | ordre explicite des freres (`node.order`) | aucune |
|
||||
| v4 | cote des branches (`node.side`) et relations secondaires (`relations`) | aucune |
|
||||
|
||||
Les fichiers v1 et v2 restent lisibles, et les cartes deja enregistrees dans
|
||||
IndexedDB s'ouvrent sans modification. Le passage en v3 est justifie : l'ordre
|
||||
des freres fait desormais partie de la semantique du document, et un lecteur v2
|
||||
Les fichiers v1, v2 et v3 restent lisibles, et les cartes deja enregistrees dans
|
||||
IndexedDB s'ouvrent sans modification. Le passage en v3 etait justifie : l'ordre
|
||||
des freres fait partie de la semantique du document, et un lecteur v2
|
||||
l'ignorerait en reordonnant silencieusement la carte. Les noeuds sont malgre
|
||||
tout ecrits en parcours de profondeur, freres dans l'ordre, ce qui donne le bon
|
||||
resultat meme a un lecteur qui ignore `order`.
|
||||
|
||||
Le passage en v4 l'est aussi : un lecteur v3 ignorerait `relations` et perdrait
|
||||
donc une partie du document. La migration v3 -> v4 n'a en revanche rien a
|
||||
inventer : aucun cote defini signifie "a repartir automatiquement", aucune
|
||||
relation signifie "aucune". Une carte v3 s'affiche exactement comme avant.
|
||||
|
||||
Toute carte qui entre dans l'editeur passe par le meme point d'entree,
|
||||
`normalizeMapDocument()`, quelle que soit sa provenance :
|
||||
|
||||
@@ -362,6 +394,8 @@ vous la modifiez.
|
||||
"text": "Infrastructure",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"parentId": null,
|
||||
"order": 0,
|
||||
"side": "right",
|
||||
"collapsed": false,
|
||||
"icon": "\ud83d\udda5\ufe0f",
|
||||
"url": "https://exemple.fr",
|
||||
@@ -389,6 +423,19 @@ vous la modifiez.
|
||||
}
|
||||
],
|
||||
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }],
|
||||
"relations": [
|
||||
{
|
||||
"id": "r-uuid",
|
||||
"sourceId": "a",
|
||||
"targetId": "b",
|
||||
"label": "depend de",
|
||||
"style": "bezier",
|
||||
"color": "#a855f7",
|
||||
"width": 2,
|
||||
"dashed": true,
|
||||
"markerEnd": "arrow"
|
||||
}
|
||||
],
|
||||
"viewport": { "x": 0, "y": 0, "zoom": 1 },
|
||||
"settings": {
|
||||
"theme": "default",
|
||||
@@ -398,11 +445,21 @@ vous la modifiez.
|
||||
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
|
||||
"snapToGrid": false,
|
||||
"snapGrid": 20,
|
||||
"alignmentGuides": true
|
||||
"alignmentGuides": true,
|
||||
"dragMovesBranch": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Deux precisions sur ce document :
|
||||
|
||||
- `edges` est **derive** de `parentId` : il est recalcule a chaque modification
|
||||
et n'est jamais une source de verite. `relations`, au contraire, est une
|
||||
donnee a part entiere, que rien ne permet de deduire de la hierarchie.
|
||||
- `side` n'a de sens que sur un enfant direct d'une racine. Tous ses descendants
|
||||
heritent du cote de leur branche, ce qui evite d'entretenir la meme
|
||||
information sur chaque noeud.
|
||||
|
||||
A l'import, le fichier est **valide et normalise** : structure, version, champs
|
||||
essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:`
|
||||
sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni
|
||||
@@ -488,12 +545,17 @@ docker run --rm -v "$PWD/frontend:/app" -w /app node:20-alpine sh -c "npm ci &&
|
||||
Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et
|
||||
suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
|
||||
jeton, partage inexistant, payload invalide, limites de taille et de noeuds,
|
||||
non-divulgation du jeton de gestion, repli SPA.
|
||||
non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et
|
||||
v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente.
|
||||
|
||||
Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
|
||||
d'import, migrations, store Zustand (creation, suppression de branche,
|
||||
undo/redo, groupement du glisser-deposer, copier/coller, duplication, detection
|
||||
de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
|
||||
d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
|
||||
store Zustand (creation, suppression de branche, undo/redo, groupement du
|
||||
glisser-deposer, copier/coller, duplication, detection de boucle, recherche,
|
||||
auto-layout, lecture seule), repartition et heritage des cotes, auto-layout
|
||||
bilateral, relations secondaires (creation, refus, style, repli, duplication,
|
||||
independance vis-a-vis de la hierarchie), analyse et export Markdown, galerie de
|
||||
modeles, assainissement des URL.
|
||||
|
||||
---
|
||||
|
||||
@@ -535,7 +597,8 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
|
||||
├── hooks/ # routeur minimal, debounce
|
||||
├── components/ # briques d'interface reutilisables
|
||||
├── features/
|
||||
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, themes
|
||||
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes
|
||||
│ ├── templates/ # galerie et definitions des modeles
|
||||
│ ├── maps/ # tableau de bord
|
||||
│ ├── export/ # PNG, SVG, PDF, impression
|
||||
│ └── sharing/ # dialogue de partage, page publique
|
||||
@@ -583,9 +646,24 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
|
||||
- **Gestion d'un partage** : le jeton de gestion vit dans l'IndexedDB du
|
||||
navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis
|
||||
a jour ni supprime.
|
||||
- **Dispositions reservees** : `mindmap-both`, `organization` et `freeform`
|
||||
existent dans le modele de donnees mais ne sont pas proposees dans
|
||||
l'interface.
|
||||
- **Dispositions reservees** : `organization` et `freeform` existent dans le
|
||||
modele de donnees mais ne sont pas proposees dans l'interface.
|
||||
- **Relations et export Markdown** : l'export `.md` decrit le contenu et la
|
||||
hierarchie. Les relations secondaires n'y ont pas de representation fidele et
|
||||
ne sont pas inventees ; un message le signale au moment de l'export.
|
||||
- **Relations et minimap** : la minimap n'affiche que les noeuds. Les relations
|
||||
y sont invisibles, comme les branches hierarchiques.
|
||||
- **Doublon de relation** : deux relations identiques (meme source vers meme
|
||||
cible) se superposeraient, la seconde serait donc invisible et impossible a
|
||||
selectionner. Une seule est acceptee par couple ; le sens inverse, lui, reste
|
||||
autorise.
|
||||
- **Relation dupliquee** : dupliquer ou coller une branche reporte les relations
|
||||
dont les deux extremites sont dans la branche. Une relation qui sort vers un
|
||||
noeud exterieur n'est pas recreee : rien ne dit que la copie doit viser la
|
||||
meme cible, et le deviner creerait un lien non demande.
|
||||
- **Bilaterale et racines multiples** : le recalage des deux cotes se fait sur
|
||||
la premiere racine. Une carte a plusieurs racines reste affichee, mais les
|
||||
racines suivantes ne sont pas recentrees individuellement.
|
||||
- **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
|
||||
acceptes par le format, mais la V1 ne produit que `root` et `standard`.
|
||||
- **Migrations de schema SQLite** : la V1 initialise le schema avec
|
||||
|
||||
@@ -31,6 +31,29 @@ class MapEdgeIn(BaseModel):
|
||||
target: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class MapRelationIn(BaseModel):
|
||||
"""Relation secondaire (format v4).
|
||||
|
||||
Purement visuelle : le backend ne l'interprete pas, il verifie seulement
|
||||
qu'elle est bien formee. Une relation qui boucle sur elle-meme est refusee,
|
||||
comme cote frontend : les deux validations disent la meme chose.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str = Field(min_length=1, max_length=128)
|
||||
# Les noms suivent le document JSON du frontend, d'ou le camelCase.
|
||||
sourceId: str = Field(min_length=1, max_length=128)
|
||||
targetId: str = Field(min_length=1, max_length=128)
|
||||
label: str | None = Field(default=None, max_length=120)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_endpoints(self) -> MapRelationIn:
|
||||
if self.sourceId == self.targetId:
|
||||
raise ValueError("Une relation doit relier deux noeuds differents.")
|
||||
return self
|
||||
|
||||
|
||||
class MapDocumentIn(BaseModel):
|
||||
"""Document carte complet, tel que produit par le frontend."""
|
||||
|
||||
@@ -41,6 +64,7 @@ class MapDocumentIn(BaseModel):
|
||||
name: str = Field(default="", max_length=255)
|
||||
nodes: list[MapNodeIn] = Field(default_factory=list)
|
||||
edges: list[MapEdgeIn] = Field(default_factory=list)
|
||||
relations: list[MapRelationIn] = Field(default_factory=list)
|
||||
viewport: dict[str, Any] = Field(default_factory=dict)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -59,4 +83,8 @@ class MapDocumentIn(BaseModel):
|
||||
max_edges = get_settings().max_nodes * 4
|
||||
if len(self.edges) > max_edges:
|
||||
raise ValueError(f"La carte depasse la limite de {max_edges} liens.")
|
||||
|
||||
max_relations = get_settings().max_nodes * 2
|
||||
if len(self.relations) > max_relations:
|
||||
raise ValueError(f"La carte depasse la limite de {max_relations} relations.")
|
||||
return self
|
||||
|
||||
149
backend/tests/test_relation_payload.py
Normal file
149
backend/tests/test_relation_payload.py
Normal 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
|
||||
@@ -71,9 +71,11 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
const requestFit = useEditorStore((state) => state.requestFit);
|
||||
const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed);
|
||||
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
|
||||
const balanceSides = useEditorStore((state) => state.balanceSides);
|
||||
|
||||
const colorMode = useUiStore((state) => state.colorMode);
|
||||
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
|
||||
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
|
||||
|
||||
@@ -158,17 +160,22 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
{(close) => (
|
||||
<>
|
||||
<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) => (
|
||||
<MenuItem
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
active={map.settings.layout === option.id}
|
||||
onSelect={() => {
|
||||
applyAutoLayout(option.id);
|
||||
close();
|
||||
}}
|
||||
onSelect={() => updateSettings({ layout: option.id })}
|
||||
/>
|
||||
))}
|
||||
<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 />
|
||||
<MenuLabel>Espacement</MenuLabel>
|
||||
<div className="flex gap-1 px-2.5 pb-2">
|
||||
@@ -235,6 +242,20 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
</div>
|
||||
|
||||
<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
|
||||
label="Reorganiser avec la disposition actuelle"
|
||||
onSelect={() => {
|
||||
|
||||
183
frontend/src/features/editor/MarkdownImportDialog.tsx
Normal file
183
frontend/src/features/editor/MarkdownImportDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
type NodeChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters';
|
||||
import {
|
||||
type MindFlowNode,
|
||||
toFlowEdges,
|
||||
toFlowNodes,
|
||||
toFlowRelationEdges,
|
||||
} from '@/features/editor/flowAdapters';
|
||||
import { MindNodeView } from '@/features/editor/MindNodeView';
|
||||
import { getTheme } from '@/features/editor/themes';
|
||||
import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer';
|
||||
@@ -27,6 +32,8 @@ import {
|
||||
} from '@/features/editor/NodeContextMenu';
|
||||
import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog';
|
||||
import { MoveToDialog } from '@/features/editor/MoveToDialog';
|
||||
import { RelationDialog } from '@/features/editor/RelationDialog';
|
||||
import { MarkdownImportDialog } from '@/features/editor/MarkdownImportDialog';
|
||||
import { SelectionToolbar } from '@/features/editor/SelectionToolbar';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
@@ -52,6 +59,7 @@ export function MindMapCanvas({
|
||||
const focusRequest = useEditorStore((state) => state.focusRequest);
|
||||
const fitRequest = useEditorStore((state) => state.fitRequest);
|
||||
const sizeVersion = useEditorStore((state) => state.sizeVersion);
|
||||
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
|
||||
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
|
||||
@@ -68,6 +76,8 @@ export function MindMapCanvas({
|
||||
null,
|
||||
);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
const [relationSource, setRelationSource] = useState<string | null>(null);
|
||||
const [markdownParent, setMarkdownParent] = useState<string | null>(null);
|
||||
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
|
||||
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
|
||||
|
||||
@@ -87,15 +97,35 @@ export function MindMapCanvas({
|
||||
searchResults,
|
||||
editingNodeId,
|
||||
readOnly,
|
||||
selectedRelationId,
|
||||
measuredSizes: useEditorStore.getState().measuredSizes,
|
||||
}),
|
||||
// sizeVersion force le recalcul quand une taille de noeud a change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[theme, selectedNodeIds, searchResults, editingNodeId, readOnly, sizeVersion],
|
||||
[
|
||||
theme,
|
||||
selectedNodeIds,
|
||||
searchResults,
|
||||
editingNodeId,
|
||||
readOnly,
|
||||
sizeVersion,
|
||||
selectedRelationId,
|
||||
],
|
||||
);
|
||||
|
||||
const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]);
|
||||
const edges = useMemo(() => (map ? toFlowEdges(map, adapterOptions) : []), [map, adapterOptions]);
|
||||
|
||||
/*
|
||||
* Les aretes affichees sont l'union de deux choses de nature differente : les
|
||||
* liens hierarchiques, derives de `parentId`, et les relations secondaires,
|
||||
* qui sont une donnee du document. Elles ne se melangent qu'ici, a
|
||||
* l'affichage : le modele, lui, les garde separees.
|
||||
*/
|
||||
const edges = useMemo(
|
||||
() =>
|
||||
map ? [...toFlowEdges(map, adapterOptions), ...toFlowRelationEdges(map, adapterOptions)] : [],
|
||||
[map, adapterOptions],
|
||||
);
|
||||
|
||||
// Cadrage initial a l'ouverture d'une carte.
|
||||
useEffect(() => {
|
||||
@@ -156,10 +186,22 @@ export function MindMapCanvas({
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange<Edge>[]) => {
|
||||
if (readOnly) return;
|
||||
const store = useEditorStore.getState();
|
||||
for (const change of changes) {
|
||||
if (change.type !== 'remove') continue;
|
||||
if (change.type === 'select') {
|
||||
// Une relation se selectionne ; un lien hierarchique, non : il n'a
|
||||
// pas de proprietes propres, tout se regle sur le noeud enfant.
|
||||
const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
|
||||
if (relation) store.setSelectedRelation(change.selected ? relation.id : null);
|
||||
continue;
|
||||
}
|
||||
if (change.type !== 'remove' || readOnly) continue;
|
||||
|
||||
const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
|
||||
if (relation) {
|
||||
store.deleteRelation(relation.id);
|
||||
continue;
|
||||
}
|
||||
const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
|
||||
if (edge) store.detachNode(edge.target);
|
||||
}
|
||||
@@ -264,6 +306,7 @@ export function MindMapCanvas({
|
||||
const store = useEditorStore.getState();
|
||||
store.setSelection([]);
|
||||
store.setEditingNode(null);
|
||||
store.setSelectedRelation(null);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
defaultViewport={map.viewport}
|
||||
@@ -313,6 +356,8 @@ export function MindMapCanvas({
|
||||
onClose={() => setContextMenu(null)}
|
||||
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
|
||||
onMoveTo={(nodeId) => setMoveTarget(nodeId)}
|
||||
onAddRelation={(nodeId) => setRelationSource(nodeId)}
|
||||
onImportMarkdown={(nodeId) => setMarkdownParent(nodeId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -328,6 +373,18 @@ export function MindMapCanvas({
|
||||
nodeId={moveTarget}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
/>
|
||||
|
||||
<RelationDialog
|
||||
open={relationSource !== null}
|
||||
sourceId={relationSource}
|
||||
onClose={() => setRelationSource(null)}
|
||||
/>
|
||||
|
||||
<MarkdownImportDialog
|
||||
open={markdownParent !== null}
|
||||
parentId={markdownParent}
|
||||
onClose={() => setMarkdownParent(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Handle, NodeResizer, type NodeProps } from '@xyflow/react';
|
||||
import { Handle, NodeResizer, useUpdateNodeInternals, type NodeProps } from '@xyflow/react';
|
||||
import { type MindFlowNode } from '@/features/editor/flowAdapters';
|
||||
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
|
||||
import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph';
|
||||
@@ -42,6 +42,22 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [draft, setDraft] = useState(node.text);
|
||||
|
||||
/*
|
||||
* React Flow mesure les poignees une seule fois, a leur apparition. Quand une
|
||||
* branche change de cote, ou que la carte passe en bilaterale, les poignees
|
||||
* changent d'identifiant et de bord : sans cette notification, React Flow
|
||||
* garde les anciennes mesures et cesse simplement de dessiner les liens
|
||||
* concernes.
|
||||
*/
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
const handleSignature = `${data.targetHandle.id}|${data.sourceHandles
|
||||
.map((handle) => handle.id)
|
||||
.join(',')}`;
|
||||
|
||||
useEffect(() => {
|
||||
updateNodeInternals(id);
|
||||
}, [id, handleSignature, updateNodeInternals]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) setDraft(node.text);
|
||||
}, [isEditing, node.text]);
|
||||
@@ -204,7 +220,8 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={data.targetPosition}
|
||||
id={data.targetHandle.id}
|
||||
position={data.targetHandle.position}
|
||||
isConnectable={!readOnly}
|
||||
aria-label="Point de connexion entrant"
|
||||
/>
|
||||
@@ -304,12 +321,18 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={data.sourcePosition}
|
||||
isConnectable={!readOnly}
|
||||
aria-label="Point de connexion sortant"
|
||||
/>
|
||||
{/* En mind map bilaterale, la racine porte deux poignees sortantes :
|
||||
ses branches partent a gauche comme a droite. */}
|
||||
{data.sourceHandles.map((handle) => (
|
||||
<Handle
|
||||
key={handle.id}
|
||||
type="source"
|
||||
id={handle.id}
|
||||
position={handle.position}
|
||||
isConnectable={!readOnly}
|
||||
aria-label="Point de connexion sortant"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface NodeContextMenuProps {
|
||||
onClose: () => void;
|
||||
onAddChildren: (parentId: string) => void;
|
||||
onMoveTo: (nodeId: string) => void;
|
||||
onAddRelation: (nodeId: string) => void;
|
||||
onImportMarkdown: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
interface MenuEntry {
|
||||
@@ -34,7 +36,14 @@ interface MenuHeading {
|
||||
type MenuLine = MenuEntry | MenuHeading;
|
||||
|
||||
/** Menu contextuel d'un noeud, organise par famille d'action. */
|
||||
export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: NodeContextMenuProps) {
|
||||
export function NodeContextMenu({
|
||||
state,
|
||||
onClose,
|
||||
onAddChildren,
|
||||
onMoveTo,
|
||||
onAddRelation,
|
||||
onImportMarkdown,
|
||||
}: NodeContextMenuProps) {
|
||||
const map = useEditorStore((store) => store.map);
|
||||
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
|
||||
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
|
||||
@@ -72,6 +81,14 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
|
||||
const hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id);
|
||||
const notify = (message: string) => pushToast(message, 'success');
|
||||
|
||||
// Le cote ne se regle que sur une branche de niveau 1 : ailleurs, il est
|
||||
// herite de la branche, il n'y a donc rien a choisir.
|
||||
const parent = node.parentId
|
||||
? map.nodes.find((candidate) => candidate.id === node.parentId)
|
||||
: undefined;
|
||||
const isBranchRoot = Boolean(parent && !parent.parentId);
|
||||
const bilateral = map.settings.layout === 'mindmap-both';
|
||||
|
||||
return [
|
||||
{ kind: 'heading', label: 'Ajouter' },
|
||||
{ kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) },
|
||||
@@ -94,6 +111,18 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
|
||||
disabled: !node.parentId,
|
||||
run: () => store.addSiblingAt(node.id, 'after'),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Importer Markdown ici...',
|
||||
run: () => onImportMarkdown(node.id),
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Relation' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Ajouter une relation...',
|
||||
run: () => onAddRelation(node.id),
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Branche' },
|
||||
{
|
||||
@@ -125,6 +154,28 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
|
||||
run: () => store.setBranchCollapsed(node.id, !node.collapsed),
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Cote' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Placer la branche a gauche',
|
||||
hint: bilateral ? undefined : 'bilaterale',
|
||||
disabled: !isBranchRoot || node.side === 'left',
|
||||
run: () => {
|
||||
store.setNodeSide(node.id, 'left');
|
||||
notify('Branche placee a gauche. Relancez l organisation pour la deplacer.');
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Placer la branche a droite',
|
||||
hint: bilateral ? undefined : 'bilaterale',
|
||||
disabled: !isBranchRoot || node.side === 'right',
|
||||
run: () => {
|
||||
store.setNodeSide(node.id, 'right');
|
||||
notify('Branche placee a droite. Relancez l organisation pour la deplacer.');
|
||||
},
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Ordre' },
|
||||
{
|
||||
kind: 'item',
|
||||
@@ -204,6 +255,8 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
|
||||
hasNodeClipboard,
|
||||
onAddChildren,
|
||||
onMoveTo,
|
||||
onAddRelation,
|
||||
onImportMarkdown,
|
||||
pushToast,
|
||||
]);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@/features/editor/nodeStyleResolver';
|
||||
import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons';
|
||||
import { PanelSection } from '@/features/editor/panel/PanelSection';
|
||||
import { RelationProperties } from '@/features/editor/RelationProperties';
|
||||
import {
|
||||
ColorField,
|
||||
NumberField,
|
||||
@@ -90,6 +91,7 @@ const ICON_SIZES = [16, 20, 24, 32];
|
||||
export function PropertiesPanel() {
|
||||
const map = useEditorStore((state) => state.map);
|
||||
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
|
||||
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
|
||||
|
||||
const updateNode = useEditorStore((state) => state.updateNode);
|
||||
const updateNodeText = useEditorStore((state) => state.updateNodeText);
|
||||
@@ -135,6 +137,8 @@ export function PropertiesPanel() {
|
||||
const palette = paletteForDepth(theme, referenceDepth);
|
||||
const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined;
|
||||
|
||||
const relation = map?.relations.find((candidate) => candidate.id === selectedRelationId);
|
||||
|
||||
const header = (
|
||||
<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">
|
||||
@@ -153,6 +157,20 @@ export function PropertiesPanel() {
|
||||
</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) {
|
||||
return (
|
||||
<aside
|
||||
@@ -162,7 +180,8 @@ export function PropertiesPanel() {
|
||||
{header}
|
||||
<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
|
||||
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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
139
frontend/src/features/editor/RelationDialog.tsx
Normal file
139
frontend/src/features/editor/RelationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/features/editor/RelationProperties.tsx
Normal file
138
frontend/src/features/editor/RelationProperties.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
221
frontend/src/features/editor/bilateral.test.ts
Normal file
221
frontend/src/features/editor/bilateral.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
152
frontend/src/features/editor/bilateral.ts
Normal file
152
frontend/src/features/editor/bilateral.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { type Edge, type Node } from '@xyflow/react';
|
||||
import { Position } from '@xyflow/react';
|
||||
import { type BranchStyle, type MindMap, type MindMapNode } from '@/types/map';
|
||||
import { type MapTheme } from '@/features/editor/themes';
|
||||
import { MarkerType, Position } from '@xyflow/react';
|
||||
import {
|
||||
type BranchStyle,
|
||||
type MindMap,
|
||||
type MindMapNode,
|
||||
type NodeSide,
|
||||
} from '@/types/map';
|
||||
import { resolveNodeSides } from '@/features/editor/bilateral';
|
||||
import { relationColor, type MapTheme } from '@/features/editor/themes';
|
||||
import {
|
||||
resolveNodeAppearance,
|
||||
type NodeAppearance,
|
||||
@@ -22,8 +28,19 @@ export interface MindNodeData extends Record<string, unknown> {
|
||||
isSearchHit: boolean;
|
||||
readOnly: boolean;
|
||||
isEditing: boolean;
|
||||
sourcePosition: Position;
|
||||
targetPosition: Position;
|
||||
/**
|
||||
* Poignees sortantes. Une seule dans les dispositions classiques ; en mind map
|
||||
* bilaterale, la racine en porte deux puisque ses branches partent des deux
|
||||
* cotes.
|
||||
*/
|
||||
sourceHandles: HandleSpec[];
|
||||
targetHandle: HandleSpec;
|
||||
}
|
||||
|
||||
/** Une poignee de connexion : identifiant stable et cote d'ancrage. */
|
||||
export interface HandleSpec {
|
||||
id: string;
|
||||
position: Position;
|
||||
}
|
||||
|
||||
export type MindFlowNode = Node<MindNodeData, 'mind'>;
|
||||
@@ -40,6 +57,81 @@ function handlePositions(layout: MindMap['settings']['layout']): {
|
||||
: { source: Position.Right, target: Position.Left };
|
||||
}
|
||||
|
||||
function handleSpec(type: 's' | 't', position: Position): HandleSpec {
|
||||
return { id: `${type}-${position}`, position };
|
||||
}
|
||||
|
||||
/** Poignees d'un noeud : une entrante, une ou deux sortantes. */
|
||||
export interface HandlePlan {
|
||||
sources: HandleSpec[];
|
||||
target: HandleSpec;
|
||||
/** Poignee sortante a utiliser pour rejoindre un enfant, par cote. */
|
||||
sourceBySide: Map<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> = {
|
||||
bezier: 'default',
|
||||
curved: 'smoothstep',
|
||||
@@ -62,12 +154,18 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
|
||||
const children = buildChildrenMap(map.nodes);
|
||||
const selected = new Set(options.selectedIds);
|
||||
const searchHits = new Set(options.searchResults);
|
||||
const positions = handlePositions(map.settings.layout);
|
||||
const plans = buildHandlePlans(map);
|
||||
const fallback: HandlePlan = {
|
||||
sources: [handleSpec('s', Position.Right)],
|
||||
target: handleSpec('t', Position.Left),
|
||||
sourceBySide: new Map(),
|
||||
};
|
||||
|
||||
return map.nodes.map((node) => {
|
||||
const depth = depths.get(node.id) ?? 0;
|
||||
const childIds = children.get(node.id) ?? [];
|
||||
const measured = options.measuredSizes?.get(node.id);
|
||||
const plan = plans.get(node.id) ?? fallback;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
@@ -89,8 +187,8 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
|
||||
isSearchHit: searchHits.has(node.id),
|
||||
readOnly: options.readOnly,
|
||||
isEditing: options.editingNodeId === node.id,
|
||||
sourcePosition: positions.source,
|
||||
targetPosition: positions.target,
|
||||
sourceHandles: plan.sources,
|
||||
targetHandle: plan.target,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -116,16 +214,21 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
|
||||
const hidden = getHiddenNodeIds(map.nodes);
|
||||
const nodeMap = buildNodeMap(map.nodes);
|
||||
const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
|
||||
const plans = buildHandlePlans(map);
|
||||
const sides = map.settings.layout === 'mindmap-both' ? resolveNodeSides(map.nodes) : null;
|
||||
|
||||
return map.edges.map((edge) => {
|
||||
const target = nodeMap.get(edge.target);
|
||||
const branch = target?.branch ?? {};
|
||||
const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType;
|
||||
const childSide: NodeSide = sides?.get(edge.target) ?? 'right';
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: plans.get(edge.source)?.sourceBySide.get(childSide)?.id,
|
||||
targetHandle: plans.get(edge.target)?.target.id,
|
||||
type,
|
||||
hidden: hidden.has(edge.source) || hidden.has(edge.target),
|
||||
deletable: !options.readOnly,
|
||||
@@ -136,3 +239,95 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Apparence par defaut d'une relation : pointille flechee, pour la distinguer. */
|
||||
export const RELATION_DEFAULTS = {
|
||||
style: 'bezier' as BranchStyle,
|
||||
width: 2,
|
||||
dashed: true,
|
||||
markerEnd: 'arrow' as const,
|
||||
markerStart: 'none' as const,
|
||||
};
|
||||
|
||||
/** Prefixe des identifiants de relation : ils ne peuvent pas etre confondus
|
||||
* avec un lien hierarchique, dont l'id est toujours `e-<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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import dagre from 'dagre';
|
||||
import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
|
||||
import { type MapTheme } from '@/features/editor/themes';
|
||||
import { resolveNodeSides } from '@/features/editor/bilateral';
|
||||
import { getRootNodes } from '@/utils/tree';
|
||||
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
|
||||
|
||||
export interface LayoutOption {
|
||||
@@ -10,9 +12,9 @@ export interface LayoutOption {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispositions proposees dans l'interface de la V1.
|
||||
* `mindmap-both`, `organization` et `freeform` existent dans le modele mais ne
|
||||
* sont pas encore implementees : elles restent disponibles pour une V2.
|
||||
* Dispositions proposees dans l'interface.
|
||||
* `organization` et `freeform` existent dans le modele mais ne sont pas encore
|
||||
* implementees : elles restent disponibles pour une version ulterieure.
|
||||
*/
|
||||
export const LAYOUT_OPTIONS: LayoutOption[] = [
|
||||
{
|
||||
@@ -25,6 +27,11 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
|
||||
label: 'Mind map gauche',
|
||||
description: 'Les branches se deploient vers la gauche.',
|
||||
},
|
||||
{
|
||||
id: 'mindmap-both',
|
||||
label: 'Mind map bilaterale',
|
||||
description: 'Racine centree, branches reparties a gauche et a droite.',
|
||||
},
|
||||
{
|
||||
id: 'tree-horizontal',
|
||||
label: 'Arbre horizontal',
|
||||
@@ -70,8 +77,10 @@ const LAYOUT_PARAMS: Record<string, DagreParams> = {
|
||||
'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
|
||||
'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
|
||||
// Replis pour les dispositions reservees.
|
||||
// La bilaterale ne passe pas par dagre directement : elle combine les deux
|
||||
// dispositions ci-dessus. L'entree sert a l'orientation des poignees.
|
||||
'mindmap-both': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
// Replis pour les dispositions reservees.
|
||||
organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
|
||||
freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
};
|
||||
@@ -111,11 +120,67 @@ export function computeLayout(
|
||||
nodes: MindMapNode[],
|
||||
layout: LayoutKind,
|
||||
options: LayoutOptions = {},
|
||||
): Map<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> {
|
||||
const positions = new Map<string, Position>();
|
||||
if (nodes.length === 0) return positions;
|
||||
|
||||
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
|
||||
const spacing = resolveSpacing(options.spacing);
|
||||
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface MapTheme {
|
||||
shadow: string;
|
||||
branchColor: string;
|
||||
branchWidth: number;
|
||||
/**
|
||||
* Couleur par defaut des relations secondaires (v4). Optionnelle : sans
|
||||
* valeur, `relationColor()` choisit une teinte volontairement distincte des
|
||||
* branches, pour qu'une relation ne se confonde jamais avec la hierarchie.
|
||||
*/
|
||||
relationColor?: string;
|
||||
root: NodePalette;
|
||||
levels: NodePalette[];
|
||||
|
||||
@@ -276,6 +282,15 @@ export function getTheme(themeId: string | undefined): MapTheme {
|
||||
return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleur d'une relation secondaire.
|
||||
* Volontairement differente des branches : le lecteur doit voir au premier
|
||||
* coup d'oeil qu'un trait n'appartient pas a la hierarchie.
|
||||
*/
|
||||
export function relationColor(theme: MapTheme): string {
|
||||
return theme.relationColor ?? (theme.isDark ? '#f0abfc' : '#a855f7');
|
||||
}
|
||||
|
||||
/** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */
|
||||
export function paletteForDepth(theme: MapTheme, depth: number): NodePalette {
|
||||
if (depth <= 0) return theme.root;
|
||||
|
||||
@@ -135,7 +135,12 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
if (store.selectedNodeIds.length > 0) {
|
||||
// Une relation selectionnee se supprime seule : aucun noeud n'est
|
||||
// touche. C'est la contrepartie du fait qu'elle n'est pas structurelle.
|
||||
if (store.selectedRelationId) {
|
||||
event.preventDefault();
|
||||
store.deleteRelation(store.selectedRelationId);
|
||||
} else if (store.selectedNodeIds.length > 0) {
|
||||
event.preventDefault();
|
||||
store.deleteNodes(store.selectedNodeIds);
|
||||
}
|
||||
@@ -154,6 +159,7 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
|
||||
case 'Escape':
|
||||
store.setEditingNode(null);
|
||||
store.setSelection([]);
|
||||
store.setSelectedRelation(null);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PrintDialog } from '@/features/export/PrintDialog';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { serializeMap } from '@/services/mapSerialization';
|
||||
import { exportMarkdown, hasUnexportableRelations } from '@/services/markdown';
|
||||
import { downloadBlob, toSafeFilename } from '@/utils/download';
|
||||
|
||||
export interface ExportMenuProps {
|
||||
@@ -37,6 +38,25 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
|
||||
pushToast('Carte exportee.', 'success');
|
||||
};
|
||||
|
||||
/**
|
||||
* Export Markdown : contenu et hierarchie.
|
||||
* Les relations secondaires n'y ont pas de representation fidele ; plutot que
|
||||
* d'inventer une hierarchie, on previent l'utilisateur et on exporte le reste.
|
||||
*/
|
||||
const exportMarkdownFile = () => {
|
||||
if (!map) return;
|
||||
const blob = new Blob([exportMarkdown(map)], { type: 'text/markdown;charset=utf-8' });
|
||||
downloadBlob(blob, toSafeFilename(map.name, '.md'));
|
||||
if (hasUnexportableRelations(map)) {
|
||||
pushToast(
|
||||
'Export Markdown termine. Les relations visuelles secondaires n y sont pas representees.',
|
||||
'info',
|
||||
);
|
||||
} else {
|
||||
pushToast('Export Markdown termine.', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
|
||||
if (!map || busy) return;
|
||||
setBusy(true);
|
||||
@@ -91,6 +111,17 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<MenuLabel>Texte</MenuLabel>
|
||||
<MenuItem
|
||||
label="Markdown (.md)"
|
||||
hint="hierarchie"
|
||||
onSelect={() => {
|
||||
exportMarkdownFile();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<MenuSeparator />
|
||||
|
||||
<MenuLabel>Image</MenuLabel>
|
||||
<MenuItem
|
||||
label="PNG - resolution normale"
|
||||
|
||||
@@ -5,10 +5,20 @@ import { Button } from '@/components/Button';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { ImportIcon, MoonIcon, PlusIcon, SearchIcon, SunIcon } from '@/components/Icons';
|
||||
import {
|
||||
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 {
|
||||
createMap,
|
||||
deleteMap,
|
||||
duplicateMap,
|
||||
getMap,
|
||||
@@ -26,6 +36,7 @@ import { downloadBlob, toSafeFilename } from '@/utils/download';
|
||||
export function DashboardPage() {
|
||||
const appName = useAppConfigStore((state) => state.name);
|
||||
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
|
||||
const maxNodes = useAppConfigStore((state) => state.maxNodes);
|
||||
const colorMode = useUiStore((state) => state.colorMode);
|
||||
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
@@ -36,7 +47,9 @@ export function DashboardPage() {
|
||||
const [renaming, setRenaming] = useState<MapSummary | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [deleting, setDeleting] = useState<MapSummary | null>(null);
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const markdownInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -56,9 +69,28 @@ export function DashboardPage() {
|
||||
return maps.filter((map) => map.name.toLowerCase().includes(needle));
|
||||
}, [maps, query]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const map = await createMap('Nouvelle carte');
|
||||
navigate(buildMapPath(map.id));
|
||||
/** Un modele est toujours copie : l'original reste une constante du code. */
|
||||
const handleChooseTemplate = async (template: MapTemplate) => {
|
||||
setGalleryOpen(false);
|
||||
const stored = await importMap(buildTemplateMap(template));
|
||||
navigate(buildMapPath(stored.id));
|
||||
};
|
||||
|
||||
const handleImportMarkdown = async (file: File) => {
|
||||
try {
|
||||
const name = file.name.replace(/\.(md|markdown|txt)$/i, '');
|
||||
const map = buildMapFromMarkdown(await file.text(), name || 'Carte importee', maxNodes);
|
||||
const stored = await importMap(map);
|
||||
pushToast(`Carte "${stored.name}" creee depuis le Markdown.`, 'success');
|
||||
navigate(buildMapPath(stored.id));
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
error instanceof MarkdownError
|
||||
? error.message
|
||||
: "Ce fichier Markdown n'a pas pu etre lu.",
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFile = async (file: File) => {
|
||||
@@ -134,14 +166,43 @@ export function DashboardPage() {
|
||||
</header>
|
||||
|
||||
<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" />
|
||||
Nouvelle carte
|
||||
</Button>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
<ImportIcon className="h-4 w-4" />
|
||||
Importer
|
||||
</Button>
|
||||
|
||||
<Menu
|
||||
label="Menu d'import"
|
||||
trigger={
|
||||
<Button>
|
||||
<ImportIcon className="h-4 w-4" />
|
||||
Importer
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
|
||||
</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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -154,6 +215,18 @@ export function DashboardPage() {
|
||||
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">
|
||||
<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}.`}
|
||||
</p>
|
||||
{!query ? (
|
||||
<Button variant="primary" onClick={() => void handleCreate()}>
|
||||
<Button variant="primary" onClick={() => setGalleryOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Nouvelle carte
|
||||
</Button>
|
||||
@@ -213,6 +286,12 @@ export function DashboardPage() {
|
||||
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
|
||||
</footer>
|
||||
|
||||
<TemplateGallery
|
||||
open={galleryOpen}
|
||||
onClose={() => setGalleryOpen(false)}
|
||||
onSelect={(template) => void handleChooseTemplate(template)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={renaming !== null}
|
||||
title="Renommer la carte"
|
||||
|
||||
68
frontend/src/features/templates/TemplateGallery.tsx
Normal file
68
frontend/src/features/templates/TemplateGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/features/templates/templates.test.ts
Normal file
122
frontend/src/features/templates/templates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
395
frontend/src/features/templates/templates.ts
Normal file
395
frontend/src/features/templates/templates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
|
||||
import { remapRelations } from '@/services/relations';
|
||||
import { createId } from '@/utils/id';
|
||||
import { rebuildEdges } from '@/utils/tree';
|
||||
|
||||
@@ -21,6 +22,7 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
|
||||
position: partial.position ?? { x: 0, y: 0 },
|
||||
parentId: partial.parentId ?? null,
|
||||
...(partial.order !== undefined ? { order: partial.order } : {}),
|
||||
...(partial.side !== undefined ? { side: partial.side } : {}),
|
||||
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
|
||||
...(partial.icon ? { icon: partial.icon } : {}),
|
||||
...(partial.url ? { url: partial.url } : {}),
|
||||
@@ -45,6 +47,7 @@ export function createEmptyMap(name: string): MindMap {
|
||||
updatedAt: timestamp,
|
||||
nodes: [root],
|
||||
edges: [],
|
||||
relations: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
};
|
||||
@@ -72,6 +75,9 @@ export function cloneMap(map: MindMap, name?: string): MindMap {
|
||||
updatedAt: timestamp,
|
||||
nodes,
|
||||
edges: rebuildEdges(nodes),
|
||||
// Les relations suivent la copie : leurs extremites pointent vers les
|
||||
// nouveaux identifiants, jamais vers ceux de la carte d'origine.
|
||||
relations: remapRelations(map.relations ?? [], idMapping),
|
||||
viewport: { ...map.viewport },
|
||||
settings: { ...map.settings },
|
||||
};
|
||||
@@ -126,6 +132,7 @@ export function createSampleMap(): MindMap {
|
||||
updatedAt: timestamp,
|
||||
nodes,
|
||||
edges: rebuildEdges(nodes),
|
||||
relations: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
};
|
||||
|
||||
255
frontend/src/services/mapRelationSerialization.test.ts
Normal file
255
frontend/src/services/mapRelationSerialization.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
type MapSettings,
|
||||
type MindMap,
|
||||
type MindMapNode,
|
||||
type MindMapRelation,
|
||||
type NodeBranchStyle,
|
||||
type NodeIconStyle,
|
||||
type NodeKind,
|
||||
type NodeLayout,
|
||||
type NodeShape,
|
||||
type NodeSide,
|
||||
type NodeStyle,
|
||||
type RelationMarker,
|
||||
type ShadowStyle,
|
||||
type SizeMode,
|
||||
type TextAlign,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { SPACING_LIMITS } from '@/features/editor/layout';
|
||||
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
|
||||
import { createRelationId, pruneRelations } from '@/services/relations';
|
||||
import { createId } from '@/utils/id';
|
||||
import {
|
||||
flattenInOrder,
|
||||
@@ -53,6 +57,9 @@ const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounde
|
||||
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
|
||||
const ICON_KINDS = ['emoji', 'builtin'] as const;
|
||||
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
|
||||
const NODE_SIDES: NodeSide[] = ['left', 'right'];
|
||||
const RELATION_MARKERS: RelationMarker[] = ['none', 'arrow'];
|
||||
const MAX_LABEL_LENGTH = 120;
|
||||
const LAYOUTS: LayoutKind[] = [
|
||||
'mindmap-right',
|
||||
'mindmap-left',
|
||||
@@ -251,6 +258,10 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
node.order = Math.max(0, Math.round(value.order));
|
||||
}
|
||||
|
||||
if (typeof value.side === 'string' && NODE_SIDES.includes(value.side as NodeSide)) {
|
||||
node.side = value.side as NodeSide;
|
||||
}
|
||||
|
||||
if (value.collapsed === true) node.collapsed = true;
|
||||
|
||||
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
|
||||
@@ -281,6 +292,54 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation secondaire (v4).
|
||||
*
|
||||
* Validee comme le reste du document : identifiants bornes, couleur filtree,
|
||||
* enumerations verifiees. Une relation incoherente est ignoree plutot que
|
||||
* corrigee au jugement.
|
||||
*/
|
||||
function parseRelation(value: unknown): MindMapRelation | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const sourceId = asString(value.sourceId, 128);
|
||||
const targetId = asString(value.targetId, 128);
|
||||
if (!sourceId || !targetId || sourceId === targetId) return null;
|
||||
|
||||
const relation: MindMapRelation = {
|
||||
id: asString(value.id, 128) ?? createRelationId(),
|
||||
sourceId,
|
||||
targetId,
|
||||
};
|
||||
|
||||
const label = asString(value.label, MAX_LABEL_LENGTH)?.trim();
|
||||
if (label) relation.label = label;
|
||||
|
||||
if (typeof value.style === 'string' && BRANCH_STYLES.includes(value.style as BranchStyle)) {
|
||||
relation.style = value.style as BranchStyle;
|
||||
}
|
||||
const color = asColor(value.color);
|
||||
if (color) relation.color = color;
|
||||
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
|
||||
relation.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
|
||||
}
|
||||
if (typeof value.dashed === 'boolean') relation.dashed = value.dashed;
|
||||
if (
|
||||
typeof value.markerStart === 'string' &&
|
||||
RELATION_MARKERS.includes(value.markerStart as RelationMarker)
|
||||
) {
|
||||
relation.markerStart = value.markerStart as RelationMarker;
|
||||
}
|
||||
if (
|
||||
typeof value.markerEnd === 'string' &&
|
||||
RELATION_MARKERS.includes(value.markerEnd as RelationMarker)
|
||||
) {
|
||||
relation.markerEnd = value.markerEnd as RelationMarker;
|
||||
}
|
||||
|
||||
return relation;
|
||||
}
|
||||
|
||||
function parseSettings(value: unknown): MapSettings {
|
||||
const settings: MapSettings = { ...DEFAULT_SETTINGS };
|
||||
if (!isRecord(value)) return settings;
|
||||
@@ -367,6 +426,18 @@ function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknow
|
||||
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> {
|
||||
let document = raw;
|
||||
let current = version;
|
||||
@@ -379,6 +450,10 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
|
||||
document = migrateV2ToV3(document);
|
||||
current = 3;
|
||||
}
|
||||
if (current === 3) {
|
||||
document = migrateV3ToV4(document);
|
||||
current = 4;
|
||||
}
|
||||
|
||||
if (current !== MAP_FORMAT_VERSION) {
|
||||
// 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.
|
||||
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 {
|
||||
version: MAP_FORMAT_VERSION,
|
||||
id: asString(document.id, 128) ?? createId(),
|
||||
@@ -511,6 +615,7 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
|
||||
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
|
||||
nodes: normalised,
|
||||
edges: rebuildEdges(normalised),
|
||||
relations,
|
||||
viewport: {
|
||||
x: asFiniteNumber(rawViewport.x, 0),
|
||||
y: asFiniteNumber(rawViewport.y, 0),
|
||||
|
||||
262
frontend/src/services/markdown.test.ts
Normal file
262
frontend/src/services/markdown.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
319
frontend/src/services/markdown.ts
Normal file
319
frontend/src/services/markdown.ts
Normal 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) };
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import { APP_CONFIG } from '@/config/app';
|
||||
export interface OutlineNode {
|
||||
text: string;
|
||||
children: OutlineNode[];
|
||||
/**
|
||||
* Note attachee au noeud. Toujours du texte brut : elle n'est jamais
|
||||
* interpretee comme du HTML. Alimentee par l'import Markdown.
|
||||
*/
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface OutlineResult {
|
||||
@@ -39,7 +44,14 @@ const MAX_TEXT_LENGTH = 500;
|
||||
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
|
||||
const NUMBERED = /^\d{1,3}[.)]\s+/;
|
||||
|
||||
function indentWidth(line: string): number {
|
||||
/** Vrai si la ligne est un element de liste : puce ou numerotation. */
|
||||
export function isListItem(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return BULLET.test(trimmed) || NUMBERED.test(trimmed);
|
||||
}
|
||||
|
||||
/** Largeur d'indentation d'une ligne, tabulation comptee pour TAB_WIDTH. */
|
||||
export function indentWidth(line: string): number {
|
||||
let width = 0;
|
||||
for (const character of line) {
|
||||
if (character === ' ') width += 1;
|
||||
@@ -49,7 +61,8 @@ function indentWidth(line: string): number {
|
||||
return width;
|
||||
}
|
||||
|
||||
function cleanText(line: string): string {
|
||||
/** Texte utile d'une ligne : sans indentation, sans puce ni numerotation. */
|
||||
export function cleanText(line: string): string {
|
||||
return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
|
||||
105
frontend/src/services/relations.ts
Normal file
105
frontend/src/services/relations.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
267
frontend/src/stores/editorRelations.test.ts
Normal file
267
frontend/src/stores/editorRelations.test.ts
Normal 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')]);
|
||||
});
|
||||
});
|
||||
182
frontend/src/stores/editorSides.test.ts
Normal file
182
frontend/src/stores/editorSides.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,25 @@ import {
|
||||
type MindMap,
|
||||
type MindMapEdge,
|
||||
type MindMapNode,
|
||||
type MindMapRelation,
|
||||
type NodeBranchStyle,
|
||||
type NodeIconStyle,
|
||||
type NodeLayout,
|
||||
type NodeSide,
|
||||
type NodeStyle,
|
||||
type Position,
|
||||
type Viewport,
|
||||
} from '@/types/map';
|
||||
import { createNode } from '@/services/mapFactory';
|
||||
import {
|
||||
checkRelation,
|
||||
createRelationId,
|
||||
pruneRelations,
|
||||
relationsWithin,
|
||||
remapRelations,
|
||||
type RelationRejection,
|
||||
} from '@/services/relations';
|
||||
import { materializeSides, resolveNodeSides } from '@/features/editor/bilateral';
|
||||
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
|
||||
import { getTheme, type MapTheme } from '@/features/editor/themes';
|
||||
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
@@ -41,10 +52,14 @@ export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
||||
interface HistorySnapshot {
|
||||
nodes: MindMapNode[];
|
||||
edges: MindMapEdge[];
|
||||
/** Les relations font partie du document : elles suivent l'annulation. */
|
||||
relations: MindMapRelation[];
|
||||
}
|
||||
|
||||
interface ClipboardPayload {
|
||||
nodes: MindMapNode[];
|
||||
/** Relations internes au lot copie, reportees au collage. */
|
||||
relations: MindMapRelation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +98,12 @@ interface EditorState {
|
||||
map: MindMap | null;
|
||||
readOnly: boolean;
|
||||
selectedNodeIds: string[];
|
||||
/**
|
||||
* Relation secondaire selectionnee. Distincte de la selection de noeuds :
|
||||
* une relation n'est pas un noeud, et la supprimer ne doit jamais supprimer
|
||||
* un noeud.
|
||||
*/
|
||||
selectedRelationId: string | null;
|
||||
editingNodeId: string | null;
|
||||
past: HistorySnapshot[];
|
||||
future: HistorySnapshot[];
|
||||
@@ -106,6 +127,21 @@ interface EditorState {
|
||||
|
||||
setSelection: (ids: string[]) => void;
|
||||
setEditingNode: (id: string | null) => void;
|
||||
setSelectedRelation: (id: string | null) => void;
|
||||
|
||||
/** Cree une relation secondaire. Ne touche jamais a `parentId` ni a `order`. */
|
||||
addRelation: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
patch?: Partial<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;
|
||||
undo: () => void;
|
||||
@@ -183,7 +219,7 @@ interface EditorState {
|
||||
}
|
||||
|
||||
function snapshot(map: MindMap): HistorySnapshot {
|
||||
return { nodes: map.nodes, edges: map.edges };
|
||||
return { nodes: map.nodes, edges: map.edges, relations: map.relations };
|
||||
}
|
||||
|
||||
/** Normalisation pour la recherche : insensible a la casse et aux accents. */
|
||||
@@ -201,9 +237,33 @@ function normalizeText(value: string): string {
|
||||
* depuis `parentId`. Les anciennes cartes recoivent ainsi un ordre explicite
|
||||
* conforme a leur affichage, sans traitement special ailleurs.
|
||||
*/
|
||||
function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
|
||||
function withEdges(
|
||||
map: MindMap,
|
||||
nodes: MindMapNode[],
|
||||
relations?: MindMapRelation[],
|
||||
): MindMap {
|
||||
const ordered = normalizeChildOrders(nodes);
|
||||
return { ...map, nodes: ordered, edges: rebuildEdges(ordered) };
|
||||
/*
|
||||
* Les relations ne sont jamais recalculees ici : elles ne se deduisent pas de
|
||||
* `parentId`. On les preserve telles quelles et on retire seulement celles
|
||||
* dont une extremite vient de disparaitre. `withEdges` ne doit sous aucun
|
||||
* pretexte devenir un endroit ou une relation se perd.
|
||||
*/
|
||||
const kept = pruneRelations(relations ?? map.relations, ordered);
|
||||
return { ...map, nodes: ordered, edges: rebuildEdges(ordered), relations: kept };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sens dans lequel placer les nouveaux enfants d'un parent.
|
||||
*
|
||||
* En mind map bilaterale, une branche de gauche fait naitre ses enfants vers la
|
||||
* gauche : sans cela, tout ce qui est cree sur le cote gauche partirait vers la
|
||||
* droite et traverserait la carte.
|
||||
*/
|
||||
function childDirection(map: MindMap, parent?: MindMapNode): 1 | -1 {
|
||||
const base = layoutDirection(map.settings.layout);
|
||||
if (map.settings.layout !== 'mindmap-both' || !parent) return base;
|
||||
return resolveNodeSides(map.nodes).get(parent.id) === 'left' ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,6 +290,12 @@ function pruneEmpty<T extends object>(value: T | undefined): T | undefined {
|
||||
return entries.length > 0 ? (Object.fromEntries(entries) as T) : undefined;
|
||||
}
|
||||
|
||||
interface ClonedSubtrees {
|
||||
nodes: MindMapNode[];
|
||||
/** Ancien identifiant -> nouveau. Sert a reporter les relations internes. */
|
||||
idMapping: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie un ensemble de sous-arbres en regenerant tous les identifiants.
|
||||
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
|
||||
@@ -238,11 +304,11 @@ function cloneSubtrees(
|
||||
source: MindMapNode[],
|
||||
offset: number,
|
||||
parentOverride?: string | null,
|
||||
): MindMapNode[] {
|
||||
): ClonedSubtrees {
|
||||
const idMapping = new Map<string, string>();
|
||||
for (const node of source) idMapping.set(node.id, createId());
|
||||
|
||||
return source.map((node) => {
|
||||
const nodes = source.map((node) => {
|
||||
const mappedParent = node.parentId ? idMapping.get(node.parentId) : undefined;
|
||||
const parentId =
|
||||
mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null));
|
||||
@@ -256,6 +322,8 @@ function cloneSubtrees(
|
||||
...(node.style ? { style: { ...node.style } } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, idMapping };
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => {
|
||||
@@ -301,6 +369,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
...current,
|
||||
nodes: target.nodes,
|
||||
edges: target.edges,
|
||||
relations: target.relations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)),
|
||||
@@ -347,6 +416,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
map: null,
|
||||
readOnly: false,
|
||||
selectedNodeIds: [],
|
||||
selectedRelationId: null,
|
||||
editingNodeId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
@@ -367,6 +437,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
map,
|
||||
readOnly: options?.readOnly ?? false,
|
||||
selectedNodeIds: [],
|
||||
selectedRelationId: null,
|
||||
editingNodeId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
@@ -388,6 +459,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
map: null,
|
||||
readOnly: false,
|
||||
selectedNodeIds: [],
|
||||
selectedRelationId: null,
|
||||
editingNodeId: null,
|
||||
past: [],
|
||||
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 }),
|
||||
setSelectedRelation: (id) =>
|
||||
set({ selectedRelationId: id, ...(id ? { selectedNodeIds: [], editingNodeId: null } : {}) }),
|
||||
|
||||
|
||||
/**
|
||||
* Cree une relation secondaire entre deux noeuds.
|
||||
*
|
||||
* Aucune ecriture sur `parentId`, `order`, `collapsed` ni sur les enfants :
|
||||
* c'est la garantie qu'une relation ne devient jamais une deuxieme
|
||||
* hierarchie. Retourne la raison du refus, ou `null` si la relation a bien
|
||||
* ete creee.
|
||||
*/
|
||||
addRelation: (sourceId, targetId, patch) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return 'unknown';
|
||||
|
||||
const rejection = checkRelation(map.nodes, map.relations, sourceId, targetId);
|
||||
if (rejection) return rejection;
|
||||
|
||||
const relation: MindMapRelation = {
|
||||
id: createRelationId(),
|
||||
sourceId,
|
||||
targetId,
|
||||
...(patch ?? {}),
|
||||
};
|
||||
|
||||
commit((current) => ({ ...current, relations: [...current.relations, relation] }));
|
||||
set({ selectedRelationId: relation.id, selectedNodeIds: [], editingNodeId: null });
|
||||
return null;
|
||||
},
|
||||
|
||||
updateRelation: (id, patch) => {
|
||||
commit((current) => {
|
||||
const index = current.relations.findIndex((relation) => relation.id === id);
|
||||
if (index === -1) return current;
|
||||
const relations = [...current.relations];
|
||||
// `id`, `sourceId` et `targetId` restent maitres : on ne peut pas
|
||||
// deplacer une extremite par une simple mise a jour de style.
|
||||
const existing = relations[index] as MindMapRelation;
|
||||
relations[index] = {
|
||||
...existing,
|
||||
...patch,
|
||||
id: existing.id,
|
||||
sourceId: existing.sourceId,
|
||||
targetId: existing.targetId,
|
||||
};
|
||||
return { ...current, relations };
|
||||
});
|
||||
},
|
||||
|
||||
deleteRelation: (id) => {
|
||||
const state = get();
|
||||
if (!state.map || state.readOnly) return;
|
||||
if (!state.map.relations.some((relation) => relation.id === id)) return;
|
||||
|
||||
// Supprimer une relation ne touche a aucun noeud.
|
||||
commit((current) => ({
|
||||
...current,
|
||||
relations: current.relations.filter((relation) => relation.id !== id),
|
||||
}));
|
||||
set({ selectedRelationId: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Change le cote d'une branche de niveau 1.
|
||||
*
|
||||
* Rien ne bouge a l'ecran : conformement au reste de l'auto-layout, les
|
||||
* positions manuelles ne sont jamais ecrasees sans que l'utilisateur
|
||||
* demande explicitement une reorganisation.
|
||||
*/
|
||||
setNodeSide: (nodeId, side) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return false;
|
||||
|
||||
const node = map.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (!node || !node.parentId) return false;
|
||||
const parent = map.nodes.find((candidate) => candidate.id === node.parentId);
|
||||
// Seule une branche de niveau 1 porte un cote : ailleurs il est herite.
|
||||
if (!parent || parent.parentId) return false;
|
||||
if (node.side === side) return true;
|
||||
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((candidate) =>
|
||||
candidate.id === nodeId ? { ...candidate, side } : candidate,
|
||||
),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Materialise les cotes manquants. Retourne le nombre de branches traitees. */
|
||||
balanceSides: () => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return 0;
|
||||
|
||||
const nodes = materializeSides(map.nodes);
|
||||
if (nodes === map.nodes) return 0;
|
||||
|
||||
let changed = 0;
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
if (nodes[index] !== map.nodes[index]) changed += 1;
|
||||
}
|
||||
|
||||
commit((current) => withEdges(current, nodes));
|
||||
return changed;
|
||||
},
|
||||
|
||||
pushHistory: () => {
|
||||
const state = get();
|
||||
@@ -443,7 +629,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined;
|
||||
|
||||
const horizontal = isHorizontalLayout(map.settings.layout);
|
||||
const direction = layoutDirection(map.settings.layout);
|
||||
const direction = childDirection(map, parent);
|
||||
|
||||
const child = parent
|
||||
? createNode({
|
||||
@@ -523,7 +709,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
|
||||
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
|
||||
const horizontal = isHorizontalLayout(map.settings.layout);
|
||||
const direction = layoutDirection(map.settings.layout);
|
||||
const direction = childDirection(map, parent);
|
||||
const startOrder = nextSiblingOrder(map.nodes, parent?.id ?? null);
|
||||
|
||||
const positions = parent
|
||||
@@ -561,7 +747,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
|
||||
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
|
||||
const horizontal = isHorizontalLayout(map.settings.layout);
|
||||
const direction = layoutDirection(map.settings.layout);
|
||||
const direction = childDirection(map, parent);
|
||||
const created: MindMapNode[] = [];
|
||||
|
||||
const insert = (
|
||||
@@ -585,6 +771,8 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
type: !anchorNode && map.nodes.length === 0 && created.length === 0 ? 'root' : 'standard',
|
||||
order: baseOrder + index,
|
||||
position,
|
||||
// L'import Markdown range les paragraphes dans la note du noeud.
|
||||
...(outline.note ? { note: outline.note } : {}),
|
||||
});
|
||||
created.push(node);
|
||||
if (outline.children.length > 0) insert(outline.children, node, depth + 1, 0);
|
||||
@@ -844,22 +1032,33 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
|
||||
const branchIds = new Set(withDescendants(map.nodes, [id]));
|
||||
const source = map.nodes.filter((node) => branchIds.has(node.id));
|
||||
const clones = cloneSubtrees(source, 40);
|
||||
const { nodes: clones, idMapping } = cloneSubtrees(source, 40);
|
||||
const cloneRoot = clones[0] as MindMapNode;
|
||||
|
||||
/*
|
||||
* Les relations entierement contenues dans la branche sont dupliquees
|
||||
* avec elle : la copie est autonome. Une relation qui sort de la branche
|
||||
* ne l'est pas, faute de savoir si la copie doit viser la meme cible.
|
||||
*/
|
||||
const internal = remapRelations(relationsWithin(map.relations, branchIds), idMapping);
|
||||
|
||||
commit((current) =>
|
||||
withEdges(current, [
|
||||
...current.nodes,
|
||||
...clones.map((node) =>
|
||||
node.id === cloneRoot.id
|
||||
? {
|
||||
...node,
|
||||
text: node.text ? `${node.text} copie` : node.text,
|
||||
order: nextSiblingOrder(current.nodes, root.parentId),
|
||||
}
|
||||
: node,
|
||||
),
|
||||
]),
|
||||
withEdges(
|
||||
current,
|
||||
[
|
||||
...current.nodes,
|
||||
...clones.map((node) =>
|
||||
node.id === cloneRoot.id
|
||||
? {
|
||||
...node,
|
||||
text: node.text ? `${node.text} copie` : node.text,
|
||||
order: nextSiblingOrder(current.nodes, root.parentId),
|
||||
}
|
||||
: node,
|
||||
),
|
||||
],
|
||||
[...current.relations, ...internal],
|
||||
),
|
||||
);
|
||||
set({ selectedNodeIds: [cloneRoot.id], editingNodeId: null });
|
||||
return clones.length;
|
||||
@@ -873,7 +1072,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
|
||||
const branchIds = new Set(withDescendants(map.nodes, [id]));
|
||||
const nodes = map.nodes.filter((node) => branchIds.has(node.id)).map((node) => ({ ...node }));
|
||||
set({ clipboard: { nodes } });
|
||||
set({ clipboard: { nodes, relations: relationsWithin(map.relations, branchIds) } });
|
||||
return nodes.length;
|
||||
},
|
||||
|
||||
@@ -885,18 +1084,23 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
|
||||
if (!map.nodes.some((node) => node.id === parentId)) return 0;
|
||||
|
||||
const clones = cloneSubtrees(clipboard.nodes, 40, parentId);
|
||||
const { nodes: clones, idMapping } = cloneSubtrees(clipboard.nodes, 40, parentId);
|
||||
const internal = remapRelations(clipboard.relations, idMapping);
|
||||
let order = nextSiblingOrder(map.nodes, parentId);
|
||||
|
||||
commit((current) =>
|
||||
withEdges(current, [
|
||||
...current.nodes.map((node) =>
|
||||
node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node,
|
||||
),
|
||||
...clones.map((node) =>
|
||||
node.parentId === parentId ? { ...node, order: order++ } : node,
|
||||
),
|
||||
]),
|
||||
withEdges(
|
||||
current,
|
||||
[
|
||||
...current.nodes.map((node) =>
|
||||
node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node,
|
||||
),
|
||||
...clones.map((node) =>
|
||||
node.parentId === parentId ? { ...node, order: order++ } : node,
|
||||
),
|
||||
],
|
||||
[...current.relations, ...internal],
|
||||
),
|
||||
);
|
||||
set({ selectedNodeIds: [clones[0]?.id as string], editingNodeId: null });
|
||||
return clones.length;
|
||||
@@ -1407,7 +1611,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
|
||||
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
|
||||
const nodes = map.nodes.filter((node) => ids.has(node.id));
|
||||
set({ clipboard: { nodes: nodes.map((node) => ({ ...node })) } });
|
||||
set({
|
||||
clipboard: {
|
||||
nodes: nodes.map((node) => ({ ...node })),
|
||||
relations: relationsWithin(map.relations, ids),
|
||||
},
|
||||
});
|
||||
return nodes.length;
|
||||
},
|
||||
|
||||
@@ -1418,9 +1627,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
|
||||
|
||||
const target = state.selectedNodeIds[0] ?? null;
|
||||
const clones = cloneSubtrees(clipboard.nodes, 40, target);
|
||||
const { nodes: clones, idMapping } = cloneSubtrees(clipboard.nodes, 40, target);
|
||||
const internal = remapRelations(clipboard.relations, idMapping);
|
||||
|
||||
commit((current) => withEdges(current, [...current.nodes, ...clones]));
|
||||
commit((current) =>
|
||||
withEdges(current, [...current.nodes, ...clones], [...current.relations, ...internal]),
|
||||
);
|
||||
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
|
||||
return clones.length;
|
||||
},
|
||||
@@ -1433,9 +1645,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
|
||||
const source = map.nodes.filter((node) => ids.has(node.id));
|
||||
// parentOverride non fourni : chaque copie reste au meme niveau que l'original.
|
||||
const clones = cloneSubtrees(source, 40);
|
||||
const { nodes: clones, idMapping } = cloneSubtrees(source, 40);
|
||||
const internal = remapRelations(relationsWithin(map.relations, ids), idMapping);
|
||||
|
||||
commit((current) => withEdges(current, [...current.nodes, ...clones]));
|
||||
commit((current) =>
|
||||
withEdges(current, [...current.nodes, ...clones], [...current.relations, ...internal]),
|
||||
);
|
||||
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
|
||||
return clones.length;
|
||||
},
|
||||
@@ -1447,13 +1662,23 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
|
||||
const target = layout ?? map.settings.layout;
|
||||
const effectiveSpacing = spacing ?? map.settings.layoutSpacing;
|
||||
const positions = computeLayout(map.nodes, target, {
|
||||
|
||||
/*
|
||||
* Passage en bilaterale : les branches qui n'ont pas encore de cote en
|
||||
* recoivent un ici, dans la meme entree d'historique que le deplacement.
|
||||
* Un cote choisi a la main n'est jamais ecrase.
|
||||
*/
|
||||
const nodes = target === 'mindmap-both' ? materializeSides(map.nodes) : map.nodes;
|
||||
|
||||
const positions = computeLayout(nodes, target, {
|
||||
measuredSizes: state.measuredSizes,
|
||||
spacing: effectiveSpacing,
|
||||
theme: getTheme(map.settings.theme),
|
||||
});
|
||||
if (positions.size === 0) return;
|
||||
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
|
||||
commit((current) => ({
|
||||
...current,
|
||||
settings: {
|
||||
@@ -1462,8 +1687,9 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
...(spacing ? { layoutSpacing: spacing } : {}),
|
||||
},
|
||||
nodes: current.nodes.map((node) => {
|
||||
const withSide = byId.get(node.id) ?? node;
|
||||
const position = positions.get(node.id);
|
||||
return position ? { ...node, position } : node;
|
||||
return position ? { ...withSide, position } : withSide;
|
||||
}),
|
||||
}));
|
||||
set({ fitRequest: get().fitRequest + 1 });
|
||||
@@ -1499,11 +1725,26 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
}
|
||||
|
||||
const needle = normalizeText(trimmed);
|
||||
/*
|
||||
* Le libelle d'une relation est aussi du contenu : chercher "protege"
|
||||
* doit ramener les deux noeuds qu'elle relie. La recherche continue de ne
|
||||
* renvoyer que des noeuds, donc la navigation entre resultats, le
|
||||
* deploiement des branches et le centrage restent inchanges.
|
||||
*/
|
||||
const fromRelations = new Set<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
|
||||
.filter(
|
||||
(node) =>
|
||||
normalizeText(node.text).includes(needle) ||
|
||||
(node.note ? normalizeText(node.note).includes(needle) : false),
|
||||
(node.note ? normalizeText(node.note).includes(needle) : false) ||
|
||||
fromRelations.has(node.id),
|
||||
)
|
||||
.map((node) => node.id);
|
||||
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
* lecteur v2 l'ignorerait silencieusement en reordonnant la carte.
|
||||
* La migration v2 -> v3 materialise l'ordre depuis la position dans le
|
||||
* tableau `nodes`, ce qui reproduit exactement l'affichage precedent.
|
||||
* v4 : mind map bilaterale (`MindMapNode.side`) et relations secondaires
|
||||
* (`MindMap.relations`). Le bump est justifie : un lecteur v3 ignorerait
|
||||
* les relations, donc perdrait silencieusement une partie du document.
|
||||
* La migration v3 -> v4 n'a rien a inventer : aucun cote defini signifie
|
||||
* "reparti automatiquement", et aucune relation signifie "aucune".
|
||||
*/
|
||||
export const MAP_FORMAT_VERSION = 3;
|
||||
export const MAP_FORMAT_VERSION = 4;
|
||||
|
||||
/**
|
||||
* Types de noeuds.
|
||||
@@ -33,6 +38,9 @@ export const EDITABLE_NODE_KINDS: NodeKind[] = ['root', 'standard'];
|
||||
|
||||
export type NodeShape = 'rectangle' | 'rounded' | 'capsule' | 'circle';
|
||||
|
||||
/** Cote d'une branche de niveau 1 en mind map bilaterale. */
|
||||
export type NodeSide = 'left' | 'right';
|
||||
|
||||
export type TextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export type VerticalAlign = 'top' | 'middle' | 'bottom';
|
||||
@@ -117,6 +125,15 @@ export interface MindMapNode {
|
||||
* position : il n'y a jamais deux sources contradictoires.
|
||||
*/
|
||||
order?: number;
|
||||
/**
|
||||
* Cote d'une branche en mind map bilaterale (v4).
|
||||
*
|
||||
* N'a de sens que sur un enfant direct d'une racine : c'est la branche de
|
||||
* niveau 1 qui choisit son cote, tous ses descendants en heritent. Le champ
|
||||
* n'est donc jamais ecrit ailleurs, et il est ignore s'il s'y trouve.
|
||||
* Absent = a repartir automatiquement.
|
||||
*/
|
||||
side?: NodeSide;
|
||||
/** Branche repliee : les descendants sont masques. */
|
||||
collapsed?: boolean;
|
||||
/** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */
|
||||
@@ -150,6 +167,34 @@ export interface MindMapEdge {
|
||||
|
||||
export type BranchStyle = 'straight' | 'curved' | 'bezier';
|
||||
|
||||
/** Extremite d'une relation secondaire. */
|
||||
export type RelationMarker = 'none' | 'arrow';
|
||||
|
||||
/**
|
||||
* Relation secondaire entre deux noeuds (v4).
|
||||
*
|
||||
* C'est une connexion purement visuelle : elle n'a aucun effet sur `parentId`,
|
||||
* `order`, les enfants, le repli ou l'auto-layout. La hierarchie reste la seule
|
||||
* structure de la carte ; une relation ne doit jamais devenir une deuxieme
|
||||
* hierarchie parallele.
|
||||
*/
|
||||
export interface MindMapRelation {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
/** Texte libre affiche au milieu du trait. Aucun vocabulaire impose. */
|
||||
label?: string;
|
||||
/** Trace du trait. Absent = bezier. */
|
||||
style?: BranchStyle;
|
||||
color?: string;
|
||||
width?: number;
|
||||
/** Trait discontinu. Absent = pointille : c'est le defaut d'une relation. */
|
||||
dashed?: boolean;
|
||||
markerStart?: RelationMarker;
|
||||
/** Absent = fleche : une relation se lit par defaut de la source vers la cible. */
|
||||
markerEnd?: RelationMarker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispositions d'auto-layout.
|
||||
* `mindmap-both`, `organization` et `freeform` sont reserves : ils sont
|
||||
@@ -206,7 +251,16 @@ export interface MindMap {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nodes: MindMapNode[];
|
||||
/**
|
||||
* Liens hierarchiques, entierement derives de `parentId`. Ils sont recalcules
|
||||
* a chaque modification : ce ne sont pas une source de verite.
|
||||
*/
|
||||
edges: MindMapEdge[];
|
||||
/**
|
||||
* Relations secondaires (v4). Elles sont, elles, une donnee a part entiere :
|
||||
* rien ne permet de les deduire de la hierarchie.
|
||||
*/
|
||||
relations: MindMapRelation[];
|
||||
viewport: Viewport;
|
||||
settings: MapSettings;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user