Compare commits

...

5 Commits

Author SHA1 Message Date
e3c530348e 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>
2026-09-10 08:12:00 +02:00
2afbf637ce Phase 1.2 : productivite de creation
Construire une grande carte demande beaucoup moins de gestes.

Ordre des freres (format v3)
`MindMapNode.order` devient la position parmi les freres. `parentId` reste la
seule source de verite de la relation hierarchique : une source par
preoccupation, jamais deux versions contradictoires. L'ordre est renumerote a
chaque modification structurelle dans `withEdges`, unique point de passage.
Le format passe en v3 : un lecteur v2 ignorerait `order` et reordonnerait
silencieusement la carte. Migration v2 -> v3 depuis la position dans le tableau,
v1 -> v2 -> v3 s'enchainent, et les noeuds sont ecrits en parcours de profondeur
pour rester corrects meme pour un lecteur anterieur.

Creation rapide
- Ajouter plusieurs enfants : une ligne = un noeud, avec apercu avant creation.
- Collage d'une liste multi-ligne sur un noeud : le presse-papiers interne reste
  prioritaire, l'edition de texte n'est jamais interceptee.
- Import d'une arborescence indentee : espaces ou tabulations, puces retirees,
  indentation incoherente signalee avec le numero de ligne plutot que devinee.
- Les nouveaux noeuds sont repartis autour du parent au lieu de se superposer.

Hierarchie
Monter, descendre, placer en premier ou en dernier, remonter d'un niveau,
"Deplacer vers..." avec recherche, suppression d'un noeud en conservant ses
enfants a la place qu'il occupait. Le re-parentage passe toujours par
`reparentNode` : la detection de cycle n'est ecrite qu'une fois.

Branches
Selectionner, dupliquer, copier, coller comme enfant, deplacer avec ses
descendants (reglage, ou Alt pour l'inverser le temps d'un glisser), replier et
developper une branche, tout replier, tout developper, replier au-dela d'un
niveau.

Clavier
Maj+Tab remonte d'un niveau, Maj+Entree ouvre la creation multiple, Alt+Haut et
Alt+Bas reordonnent. Tab, Entree et F2 gardent leur comportement.

Historique
Chaque operation logique reste une seule entree : creation multiple, import
d'outline, reordonnancement, deplacement de branche, duplication, collage,
suppression, tout replier.

Tests : 172 vitest (103 -> 172) et 36 pytest (31 -> 36).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:35:57 +02:00
a23a68629d Phase 1.1.1 : consolidation et coherence UX
Trois corrections ciblees, sans changement d'architecture ni d'interface.

1. Le style ne transporte plus l'icone
L'icone d'un noeud est du contenu, comme son texte ou sa note. Copier le style
de "Reseau" et le coller sur "Stockage" et "Securite" applique desormais les
couleurs, la forme et la mise en page en laissant chaque noeud avec sa propre
icone. Seule la presentation de l'icone (position, taille, espacement) reste
une propriete de style.

2. Le cercle conserve un rapport 1:1
`keepAspectRatio` sur le redimensionneur, et contrainte cote store pour les
trois autres chemins : redimensionnement direct, saisie d'une largeur ou d'une
hauteur dans le panneau, et conversion d'un noeud rectangulaire en cercle.
Un cercle ne peut plus devenir un ovale.

3. Point d'entree unique des documents
`normalizeMapDocument()` remplace `parseMapDocument()` et devient le seul
chemin par lequel une carte entre dans l'editeur, qu'elle vienne d'un fichier
.mmap, d'IndexedDB ou d'un partage. Le pipeline est toujours validation ->
migration -> normalisation. Les cartes locales passaient jusqu'ici a cote de la
migration : elles y passent maintenant a l'ouverture, sans etre reecrites en
base tant que l'utilisateur ne les modifie pas. Une carte locale videe de ses
noeuds reste ouvrable, et le message d'erreur s'adapte a la provenance.

Egalement
- Zoom minimum abaisse de 0.1 a 0.02 : "Adapter a l'ecran" butait sur ce
  plancher et ne montrait pas toute une carte de 181 noeuds.
- README : pipeline de normalisation, comportement du style et du cercle,
  limitations mesurees.

Tests : 103 vitest (85 -> 103) et 31 pytest, ruff, eslint et tsc au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:48:22 +02:00
bf83292e65 Regrouper les notifications de mesure en un seul rendu
Chaque noeud remonte sa taille reelle apres rendu. Sur une carte de 120 noeuds,
appliquer un reglage a toute la selection declenchait autant d'increments de
`sizeVersion` que de noeuds redimensionnes, donc autant de rendus successifs.

Les increments sont desormais regroupes dans une microtache : un seul rendu par
lot. Mesure sur 121 noeuds, onglet en arriere-plan (donc pessimiste) :
application d'un padding a toute la selection de ~730 ms a ~500 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:36:06 +02:00
af6fd519c5 Phase 1.1 : mise en page et edition visuelle des noeuds
Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite
retiree, compatibilite .mmap v1 et IndexedDB preservee.

Modele (format v2)
- MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels.
- NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow.
- MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides.
- Un champ absent signifie "herite du theme" : rien n'est ecrit dans le
  document pour materialiser un defaut, donc les cartes deja stockees restent
  lisibles telles quelles. Migration v1 -> v2 : style.width devient
  layout.width, toujours lu en repli par le resolveur.

Mise en page
- Nouveau resolveur de style qui fusionne theme et personnalisations.
- Taille automatique fidele au contenu, taille personnalisee bornee,
  redimensionnement a la souris via NodeResizer.
- Padding horizontal et vertical, retour a la ligne, largeur maximale du texte,
  interligne, police, alignement horizontal et vertical, ombre.
- Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et
  espacement configurables.

Multi-selection
- Barre contextuelle d'alignement (6 axes) et de distribution (2 axes).
- Application d'un reglage a toute la selection, avec etat "Mixte".

Style
- Copier / coller / reinitialiser le style, au clavier et par clic droit.

Auto-layout
- Espacement parent/enfant et entre branches configurable, presets Compact,
  Normal et Aere. Le layout reste declenche uniquement a la demande.

Historique
- Une seule entree par redimensionnement complet, par alignement, par
  distribution, par application multiple et par collage de style.

Panneau Proprietes
- Reorganise en cinq sections repliables dont l'etat est conserve pendant la
  session. Vraies icones d'alignement, tooltips et aria-labels partout.

Corrections
- Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la
  largeur min-content a un caractere, ce qui faisait s'effondrer tous les
  noeuds automatiques.
- Largeur auto en max-content bornee, pour qu'une phrase longue occupe la
  largeur maximale au lieu de former une colonne etroite.

Tests
- 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:29:48 +02:00
73 changed files with 10978 additions and 580 deletions

226
README.md
View File

@@ -105,6 +105,7 @@ Choix structurants :
| Sujet | Choix | Pourquoi |
|---|---|---|
| Hierarchie | `parentId` fait autorite, les liens en sont derives | un seul invariant a maintenir, pas de desynchronisation |
| Ordre des freres | `node.order`, renumerote a chaque modification | une source par preoccupation : la relation d'un cote, la position de l'autre |
| Modele | types metier propres, adaptes vers React Flow a l'affichage | le format de fichier ne depend pas de la bibliotheque de rendu |
| Historique | instantanes `{nodes, edges}`, groupes par action | un glisser-deposer = une seule annulation |
| Auto-layout | dagre, declenche uniquement a la demande | les deplacements manuels ne sont jamais ecrases |
@@ -226,11 +227,70 @@ 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.
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.
**Proprietes du noeud** : texte, couleurs de fond/texte/bordure, forme
(rectangle, arrondi, capsule, cercle), taille du texte, gras, italique,
alignement, icone, URL ouverte dans un nouvel onglet, note.
**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 ;
police, taille, gras, italique, alignement horizontal et vertical, interligne ;
couleurs de fond/texte/bordure, forme (rectangle, arrondi, capsule, cercle),
ombre ; taille automatique ou personnalisee, largeur, hauteur, padding
horizontal et vertical, retour a la ligne, largeur maximale du texte ; style,
epaisseur et couleur de la branche. Un reglage s'applique a toute la selection,
avec un etat *Mixte* quand les valeurs different.
**Creation rapide** : *Ajouter plusieurs enfants* (une ligne = un noeud, avec
apercu), collage d'une liste multi-ligne directement sur un noeud, import d'une
arborescence indentee (espaces ou tabulations, puces retirees, indentation
incoherente signalee), ajout avant / apres un frere.
**Hierarchie** : ordre explicite des freres (monter, descendre, placer en
premier / en dernier), remontee d'un niveau, *Deplacer vers...* avec recherche
du nouveau parent, suppression d'un noeud en conservant ses enfants.
**Branches** : selectionner, dupliquer, copier / coller comme enfant, deplacer
sur le canvas avec ses descendants, replier / developper une branche, tout
replier, tout developper, replier au-dela d'un niveau donne.
**Mise en page visuelle** : redimensionnement des noeuds a la souris (une seule
annulation par redimensionnement), taille automatique qui epouse le contenu,
alignement et distribution d'une multi-selection, aimantation sur la grille,
guides d'alignement pendant le deplacement, copier / coller / reinitialiser le
style (Ctrl+Maj+C, Ctrl+Maj+V), menu contextuel par clic droit. Le style couvre
les couleurs, la forme, la typographie, la mise en page, la presentation de
l'icone et la branche ; l'icone elle-meme, comme le texte, la note et l'URL,
appartient au contenu du noeud et n'est jamais remplacee par un collage.
**Bibliotheque d'icones** : emojis et icones integrees (Lucide), avec position
(gauche, droite, au-dessus), taille et espacement configurables.
**Themes** : Default, Minimal, Dark, Midnight, Ocean, Forest, Pastel,
Monochrome, Blueprint. Plus un mode clair/sombre pour l'interface. Changer de
@@ -240,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`.
@@ -265,6 +325,13 @@ l'application chargee.
| `Ctrl+C` | Copier |
| `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer |
| `Ctrl+Maj+C` | Copier le style du noeud (sans son icone) |
| `Ctrl+Maj+V` | Coller le style sur la selection |
| `Maj+Tab` | Remonter le noeud d'un niveau |
| `Maj+Entree` | Ajouter plusieurs enfants |
| `Alt+Haut` / `Alt+Bas` | Monter / descendre parmi les freres |
| `Alt` + glisser | Inverser le mode de deplacement de branche |
| `Ctrl+V` | Coller : noeuds OpenMind, sinon liste de texte en enfants |
| `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite |
| `Ctrl+F` | Rechercher dans la carte |
@@ -278,6 +345,41 @@ l'application chargee.
Un `.mmap` est un fichier **JSON UTF-8** contenant le document complet de la
carte. Il ne contient jamais de jeton de partage.
Le format est en **version 3**.
| Version | Apport | Migration a l'import |
|---|---|---|
| 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, 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 :
```
fichier .mmap ┐
IndexedDB ├─> validation -> migration de version -> normalisation -> modele courant
partage ┘
```
La normalisation est **non destructive** : le document fourni n'est jamais
modifie et rien n'est reecrit en base. Une carte v1 restee en IndexedDB s'ouvre
donc exactement comme avant, et n'est reenregistree au format courant que si
vous la modifiez.
```json
{
"version": 1,
@@ -292,24 +394,72 @@ carte. Il ne contient jamais de jeton de partage.
"text": "Infrastructure",
"position": { "x": 0, "y": 0 },
"parentId": null,
"order": 0,
"side": "right",
"collapsed": false,
"icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr",
"note": "Texte libre",
"style": { "backgroundColor": "#eef2ff", "shape": "rounded" }
"style": {
"backgroundColor": "#eef2ff",
"shape": "rounded",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.35,
"fontFamily": "sans",
"shadow": "soft"
},
"layout": {
"widthMode": "fixed",
"width": 240,
"heightMode": "auto",
"paddingX": 14,
"paddingY": 8,
"wrap": true,
"maxTextWidth": 260
},
"iconStyle": { "kind": "emoji", "position": "left", "size": 16, "gap": 8 },
"branch": { "style": "bezier", "width": 2, "color": "#94a3b8" }
}
],
"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",
"layout": "mindmap-right",
"branchStyle": "bezier",
"showGrid": true
"showGrid": true,
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
"snapToGrid": false,
"snapGrid": 20,
"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
@@ -395,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.
---
@@ -442,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
@@ -490,14 +646,53 @@ 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
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra
l'introduire au premier changement de schema.
- **Guides d'alignement** : ils apparaissent pendant un deplacement lorsqu'un
bord ou un centre coincide (a 6 px pres) avec celui d'un autre noeud. Ils sont
purement visuels : ils n'aimantent pas le noeud. Pour aimanter, activez
*Aimanter sur la grille*.
- **Alignement vertical** : il n'a d'effet visible que si le noeud est plus haut
que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical
important.
- **Cercle** : un noeud circulaire garde toujours un rapport 1:1. Modifier sa
largeur ajuste sa hauteur et inversement ; convertir un noeud rectangulaire en
cercle le ramene a un carre. Il n'est donc pas possible d'obtenir un ovale.
- **Plusieurs racines** : le modele autorise depuis la v1 une carte a plusieurs
racines, et certaines cartes existantes en dependent ; ce n'est donc pas
interdit. Une carte mentale classique n'en a qu'une, et l'interface pousse
dans ce sens : *Detacher* est une action explicite, et un noeud detache
devient une racine a part entiere. Un noeud ne peut jamais devenir son propre
parent ni celui d'un de ses ancetres.
- **Reordonner ne deplace pas les noeuds sur le canvas** : l'ordre est logique.
Il se voit immediatement dans l'ordre des liens et dans le resultat de
*Reorganiser* ; les positions libres, elles, restent celles choisies par
l'utilisateur.
- **Deplacement sur une tres grande carte** : sur une carte de ~180 noeuds, une
frame de glisser-deposer coute environ 60 a 80 ms car React Flow recalcule
l'ensemble des noeuds controles. Le deplacement reste utilisable mais moins
fluide que sur une carte de taille courante.
- **Mobile** : consultation et edition basique fonctionnent, mais l'interface
est concue pour desktop et tablette.
- **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de
@@ -520,6 +715,7 @@ n'est utilisee.
| dagre | MIT |
| html-to-image | MIT |
| jsPDF | MIT |
| lucide-react | ISC |
| Vite, Tailwind CSS, TypeScript, ESLint, Vitest | MIT |
| FastAPI, Starlette, Pydantic, SQLAlchemy, Uvicorn | MIT / BSD |

View File

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

View File

@@ -0,0 +1,120 @@
"""Le backend doit accepter la mise en page v2 sans relacher sa validation."""
from __future__ import annotations
import os
from fastapi.testclient import TestClient
from app.config import get_settings
from tests.conftest import build_map
RICH_NODE = {
"id": "styled",
"type": "standard",
"text": "Firewall",
"position": {"x": 120, "y": 40},
"parentId": "root",
"icon": "server",
"iconStyle": {"kind": "builtin", "position": "top", "size": 24, "gap": 12},
"style": {
"backgroundColor": "#112233",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.6,
"fontFamily": "mono",
"shadow": "strong",
},
"layout": {
"widthMode": "fixed",
"width": 320,
"heightMode": "fixed",
"height": 140,
"paddingX": 28,
"paddingY": 18,
"wrap": False,
"maxTextWidth": 400,
},
"branch": {"style": "straight", "width": 4, "color": "#ff8800"},
}
def build_v2_map() -> dict:
payload = build_map(node_count=2, name="Infrastructure")
payload["version"] = 2
payload["nodes"].append(RICH_NODE)
payload["edges"].append({"id": "e-root-styled", "source": "root", "target": "styled"})
payload["settings"]["layoutSpacing"] = {"parentGap": 140, "siblingGap": 80}
payload["settings"]["snapToGrid"] = True
payload["settings"]["snapGrid"] = 10
payload["settings"]["alignmentGuides"] = False
return payload
def test_share_accepts_layout_properties(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
assert response.status_code == 201, response.text
def test_shared_map_is_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 2
node = next(item for item in public["map"]["nodes"] if item["id"] == "styled")
assert node["layout"] == RICH_NODE["layout"]
assert node["style"] == RICH_NODE["style"]
assert node["iconStyle"] == RICH_NODE["iconStyle"]
assert node["branch"] == RICH_NODE["branch"]
assert node["icon"] == "server"
assert node["position"] == {"x": 120, "y": 40}
settings = public["map"]["settings"]
assert settings["layoutSpacing"] == {"parentGap": 140, "siblingGap": 80}
assert settings["snapToGrid"] is True
assert settings["alignmentGuides"] is False
def test_version_1_maps_are_still_accepted(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v1", "map": build_map()})
assert response.status_code == 201
assert response.json()["share_token"]
def test_node_limit_still_applies_to_v2_maps(client: TestClient) -> None:
os.environ["MAX_NODES"] = "5"
get_settings.cache_clear()
try:
payload = build_v2_map()
for index in range(20):
payload["nodes"].append(
{
"id": f"extra{index}",
"type": "standard",
"text": "x",
"position": {"x": 0, "y": 0},
"parentId": "root",
"layout": {"paddingX": 10},
}
)
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
finally:
os.environ["MAX_NODES"] = "5000"
get_settings.cache_clear()
def test_unknown_top_level_field_is_still_rejected(client: TestClient) -> None:
response = client.post(
"/api/shares", json={"title": "x", "map": build_v2_map(), "premium": True}
)
assert response.status_code == 422
def test_node_without_id_is_still_rejected(client: TestClient) -> None:
payload = build_v2_map()
payload["nodes"].append({"text": "sans id", "layout": {"paddingX": 4}})
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422

View File

@@ -0,0 +1,111 @@
"""Le backend doit accepter et restituer l'ordre des freres (format v3)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import build_map
def build_v3_map() -> dict:
payload = build_map(node_count=1, name="Reseau")
payload["version"] = 3
payload["nodes"] = [
{
"id": "root",
"type": "root",
"text": "Reseau",
"position": {"x": 0, "y": 0},
"parentId": None,
"order": 0,
},
{
"id": "vlan",
"type": "standard",
"text": "VLAN",
"position": {"x": 200, "y": 0},
"parentId": "root",
"order": 0,
},
{
"id": "vpn",
"type": "standard",
"text": "VPN",
"position": {"x": 200, "y": 60},
"parentId": "root",
"order": 1,
},
{
"id": "dns",
"type": "standard",
"text": "DNS",
"position": {"x": 200, "y": 120},
"parentId": "root",
"order": 2,
},
]
payload["edges"] = [
{"id": "e-root-vlan", "source": "root", "target": "vlan"},
{"id": "e-root-vpn", "source": "root", "target": "vpn"},
{"id": "e-root-dns", "source": "root", "target": "dns"},
]
payload["settings"]["dragMovesBranch"] = True
return payload
def test_share_accepts_v3_map(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v3", "map": build_v3_map()})
assert response.status_code == 201, response.text
def test_child_order_is_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v3", "map": build_v3_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 3
orders = {node["id"]: node.get("order") for node in public["map"]["nodes"]}
assert orders == {"root": 0, "vlan": 0, "vpn": 1, "dns": 2}
assert public["map"]["settings"]["dragMovesBranch"] is True
def test_reordered_update_is_visible_publicly(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v3", "map": build_v3_map()})
body = created.json()
reordered = build_v3_map()
for node in reordered["nodes"]:
if node["id"] == "dns":
node["order"] = 0
elif node["id"] == "vlan":
node["order"] = 2
response = client.put(
"/api/shares/" + body["share_token"],
json={"title": "v3", "map": reordered},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
public = client.get("/api/shares/" + body["share_token"]).json()
orders = {node["id"]: node.get("order") for node in public["map"]["nodes"]}
assert orders["dns"] == 0
assert orders["vlan"] == 2
def test_older_versions_are_still_accepted(client: TestClient) -> None:
for version in (1, 2):
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
def test_invalid_order_type_is_rejected_or_ignored(client: TestClient) -> None:
"""Un ordre non numerique ne doit jamais faire tomber le serveur."""
payload = build_v3_map()
payload["nodes"][1]["order"] = "premier"
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code in (201, 422)

View File

@@ -0,0 +1,149 @@
"""Le backend doit accepter le format v4 : cotes de branche et relations."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import build_map
def build_v4_map() -> dict:
payload = build_map(node_count=1, name="Infrastructure")
payload["version"] = 4
payload["nodes"] = [
{
"id": "root",
"type": "root",
"text": "Infrastructure",
"position": {"x": 0, "y": 0},
"parentId": None,
"order": 0,
},
{
"id": "reseau",
"type": "standard",
"text": "Reseau",
"position": {"x": 240, "y": 0},
"parentId": "root",
"order": 0,
"side": "right",
},
{
"id": "firewall",
"type": "standard",
"text": "Firewall",
"position": {"x": 460, "y": 0},
"parentId": "reseau",
"order": 0,
},
{
"id": "services",
"type": "standard",
"text": "Services",
"position": {"x": -240, "y": 0},
"parentId": "root",
"order": 1,
"side": "left",
},
{
"id": "web",
"type": "standard",
"text": "Web",
"position": {"x": -460, "y": 0},
"parentId": "services",
"order": 0,
},
]
payload["edges"] = [
{"id": "e-root-reseau", "source": "root", "target": "reseau"},
{"id": "e-reseau-firewall", "source": "reseau", "target": "firewall"},
{"id": "e-root-services", "source": "root", "target": "services"},
{"id": "e-services-web", "source": "services", "target": "web"},
]
payload["relations"] = [
{
"id": "r-1",
"sourceId": "firewall",
"targetId": "web",
"label": "protege",
"style": "bezier",
"color": "#a855f7",
"width": 2,
"dashed": True,
"markerEnd": "arrow",
}
]
payload["settings"]["layout"] = "mindmap-both"
return payload
def test_share_accepts_v4_map(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
assert response.status_code == 201, response.text
def test_sides_and_relations_are_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 4
assert public["map"]["settings"]["layout"] == "mindmap-both"
sides = {node["id"]: node.get("side") for node in public["map"]["nodes"]}
assert sides["reseau"] == "right"
assert sides["services"] == "left"
# Le cote n'est porte que par les branches de niveau 1.
assert sides["firewall"] is None
relations = public["map"]["relations"]
assert len(relations) == 1
assert relations[0]["sourceId"] == "firewall"
assert relations[0]["targetId"] == "web"
assert relations[0]["label"] == "protege"
assert relations[0]["dashed"] is True
def test_relation_does_not_alter_hierarchy(client: TestClient) -> None:
"""Une relation ne doit jamais devenir un lien parent / enfant."""
created = client.post("/api/shares", json={"title": "v4", "map": build_v4_map()})
public = client.get("/api/shares/" + created.json()["share_token"]).json()
parents = {node["id"]: node.get("parentId") for node in public["map"]["nodes"]}
assert parents["firewall"] == "reseau"
assert parents["web"] == "services"
edge_pairs = {(edge["source"], edge["target"]) for edge in public["map"]["edges"]}
assert ("firewall", "web") not in edge_pairs
def test_self_relation_is_rejected(client: TestClient) -> None:
payload = build_v4_map()
payload["relations"][0]["targetId"] = payload["relations"][0]["sourceId"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
def test_relation_without_endpoints_is_rejected(client: TestClient) -> None:
payload = build_v4_map()
del payload["relations"][0]["targetId"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
def test_map_without_relations_is_still_accepted(client: TestClient) -> None:
payload = build_v4_map()
del payload["relations"]
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 201, response.text
def test_older_versions_are_still_accepted(client: TestClient) -> None:
for version in (1, 2, 3):
payload = build_map()
payload["version"] = version
response = client.post("/api/shares", json={"title": f"v{version}", "map": payload})
assert response.status_code == 201, response.text

View File

@@ -14,6 +14,7 @@
"dexie": "^4.0.10",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.2",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2"
@@ -3645,6 +3646,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.462.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -18,6 +18,7 @@
"dexie": "^4.0.10",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.2",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2"

View File

@@ -235,6 +235,14 @@ export const MapIcon = (p: IconProps) => (
</Svg>
);
export const FoldIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 6h16M4 18h16" />
<path d="M12 9v-3l-2 2M12 6l2 2" />
<path d="M12 15v3l-2-2M12 18l2-2" />
</Svg>
);
export const SpinnerIcon = ({ className }: IconProps) => (
<svg
viewBox="0 0 24 24"

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import {
OutlineError,
looksIndented,
parseOutline,
toFlatLines,
type OutlineNode,
} from '@/services/outlineParser';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
export interface AddChildrenDialogProps {
open: boolean;
parentId: string | null;
/** Texte pre-rempli, par exemple le contenu colle. */
initialText?: string;
onClose: () => void;
}
interface Analysis {
mode: 'flat' | 'outline';
roots: OutlineNode[];
count: number;
error: string | null;
}
function analyse(text: string, maxNodes: number): Analysis {
if (text.trim().length === 0) {
return { mode: 'flat', roots: [], count: 0, error: null };
}
if (!looksIndented(text)) {
const lines = toFlatLines(text);
return {
mode: 'flat',
roots: lines.map((line) => ({ text: line, children: [] })),
count: lines.length,
error: lines.length > maxNodes ? `Maximum ${maxNodes} noeuds.` : null,
};
}
try {
const result = parseOutline(text, maxNodes);
return { mode: 'outline', roots: result.roots, count: result.count, error: null };
} catch (error) {
return {
mode: 'outline',
roots: [],
count: 0,
error: error instanceof OutlineError ? error.message : "Ce texte 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="truncate text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
</li>
))}
</ul>
);
}
/**
* Creation rapide de plusieurs noeuds.
*
* Une ligne = un enfant. Si le texte est indente, la hierarchie est reprise
* telle quelle. L'apercu montre exactement ce qui sera cree.
*/
export function AddChildrenDialog({ open, parentId, initialText, onClose }: AddChildrenDialogProps) {
const addChildren = useEditorStore((state) => state.addChildren);
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('');
useEffect(() => {
if (open) setText(initialText ?? '');
}, [open, initialText]);
const analysis = useMemo(() => analyse(text, maxNodes), [text, maxNodes]);
const canSubmit = analysis.count > 0 && analysis.error === null;
const submit = () => {
if (!canSubmit) return;
const created =
analysis.mode === 'outline'
? insertOutline(parentId, analysis.roots)
: addChildren(
parentId,
analysis.roots.map((node) => node.text),
);
if (created > 0) {
pushToast(`${created} noeud(s) ajoute(s).`, 'success');
onClose();
}
};
return (
<Modal
open={open}
title="Ajouter plusieurs enfants"
description={
parentName
? `Une ligne = un enfant de "${parentName}". Indentez pour creer une arborescence.`
: 'Une ligne = un noeud. Indentez pour creer une arborescence.'
}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
{analysis.count > 0 ? `Creer ${analysis.count} noeud(s)` : 'Creer'}
</Button>
</>
}
>
<div className="space-y-3">
<div>
<label className="field-label" htmlFor="add-children-text">
Contenu
</label>
<textarea
id="add-children-text"
autoFocus
spellCheck={false}
className="field-input min-h-[180px] resize-y font-mono text-xs leading-relaxed"
placeholder={'VLAN\nVPN\nFirewall\n\nou avec indentation :\nReseau\n VLAN\n VPN'}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
}}
/>
<p className="mt-1 text-[11px] text-zinc-400">
Les lignes vides sont ignorees, les puces (-, *, 1.) sont retirees. Ctrl+Entree valide.
</p>
</div>
{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}
{analysis.count > 0 && !analysis.error ? (
<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.mode === 'outline' ? '(arborescence)' : '(liste)'}
</p>
<div className="max-h-48 overflow-y-auto">
<PreviewTree nodes={analysis.roots} />
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,50 @@
import { ViewportPortal, useStore } from '@xyflow/react';
import { type AlignmentGuide } from '@/features/editor/alignmentGuides';
const GUIDE_COLOR = '#f59e0b';
/**
* Rendu des guides dans le repere du canvas.
* L'epaisseur est compensee par le zoom pour rester fine a l'ecran.
*/
export function AlignmentGuidesLayer({ guides }: { guides: AlignmentGuide[] }) {
const zoom = useStore((state) => state.transform[2]);
if (guides.length === 0) return null;
const thickness = Math.max(0.5, 1 / zoom);
return (
<ViewportPortal>
{guides.map((guide) => {
const vertical = guide.orientation === 'vertical';
const margin = 24;
return (
<div
key={`${guide.orientation}-${guide.position}`}
aria-hidden="true"
style={{
position: 'absolute',
pointerEvents: 'none',
background: GUIDE_COLOR,
opacity: 0.85,
zIndex: 5,
...(vertical
? {
left: guide.position,
top: guide.start - margin,
width: thickness,
height: guide.end - guide.start + margin * 2,
}
: {
left: guide.start - margin,
top: guide.position,
height: thickness,
width: guide.end - guide.start + margin * 2,
}),
}}
/>
);
})}
</ViewportPortal>
);
}

View File

@@ -10,6 +10,7 @@ import { useAutosave } from '@/features/editor/useAutosave';
import { useEditorShortcuts } from '@/features/editor/useEditorShortcuts';
import { ShareDialog } from '@/features/sharing/ShareDialog';
import { getMap } from '@/services/mapRepository';
import { toFlatLines } from '@/services/outlineParser';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
@@ -28,6 +29,7 @@ function EditorPageContent({ mapId }: EditorPageProps) {
const searchPanelOpen = useUiStore((state) => state.searchPanelOpen);
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const requestAddChildren = useUiStore((state) => state.requestAddChildren);
const [status, setStatus] = useState<'loading' | 'ready' | 'missing'>('loading');
const [shareOpen, setShareOpen] = useState(false);
@@ -39,15 +41,20 @@ function EditorPageContent({ mapId }: EditorPageProps) {
let cancelled = false;
setStatus('loading');
void getMap(mapId).then((map) => {
if (cancelled) return;
if (!map) {
setStatus('missing');
return;
}
loadMap(map);
setStatus('ready');
});
getMap(mapId)
.then((map) => {
if (cancelled) return;
if (!map) {
setStatus('missing');
return;
}
loadMap(map);
setStatus('ready');
})
.catch(() => {
// Document illisible : message propre plutot qu'une erreur technique.
if (!cancelled) setStatus('missing');
});
return () => {
cancelled = true;
@@ -66,8 +73,42 @@ function EditorPageContent({ mapId }: EditorPageProps) {
},
onFit: requestFit,
onFocusSearch: openSearch,
onAddChildren: (parentId) => requestAddChildren(parentId),
});
/*
* Collage intelligent.
*
* On ecoute l'evenement `paste` plutot que de lire le presse-papiers :
* l'evenement porte le texte sans demander de permission. Le presse-papiers
* interne d'OpenMind reste prioritaire (gere par le raccourci Ctrl+V), et
* l'edition d'un texte n'est jamais interceptee.
*/
useEffect(() => {
if (status !== 'ready') return undefined;
const onPaste = (event: ClipboardEvent) => {
const store = useEditorStore.getState();
if (store.readOnly || store.editingNodeId !== null) return;
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return;
}
if ((store.clipboard?.nodes.length ?? 0) > 0) return;
const text = event.clipboardData?.getData('text/plain') ?? '';
if (toFlatLines(text).length < 2) return;
event.preventDefault();
requestAddChildren(store.selectedNodeIds[0] ?? null, text);
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [status, requestAddChildren]);
if (status === 'loading') {
return (
<div className="flex h-full items-center justify-center text-zinc-500">

View File

@@ -8,6 +8,7 @@ import {
ArrowLeftIcon,
ChevronDownIcon,
FitIcon,
FoldIcon,
GridIcon,
LayoutIcon,
MoonIcon,
@@ -24,7 +25,12 @@ import {
ZoomOutIcon,
} from '@/components/Icons';
import { ExportMenu } from '@/features/export/ExportMenu';
import { LAYOUT_OPTIONS } from '@/features/editor/layout';
import {
LAYOUT_OPTIONS,
SPACING_LIMITS,
SPACING_PRESETS,
resolveSpacing,
} from '@/features/editor/layout';
import { THEMES } from '@/features/editor/themes';
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
@@ -63,14 +69,19 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const updateSettings = useEditorStore((state) => state.updateSettings);
const renameMap = useEditorStore((state) => state.renameMap);
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);
const { zoomIn, zoomOut } = useReactFlow();
const [name, setName] = useState(map?.name ?? '');
const spacing = resolveSpacing(map?.settings.layoutSpacing);
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
@@ -149,18 +160,102 @@ 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">
{SPACING_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => updateSettings({ layoutSpacing: preset.spacing })}
aria-pressed={
spacing.parentGap === preset.spacing.parentGap &&
spacing.siblingGap === preset.spacing.siblingGap
}
className={`flex-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
spacing.parentGap === preset.spacing.parentGap &&
spacing.siblingGap === preset.spacing.siblingGap
? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800'
}`}
>
{preset.label}
</button>
))}
</div>
<div className="space-y-2 px-2.5 pb-2">
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-parent">
Distance parent / enfant : {spacing.parentGap} px
</label>
<input
id="spacing-parent"
type="range"
min={SPACING_LIMITS.parentGap.min}
max={SPACING_LIMITS.parentGap.max}
step={5}
value={spacing.parentGap}
onChange={(event) =>
updateSettings({
layoutSpacing: { ...spacing, parentGap: Number(event.target.value) },
})
}
className="h-1.5 w-full accent-indigo-600"
/>
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-sibling">
Espacement entre branches : {spacing.siblingGap} px
</label>
<input
id="spacing-sibling"
type="range"
min={SPACING_LIMITS.siblingGap.min}
max={SPACING_LIMITS.siblingGap.max}
step={5}
value={spacing.siblingGap}
onChange={(event) =>
updateSettings({
layoutSpacing: { ...spacing, siblingGap: Number(event.target.value) },
})
}
className="h-1.5 w-full accent-indigo-600"
/>
<p className="text-[11px] leading-snug text-zinc-400">
Changer l&apos;espacement ne deplace rien : relancez l&apos;organisation.
</p>
</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={() => {
@@ -211,6 +306,68 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
label={map.settings.showGrid ? 'Masquer la grille' : 'Afficher la grille'}
onSelect={() => updateSettings({ showGrid: !map.settings.showGrid })}
/>
<MenuItem
label="Aimanter sur la grille"
active={map.settings.snapToGrid === true}
hint={`${map.settings.snapGrid ?? 20} px`}
onSelect={() => updateSettings({ snapToGrid: !map.settings.snapToGrid })}
/>
<MenuItem
label="Guides d'alignement"
active={map.settings.alignmentGuides !== false}
onSelect={() =>
updateSettings({ alignmentGuides: map.settings.alignmentGuides === false })
}
/>
</>
)}
</Menu>
<Menu
label="Affichage des branches"
trigger={
<Button size="sm">
<FoldIcon className="h-4 w-4" />
<span className="hidden lg:inline">Branches</span>
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{(close) => (
<>
<MenuItem
label="Tout replier"
onSelect={() => {
setAllCollapsed(true);
close();
}}
/>
<MenuItem
label="Tout developper"
onSelect={() => {
setAllCollapsed(false);
close();
}}
/>
<MenuSeparator />
<MenuLabel>Replier au-dela du niveau</MenuLabel>
{[1, 2, 3].map((level) => (
<MenuItem
key={level}
label={`Niveau ${level}`}
onSelect={() => {
collapseToLevel(level);
close();
}}
/>
))}
<MenuSeparator />
<MenuItem
label="Le glisser-deposer entraine la branche"
active={map.settings.dragMovesBranch === true}
hint="Alt inverse"
onSelect={() => updateSettings({ dragMovesBranch: !map.settings.dragMovesBranch })}
/>
</>
)}
</Menu>

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { MarkdownError, parseMarkdown } from '@/services/markdown';
import { type OutlineNode } from '@/services/outlineParser';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
export interface MarkdownImportDialogProps {
open: boolean;
/** Noeud sous lequel importer. `null` = a la racine de la carte. */
parentId: string | null;
onClose: () => void;
}
interface Analysis {
roots: OutlineNode[];
count: number;
error: string | null;
}
function analyse(text: string, maxNodes: number): Analysis {
if (text.trim().length === 0) return { roots: [], count: 0, error: null };
try {
const result = parseMarkdown(text, maxNodes);
return { roots: result.roots, count: result.count, error: null };
} catch (error) {
return {
roots: [],
count: 0,
error:
error instanceof MarkdownError
? error.message
: "Ce document n'a pas pu etre analyse.",
};
}
}
function PreviewTree({ nodes, depth = 0 }: { nodes: OutlineNode[]; depth?: number }) {
return (
<ul
className={
depth === 0
? 'space-y-0.5'
: 'space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-700'
}
>
{nodes.map((node, index) => (
<li key={`${depth}-${index}-${node.text}`}>
<span className="text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
{node.note ? (
<span className="ml-1 text-[10px] uppercase tracking-wide text-indigo-500">note</span>
) : null}
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
</li>
))}
</ul>
);
}
/**
* Import Markdown dans une carte ouverte.
*
* Le contenu devient un sous-arbre du noeud vise, en une seule entree
* d'historique. Les paragraphes rejoignent la note du noeud correspondant, les
* blocs de code aussi : rien n'est execute, rien n'est rendu en HTML.
*/
export function MarkdownImportDialog({ open, parentId, onClose }: MarkdownImportDialogProps) {
const insertOutline = useEditorStore((state) => state.insertOutline);
const parentName = useEditorStore(
(state) => state.map?.nodes.find((node) => node.id === parentId)?.text,
);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) setText('');
}, [open]);
const analysis = useMemo(() => analyse(text, maxNodes), [text, maxNodes]);
const canSubmit = analysis.count > 0 && analysis.error === null;
const submit = () => {
if (!canSubmit) return;
const created = insertOutline(parentId, analysis.roots);
if (created > 0) {
pushToast(`${created} noeud(s) importe(s) depuis le Markdown.`, 'success');
onClose();
}
};
const readFile = async (file: File) => {
const content = await file.text();
setText(content);
};
return (
<Modal
open={open}
title="Importer du Markdown"
description={
parentName
? `Le contenu deviendra un sous-arbre de "${parentName}".`
: 'Le contenu sera ajoute a la carte.'
}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
{analysis.count > 0 ? `Importer ${analysis.count} noeud(s)` : 'Importer'}
</Button>
</>
}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => fileInputRef.current?.click()}>
Choisir un fichier .md
</Button>
<span className="text-[11px] text-zinc-400">ou collez le contenu ci-dessous</span>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Choisir un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void readFile(file);
}}
/>
</div>
<textarea
id="markdown-import-text"
autoFocus
spellCheck={false}
className="field-input min-h-[200px] resize-y font-mono text-xs leading-relaxed"
placeholder={'# Infrastructure\n\n## Reseau\n- VLAN\n- VPN\n\n## Virtualisation\n- Proxmox\n - VM'}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
}}
aria-label="Contenu Markdown"
/>
<p className="text-[11px] text-zinc-400">
Titres (#, ##, ###) et listes construisent la hierarchie. Les paragraphes et les blocs de
code deviennent la note du noeud au-dessus. Ctrl+Entree valide.
</p>
{analysis.error ? (
<p
role="alert"
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300"
>
{analysis.error}
</p>
) : null}
{canSubmit ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/60">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-zinc-500">
Apercu - {analysis.count} noeud(s) seront crees
</p>
<div className="max-h-48 overflow-y-auto">
<PreviewTree nodes={analysis.roots} />
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Background,
BackgroundVariant,
@@ -12,9 +12,29 @@ 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';
import {
computeAlignmentGuides,
type AlignmentGuide,
type GuideBox,
} from '@/features/editor/alignmentGuides';
import {
NodeContextMenu,
type NodeContextMenuState,
} 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';
import { resolveNodeSize } from '@/utils/nodeSize';
@@ -39,10 +59,34 @@ 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();
const initialisedFor = useRef<string | null>(null);
/**
* Vrai pendant un drag qui doit entrainer toute la branche.
* Le reglage de la carte donne le comportement par defaut ; la touche Alt
* l'inverse le temps d'un deplacement.
*/
const branchDragRef = useRef(false);
const [guides, setGuides] = useState<AlignmentGuide[]>([]);
const [contextMenu, setContextMenu] = useState<NodeContextMenuState | null>(null);
const [addChildrenFor, setAddChildrenFor] = useState<{ parentId: string | null; text: string } | null>(
null,
);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const [relationSource, setRelationSource] = useState<string | null>(null);
const [markdownParent, setMarkdownParent] = useState<string | null>(null);
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
// Ouverture demandee par un raccourci clavier ou par un collage multi-ligne.
useEffect(() => {
if (readOnly || !addChildrenRequest) return;
setAddChildrenFor({ parentId: addChildrenRequest.parentId, text: addChildrenRequest.text });
clearAddChildrenRequest();
}, [addChildrenRequest, clearAddChildrenRequest, readOnly]);
const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]);
@@ -53,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(() => {
@@ -82,12 +146,12 @@ export function MindMapCanvas({
if (!focusRequest || !map) return;
const target = map.nodes.find((node) => node.id === focusRequest.nodeId);
if (!target) return;
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes);
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes, theme);
setCenter(target.position.x + size.width / 2, target.position.y + size.height / 2, {
zoom: 1.15,
duration: 400,
});
}, [focusRequest, map, setCenter]);
}, [focusRequest, map, setCenter, theme]);
const onNodesChange = useCallback(
(changes: NodeChange<MindFlowNode>[]) => {
@@ -112,7 +176,9 @@ export function MindMapCanvas({
}
}
if (positions.length > 0 && !readOnly) store.moveNodes(positions);
if (positions.length > 0 && !readOnly) {
store.moveNodes(positions, { withDescendants: branchDragRef.current });
}
if (selection) store.setSelection([...selection]);
},
[readOnly],
@@ -120,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);
}
@@ -142,6 +220,40 @@ export function MindMapCanvas({
[readOnly, pushToast],
);
/** Guides d'alignement : etat purement local, jamais persiste ni annulable. */
const handleNodeDrag = useCallback(
(_event: MouseEvent | TouchEvent, dragged: MindFlowNode) => {
const store = useEditorStore.getState();
const current = store.map;
if (!current || current.settings.alignmentGuides === false) return;
const sizes = store.measuredSizes;
const toBox = (node: (typeof current.nodes)[number]): GuideBox => {
const size = resolveNodeSize(node, sizes, theme);
return {
id: node.id,
x: node.id === dragged.id ? dragged.position.x : node.position.x,
y: node.id === dragged.id ? dragged.position.y : node.position.y,
width: size.width,
height: size.height,
};
};
const hiddenIds = new Set(nodes.filter((node) => node.hidden).map((node) => node.id));
const boxes = current.nodes.filter((node) => !hiddenIds.has(node.id)).map(toBox);
const moving = boxes.find((box) => box.id === dragged.id);
if (!moving) return;
setGuides(computeAlignmentGuides(moving, boxes));
},
[nodes, theme],
);
const handleNodeDragStop = useCallback(() => {
setGuides([]);
branchDragRef.current = false;
}, []);
const handlePaneDoubleClick = useCallback(
(event: React.MouseEvent) => {
if (readOnly) return;
@@ -171,15 +283,36 @@ export function MindMapCanvas({
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStart={() => useEditorStore.getState().pushHistory()}
onNodeDragStart={(event) => {
const store = useEditorStore.getState();
const byDefault = store.map?.settings.dragMovesBranch === true;
const altPressed = 'altKey' in event ? event.altKey : false;
branchDragRef.current = byDefault !== altPressed;
store.pushHistory();
}}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
onNodeContextMenu={(event, node) => {
if (readOnly) return;
event.preventDefault();
const store = useEditorStore.getState();
if (!store.selectedNodeIds.includes(node.id)) store.setSelection([node.id]);
setContextMenu({ nodeId: node.id, x: event.clientX, y: event.clientY });
}}
snapToGrid={map.settings.snapToGrid === true}
snapGrid={[map.settings.snapGrid ?? 20, map.settings.snapGrid ?? 20]}
onMoveEnd={(_event, viewport) => useEditorStore.getState().setViewport(viewport)}
onPaneClick={() => {
const store = useEditorStore.getState();
store.setSelection([]);
store.setEditingNode(null);
store.setSelectedRelation(null);
setContextMenu(null);
}}
defaultViewport={map.viewport}
minZoom={0.1}
// Plancher bas volontairement : sur une grande carte, "Adapter a
// l'ecran" doit pouvoir descendre assez pour tout montrer.
minZoom={0.02}
maxZoom={3}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode="Shift"
@@ -196,6 +329,8 @@ export function MindMapCanvas({
zoomOnDoubleClick={false}
aria-label="Canvas de la carte mentale"
>
<AlignmentGuidesLayer guides={guides} />
{!readOnly ? <SelectionToolbar /> : null}
{map.settings.showGrid ? (
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color={theme.gridColor} />
) : null}
@@ -214,6 +349,42 @@ export function MindMapCanvas({
/>
) : null}
</ReactFlow>
{contextMenu ? (
<NodeContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
onMoveTo={(nodeId) => setMoveTarget(nodeId)}
onAddRelation={(nodeId) => setRelationSource(nodeId)}
onImportMarkdown={(nodeId) => setMarkdownParent(nodeId)}
/>
) : null}
<AddChildrenDialog
open={addChildrenFor !== null}
parentId={addChildrenFor?.parentId ?? null}
initialText={addChildrenFor?.text}
onClose={() => setAddChildrenFor(null)}
/>
<MoveToDialog
open={moveTarget !== null}
nodeId={moveTarget}
onClose={() => setMoveTarget(null)}
/>
<RelationDialog
open={relationSource !== null}
sourceId={relationSource}
onClose={() => setRelationSource(null)}
/>
<MarkdownImportDialog
open={markdownParent !== null}
parentId={markdownParent}
onClose={() => setMarkdownParent(null)}
/>
</div>
);
}

View File

@@ -1,28 +1,63 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Handle, type NodeProps } from '@xyflow/react';
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from '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';
import { useEditorStore } from '@/stores/editorStore';
import { LinkIcon, NoteIcon } from '@/components/Icons';
import { cn } from '@/utils/cn';
/** Largeur reservee a un indicateur (note / lien), alignee sur `nodeSize.ts`. */
const INDICATOR_WIDTH = 18;
const VERTICAL_ALIGNMENT = {
top: 'flex-start',
middle: 'center',
bottom: 'flex-end',
} as const;
const CROSS_ALIGNMENT = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
} as const;
/**
* Rendu d'un noeud.
*
* Le texte est toujours rendu comme du texte : aucun HTML provenant d'une carte
* n'est jamais interprete.
*/
function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>) {
const { node, appearance, childCount, hiddenCount, isSearchHit, readOnly, isEditing } = data;
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const setEditingNode = useEditorStore((state) => state.setEditingNode);
const toggleCollapse = useEditorStore((state) => state.toggleCollapse);
const setMeasuredSize = useEditorStore((state) => state.setMeasuredSize);
const beginResize = useEditorStore((state) => state.beginResize);
const resizeNode = useEditorStore((state) => state.resizeNode);
const containerRef = useRef<HTMLDivElement>(null);
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]);
@@ -44,7 +79,7 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
return () => window.clearTimeout(timer);
}, [isEditing]);
// Les tailles reelles alimentent l'auto-layout et les exports.
// Les tailles reelles alimentent l'auto-layout, la minimap et les exports.
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
@@ -52,7 +87,7 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
width: Math.round(element.offsetWidth),
height: Math.round(element.offsetHeight),
});
}, [id, node.text, node.style, node.icon, setMeasuredSize, isEditing]);
}, [id, node.text, node.style, node.layout, node.icon, node.iconStyle, setMeasuredSize, isEditing]);
const commitText = () => {
const value = draft.trim();
@@ -68,31 +103,91 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
const isRoot = node.type === 'root';
const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre');
const hasIcon = Boolean(node.icon);
const iconKind = node.iconStyle?.kind ?? 'emoji';
const iconOnTop = hasIcon && appearance.iconPosition === 'top';
const decorations =
(hasIcon && !iconOnTop ? appearance.iconSize + appearance.iconGap : 0) +
(node.note ? INDICATOR_WIDTH : 0) +
(node.url ? INDICATOR_WIDTH : 0);
const containerStyle = useMemo(() => {
const fixedWidth = appearance.widthMode === 'fixed' ? appearance.width : undefined;
const fixedHeight = appearance.heightMode === 'fixed' ? appearance.height : undefined;
return {
background: appearance.background,
color: appearance.color,
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
borderRadius: appearance.borderRadius,
boxShadow: appearance.boxShadow,
fontFamily: appearance.fontFamily,
fontSize: appearance.fontSize,
fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle,
lineHeight: appearance.lineHeight,
paddingLeft: appearance.paddingX,
paddingRight: appearance.paddingX,
paddingTop: appearance.paddingY,
paddingBottom: appearance.paddingY,
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
justifyContent: VERTICAL_ALIGNMENT[appearance.verticalAlign],
minWidth: appearance.minWidth,
/*
* En mode automatique, `max-content` plutot que la largeur par defaut :
* le noeud est absolument positionne, sa largeur "auto" se resoudrait
* sinon a la largeur min-content (le mot le plus long), ce qui produirait
* une colonne etroite et haute pour une phrase. Avec `max-content` borne
* par `maxWidth`, une phrase longue occupe la largeur maximale autorisee
* puis passe a la ligne, et un texte court reste compact.
*/
...(fixedWidth ? { width: fixedWidth } : { width: 'max-content' }),
...(fixedHeight ? { height: fixedHeight } : { minHeight: appearance.minHeight }),
...(fixedWidth || !appearance.wrap
? {}
: { maxWidth: appearance.maxTextWidth + appearance.paddingX * 2 + decorations }),
...(appearance.circle && !fixedWidth ? { aspectRatio: '1 / 1', alignItems: 'center' } : {}),
};
}, [appearance, decorations]);
const iconElement = hasIcon ? (
<span
className="shrink-0"
style={{
// Aligne l'icone sur la premiere ligne de texte.
marginTop: iconOnTop
? 0
: Math.max(0, (appearance.fontSize * appearance.lineHeight - appearance.iconSize) / 2),
}}
>
<NodeIconGlyph
value={node.icon as string}
kind={iconKind}
size={appearance.iconSize}
/>
</span>
) : null;
/*
* Uniquement `overflow-wrap: break-word`.
* `word-break: break-word` (et `overflow-wrap: anywhere`) ramenent la largeur
* min-content a un seul caractere : dans une boite dimensionnee par son
* contenu, tous les noeuds s'effondreraient a leur largeur minimale.
* Avec `break-word`, la largeur naturelle reste celle du mot le plus long, et
* une URL trop longue n'est coupee que si elle depasse la largeur maximale.
*/
const textStyle = appearance.wrap
? ({ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' } as const)
: ({ whiteSpace: 'pre', overflow: 'hidden', textOverflow: 'ellipsis' } as const);
return (
<div
ref={containerRef}
className={cn(
'mind-node relative select-none px-3.5 py-2',
isSearchHit && 'mind-node--search-hit',
)}
style={{
background: appearance.background,
color: appearance.color,
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
borderRadius: appearance.borderRadius,
boxShadow: appearance.boxShadow,
fontFamily: appearance.fontFamily,
fontSize: appearance.fontSize,
fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle,
textAlign: appearance.textAlign,
minWidth: appearance.circle ? 96 : 88,
maxWidth: appearance.width ?? 260,
width: appearance.width,
...(appearance.circle
? { aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }
: {}),
}}
className={cn('mind-node relative select-none', isSearchHit && 'mind-node--search-hit')}
style={containerStyle}
aria-label={label}
onDoubleClick={(event) => {
if (readOnly) return;
@@ -100,47 +195,86 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
setEditingNode(id);
}}
>
{!readOnly && selected && !isEditing ? (
<NodeResizer
isVisible
color="#6366f1"
// Un cercle reste circulaire pendant tout le redimensionnement.
keepAspectRatio={appearance.circle}
minWidth={appearance.minWidth}
minHeight={appearance.minHeight}
maxWidth={LAYOUT_LIMITS.width.max}
maxHeight={LAYOUT_LIMITS.height.max}
handleClassName="openmind-resize-handle"
lineClassName="openmind-resize-line"
onResizeStart={() => beginResize()}
onResize={(_event, params) =>
resizeNode(id, {
width: params.width,
height: params.height,
position: { x: params.x, y: params.y },
})
}
/>
) : null}
<Handle
type="target"
position={data.targetPosition}
id={data.targetHandle.id}
position={data.targetHandle.position}
isConnectable={!readOnly}
aria-label="Point de connexion entrant"
/>
<div className="flex items-start gap-2">
{node.icon ? (
<span aria-hidden="true" className="leading-5">
{node.icon}
</span>
) : null}
<div
style={{
display: 'flex',
flexDirection: iconOnTop ? 'column' : 'row',
gap: hasIcon ? appearance.iconGap : 0,
alignItems: iconOnTop ? CROSS_ALIGNMENT[appearance.textAlign] : 'flex-start',
width: '100%',
minWidth: 0,
}}
>
{hasIcon && (appearance.iconPosition === 'left' || iconOnTop) ? iconElement : null}
{isEditing ? (
<textarea
ref={textareaRef}
className="nodrag nowheel w-full resize-none bg-transparent leading-snug outline-none"
style={{ color: appearance.color, minWidth: 90 }}
rows={Math.min(6, Math.max(1, draft.split('\n').length))}
value={draft}
aria-label="Texte du noeud"
onChange={(event) => setDraft(event.target.value)}
onBlur={commitText}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitText();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
/>
) : (
<span className="whitespace-pre-wrap break-words leading-snug">
{node.text || <span className="opacity-50">Sans titre</span>}
</span>
)}
<div style={{ flex: '1 1 auto', minWidth: 0, textAlign: appearance.textAlign }}>
{isEditing ? (
<textarea
ref={textareaRef}
className="nodrag nowheel w-full resize-none bg-transparent outline-none"
style={{
color: appearance.color,
font: 'inherit',
lineHeight: appearance.lineHeight,
textAlign: appearance.textAlign,
minWidth: 80,
}}
rows={Math.min(8, Math.max(1, draft.split('\n').length))}
value={draft}
aria-label="Texte du noeud"
onChange={(event) => setDraft(event.target.value)}
onBlur={commitText}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitText();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
/>
) : (
<span style={textStyle}>
{node.text || <span className="opacity-50">Sans titre</span>}
</span>
)}
</div>
{hasIcon && appearance.iconPosition === 'right' ? iconElement : null}
{node.note ? (
<span title="Ce noeud contient une note" className="mt-0.5 shrink-0 opacity-70">
@@ -187,12 +321,18 @@ function MindNodeViewComponent({ data, id }: 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>
);
}

View File

@@ -0,0 +1,124 @@
import { useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SearchIcon } from '@/components/Icons';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths, getDescendantIds } from '@/utils/tree';
import { cn } from '@/utils/cn';
export interface MoveToDialogProps {
open: boolean;
nodeId: string | null;
onClose: () => void;
}
function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Choix d'un nouveau parent par recherche.
*
* Les cibles impossibles (le noeud lui-meme et ses descendants) sont exclues de
* la liste : la detection de cycle de `reparentNode` reste la regle unique, ce
* filtre ne fait qu'eviter de proposer un choix qui serait refuse.
*/
export function MoveToDialog({ open, nodeId, onClose }: MoveToDialogProps) {
const map = useEditorStore((state) => state.map);
const moveNodeToParent = useEditorStore((state) => state.moveNodeToParent);
const pushToast = useUiStore((state) => state.pushToast);
const [query, setQuery] = useState('');
const node = map?.nodes.find((candidate) => candidate.id === nodeId);
const candidates = useMemo(() => {
if (!map || !node) return [];
const excluded = new Set([node.id, ...getDescendantIds(map.nodes, node.id)]);
const depths = computeDepths(map.nodes);
const needle = normalize(query.trim());
return map.nodes
.filter((candidate) => !excluded.has(candidate.id) && candidate.id !== node.parentId)
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
.slice(0, 60);
}, [map, node, query]);
const move = (parentId: string | null) => {
if (!node) return;
if (moveNodeToParent(node.id, parentId)) {
pushToast(
parentId ? 'Branche deplacee.' : 'Noeud detache : il devient une racine.',
'success',
);
onClose();
return;
}
pushToast('Deplacement impossible : cela creerait une boucle.', 'error');
};
return (
<Modal
open={open}
title="Deplacer vers..."
description={node ? `"${node.text || 'Sans titre'}" et sa branche changeront de parent.` : ''}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<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 un noeud parent"
aria-label="Rechercher un noeud parent"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
<button
type="button"
onClick={() => move(null)}
className="w-full rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-left text-sm text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
Detacher : en faire une racine
</button>
{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="Nouveaux parents possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => move(candidate.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors',
'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
)}
>
<span
aria-hidden="true"
className="shrink-0 font-mono text-[11px] text-zinc-400"
style={{ paddingLeft: depth * 10 }}
>
{depth === 0 ? '\u25cf' : '\u2514'}
</span>
<span className="truncate">{candidate.text || 'Sans titre'}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,311 @@
import { useEffect, useMemo } from 'react';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { getOrderedChildren } from '@/utils/tree';
import { cn } from '@/utils/cn';
export interface NodeContextMenuState {
nodeId: string;
x: number;
y: number;
}
export interface NodeContextMenuProps {
state: NodeContextMenuState;
onClose: () => void;
onAddChildren: (parentId: string) => void;
onMoveTo: (nodeId: string) => void;
onAddRelation: (nodeId: string) => void;
onImportMarkdown: (nodeId: string) => void;
}
interface MenuEntry {
kind: 'item';
label: string;
hint?: string;
disabled?: boolean;
danger?: boolean;
run: () => void;
}
interface MenuHeading {
kind: 'heading';
label: string;
}
type MenuLine = MenuEntry | MenuHeading;
/** Menu contextuel d'un noeud, organise par famille d'action. */
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);
const hasNodeClipboard = useEditorStore((store) => (store.clipboard?.nodes.length ?? 0) > 0);
const pushToast = useUiStore((store) => store.pushToast);
useEffect(() => {
const close = () => onClose();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('click', close);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('keydown', onKeyDown);
};
}, [onClose]);
// Le clic droit agit sur la selection si le noeud vise en fait partie,
// sinon sur ce seul noeud.
const targets = useMemo(
() => (selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId]),
[selectedIds, state.nodeId],
);
const lines = useMemo<MenuLine[]>(() => {
const store = useEditorStore.getState();
const node = map?.nodes.find((candidate) => candidate.id === state.nodeId);
if (!node || !map) return [];
const siblings = getOrderedChildren(map.nodes, node.parentId);
const position = siblings.findIndex((candidate) => candidate.id === node.id);
const hasSiblings = siblings.length > 1;
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) },
{
kind: 'item',
label: 'Ajouter plusieurs enfants...',
hint: 'Maj+Entree',
run: () => onAddChildren(node.id),
},
{
kind: 'item',
label: 'Ajouter avant',
disabled: !node.parentId,
run: () => store.addSiblingAt(node.id, 'before'),
},
{
kind: 'item',
label: 'Ajouter apres',
hint: 'Entree',
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' },
{
kind: 'item',
label: 'Selectionner la branche',
run: () => notify(`${store.selectBranch(node.id)} noeuds selectionnes.`),
},
{
kind: 'item',
label: 'Dupliquer la branche',
run: () => notify(`Branche dupliquee (${store.duplicateBranch(node.id)} noeuds).`),
},
{
kind: 'item',
label: 'Copier la branche',
run: () => notify(`${store.copyBranch(node.id)} noeuds copies.`),
},
{
kind: 'item',
label: 'Coller comme enfant',
disabled: !hasNodeClipboard,
run: () => notify(`${store.pasteAsChild(node.id)} noeuds colles.`),
},
{ kind: 'item', label: 'Deplacer vers...', run: () => onMoveTo(node.id) },
{
kind: 'item',
label: node.collapsed ? 'Developper la branche' : 'Replier la branche',
disabled: !hasChildren,
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',
label: 'Monter',
hint: 'Alt+Haut',
disabled: !hasSiblings || position <= 0,
run: () => store.moveSibling(node.id, 'up'),
},
{
kind: 'item',
label: 'Descendre',
hint: 'Alt+Bas',
disabled: !hasSiblings || position >= siblings.length - 1,
run: () => store.moveSibling(node.id, 'down'),
},
{
kind: 'item',
label: 'Placer en premier',
disabled: !hasSiblings || position <= 0,
run: () => store.moveSibling(node.id, 'first'),
},
{
kind: 'item',
label: 'Placer en dernier',
disabled: !hasSiblings || position >= siblings.length - 1,
run: () => store.moveSibling(node.id, 'last'),
},
{
kind: 'item',
label: 'Remonter d un niveau',
hint: 'Maj+Tab',
disabled: !node.parentId,
run: () => store.outdentNode(node.id),
},
{ kind: 'heading', label: 'Style' },
{
kind: 'item',
label: 'Copier le style',
hint: 'Ctrl+Maj+C',
disabled: targets.length !== 1,
run: () => {
if (store.copyStyle()) pushToast('Style copie.', 'info');
},
},
{
kind: 'item',
label: 'Coller le style',
hint: 'Ctrl+Maj+V',
disabled: !hasStyleClipboard,
run: () => notify(`Style applique a ${store.pasteStyle()} noeud(s).`),
},
{ kind: 'item', label: 'Reinitialiser le style', run: () => store.resetNodesStyle(targets) },
{ kind: 'item', label: 'Adapter au contenu', run: () => store.fitNodesToContent(targets) },
{ kind: 'heading', label: 'Suppression' },
{
kind: 'item',
label: 'Supprimer la branche',
hint: 'Suppr',
danger: true,
run: () => store.deleteNodes(targets),
},
{
kind: 'item',
label: 'Supprimer en conservant les enfants',
danger: true,
disabled: !hasChildren,
run: () => store.deleteNodesKeepChildren(targets),
},
];
}, [
map,
state.nodeId,
targets,
hasStyleClipboard,
hasNodeClipboard,
onAddChildren,
onMoveTo,
onAddRelation,
onImportMarkdown,
pushToast,
]);
return (
<div
role="menu"
aria-label="Actions du noeud"
className="panel fixed z-50 max-h-[80vh] min-w-[15rem] animate-fade-in overflow-y-auto p-1"
style={{ left: state.x, top: state.y }}
onClick={(event) => event.stopPropagation()}
>
{lines.map((line, index) =>
line.kind === 'heading' ? (
<div
key={`heading-${line.label}`}
className={cn(
'px-2.5 pb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-400',
index === 0 ? 'pt-1.5' : 'mt-1 border-t border-zinc-100 pt-2 dark:border-zinc-800',
)}
>
{line.label}
</div>
) : (
<button
key={line.label}
type="button"
role="menuitem"
disabled={line.disabled}
onClick={() => {
line.run();
onClose();
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
line.danger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
)}
>
<span className="flex-1">{line.label}</span>
{line.hint ? (
<span className="shrink-0 text-[11px] uppercase tracking-wide text-zinc-400">
{line.hint}
</span>
) : null}
</button>
),
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { BUILTIN_ICONS } from '@/features/editor/nodeIcons';
export interface NodeIconGlyphProps {
value: string;
kind: 'emoji' | 'builtin';
size: number;
className?: string;
}
/** Rendu d'une icone de noeud : emoji brut ou icone integree. */
export function NodeIconGlyph({ value, kind, size, className }: NodeIconGlyphProps) {
if (kind === 'builtin') {
const entry = BUILTIN_ICONS[value];
if (!entry) return null;
const { Icon } = entry;
return <Icon size={size} strokeWidth={1.8} className={className} aria-hidden="true" />;
}
return (
<span
aria-hidden="true"
className={className}
style={{ fontSize: size, lineHeight: 1, display: 'inline-block' }}
>
{value}
</span>
);
}

View File

@@ -1,10 +1,43 @@
import { useEffect, useState } from 'react';
import { type NodeShape, type TextAlign } from '@/types/map';
import { useEffect, useMemo, useState } from 'react';
import {
AlignCenter,
AlignLeft,
AlignRight,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignVerticalJustifyStart,
} from 'lucide-react';
import {
type BranchStyle,
type FontFamilyId,
type IconPosition,
type NodeShape,
type ShadowStyle,
type TextAlign,
type VerticalAlign,
} from '@/types/map';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { CloseIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth } from '@/features/editor/themes';
import { selectSelectedNode, useEditorStore } from '@/stores/editorStore';
import { CloseIcon, CopyIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
import {
FONT_FAMILY_OPTIONS,
LAYOUT_LIMITS,
SHADOW_OPTIONS,
resolveNodeAppearance,
} 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,
SegmentedControl,
SelectField,
ToggleRow,
} from '@/features/editor/panel/controls';
import { sharedValue } from '@/features/editor/panel/sharedValue';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
@@ -16,92 +49,106 @@ const SHAPES: Array<{ id: NodeShape; label: string }> = [
{ id: 'circle', label: 'Cercle' },
];
const ALIGNMENTS: Array<{ id: TextAlign; label: string }> = [
{ id: 'left', label: 'Gauche' },
{ id: 'center', label: 'Centre' },
{ id: 'right', label: 'Droite' },
const BRANCH_STYLES: Array<{ id: BranchStyle | 'theme'; label: string }> = [
{ id: 'theme', label: 'Theme' },
{ id: 'bezier', label: 'Bezier' },
{ id: 'curved', label: 'Courbe' },
{ id: 'straight', label: 'Droite' },
];
const EMOJIS = ['', '💡', '✅', '📌', '⚠️', '🔒', '🌐', '🖥️', '📦', '📄', '🔗', '⭐', '🐳', '🗄️'];
const HORIZONTAL_ALIGN = [
{ value: 'left' as TextAlign, label: 'Aligner a gauche', icon: <AlignLeft size={15} /> },
{ value: 'center' as TextAlign, label: 'Centrer', icon: <AlignCenter size={15} /> },
{ value: 'right' as TextAlign, label: 'Aligner a droite', icon: <AlignRight size={15} /> },
];
/** Petit selecteur de couleur : palette native + valeur libre. */
function ColorField({
id,
label,
value,
fallback,
onChange,
}: {
id: string;
label: string;
value: string | undefined;
fallback: string;
onChange: (color: string | undefined) => void;
}) {
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={value ?? normaliseHex(fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{value ?? `theme (${fallback})`}
</span>
{value ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
const VERTICAL_ALIGN = [
{
value: 'top' as VerticalAlign,
label: 'Aligner en haut',
icon: <AlignVerticalJustifyStart size={15} />,
},
{
value: 'middle' as VerticalAlign,
label: 'Centrer verticalement',
icon: <AlignVerticalJustifyCenter size={15} />,
},
{
value: 'bottom' as VerticalAlign,
label: 'Aligner en bas',
icon: <AlignVerticalJustifyEnd size={15} />,
},
];
function normaliseHex(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
}
const ICON_POSITIONS: Array<{ id: IconPosition; label: string }> = [
{ id: 'left', label: 'Gauche' },
{ id: 'right', label: 'Droite' },
{ id: 'top', label: 'Au-dessus' },
];
const ICON_SIZES = [16, 20, 24, 32];
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const node = useEditorStore(selectSelectedNode);
const selectedCount = useEditorStore((state) => state.selectedNodeIds.length);
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const updateNodeStyle = useEditorStore((state) => state.updateNodeStyle);
const resetNodeStyle = useEditorStore((state) => state.resetNodeStyle);
const updateNodesStyle = useEditorStore((state) => state.updateNodesStyle);
const updateNodesLayout = useEditorStore((state) => state.updateNodesLayout);
const updateNodesIconStyle = useEditorStore((state) => state.updateNodesIconStyle);
const updateNodesBranch = useEditorStore((state) => state.updateNodesBranch);
const setNodesIcon = useEditorStore((state) => state.setNodesIcon);
const resetNodesStyle = useEditorStore((state) => state.resetNodesStyle);
const fitNodesToContent = useEditorStore((state) => state.fitNodesToContent);
const copyStyle = useEditorStore((state) => state.copyStyle);
const pasteStyle = useEditorStore((state) => state.pasteStyle);
const hasStyleClipboard = useEditorStore((state) => state.styleClipboard !== null);
const detachNode = useEditorStore((state) => state.detachNode);
const deleteNodes = useEditorStore((state) => state.deleteNodes);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const nodes = useMemo(() => {
if (!map) return [];
const wanted = new Set(selectedIds);
return map.nodes.filter((node) => wanted.has(node.id));
}, [map, selectedIds]);
const single = nodes.length === 1 ? nodes[0] : undefined;
const ids = useMemo(() => nodes.map((node) => node.id), [nodes]);
const [text, setText] = useState('');
const [url, setUrl] = useState('');
const [note, setNote] = useState('');
useEffect(() => {
setText(node?.text ?? '');
setUrl(node?.url ?? '');
setNote(node?.note ?? '');
}, [node?.id, node?.text, node?.url, node?.note]);
setText(single?.text ?? '');
setUrl(single?.url ?? '');
setNote(single?.note ?? '');
}, [single?.id, single?.text, single?.url, single?.note]);
const theme = getTheme(map?.settings.theme);
const depth = node && map ? (computeDepths(map.nodes).get(node.id) ?? 0) : 0;
const palette = paletteForDepth(theme, depth);
const style = node?.style ?? {};
const metrics = themeMetrics(theme);
const depths = useMemo(() => (map ? computeDepths(map.nodes) : new Map<string, number>()), [map]);
const referenceDepth = single ? (depths.get(single.id) ?? 0) : 0;
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 border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">Proprietes</h2>
<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">
Proprietes
{nodes.length > 1 ? (
<span className="ml-2 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300">
{nodes.length} noeuds
</span>
) : null}
</h2>
<IconButton
label="Masquer le panneau de proprietes"
icon={<CloseIcon />}
@@ -110,7 +157,21 @@ export function PropertiesPanel() {
</header>
);
if (!node) {
// 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
className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
@@ -118,28 +179,30 @@ export function PropertiesPanel() {
>
{header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{selectedCount > 1
? `${selectedCount} noeuds selectionnes. Selectionnez un seul noeud pour modifier son apparence.`
: 'Selectionnez un noeud pour modifier son texte, ses couleurs, sa forme ou y ajouter une note.'}
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. Cliquez une
relation pour en modifier le libelle et le trace.
</p>
</aside>
);
}
const styleOf = sharedValue;
const commitUrl = () => {
if (!single) return;
const trimmed = url.trim();
if (!trimmed) {
updateNode(node.id, { url: undefined });
updateNode(single.id, { url: undefined });
return;
}
const safe = sanitizeUrl(trimmed);
if (!safe) {
pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error');
setUrl(node.url ?? '');
setUrl(single.url ?? '');
return;
}
setUrl(safe);
updateNode(node.id, { url: safe });
updateNode(single.id, { url: safe });
};
return (
@@ -149,196 +212,449 @@ export function PropertiesPanel() {
>
{header}
<div className="space-y-5 px-4 py-4">
<div>
<label className="field-label" htmlFor="node-text">
Texte
</label>
<textarea
id="node-text"
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== node.text && updateNodeText(node.id, text)}
/>
</div>
<div className="flex flex-wrap gap-1.5 border-b border-zinc-100 px-4 py-2.5 dark:border-zinc-800">
<Button
size="sm"
disabled={!single}
title="Copier le style (Ctrl+Shift+C)"
onClick={() => {
if (copyStyle()) pushToast('Style copie.', 'info');
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copier le style
</Button>
<Button
size="sm"
disabled={!hasStyleClipboard}
title="Coller le style (Ctrl+Shift+V)"
onClick={() => {
const count = pasteStyle();
if (count > 0) pushToast(`Style applique a ${count} noeud(s).`, 'success');
}}
>
Coller le style
</Button>
<Button size="sm" onClick={() => fitNodesToContent(ids)}>
Adapter au contenu
</Button>
<Button size="sm" onClick={() => resetNodesStyle(ids)}>
Reinitialiser le style
</Button>
</div>
<PanelSection id="content" title="Contenu">
{single ? (
<>
<div>
<label className="field-label" htmlFor="node-text">
Texte
</label>
<textarea
id="node-text"
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== single.text && updateNodeText(single.id, text)}
/>
</div>
<div>
<label className="field-label" htmlFor="node-url">
Lien
</label>
<div className="flex items-center gap-2">
<input
id="node-url"
type="url"
inputMode="url"
placeholder="https://exemple.fr"
className="field-input"
value={url}
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{single.url ? (
<a
href={single.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
aria-label="Ouvrir le lien dans un nouvel onglet"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LinkIcon />
</a>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-note">
Notes
</label>
<textarea
id="node-note"
className="field-input min-h-[70px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note}
onChange={(event) => setNote(event.target.value)}
onBlur={() =>
note !== (single.note ?? '') &&
updateNode(single.id, { note: note || undefined })
}
/>
</div>
</>
) : (
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Texte, lien et notes se modifient noeud par noeud. Les reglages d&apos;icone et de style
ci-dessous s&apos;appliquent a toute la selection.
</p>
)}
<div>
<span className="field-label">Icone</span>
<div className="flex flex-wrap gap-1">
{EMOJIS.map((emoji) => (
<div className="mb-2 flex flex-wrap gap-1">
<button
type="button"
aria-label="Aucune icone"
title="Aucune icone"
onClick={() => setNodesIcon(ids, undefined)}
className="h-8 w-8 rounded-md border border-zinc-200 text-xs text-zinc-500 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
-
</button>
{EMOJI_ICONS.map((emoji) => (
<button
key={emoji || 'none'}
key={emoji}
type="button"
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
aria-pressed={(node.icon ?? '') === emoji}
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
aria-label={`Icone ${emoji}`}
aria-pressed={single?.icon === emoji}
onClick={() => setNodesIcon(ids, emoji, 'emoji')}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
(node.icon ?? '') === emoji
single?.icon === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji || '-'}
{emoji}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{BUILTIN_ICON_IDS.map((iconId) => {
const { label, Icon } = BUILTIN_ICONS[iconId] as (typeof BUILTIN_ICONS)[string];
return (
<button
key={iconId}
type="button"
aria-label={`Icone ${label}`}
title={label}
aria-pressed={single?.icon === iconId}
onClick={() => setNodesIcon(ids, iconId, 'builtin')}
className={`flex h-8 w-8 items-center justify-center rounded-md border text-zinc-600 transition-colors dark:text-zinc-300 ${
single?.icon === iconId
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</button>
);
})}
</div>
</div>
<ColorField
id="node-bg"
label="Fond"
value={style.backgroundColor}
fallback={palette.background}
onChange={(color) => updateNodeStyle(node.id, { backgroundColor: color })}
/>
<ColorField
id="node-text-color"
label="Texte"
value={style.textColor}
fallback={palette.text}
onChange={(color) => updateNodeStyle(node.id, { textColor: color })}
/>
<ColorField
id="node-border"
label="Bordure"
value={style.borderColor}
fallback={palette.border}
onChange={(color) => updateNodeStyle(node.id, { borderColor: color })}
<SelectField
id="icon-position"
label="Position de l'icone"
value={styleOf(nodes, (node) => node.iconStyle?.position).value ?? 'left'}
mixed={styleOf(nodes, (node) => node.iconStyle?.position).mixed}
options={ICON_POSITIONS}
onChange={(position) => updateNodesIconStyle(ids, { position })}
/>
<div>
<label className="field-label" htmlFor="node-shape">
Forme
</label>
<select
id="node-shape"
className="field-input"
value={style.shape ?? theme.nodeShape}
onChange={(event) => updateNodeStyle(node.id, { shape: event.target.value as NodeShape })}
>
{SHAPES.map((shape) => (
<option key={shape.id} value={shape.id}>
{shape.label}
</option>
))}
</select>
</div>
<SegmentedControl
legend="Taille de l'icone"
mixed={styleOf(nodes, (node) => node.iconStyle?.size).mixed}
selected={String(
styleOf(nodes, (node) => node.iconStyle?.size).value ?? metrics.iconSize,
)}
options={ICON_SIZES.map((size) => ({ value: String(size), label: `${size} px` }))}
onSelect={(value) => updateNodesIconStyle(ids, { size: Number(value) })}
/>
<div>
<label className="field-label" htmlFor="node-size">
Taille du texte : {style.fontSize ?? (depth === 0 ? 16 : 14)} px
</label>
<input
id="node-size"
type="range"
min={10}
max={32}
step={1}
value={style.fontSize ?? (depth === 0 ? 16 : 14)}
onChange={(event) => updateNodeStyle(node.id, { fontSize: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<NumberField
id="icon-gap"
label="Espacement icone / texte"
value={styleOf(nodes, (node) => node.iconStyle?.gap).value ?? metrics.iconGap}
mixed={styleOf(nodes, (node) => node.iconStyle?.gap).mixed}
min={LAYOUT_LIMITS.iconGap.min}
max={LAYOUT_LIMITS.iconGap.max}
onChange={(gap) => updateNodesIconStyle(ids, { gap })}
/>
</PanelSection>
<div className="flex items-center gap-2">
<PanelSection id="typography" title="Typographie">
<SelectField
id="font-family"
label="Police"
value={styleOf(nodes, (node) => node.style?.fontFamily).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.fontFamily).mixed}
options={FONT_FAMILY_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(fontFamily) =>
updateNodesStyle(ids, {
fontFamily: fontFamily === 'theme' ? undefined : (fontFamily as FontFamilyId),
})
}
/>
<NumberField
id="font-size"
label="Taille du texte"
value={
styleOf(nodes, (node) => node.style?.fontSize).value ?? (reference?.fontSize ?? 14)
}
mixed={styleOf(nodes, (node) => node.style?.fontSize).mixed}
min={LAYOUT_LIMITS.fontSize.min}
max={LAYOUT_LIMITS.fontSize.max}
onChange={(fontSize) => updateNodesStyle(ids, { fontSize })}
/>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant={style.bold ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.bold)}
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
variant={styleOf(nodes, (node) => node.style?.bold).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.bold).value)}
aria-label="Texte gras"
title="Gras"
className="font-bold"
onClick={() =>
updateNodesStyle(ids, {
bold: !styleOf(nodes, (node) => node.style?.bold).value,
})
}
>
G
</Button>
<Button
size="sm"
variant={style.italic ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.italic)}
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
variant={styleOf(nodes, (node) => node.style?.italic).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.italic).value)}
aria-label="Texte italique"
title="Italique"
className="italic"
onClick={() =>
updateNodesStyle(ids, {
italic: !styleOf(nodes, (node) => node.style?.italic).value,
})
}
>
I
</Button>
<div className="ml-auto flex gap-1">
{ALIGNMENTS.map((alignment) => (
<Button
key={alignment.id}
size="sm"
variant={(style.textAlign ?? 'left') === alignment.id ? 'primary' : 'secondary'}
aria-pressed={(style.textAlign ?? 'left') === alignment.id}
aria-label={`Alignement ${alignment.label}`}
onClick={() => updateNodeStyle(node.id, { textAlign: alignment.id })}
>
{alignment.label.charAt(0)}
</Button>
))}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-url">
Lien
</label>
<div className="flex items-center gap-2">
<input
id="node-url"
type="url"
inputMode="url"
placeholder="https://exemple.fr"
className="field-input"
value={url}
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{node.url ? (
<a
href={node.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
aria-label="Ouvrir le lien dans un nouvel onglet"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LinkIcon />
</a>
) : null}
</div>
</div>
<SegmentedControl
legend="Alignement horizontal"
options={HORIZONTAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.textAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.textAlign).value ?? 'left'}
onSelect={(textAlign) => updateNodesStyle(ids, { textAlign })}
/>
<div>
<label className="field-label" htmlFor="node-note">
Note
</label>
<textarea
id="node-note"
className="field-input min-h-[80px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note}
onChange={(event) => setNote(event.target.value)}
onBlur={() => note !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })}
/>
</div>
<SegmentedControl
legend="Alignement vertical"
options={VERTICAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.verticalAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.verticalAlign).value ?? 'top'}
onSelect={(verticalAlign) => updateNodesStyle(ids, { verticalAlign })}
/>
<div className="space-y-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<Button size="sm" className="w-full" onClick={() => resetNodeStyle(node.id)}>
Revenir au style du theme
<NumberField
id="line-height"
label="Interligne"
unit=""
step={0.1}
value={styleOf(nodes, (node) => node.style?.lineHeight).value ?? metrics.lineHeight}
mixed={styleOf(nodes, (node) => node.style?.lineHeight).mixed}
min={LAYOUT_LIMITS.lineHeight.min}
max={LAYOUT_LIMITS.lineHeight.max}
onChange={(lineHeight) => updateNodesStyle(ids, { lineHeight })}
/>
</PanelSection>
<PanelSection id="appearance" title="Apparence">
<ColorField
id="node-bg"
label="Fond"
shared={styleOf(nodes, (node) => node.style?.backgroundColor)}
fallback={palette.background}
onChange={(backgroundColor) => updateNodesStyle(ids, { backgroundColor })}
/>
<ColorField
id="node-text-color"
label="Couleur du texte"
shared={styleOf(nodes, (node) => node.style?.textColor)}
fallback={palette.text}
onChange={(textColor) => updateNodesStyle(ids, { textColor })}
/>
<ColorField
id="node-border"
label="Bordure"
shared={styleOf(nodes, (node) => node.style?.borderColor)}
fallback={palette.border}
onChange={(borderColor) => updateNodesStyle(ids, { borderColor })}
/>
<SelectField
id="node-shape"
label="Forme"
value={styleOf(nodes, (node) => node.style?.shape).value ?? theme.nodeShape}
mixed={styleOf(nodes, (node) => node.style?.shape).mixed}
options={SHAPES}
onChange={(shape) => updateNodesStyle(ids, { shape })}
/>
<SelectField
id="node-shadow"
label="Ombre"
value={styleOf(nodes, (node) => node.style?.shadow).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.shadow).mixed}
options={SHADOW_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(shadow) =>
updateNodesStyle(ids, { shadow: shadow === 'theme' ? undefined : (shadow as ShadowStyle) })
}
/>
</PanelSection>
<PanelSection id="layout" title="Mise en page">
<SegmentedControl
legend="Taille du noeud"
mixed={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').mixed}
selected={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').value ?? 'auto'}
options={[
{ value: 'auto', label: 'Automatique' },
{ value: 'fixed', label: 'Personnalisee' },
]}
onSelect={(widthMode) => {
if (widthMode === 'auto') {
fitNodesToContent(ids);
return;
}
updateNodesLayout(ids, {
widthMode: 'fixed',
width: reference?.width ?? Math.round(reference?.minWidth ?? 160),
});
}}
/>
<NumberField
id="node-width"
label="Largeur"
value={
styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).value ??
(reference?.width ?? 160)
}
mixed={styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).mixed}
min={LAYOUT_LIMITS.width.min}
max={LAYOUT_LIMITS.width.max}
onChange={(width) => updateNodesLayout(ids, { widthMode: 'fixed', width })}
/>
<NumberField
id="node-height"
label="Hauteur"
value={styleOf(nodes, (node) => node.layout?.height).value ?? (reference?.height ?? 48)}
mixed={styleOf(nodes, (node) => node.layout?.height).mixed}
min={LAYOUT_LIMITS.height.min}
max={LAYOUT_LIMITS.height.max}
onChange={(height) => updateNodesLayout(ids, { heightMode: 'fixed', height })}
/>
<NumberField
id="padding-x"
label="Padding horizontal"
value={styleOf(nodes, (node) => node.layout?.paddingX).value ?? metrics.paddingX}
mixed={styleOf(nodes, (node) => node.layout?.paddingX).mixed}
min={LAYOUT_LIMITS.paddingX.min}
max={LAYOUT_LIMITS.paddingX.max}
onChange={(paddingX) => updateNodesLayout(ids, { paddingX })}
/>
<NumberField
id="padding-y"
label="Padding vertical"
value={styleOf(nodes, (node) => node.layout?.paddingY).value ?? metrics.paddingY}
mixed={styleOf(nodes, (node) => node.layout?.paddingY).mixed}
min={LAYOUT_LIMITS.paddingY.min}
max={LAYOUT_LIMITS.paddingY.max}
onChange={(paddingY) => updateNodesLayout(ids, { paddingY })}
/>
<ToggleRow
id="node-wrap"
label="Retour a la ligne automatique"
checked={styleOf(nodes, (node) => node.layout?.wrap ?? true).value ?? true}
mixed={styleOf(nodes, (node) => node.layout?.wrap ?? true).mixed}
onChange={(wrap) => updateNodesLayout(ids, { wrap })}
/>
<NumberField
id="max-text-width"
label="Largeur maximale du texte"
value={styleOf(nodes, (node) => node.layout?.maxTextWidth).value ?? metrics.maxTextWidth}
mixed={styleOf(nodes, (node) => node.layout?.maxTextWidth).mixed}
min={LAYOUT_LIMITS.maxTextWidth.min}
max={LAYOUT_LIMITS.maxTextWidth.max}
disabled={styleOf(nodes, (node) => node.layout?.wrap ?? true).value === false}
onChange={(maxTextWidth) => updateNodesLayout(ids, { maxTextWidth })}
/>
</PanelSection>
<PanelSection id="branch" title="Branche">
<SelectField
id="branch-style"
label="Style"
value={styleOf(nodes, (node) => node.branch?.style).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.branch?.style).mixed}
options={BRANCH_STYLES}
onChange={(style) =>
updateNodesBranch(ids, {
style: style === 'theme' ? undefined : (style as BranchStyle),
})
}
/>
<NumberField
id="branch-width"
label="Epaisseur"
value={styleOf(nodes, (node) => node.branch?.width).value ?? theme.branchWidth}
mixed={styleOf(nodes, (node) => node.branch?.width).mixed}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
onChange={(width) => updateNodesBranch(ids, { width })}
/>
<ColorField
id="branch-color"
label="Couleur"
shared={styleOf(nodes, (node) => node.branch?.color)}
fallback={theme.branchColor}
onChange={(color) => updateNodesBranch(ids, { color })}
/>
</PanelSection>
<div className="space-y-2 px-4 py-4">
{single?.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(single.id)}>
Detacher de son parent
</Button>
{node.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}>
Detacher de son parent
</Button>
) : null}
<Button
size="sm"
variant="danger"
className="w-full"
onClick={() => deleteNodes([node.id])}
>
<TrashIcon className="h-4 w-4" />
Supprimer ce noeud
</Button>
</div>
) : null}
<Button size="sm" variant="danger" className="w-full" onClick={() => deleteNodes(ids)}>
<TrashIcon className="h-4 w-4" />
{nodes.length > 1 ? `Supprimer ${nodes.length} noeuds` : 'Supprimer ce noeud'}
</Button>
</div>
</aside>
);

View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SearchIcon } from '@/components/Icons';
import { RELATION_REJECTION_MESSAGE } from '@/services/relations';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
export interface RelationDialogProps {
open: boolean;
sourceId: string | null;
onClose: () => void;
}
function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Creation d'une relation secondaire.
*
* Le choix passe par une recherche explicite, et non par un glissement depuis
* une poignee : tirer un lien depuis une poignee cree deja un rattachement
* parent / enfant. Deux gestes differents pour deux natures differentes, il n'y
* a donc aucune ambiguite sur ce que l'on est en train de creer.
*/
export function RelationDialog({ open, sourceId, onClose }: RelationDialogProps) {
const map = useEditorStore((state) => state.map);
const addRelation = useEditorStore((state) => state.addRelation);
const pushToast = useUiStore((state) => state.pushToast);
const [query, setQuery] = useState('');
const [label, setLabel] = useState('');
useEffect(() => {
if (open) {
setQuery('');
setLabel('');
}
}, [open]);
const source = map?.nodes.find((candidate) => candidate.id === sourceId);
const candidates = useMemo(() => {
if (!map || !source) return [];
const depths = computeDepths(map.nodes);
const needle = normalize(query.trim());
return map.nodes
.filter((candidate) => candidate.id !== source.id)
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
.slice(0, 60);
}, [map, source, query]);
const create = (targetId: string) => {
if (!source) return;
const trimmed = label.trim();
const rejection = addRelation(source.id, targetId, trimmed ? { label: trimmed } : undefined);
if (rejection) {
pushToast(RELATION_REJECTION_MESSAGE[rejection], 'error');
return;
}
pushToast('Relation creee. La hierarchie est inchangee.', 'success');
onClose();
};
return (
<Modal
open={open}
title="Ajouter une relation"
description={
source
? `Relation visuelle depuis "${source.text || 'Sans titre'}". Elle ne modifie ni le parent ni les enfants.`
: ''
}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<div>
<label className="field-label" htmlFor="relation-label">
Libelle (facultatif)
</label>
<input
id="relation-label"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise, protege..."
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
autoFocus
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher le noeud cible"
aria-label="Rechercher le noeud cible"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
{candidates.length === 0 ? (
<p className="px-1 py-3 text-sm text-zinc-500">Aucun noeud ne correspond.</p>
) : (
<ul className="max-h-64 overflow-y-auto" aria-label="Cibles possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => create(candidate.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span
aria-hidden="true"
className="shrink-0 font-mono text-[11px] text-zinc-400"
style={{ paddingLeft: depth * 10 }}
>
{depth === 0 ? '\u25cf' : '\u2514'}
</span>
<span className="truncate">{candidate.text || 'Sans titre'}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { type BranchStyle, type RelationMarker } from '@/types/map';
import { Button } from '@/components/Button';
import { TrashIcon } from '@/components/Icons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
import {
ColorField,
NumberField,
SegmentedControl,
ToggleRow,
} from '@/features/editor/panel/controls';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { RELATION_DEFAULTS } from '@/features/editor/flowAdapters';
import { getTheme, relationColor } from '@/features/editor/themes';
import { useEditorStore } from '@/stores/editorStore';
const LINE_STYLES: Array<{ value: BranchStyle; label: string }> = [
{ value: 'bezier', label: 'Bezier' },
{ value: 'curved', label: 'Courbe' },
{ value: 'straight', label: 'Droite' },
];
const MARKERS: Array<{ value: RelationMarker; label: string }> = [
{ value: 'none', label: 'Aucune' },
{ value: 'arrow', label: 'Fleche' },
];
/**
* Proprietes d'une relation secondaire.
*
* Volontairement limite a ce qui concerne le trait : une relation n'a ni
* parent, ni enfants, ni ordre. La supprimer ici ne supprime aucun noeud.
*/
export function RelationProperties({ relationId }: { relationId: string }) {
const map = useEditorStore((state) => state.map);
const updateRelation = useEditorStore((state) => state.updateRelation);
const deleteRelation = useEditorStore((state) => state.deleteRelation);
const relation = map?.relations.find((candidate) => candidate.id === relationId);
const [label, setLabel] = useState('');
useEffect(() => setLabel(relation?.label ?? ''), [relation?.id, relation?.label]);
if (!map || !relation) return null;
const theme = getTheme(map.settings.theme);
const sourceText = map.nodes.find((node) => node.id === relation.sourceId)?.text ?? '';
const targetText = map.nodes.find((node) => node.id === relation.targetId)?.text ?? '';
return (
<>
<PanelSection id="relation-content" title="Relation">
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{sourceText || 'Sans titre'}
</span>
{' → '}
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{targetText || 'Sans titre'}
</span>
</p>
<p className="text-[11px] leading-snug text-zinc-400">
Connexion visuelle uniquement : la hierarchie de ces deux noeuds est inchangee.
</p>
<div>
<label className="field-label" htmlFor="relation-label-field">
Libelle
</label>
<input
id="relation-label-field"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise..."
onChange={(event) => setLabel(event.target.value)}
onBlur={() => {
const trimmed = label.trim();
if (trimmed !== (relation.label ?? '')) {
updateRelation(relation.id, trimmed ? { label: trimmed } : { label: undefined });
}
}}
/>
</div>
</PanelSection>
<PanelSection id="relation-style" title="Apparence">
<SegmentedControl
legend="Trace"
selected={relation.style ?? RELATION_DEFAULTS.style}
options={LINE_STYLES}
onSelect={(value) => updateRelation(relation.id, { style: value })}
/>
<ColorField
id="relation-color"
label="Couleur"
shared={{ value: relation.color, mixed: false }}
fallback={relationColor(theme)}
onChange={(value) => updateRelation(relation.id, { color: value })}
/>
<NumberField
id="relation-width"
label="Epaisseur"
value={relation.width ?? RELATION_DEFAULTS.width}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
step={0.5}
onChange={(value) => updateRelation(relation.id, { width: value })}
/>
<ToggleRow
id="relation-dashed"
label="Trait pointille"
checked={relation.dashed ?? RELATION_DEFAULTS.dashed}
onChange={(checked) => updateRelation(relation.id, { dashed: checked })}
/>
<SegmentedControl
legend="Extremite d arrivee"
selected={relation.markerEnd ?? RELATION_DEFAULTS.markerEnd}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerEnd: value })}
/>
<SegmentedControl
legend="Extremite de depart"
selected={relation.markerStart ?? RELATION_DEFAULTS.markerStart}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerStart: value })}
/>
</PanelSection>
<div className="px-4 py-3">
<Button size="sm" variant="danger" onClick={() => deleteRelation(relation.id)}>
<TrashIcon className="h-3.5 w-3.5" />
Supprimer la relation
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,112 @@
import {
AlignCenterHorizontal,
AlignCenterVertical,
AlignEndHorizontal,
AlignEndVertical,
AlignHorizontalDistributeCenter,
AlignStartHorizontal,
AlignStartVertical,
AlignVerticalDistributeCenter,
} from 'lucide-react';
import { type AlignAxis, type DistributeAxis, useEditorStore } from '@/stores/editorStore';
import { cn } from '@/utils/cn';
interface ToolButton {
label: string;
icon: React.ReactNode;
run: () => void;
}
/**
* Barre contextuelle de mise en page, visible des qu'au moins deux noeuds sont
* selectionnes. Chaque action produit une seule entree d'historique.
*/
export function SelectionToolbar() {
const count = useEditorStore((state) => state.selectedNodeIds.length);
const readOnly = useEditorStore((state) => state.readOnly);
const alignNodes = useEditorStore((state) => state.alignNodes);
const distributeNodes = useEditorStore((state) => state.distributeNodes);
if (readOnly || count < 2) return null;
const align = (axis: AlignAxis) => () => alignNodes(axis);
const distribute = (axis: DistributeAxis) => () => distributeNodes(axis);
const alignButtons: ToolButton[] = [
{ label: 'Aligner a gauche', icon: <AlignStartVertical size={16} />, run: align('left') },
{
label: 'Aligner horizontalement au centre',
icon: <AlignCenterVertical size={16} />,
run: align('hcenter'),
},
{ label: 'Aligner a droite', icon: <AlignEndVertical size={16} />, run: align('right') },
{ label: 'Aligner en haut', icon: <AlignStartHorizontal size={16} />, run: align('top') },
{
label: 'Aligner verticalement au centre',
icon: <AlignCenterHorizontal size={16} />,
run: align('vcenter'),
},
{ label: 'Aligner en bas', icon: <AlignEndHorizontal size={16} />, run: align('bottom') },
];
const distributeButtons: ToolButton[] = [
{
label: 'Distribuer horizontalement',
icon: <AlignHorizontalDistributeCenter size={16} />,
run: distribute('horizontal'),
},
{
label: 'Distribuer verticalement',
icon: <AlignVerticalDistributeCenter size={16} />,
run: distribute('vertical'),
},
];
const buttonClass = cn(
'flex h-8 w-8 items-center justify-center rounded-md text-zinc-600 transition-colors',
'hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-40',
'dark:text-zinc-300 dark:hover:bg-zinc-800',
);
return (
<div
className="no-print panel absolute left-1/2 top-4 z-20 flex -translate-x-1/2 animate-fade-in items-center gap-1 px-2 py-1.5"
role="toolbar"
aria-label="Mise en page de la selection"
>
<span className="px-1.5 text-xs font-medium text-zinc-400">{count} noeuds</span>
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
{alignButtons.map((button) => (
<button
key={button.label}
type="button"
className={buttonClass}
title={button.label}
aria-label={button.label}
onClick={button.run}
>
{button.icon}
</button>
))}
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
{distributeButtons.map((button) => (
<button
key={button.label}
type="button"
className={buttonClass}
title={
count < 3 ? `${button.label} (3 noeuds minimum)` : button.label
}
aria-label={button.label}
disabled={count < 3}
onClick={button.run}
>
{button.icon}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { computeAlignmentGuides, type GuideBox } from '@/features/editor/alignmentGuides';
const box = (id: string, x: number, y: number, width = 100, height = 40): GuideBox => ({
id,
x,
y,
width,
height,
});
describe('guides d alignement', () => {
it('detecte un alignement des bords gauches', () => {
const moving = box('a', 100, 300);
const guides = computeAlignmentGuides(moving, [moving, box('b', 102, 0)]);
const vertical = guides.find((guide) => guide.orientation === 'vertical');
expect(vertical?.position).toBe(102);
});
it('detecte un alignement des centres horizontaux', () => {
const moving = box('a', 0, 200);
const guides = computeAlignmentGuides(moving, [moving, box('b', 500, 202)]);
const horizontal = guides.find((guide) => guide.orientation === 'horizontal');
expect(horizontal?.position).toBe(202);
});
it('ne propose rien au-dela du seuil', () => {
const moving = box('a', 0, 0);
expect(computeAlignmentGuides(moving, [moving, box('b', 400, 400)])).toHaveLength(0);
});
it('ignore le noeud deplace lui-meme', () => {
const moving = box('a', 0, 0);
expect(computeAlignmentGuides(moving, [moving])).toHaveLength(0);
});
it('produit au plus un guide par axe', () => {
const moving = box('a', 100, 100);
const guides = computeAlignmentGuides(moving, [
moving,
box('b', 100, 300),
box('c', 101, 400),
box('d', 400, 100),
]);
expect(guides.filter((guide) => guide.orientation === 'vertical')).toHaveLength(1);
expect(guides.filter((guide) => guide.orientation === 'horizontal')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,119 @@
/**
* Guides d'alignement affiches pendant un deplacement.
*
* Volontairement simple : on compare les bords et les centres du noeud deplace
* a ceux des autres noeuds visibles, et on ne garde que la meilleure
* correspondance sur chaque axe. Aucun etat global, aucune entree d'historique.
*/
export interface GuideBox {
id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface AlignmentGuide {
orientation: 'vertical' | 'horizontal';
/** Coordonnee de la ligne (x pour verticale, y pour horizontale). */
position: number;
/** Etendue de la ligne, pour ne pas traverser tout le canvas. */
start: number;
end: number;
}
export const GUIDE_THRESHOLD = 6;
type Candidate = { value: number; span: [number, number] };
function verticalCandidates(box: GuideBox): Candidate[] {
const span: [number, number] = [box.y, box.y + box.height];
return [
{ value: box.x, span },
{ value: box.x + box.width / 2, span },
{ value: box.x + box.width, span },
];
}
function horizontalCandidates(box: GuideBox): Candidate[] {
const span: [number, number] = [box.x, box.x + box.width];
return [
{ value: box.y, span },
{ value: box.y + box.height / 2, span },
{ value: box.y + box.height, span },
];
}
function bestMatch(
moving: Candidate[],
others: GuideBox[],
extract: (box: GuideBox) => Candidate[],
threshold: number,
): { position: number; span: [number, number] } | null {
let best: { position: number; span: [number, number]; distance: number } | null = null;
for (const other of others) {
for (const candidate of extract(other)) {
for (const source of moving) {
const distance = Math.abs(source.value - candidate.value);
if (distance > threshold) continue;
if (best && distance >= best.distance) continue;
best = {
position: candidate.value,
span: [
Math.min(source.span[0], candidate.span[0]),
Math.max(source.span[1], candidate.span[1]),
],
distance,
};
}
}
}
return best ? { position: best.position, span: best.span } : null;
}
/** Au plus un guide vertical et un guide horizontal. */
export function computeAlignmentGuides(
moving: GuideBox,
others: GuideBox[],
threshold: number = GUIDE_THRESHOLD,
): AlignmentGuide[] {
const candidates = others.filter((box) => box.id !== moving.id);
if (candidates.length === 0) return [];
const guides: AlignmentGuide[] = [];
const vertical = bestMatch(
verticalCandidates(moving),
candidates,
verticalCandidates,
threshold,
);
if (vertical) {
guides.push({
orientation: 'vertical',
position: vertical.position,
start: vertical.span[0],
end: vertical.span[1],
});
}
const horizontal = bestMatch(
horizontalCandidates(moving),
candidates,
horizontalCandidates,
threshold,
);
if (horizontal) {
guides.push({
orientation: 'horizontal',
position: horizontal.position,
start: horizontal.span[0],
end: horizontal.span[1],
});
}
return guides;
}

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest';
import { type MindMapNode } from '@/types/map';
import {
assignSides,
findBranchRoot,
getLevelOneBranches,
materializeSides,
resolveNodeSides,
} from '@/features/editor/bilateral';
import { computeLayout } from '@/features/editor/layout';
function node(
id: string,
parentId: string | null,
order: number,
extra: Partial<MindMapNode> = {},
): MindMapNode {
return {
id,
type: parentId === null ? 'root' : 'standard',
text: id,
position: { x: 0, y: 0 },
parentId,
order,
...extra,
};
}
/**
* Racine avec quatre branches de poids tres differents :
* A = 25 noeuds, B = 3, C = 12, D = 5.
*/
function unbalancedMap(): MindMapNode[] {
const nodes: MindMapNode[] = [node('root', null, 0)];
const weights: Array<[string, number]> = [
['A', 24],
['B', 2],
['C', 11],
['D', 4],
];
weights.forEach(([branch, children], index) => {
nodes.push(node(branch, 'root', index));
for (let i = 0; i < children; i += 1) nodes.push(node(`${branch}${i}`, branch, i));
});
return nodes;
}
describe('attribution automatique des cotes', () => {
it('repartit les branches sans cote de part et d autre', () => {
const sides = resolveNodeSides(unbalancedMap());
const branches = ['A', 'B', 'C', 'D'].map((id) => sides.get(id));
expect(branches).toContain('left');
expect(branches).toContain('right');
});
it('equilibre les deux cotes en tenant compte de la taille des sous-arbres', () => {
const nodes = unbalancedMap();
const sides = resolveNodeSides(nodes);
const weights: Record<string, number> = { A: 25, B: 3, C: 12, D: 5 };
let left = 0;
let right = 0;
for (const [branch, weight] of Object.entries(weights)) {
if (sides.get(branch) === 'left') left += weight;
else right += weight;
}
// 25 d'un cote, 3 + 12 + 5 = 20 de l'autre : l'ecart reste faible.
expect(Math.abs(left - right)).toBeLessThanOrEqual(5);
});
it('est deterministe : deux appels donnent le meme resultat', () => {
const first = resolveNodeSides(unbalancedMap());
const second = resolveNodeSides(unbalancedMap());
for (const id of ['A', 'B', 'C', 'D']) expect(second.get(id)).toBe(first.get(id));
});
it('respecte un cote choisi a la main et equilibre le reste autour', () => {
const nodes = unbalancedMap().map((candidate) =>
candidate.id === 'A' ? { ...candidate, side: 'left' as const } : candidate,
);
const sides = resolveNodeSides(nodes);
expect(sides.get('A')).toBe('left');
// 25 a gauche : les trois autres branches vont a droite.
expect(sides.get('B')).toBe('right');
expect(sides.get('C')).toBe('right');
expect(sides.get('D')).toBe('right');
});
it('departage a poids egal par l ordre de fratrie', () => {
const result = assignSides([
{ id: 'x', weight: 4, order: 0 },
{ id: 'y', weight: 4, order: 1 },
]);
expect(result.get('x')).toBe('right');
expect(result.get('y')).toBe('left');
});
});
describe('heritage du cote', () => {
it('donne a tous les descendants le cote de leur branche de niveau 1', () => {
const nodes = unbalancedMap();
const sides = resolveNodeSides(nodes);
for (const branch of ['A', 'B', 'C', 'D']) {
const expected = sides.get(branch);
for (const candidate of nodes.filter((item) => item.parentId === branch)) {
expect(sides.get(candidate.id)).toBe(expected);
}
}
});
it('identifie la branche de niveau 1 dont depend un noeud profond', () => {
const nodes = unbalancedMap();
expect(findBranchRoot(nodes, 'A3')?.id).toBe('A');
expect(findBranchRoot(nodes, 'A')?.id).toBe('A');
expect(findBranchRoot(nodes, 'root')).toBeNull();
});
it('ne liste comme branches que les enfants directs des racines', () => {
const branches = getLevelOneBranches(unbalancedMap()).map((branch) => branch.id);
expect(branches).toEqual(['A', 'B', 'C', 'D']);
});
});
describe('materialisation des cotes', () => {
it('ecrit un cote uniquement sur les branches de niveau 1', () => {
const materialized = materializeSides(unbalancedMap());
for (const candidate of materialized) {
const isBranch = ['A', 'B', 'C', 'D'].includes(candidate.id);
expect(candidate.side !== undefined).toBe(isBranch);
}
});
it('ne modifie pas le tableau si tous les cotes sont deja definis', () => {
const once = materializeSides(unbalancedMap());
expect(materializeSides(once)).toBe(once);
});
it('n ecrase jamais un cote choisi a la main', () => {
const nodes = unbalancedMap().map((candidate) =>
candidate.id === 'B' ? { ...candidate, side: 'left' as const } : candidate,
);
const materialized = materializeSides(nodes);
expect(materialized.find((candidate) => candidate.id === 'B')?.side).toBe('left');
});
});
describe('auto-layout bilateral', () => {
it('place la racine entre les deux cotes', () => {
const nodes = materializeSides(unbalancedMap());
const sides = resolveNodeSides(nodes);
const positions = computeLayout(nodes, 'mindmap-both');
const rootX = positions.get('root')?.x as number;
const lefts = nodes
.filter((candidate) => sides.get(candidate.id) === 'left' && candidate.parentId === 'root')
.map((candidate) => positions.get(candidate.id)?.x as number);
const rights = nodes
.filter((candidate) => sides.get(candidate.id) === 'right' && candidate.parentId === 'root')
.map((candidate) => positions.get(candidate.id)?.x as number);
expect(lefts.length).toBeGreaterThan(0);
expect(rights.length).toBeGreaterThan(0);
for (const x of lefts) expect(x).toBeLessThan(rootX);
for (const x of rights) expect(x).toBeGreaterThan(rootX);
});
it('fait progresser chaque cote en s eloignant de la racine', () => {
const nodes = materializeSides(unbalancedMap());
const sides = resolveNodeSides(nodes);
const positions = computeLayout(nodes, 'mindmap-both');
const rootX = positions.get('root')?.x as number;
for (const branch of ['A', 'B', 'C', 'D']) {
const branchX = positions.get(branch)?.x as number;
const child = nodes.find((candidate) => candidate.parentId === branch);
if (!child) continue;
const childX = positions.get(child.id)?.x as number;
if (sides.get(branch) === 'left') {
expect(childX).toBeLessThan(branchX);
expect(branchX).toBeLessThan(rootX);
} else {
expect(childX).toBeGreaterThan(branchX);
expect(branchX).toBeGreaterThan(rootX);
}
}
});
it('respecte l ordre des freres a l interieur d un cote', () => {
const nodes: MindMapNode[] = [
node('root', null, 0),
node('a', 'root', 0, { side: 'right' }),
node('b', 'root', 1, { side: 'right' }),
node('c', 'root', 2, { side: 'right' }),
];
const positions = computeLayout(nodes, 'mindmap-both');
const y = (id: string) => positions.get(id)?.y as number;
expect(y('a')).toBeLessThan(y('b'));
expect(y('b')).toBeLessThan(y('c'));
});
it('deploie vers la droite ou vers la gauche selon la disposition classique', () => {
const nodes: MindMapNode[] = [node('root', null, 0), node('a', 'root', 0)];
const right = computeLayout(nodes, 'mindmap-right');
expect((right.get('a')?.x as number)).toBeGreaterThan(right.get('root')?.x as number);
const left = computeLayout(nodes, 'mindmap-left');
expect((left.get('a')?.x as number)).toBeLessThan(left.get('root')?.x as number);
});
it('ignore les relations secondaires : elles ne sont pas dans le graphe', () => {
// computeLayout ne recoit que des noeuds : aucune relation ne peut
// influencer dagre, par construction de sa signature.
const nodes = materializeSides(unbalancedMap());
const withoutRelations = computeLayout(nodes, 'mindmap-both');
const again = computeLayout(nodes, 'mindmap-both');
for (const [id, position] of withoutRelations) {
expect(again.get(id)).toEqual(position);
}
});
});

View File

@@ -0,0 +1,152 @@
import { type MindMapNode, type NodeSide } from '@/types/map';
import { buildChildrenMap, buildNodeMap, getOrderedChildren, getRootNodes } from '@/utils/tree';
/**
* Cotes gauche / droite d'une mind map bilaterale.
*
* Le modele est volontairement minimal : seul un enfant direct d'une racine
* porte un `side`. Tous ses descendants heritent du cote de leur branche de
* niveau 1, ce qui evite d'entretenir la meme information sur chaque noeud et
* garantit qu'une branche ne peut pas se retrouver a cheval sur les deux cotes.
*
* `order` reste la seule source de verite de l'ordre : on filtre les enfants
* par cote, puis on respecte leur ordre existant. Il n'y a jamais de
* `leftOrder` / `rightOrder` a maintenir en parallele.
*/
/** Poids d'une branche : elle-meme plus tous ses descendants. */
function branchWeight(children: Map<string, string[]>, rootId: string): number {
let total = 1;
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
total += 1;
const next = children.get(current);
if (next) queue.push(...next);
}
return total;
}
/**
* Repartition gloutonne et deterministe.
*
* Les branches sans cote sont traitees de la plus lourde a la plus legere et
* placees du cote le moins charge. C'est suffisant pour equilibrer visuellement
* une carte reelle, sans construire un solveur : a poids egal, l'ordre de
* fratrie tranche, donc le resultat est reproductible.
*/
export function assignSides(
branches: Array<{ id: string; weight: number; order: number; side?: NodeSide }>,
): Map<string, NodeSide> {
const result = new Map<string, NodeSide>();
let leftLoad = 0;
let rightLoad = 0;
for (const branch of branches) {
if (!branch.side) continue;
result.set(branch.id, branch.side);
if (branch.side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
const pending = branches
.filter((branch) => !branch.side)
.sort((a, b) => (b.weight !== a.weight ? b.weight - a.weight : a.order - b.order));
for (const branch of pending) {
// A charge egale, la droite d'abord : c'est le cote de lecture naturel.
const side: NodeSide = rightLoad <= leftLoad ? 'right' : 'left';
result.set(branch.id, side);
if (side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
return result;
}
/** Branches de niveau 1 : les enfants directs des racines, dans l'ordre. */
export function getLevelOneBranches(nodes: MindMapNode[]): MindMapNode[] {
const branches: MindMapNode[] = [];
for (const root of getRootNodes(nodes)) {
branches.push(...getOrderedChildren(nodes, root.id));
}
return branches;
}
/**
* Cote effectif de chaque noeud.
*
* Fonction pure : elle ne modifie pas le document. Une branche sans cote defini
* en recoit un ici aussi, par le meme algorithme que l'attribution explicite :
* une branche ajoutee apres coup se place donc immediatement du bon cote, sans
* qu'il faille reecrire la carte.
*/
export function resolveNodeSides(nodes: MindMapNode[]): Map<string, NodeSide> {
const children = buildChildrenMap(nodes);
const branches = getLevelOneBranches(nodes).map((node) => ({
id: node.id,
weight: branchWeight(children, node.id),
order: node.order ?? 0,
side: node.side,
}));
const byBranch = assignSides(branches);
const sides = new Map<string, NodeSide>();
// Les racines restent au centre : leur cote ne sert qu'aux cas degenres.
for (const root of getRootNodes(nodes)) sides.set(root.id, 'right');
for (const [branchId, side] of byBranch) {
sides.set(branchId, side);
const queue = [...(children.get(branchId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
sides.set(current, side);
const next = children.get(current);
if (next) queue.push(...next);
}
}
// Securite : un noeud orphelin d'une donnee incoherente reste positionnable.
for (const node of nodes) if (!sides.has(node.id)) sides.set(node.id, 'right');
return sides;
}
/**
* Materialise les cotes manquants dans le document.
*
* Seules les branches de niveau 1 sans cote sont completees : un cote choisi a
* la main n'est jamais ecrase. Retourne le tableau d'origine si rien ne change.
*/
export function materializeSides(nodes: MindMapNode[]): MindMapNode[] {
const branches = getLevelOneBranches(nodes);
if (branches.every((branch) => branch.side)) return nodes;
const resolved = resolveNodeSides(nodes);
const branchIds = new Set(branches.map((branch) => branch.id));
return nodes.map((node) =>
branchIds.has(node.id) && !node.side
? { ...node, side: resolved.get(node.id) ?? 'right' }
: node,
);
}
/**
* Branche de niveau 1 dont depend un noeud, c'est-a-dire le noeud dont le
* `side` fait foi pour lui. `null` pour une racine.
*/
export function findBranchRoot(nodes: MindMapNode[], nodeId: string): MindMapNode | null {
const byId = buildNodeMap(nodes);
let current = byId.get(nodeId);
if (!current) return null;
const guard = new Set<string>();
while (current.parentId && byId.has(current.parentId) && !guard.has(current.id)) {
guard.add(current.id);
const parent = byId.get(current.parentId) as MindMapNode;
if (!parent.parentId || !byId.has(parent.parentId)) return current;
current = parent;
}
return null;
}

View File

@@ -1,27 +1,23 @@
import { type Edge, type Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
import { type BranchStyle, type MindMap, type MindMapNode, type NodeShape } from '@/types/map';
import { type MapTheme, paletteForDepth } from '@/features/editor/themes';
import { 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,
} from '@/features/editor/nodeStyleResolver';
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { buildChildrenMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
import { buildChildrenMap, buildNodeMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
import { type NodeSize } from '@/utils/nodeSize';
/** Style CSS resolu d'un noeud : theme + personnalisations explicites. */
export interface NodeAppearance {
background: string;
color: string;
borderColor: string;
borderWidth: number;
borderRadius: string;
fontSize: number;
fontFamily: string;
fontWeight: number;
fontStyle: 'normal' | 'italic';
textAlign: 'left' | 'center' | 'right';
boxShadow: string;
width?: number;
circle: boolean;
}
export { resolveNodeAppearance };
export type { NodeAppearance };
export interface MindNodeData extends Record<string, unknown> {
node: MindMapNode;
@@ -32,57 +28,23 @@ 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'>;
function radiusForShape(shape: NodeShape, themeRadius: number): string {
switch (shape) {
case 'rectangle':
return '3px';
case 'capsule':
return '999px';
case 'circle':
return '50%';
case 'rounded':
default:
return `${themeRadius}px`;
}
}
/**
* Fusionne le theme et les personnalisations du noeud.
* Une valeur definie explicitement sur le noeud gagne toujours : changer de
* theme ne detruit donc jamais une personnalisation.
*/
export function resolveNodeAppearance(
node: MindMapNode,
depth: number,
theme: MapTheme,
): NodeAppearance {
const palette = paletteForDepth(theme, depth);
const style = node.style ?? {};
const shape = style.shape ?? theme.nodeShape;
return {
background: style.backgroundColor ?? palette.background,
color: style.textColor ?? palette.text,
borderColor: style.borderColor ?? palette.border,
borderWidth: theme.borderWidth,
borderRadius: radiusForShape(shape, theme.nodeRadius),
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
fontFamily: theme.fontFamily,
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
fontStyle: style.italic ? 'italic' : 'normal',
textAlign: style.textAlign ?? 'left',
boxShadow: theme.shadow,
...(style.width ? { width: style.width } : {}),
circle: shape === 'circle',
};
}
function handlePositions(layout: MindMap['settings']['layout']): {
source: Position;
target: Position;
@@ -95,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',
@@ -117,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,
@@ -144,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,
},
};
});
@@ -163,20 +206,128 @@ function countBranch(children: Map<string, string[]>, rootId: string): number {
return total;
}
/**
* Les liens heritent du style de branche de la carte, sauf si le noeud enfant
* definit sa propre personnalisation (`node.branch`).
*/
export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes);
const edgeType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
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) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edgeType,
hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly,
style: {
stroke: edge.color ?? options.theme.branchColor,
strokeWidth: options.theme.branchWidth,
},
}));
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,
style: {
stroke: branch.color ?? edge.color ?? options.theme.branchColor,
strokeWidth: branch.width ?? options.theme.branchWidth,
},
};
});
}
/** Apparence par defaut d'une relation : pointille flechee, pour la distinguer. */
export const RELATION_DEFAULTS = {
style: 'bezier' as BranchStyle,
width: 2,
dashed: true,
markerEnd: 'arrow' as const,
markerStart: 'none' as const,
};
/** Prefixe des identifiants de relation : ils ne peuvent pas etre confondus
* avec un lien hierarchique, dont l'id est toujours `e-<parent>-<enfant>`. */
export const RELATION_ID_PREFIX = 'r-';
/**
* Relations secondaires converties en aretes React Flow.
*
* Elles ne sont jamais melangees a `map.edges` dans le document : les liens
* hierarchiques restent derives de `parentId`, les relations restent une donnee
* propre. C'est seulement a l'affichage que les deux se rejoignent.
*
* Une relation dont une extremite est masquee par un repli est masquee elle
* aussi : on ne trace pas un trait vers une position qui n'existe pas. Elle
* reste dans le document et reapparait au deploiement.
*/
export function toFlowRelationEdges(
map: MindMap,
options: AdapterOptions & { selectedRelationId?: string | null },
): Edge[] {
if (map.relations.length === 0) return [];
const hidden = getHiddenNodeIds(map.nodes);
const nodeMap = buildNodeMap(map.nodes);
const plans = buildHandlePlans(map);
return map.relations
.filter((relation) => nodeMap.has(relation.sourceId) && nodeMap.has(relation.targetId))
.map((relation) => {
const source = nodeMap.get(relation.sourceId) as MindMapNode;
const target = nodeMap.get(relation.targetId) as MindMapNode;
const type =
EDGE_TYPE_BY_BRANCH[relation.style ?? RELATION_DEFAULTS.style] ?? 'default';
const color = relation.color ?? relationColor(options.theme);
const width = relation.width ?? RELATION_DEFAULTS.width;
const dashed = relation.dashed ?? RELATION_DEFAULTS.dashed;
// La poignee sortante la plus proche de la cible evite un trait qui
// contourne le noeud avant de repartir.
const towards: NodeSide = target.position.x >= source.position.x ? 'right' : 'left';
const sourcePlan = plans.get(relation.sourceId);
const sourceHandle =
sourcePlan?.sources.find(
(handle) => handle.position === SIDE_POSITION[towards],
)?.id ?? sourcePlan?.sources[0]?.id;
return {
id: relation.id,
source: relation.sourceId,
target: relation.targetId,
sourceHandle,
targetHandle: plans.get(relation.targetId)?.target.id,
type,
selected: options.selectedRelationId === relation.id,
hidden: hidden.has(relation.sourceId) || hidden.has(relation.targetId),
deletable: !options.readOnly,
selectable: true,
...(relation.label ? { label: relation.label } : {}),
labelStyle: { fill: color, fontSize: 11, fontWeight: 500 },
labelBgStyle: { fill: options.theme.canvasBackground, fillOpacity: 0.85 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
markerEnd:
(relation.markerEnd ?? RELATION_DEFAULTS.markerEnd) === 'arrow'
? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
: undefined,
markerStart:
(relation.markerStart ?? RELATION_DEFAULTS.markerStart) === 'arrow'
? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
: undefined,
style: {
stroke: color,
strokeWidth: width,
...(dashed ? { strokeDasharray: '6 5' } : {}),
},
data: { relation },
};
});
}
/** Vrai si cette arete affichee est une relation secondaire. */
export function isRelationEdgeId(map: MindMap, edgeId: string): boolean {
return map.relations.some((relation) => relation.id === edgeId);
}

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import {
DEFAULT_LAYOUT_SPACING,
computeLayout,
isHorizontalLayout,
layoutDirection,
} from '@/features/editor/layout';
import { createSampleMap } from '@/services/mapFactory';
describe('auto-layout', () => {
@@ -47,3 +52,68 @@ describe('auto-layout', () => {
expect(computeLayout([], 'mindmap-right').size).toBe(0);
});
});
describe('espacement de l auto-layout', () => {
it('ecarte davantage les rangs quand parentGap augmente', () => {
const map = createSampleMap();
const root = map.nodes[0] as { id: string };
const child = map.nodes[1] as { id: string };
const compact = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 70, siblingGap: 30 },
});
const airy = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 200, siblingGap: 30 },
});
const compactDistance = (compact.get(child.id)?.x ?? 0) - (compact.get(root.id)?.x ?? 0);
const airyDistance = (airy.get(child.id)?.x ?? 0) - (airy.get(root.id)?.x ?? 0);
expect(airyDistance).toBeGreaterThan(compactDistance);
});
it('ecarte davantage les freres quand siblingGap augmente', () => {
const map = createSampleMap();
const siblings = map.nodes.filter((node) => node.parentId === map.nodes[0]?.id);
const first = siblings[0] as { id: string };
const second = siblings[1] as { id: string };
const tight = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 100, siblingGap: 10 },
});
const loose = computeLayout(map.nodes, 'mindmap-right', {
spacing: { parentGap: 100, siblingGap: 200 },
});
const tightGap = Math.abs((tight.get(second.id)?.y ?? 0) - (tight.get(first.id)?.y ?? 0));
const looseGap = Math.abs((loose.get(second.id)?.y ?? 0) - (loose.get(first.id)?.y ?? 0));
expect(looseGap).toBeGreaterThan(tightGap);
});
it('utilise le preset Normal quand aucun espacement n est fourni', () => {
const map = createSampleMap();
const implicit = computeLayout(map.nodes, 'mindmap-right');
const explicit = computeLayout(map.nodes, 'mindmap-right', {
spacing: DEFAULT_LAYOUT_SPACING,
});
for (const node of map.nodes) {
expect(implicit.get(node.id)).toEqual(explicit.get(node.id));
}
});
it('tient compte des tailles fixes des noeuds', () => {
const map = createSampleMap();
const wide = { ...(map.nodes[1] as (typeof map.nodes)[number]) };
wide.layout = { widthMode: 'fixed', width: 600, heightMode: 'fixed', height: 200 };
const nodes = map.nodes.map((node) => (node.id === wide.id ? wide : node));
const positions = computeLayout(nodes, 'mindmap-right');
const grandChild = nodes.find((node) => node.parentId === wide.id);
if (!grandChild) return;
// Le petit-enfant doit etre place au-dela du noeud large.
expect(positions.get(grandChild.id)?.x ?? 0).toBeGreaterThan(
(positions.get(wide.id)?.x ?? 0) + 400,
);
});
});

View File

@@ -1,5 +1,8 @@
import dagre from 'dagre';
import { type LayoutKind, type MindMapNode, type Position } from '@/types/map';
import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
import { type MapTheme } from '@/features/editor/themes';
import { resolveNodeSides } from '@/features/editor/bilateral';
import { getRootNodes } from '@/utils/tree';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption {
@@ -9,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[] = [
{
@@ -24,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',
@@ -36,21 +44,45 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
},
];
/** Espacement par defaut quand la carte n'en definit pas (preset "Normal"). */
export const DEFAULT_LAYOUT_SPACING: LayoutSpacing = { parentGap: 100, siblingGap: 50 };
export const SPACING_PRESETS: Array<{ id: string; label: string; spacing: LayoutSpacing }> = [
{ id: 'compact', label: 'Compact', spacing: { parentGap: 70, siblingGap: 30 } },
{ id: 'normal', label: 'Normal', spacing: DEFAULT_LAYOUT_SPACING },
{ id: 'airy', label: 'Aere', spacing: { parentGap: 140, siblingGap: 80 } },
];
export const SPACING_LIMITS = {
parentGap: { min: 30, max: 320 },
siblingGap: { min: 10, max: 240 },
} as const;
export function resolveSpacing(spacing?: LayoutSpacing): LayoutSpacing {
return {
parentGap: spacing?.parentGap ?? DEFAULT_LAYOUT_SPACING.parentGap,
siblingGap: spacing?.siblingGap ?? DEFAULT_LAYOUT_SPACING.siblingGap,
};
}
interface DagreParams {
rankdir: 'LR' | 'RL' | 'TB';
ranksep: number;
nodesep: number;
/** Multiplicateurs appliques a l'espacement demande par l'utilisateur. */
rankFactor: number;
nodeFactor: number;
}
const LAYOUT_PARAMS: Record<string, DagreParams> = {
'mindmap-right': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
'mindmap-left': { rankdir: 'RL', ranksep: 90, nodesep: 22 },
'tree-horizontal': { rankdir: 'LR', ranksep: 130, nodesep: 44 },
'tree-vertical': { rankdir: 'TB', ranksep: 90, nodesep: 48 },
'mindmap-right': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
// 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.
'mindmap-both': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
organization: { rankdir: 'TB', ranksep: 90, nodesep: 48 },
freeform: { rankdir: 'LR', ranksep: 90, nodesep: 22 },
organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
};
/** Orientation d'un layout : sert a placer les poignees de connexion. */
@@ -70,20 +102,92 @@ export function layoutDirection(layout: LayoutKind): 1 | -1 {
* par le bouton "Organisation automatique", ce qui preserve les deplacements
* manuels de l'utilisateur.
*/
export interface LayoutOptions {
measuredSizes?: Map<string, NodeSize>;
spacing?: LayoutSpacing;
theme?: MapTheme;
}
/**
* Calcule les positions via dagre.
*
* Le layout n'est jamais applique automatiquement : il est declenche seulement
* par le bouton "Organisation automatique", ce qui preserve les deplacements
* manuels de l'utilisateur. Modifier l'espacement ne deplace donc rien tant que
* l'utilisateur n'a pas relance l'organisation.
*/
export function computeLayout(
nodes: MindMapNode[],
layout: LayoutKind,
measuredSizes?: Map<string, NodeSize>,
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();
graph.setGraph({
rankdir: params?.rankdir ?? 'LR',
ranksep: params?.ranksep ?? 90,
nodesep: params?.nodesep ?? 22,
ranksep: Math.max(20, Math.round(spacing.parentGap * (params?.rankFactor ?? 0.9))),
nodesep: Math.max(8, Math.round(spacing.siblingGap * (params?.nodeFactor ?? 0.44))),
marginx: 40,
marginy: 40,
});
@@ -93,7 +197,7 @@ export function computeLayout(
const sizes = new Map<string, NodeSize>();
for (const node of nodes) {
const size = resolveNodeSize(node, measuredSizes);
const size = resolveNodeSize(node, options.measuredSizes, options.theme);
sizes.set(node.id, size);
graph.setNode(node.id, { width: size.width, height: size.height });
}

View File

@@ -0,0 +1,90 @@
import {
AlertTriangle,
Boxes,
CheckCircle2,
Cloud,
Container,
Cpu,
Database,
FileText,
Flag,
Folder,
Globe,
HardDrive,
Key,
Layers,
Lightbulb,
Link2,
Lock,
type LucideIcon,
Mail,
Monitor,
Network,
Router,
Server,
Settings,
Shield,
Star,
Terminal,
Timer,
Users,
Wifi,
} from 'lucide-react';
/**
* Bibliotheque d'icones integrees (Lucide, licence ISC).
*
* Volontairement restreinte et orientee schema technique : aucune icone n'est
* chargee dynamiquement, et aucun SVG fourni par l'utilisateur n'est stocke.
*/
export const BUILTIN_ICONS: Record<string, { label: string; Icon: LucideIcon }> = {
server: { label: 'Serveur', Icon: Server },
network: { label: 'Reseau', Icon: Network },
router: { label: 'Routeur', Icon: Router },
wifi: { label: 'Wi-Fi', Icon: Wifi },
shield: { label: 'Securite', Icon: Shield },
lock: { label: 'Verrou', Icon: Lock },
key: { label: 'Cle', Icon: Key },
database: { label: 'Base de donnees', Icon: Database },
harddrive: { label: 'Stockage', Icon: HardDrive },
container: { label: 'Conteneur', Icon: Container },
cpu: { label: 'Processeur', Icon: Cpu },
monitor: { label: 'Poste', Icon: Monitor },
cloud: { label: 'Cloud', Icon: Cloud },
globe: { label: 'Internet', Icon: Globe },
terminal: { label: 'Terminal', Icon: Terminal },
settings: { label: 'Configuration', Icon: Settings },
layers: { label: 'Couches', Icon: Layers },
boxes: { label: 'Services', Icon: Boxes },
folder: { label: 'Dossier', Icon: Folder },
file: { label: 'Document', Icon: FileText },
link: { label: 'Lien', Icon: Link2 },
mail: { label: 'Messagerie', Icon: Mail },
users: { label: 'Utilisateurs', Icon: Users },
check: { label: 'Termine', Icon: CheckCircle2 },
warning: { label: 'Attention', Icon: AlertTriangle },
flag: { label: 'Jalon', Icon: Flag },
star: { label: 'Important', Icon: Star },
timer: { label: 'Echeance', Icon: Timer },
idea: { label: 'Idee', Icon: Lightbulb },
};
export const BUILTIN_ICON_IDS = Object.keys(BUILTIN_ICONS);
/** Emojis proposes, conserves depuis la V1 pour ne rien casser. */
export const EMOJI_ICONS = [
'💡',
'✅',
'📌',
'⚠️',
'🔒',
'🌐',
'🖥️',
'📦',
'📄',
'🔗',
'⭐',
'🐳',
'🗄️',
'🚀',
];

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { type MindMapNode } from '@/types/map';
import { getTheme, themeMetrics } from '@/features/editor/themes';
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
function node(partial: Partial<MindMapNode> = {}): MindMapNode {
return {
id: 'n1',
type: 'standard',
text: 'Reseau',
position: { x: 0, y: 0 },
parentId: null,
...partial,
};
}
const theme = getTheme('default');
const metrics = themeMetrics(theme);
describe('resolution du style d un noeud', () => {
it('applique les valeurs du theme quand le noeud ne precise rien', () => {
const appearance = resolveNodeAppearance(node(), 1, theme);
expect(appearance.paddingX).toBe(metrics.paddingX);
expect(appearance.paddingY).toBe(metrics.paddingY);
expect(appearance.lineHeight).toBe(metrics.lineHeight);
expect(appearance.maxTextWidth).toBe(metrics.maxTextWidth);
expect(appearance.textAlign).toBe('left');
expect(appearance.verticalAlign).toBe('top');
expect(appearance.widthMode).toBe('auto');
expect(appearance.heightMode).toBe('auto');
expect(appearance.wrap).toBe(true);
expect(appearance.iconPosition).toBe('left');
});
it('donne la priorite aux personnalisations du noeud', () => {
const appearance = resolveNodeAppearance(
node({
style: {
backgroundColor: '#123456',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: 1.8,
fontFamily: 'mono',
shadow: 'none',
},
layout: { paddingX: 30, paddingY: 20, wrap: false, maxTextWidth: 400 },
}),
1,
theme,
);
expect(appearance.background).toBe('#123456');
expect(appearance.textAlign).toBe('center');
expect(appearance.verticalAlign).toBe('middle');
expect(appearance.lineHeight).toBe(1.8);
expect(appearance.fontFamily).toContain('monospace');
expect(appearance.boxShadow).toBe('none');
expect(appearance.paddingX).toBe(30);
expect(appearance.paddingY).toBe(20);
expect(appearance.wrap).toBe(false);
expect(appearance.maxTextWidth).toBe(400);
});
it('change de theme sans perdre les personnalisations', () => {
const customised = node({ style: { backgroundColor: '#ff8800', textAlign: 'right' } });
const light = resolveNodeAppearance(customised, 1, getTheme('default'));
const dark = resolveNodeAppearance(customised, 1, getTheme('midnight'));
expect(light.background).toBe('#ff8800');
expect(dark.background).toBe('#ff8800');
expect(dark.textAlign).toBe('right');
// Les valeurs non personnalisees suivent bien le nouveau theme.
expect(dark.color).not.toBe(light.color);
});
it('lit la largeur fixe du format v1 (style.width)', () => {
const legacy = resolveNodeAppearance(node({ style: { width: 240 } }), 1, theme);
expect(legacy.widthMode).toBe('fixed');
expect(legacy.width).toBe(240);
});
it('donne la priorite a layout.width sur la largeur v1', () => {
const appearance = resolveNodeAppearance(
node({ style: { width: 240 }, layout: { widthMode: 'fixed', width: 320 } }),
1,
theme,
);
expect(appearance.width).toBe(320);
});
it('herite des metriques propres a chaque theme', () => {
const pastel = resolveNodeAppearance(node(), 1, getTheme('pastel'));
const minimal = resolveNodeAppearance(node(), 1, getTheme('minimal'));
expect(pastel.paddingX).toBe(18);
expect(minimal.paddingX).toBe(12);
});
it('ignore une hauteur fixe declaree sans valeur', () => {
const appearance = resolveNodeAppearance(
node({ layout: { heightMode: 'fixed' } }),
1,
theme,
);
expect(appearance.heightMode).toBe('auto');
});
});

View File

@@ -0,0 +1,187 @@
import {
type FontFamilyId,
type IconPosition,
type MindMapNode,
type NodeShape,
type ShadowStyle,
type TextAlign,
type VerticalAlign,
} from '@/types/map';
import { type MapTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
/**
* Fusion theme + personnalisations explicites du noeud.
*
* Regle unique : un champ absent du noeud herite du theme. Rien n'est jamais
* ecrit dans le document pour "materialiser" un defaut, ce qui garantit que les
* cartes v1 deja stockees restent lisibles et que changer de theme n'ecrase
* aucune personnalisation.
*/
export const FONT_FAMILIES: Record<Exclude<FontFamilyId, 'theme'>, string> = {
sans: "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', Times, serif",
mono: "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace",
rounded: "'Segoe UI', 'Trebuchet MS', ui-rounded, system-ui, sans-serif",
};
export const FONT_FAMILY_OPTIONS: Array<{ id: FontFamilyId; label: string }> = [
{ id: 'theme', label: 'Theme' },
{ id: 'sans', label: 'Sans-serif' },
{ id: 'serif', label: 'Serif' },
{ id: 'mono', label: 'Monospace' },
{ id: 'rounded', label: 'Arrondie' },
];
export const SHADOW_OPTIONS: Array<{ id: ShadowStyle; label: string }> = [
{ id: 'theme', label: 'Theme' },
{ id: 'none', label: 'Aucune' },
{ id: 'soft', label: 'Douce' },
{ id: 'strong', label: 'Marquee' },
];
const SHADOWS: Record<Exclude<ShadowStyle, 'theme'>, string> = {
none: 'none',
soft: '0 1px 2px rgba(15,23,42,0.06), 0 6px 16px -10px rgba(15,23,42,0.35)',
strong: '0 2px 4px rgba(15,23,42,0.10), 0 14px 28px -12px rgba(15,23,42,0.55)',
};
/** Bornes appliquees a toute saisie utilisateur. */
export const LAYOUT_LIMITS = {
width: { min: 60, max: 900 },
height: { min: 32, max: 900 },
paddingX: { min: 0, max: 64 },
paddingY: { min: 0, max: 48 },
maxTextWidth: { min: 80, max: 900 },
fontSize: { min: 10, max: 48 },
lineHeight: { min: 1, max: 2.4 },
iconSize: { min: 12, max: 48 },
iconGap: { min: 0, max: 32 },
branchWidth: { min: 1, max: 10 },
} as const;
export function clampTo(value: number, bounds: { min: number; max: number }): number {
return Math.min(bounds.max, Math.max(bounds.min, Math.round(value)));
}
/** Apparence entierement resolue d'un noeud, prete a etre appliquee en CSS. */
export interface NodeAppearance {
background: string;
color: string;
borderColor: string;
borderWidth: number;
borderRadius: string;
shape: NodeShape;
circle: boolean;
boxShadow: string;
fontFamily: string;
fontSize: number;
fontWeight: number;
fontStyle: 'normal' | 'italic';
lineHeight: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
paddingX: number;
paddingY: number;
wrap: boolean;
widthMode: 'auto' | 'fixed';
heightMode: 'auto' | 'fixed';
width?: number;
height?: number;
maxTextWidth: number;
minWidth: number;
minHeight: number;
iconSize: number;
iconGap: number;
iconPosition: IconPosition;
}
function radiusForShape(shape: NodeShape, themeRadius: number): string {
switch (shape) {
case 'rectangle':
return '3px';
case 'capsule':
return '999px';
case 'circle':
return '50%';
case 'rounded':
default:
return `${themeRadius}px`;
}
}
export function resolveNodeAppearance(
node: MindMapNode,
depth: number,
theme: MapTheme,
): NodeAppearance {
const palette = paletteForDepth(theme, depth);
const metrics = themeMetrics(theme);
const style = node.style ?? {};
const layout = node.layout ?? {};
const iconStyle = node.iconStyle ?? {};
const shape = style.shape ?? theme.nodeShape;
const fontFamily =
style.fontFamily && style.fontFamily !== 'theme'
? FONT_FAMILIES[style.fontFamily]
: theme.fontFamily;
const shadow = style.shadow && style.shadow !== 'theme' ? SHADOWS[style.shadow] : theme.shadow;
// `style.width` est l'ancien champ v1 : il vaut largeur fixe.
const legacyWidth = style.width;
const widthMode = layout.widthMode ?? (layout.width ?? legacyWidth ? 'fixed' : 'auto');
const width = layout.width ?? legacyWidth;
const heightMode = layout.heightMode ?? (layout.height ? 'fixed' : 'auto');
return {
background: style.backgroundColor ?? palette.background,
color: style.textColor ?? palette.text,
borderColor: style.borderColor ?? palette.border,
borderWidth: theme.borderWidth,
borderRadius: radiusForShape(shape, theme.nodeRadius),
shape,
circle: shape === 'circle',
boxShadow: shadow,
fontFamily,
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
fontStyle: style.italic ? 'italic' : 'normal',
lineHeight: style.lineHeight ?? metrics.lineHeight,
textAlign: style.textAlign ?? 'left',
verticalAlign: style.verticalAlign ?? 'top',
paddingX: layout.paddingX ?? metrics.paddingX,
paddingY: layout.paddingY ?? metrics.paddingY,
wrap: layout.wrap ?? true,
widthMode: widthMode === 'fixed' && width ? 'fixed' : 'auto',
heightMode: heightMode === 'fixed' && layout.height ? 'fixed' : 'auto',
...(width ? { width } : {}),
...(layout.height ? { height: layout.height } : {}),
maxTextWidth: layout.maxTextWidth ?? metrics.maxTextWidth,
minWidth: metrics.minNodeWidth,
minHeight: metrics.minNodeHeight,
iconSize: iconStyle.size ?? metrics.iconSize,
iconGap: iconStyle.gap ?? metrics.iconGap,
iconPosition: iconStyle.position ?? 'left',
};
}
/** Style effectif de la branche entrante d'un noeud. */
export function resolveBranchAppearance(
node: MindMapNode,
theme: MapTheme,
fallbackStyle: MindMapNode['branch'],
): { style: NonNullable<MindMapNode['branch']>['style']; width: number; color: string } {
const branch = node.branch ?? {};
return {
style: branch.style ?? fallbackStyle?.style,
width: branch.width ?? theme.branchWidth,
color: branch.color ?? theme.branchColor,
};
}

View File

@@ -0,0 +1,56 @@
import { type ReactNode } from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@/components/Icons';
import { useUiStore } from '@/stores/uiStore';
import { cn } from '@/utils/cn';
export interface PanelSectionProps {
id: string;
title: string;
icon?: ReactNode;
children: ReactNode;
}
/**
* Section repliable du panneau Proprietes.
* L'etat ouvert/ferme est conserve pendant la session (voir `uiStore`).
*/
export function PanelSection({ id, title, icon, children }: PanelSectionProps) {
const open = useUiStore((state) => state.panelSections[id] ?? true);
const togglePanelSection = useUiStore((state) => state.togglePanelSection);
const contentId = `panel-section-${id}`;
return (
<section className="border-b border-zinc-100 last:border-b-0 dark:border-zinc-800">
<h3>
<button
type="button"
onClick={() => togglePanelSection(id)}
aria-expanded={open}
aria-controls={contentId}
className={cn(
'flex w-full items-center gap-2 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide',
'text-zinc-500 transition-colors hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100',
)}
>
<span aria-hidden="true" className="text-zinc-400">
{open ? (
<ChevronDownIcon className="h-3.5 w-3.5" />
) : (
<ChevronRightIcon className="h-3.5 w-3.5" />
)}
</span>
{icon ? (
<span aria-hidden="true" className="text-zinc-400">
{icon}
</span>
) : null}
<span className="flex-1">{title}</span>
</button>
</h3>
<div id={contentId} hidden={!open} className="space-y-3 px-4 pb-4">
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,288 @@
import { useEffect, useState, type ReactNode } from 'react';
import { CloseIcon } from '@/components/Icons';
import { IconButton } from '@/components/IconButton';
import { MIXED_LABEL, type SharedValue } from '@/features/editor/panel/sharedValue';
import { cn } from '@/utils/cn';
export function Field({
label,
htmlFor,
hint,
children,
}: {
label: string;
htmlFor?: string;
hint?: string;
children: ReactNode;
}) {
return (
<div>
<label className="field-label" htmlFor={htmlFor}>
{label}
{hint ? <span className="ml-1 font-normal normal-case text-zinc-400">{hint}</span> : null}
</label>
{children}
</div>
);
}
export interface SegmentOption<T> {
value: T;
label: string;
icon?: ReactNode;
}
/** Groupe de boutons compact. Chaque bouton porte un libelle accessible. */
export function SegmentedControl<T extends string>({
legend,
options,
selected,
onSelect,
mixed = false,
}: {
legend: string;
options: Array<SegmentOption<T>>;
selected: T | undefined;
onSelect: (value: T) => void;
mixed?: boolean;
}) {
return (
<fieldset>
<legend className="field-label">
{legend}
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60" role="group">
{options.map((option) => {
const active = !mixed && selected === option.value;
return (
<button
key={option.value}
type="button"
title={option.label}
aria-label={option.label}
aria-pressed={active}
onClick={() => onSelect(option.value)}
className={cn(
'flex flex-1 items-center justify-center rounded-md px-2 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-white text-indigo-700 shadow-sm dark:bg-zinc-900 dark:text-indigo-300'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100',
)}
>
{option.icon ?? option.label}
</button>
);
})}
</div>
</fieldset>
);
}
/** Champ numerique borne, avec slider optionnel. La saisie est validee au blur. */
export function NumberField({
id,
label,
value,
min,
max,
step = 1,
unit = 'px',
slider = true,
mixed = false,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
slider?: boolean;
mixed?: boolean;
disabled?: boolean;
onChange: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = (raw: string) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
setDraft(String(value));
return;
}
const clamped = Math.min(max, Math.max(min, Math.round(parsed / step) * step));
setDraft(String(clamped));
if (clamped !== value) onChange(clamped);
};
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
</label>
<div className="flex items-center gap-2">
{slider ? (
<input
type="range"
aria-label={`${label} (curseur)`}
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1.5 flex-1 accent-indigo-600 disabled:opacity-40"
/>
) : null}
<input
id={id}
type="number"
inputMode="numeric"
min={min}
max={max}
step={step}
value={draft}
disabled={disabled}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => commit(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
className="field-input w-20 px-2 py-1 text-right text-xs tabular-nums disabled:opacity-40"
/>
<span className="w-5 shrink-0 text-xs text-zinc-400">{unit}</span>
</div>
</div>
);
}
function normaliseHex(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
}
/**
* Selecteur de couleur : palette native, valeur hexadecimale lisible et retour
* explicite a la valeur du theme.
*/
export function ColorField({
id,
label,
shared,
fallback,
onChange,
}: {
id: string;
label: string;
shared: SharedValue<string>;
fallback: string;
onChange: (color: string | undefined) => void;
}) {
const current = shared.mixed ? undefined : shared.value;
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
{shared.mixed ? (
<span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span>
) : null}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={normaliseHex(current ?? fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-7 w-9 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-[11px] text-zinc-500">
{shared.mixed ? MIXED_LABEL : (current ?? `theme (${fallback})`)}
</span>
{current ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
/** Case a cocher avec libelle, alignee sur le reste du panneau. */
export function ToggleRow({
id,
label,
checked,
mixed = false,
onChange,
}: {
id: string;
label: string;
checked: boolean;
mixed?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label htmlFor={id} className="flex items-center gap-2.5 text-sm">
<input
id={id}
type="checkbox"
checked={checked}
ref={(element) => {
if (element) element.indeterminate = mixed;
}}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 rounded border-zinc-300 accent-indigo-600"
/>
<span className="text-zinc-700 dark:text-zinc-200">{label}</span>
{mixed ? <span className="text-xs text-amber-500">{MIXED_LABEL}</span> : null}
</label>
);
}
/** Liste deroulante compacte. */
export function SelectField<T extends string>({
id,
label,
value,
options,
mixed = false,
onChange,
}: {
id: string;
label: string;
value: T;
options: Array<{ id: T; label: string }>;
mixed?: boolean;
onChange: (value: T) => void;
}) {
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
</label>
<select
id={id}
className="field-input py-1.5 text-sm"
value={mixed ? '' : value}
onChange={(event) => onChange(event.target.value as T)}
>
{mixed ? <option value="">{MIXED_LABEL}</option> : null}
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { type MindMapNode } from '@/types/map';
/**
* Valeur commune a une selection.
*
* `mixed` indique que les noeuds selectionnes n'ont pas la meme valeur : le
* panneau affiche alors "Mixte" plutot que de faire passer la valeur du premier
* noeud pour une valeur commune.
*/
export interface SharedValue<T> {
value: T | undefined;
mixed: boolean;
}
export function sharedValue<T>(
nodes: MindMapNode[],
read: (node: MindMapNode) => T | undefined,
): SharedValue<T> {
if (nodes.length === 0) return { value: undefined, mixed: false };
const first = read(nodes[0] as MindMapNode);
for (let index = 1; index < nodes.length; index += 1) {
if (read(nodes[index] as MindMapNode) !== first) {
return { value: undefined, mixed: true };
}
}
return { value: first, mixed: false };
}
/** Valeur a afficher dans un controle : la valeur commune, sinon le defaut. */
export function displayValue<T>(shared: SharedValue<T>, fallback: T): T {
return shared.mixed ? fallback : (shared.value ?? fallback);
}
export const MIXED_LABEL = 'Mixte';

View File

@@ -24,8 +24,56 @@ 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[];
/*
* Metriques de mise en page (v2). Optionnelles : `themeMetrics()` applique
* les valeurs par defaut, ce qui evite de dupliquer les memes chiffres dans
* les neuf themes.
*/
paddingX?: number;
paddingY?: number;
lineHeight?: number;
minNodeWidth?: number;
minNodeHeight?: number;
maxTextWidth?: number;
iconSize?: number;
iconGap?: number;
}
/** Valeurs de mise en page utilisees quand le theme ne precise rien. */
export const THEME_METRIC_DEFAULTS = {
paddingX: 14,
paddingY: 8,
lineHeight: 1.35,
minNodeWidth: 72,
minNodeHeight: 36,
maxTextWidth: 260,
iconSize: 16,
iconGap: 8,
} as const;
export type ThemeMetrics = { -readonly [K in keyof typeof THEME_METRIC_DEFAULTS]: number };
/** Metriques effectives d'un theme : ses valeurs, completees par les defauts. */
export function themeMetrics(theme: MapTheme): ThemeMetrics {
return {
paddingX: theme.paddingX ?? THEME_METRIC_DEFAULTS.paddingX,
paddingY: theme.paddingY ?? THEME_METRIC_DEFAULTS.paddingY,
lineHeight: theme.lineHeight ?? THEME_METRIC_DEFAULTS.lineHeight,
minNodeWidth: theme.minNodeWidth ?? THEME_METRIC_DEFAULTS.minNodeWidth,
minNodeHeight: theme.minNodeHeight ?? THEME_METRIC_DEFAULTS.minNodeHeight,
maxTextWidth: theme.maxTextWidth ?? THEME_METRIC_DEFAULTS.maxTextWidth,
iconSize: theme.iconSize ?? THEME_METRIC_DEFAULTS.iconSize,
iconGap: theme.iconGap ?? THEME_METRIC_DEFAULTS.iconGap,
};
}
const SANS = "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif";
@@ -61,6 +109,8 @@ export const THEMES: MapTheme[] = [
{
id: 'minimal',
name: 'Minimal',
paddingX: 12,
paddingY: 6,
isDark: false,
canvasBackground: '#ffffff',
gridColor: '#e5e7eb',
@@ -161,6 +211,8 @@ export const THEMES: MapTheme[] = [
{
id: 'pastel',
name: 'Pastel',
paddingX: 18,
paddingY: 10,
isDark: false,
canvasBackground: '#fffdf9',
gridColor: '#f1e7dc',
@@ -202,6 +254,9 @@ export const THEMES: MapTheme[] = [
{
id: 'blueprint',
name: 'Blueprint',
paddingX: 12,
paddingY: 7,
lineHeight: 1.25,
isDark: true,
canvasBackground: '#0d2b45',
gridColor: '#1d4666',
@@ -227,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;

View File

@@ -15,6 +15,8 @@ export interface ShortcutHandlers {
onSave: () => void;
onFit: () => void;
onFocusSearch: () => void;
/** Ouvre le dialogue de creation multiple sur le parent donne. */
onAddChildren: (parentId: string | null) => void;
}
/**
@@ -49,6 +51,19 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
if (typing) return;
if (modifier && event.shiftKey && (key === 'c' || key === 'v')) {
event.preventDefault();
if (key === 'c') {
if (store.copyStyle()) useUiStore.getState().pushToast('Style copie.', 'info');
return;
}
const applied = store.pasteStyle();
if (applied > 0) {
useUiStore.getState().pushToast(`Style applique a ${applied} noeud(s).`, 'success');
}
return;
}
if (modifier) {
switch (key) {
case 'z':
@@ -66,8 +81,15 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
}
return;
case 'v':
event.preventDefault();
store.paste();
/*
* Priorite au presse-papiers interne d'OpenMind. S'il est vide, on
* laisse l'evenement `paste` du navigateur remonter : il porte le
* texte du systeme et declenche la creation d'enfants.
*/
if ((store.clipboard?.nodes.length ?? 0) > 0) {
event.preventDefault();
store.paste();
}
return;
case 'd':
event.preventDefault();
@@ -82,18 +104,43 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
}
}
// Alt + fleches : ordre du noeud parmi ses freres.
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
const selected = store.selectedNodeIds[0];
if (selected) {
event.preventDefault();
store.moveSibling(selected, event.key === 'ArrowUp' ? 'up' : 'down');
}
return;
}
switch (event.key) {
case 'Tab':
event.preventDefault();
store.addChild();
// Maj+Tab remonte le noeud d'un niveau, Tab cree un enfant.
if (event.shiftKey) {
const selected = store.selectedNodeIds[0];
if (selected) store.outdentNode(selected);
} else {
store.addChild();
}
break;
case 'Enter':
event.preventDefault();
store.addSibling();
if (event.shiftKey) {
handlersRef.current.onAddChildren(store.selectedNodeIds[0] ?? null);
} else {
store.addSibling();
}
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);
}
@@ -112,6 +159,7 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
case 'Escape':
store.setEditingNode(null);
store.setSelection([]);
store.setSelectedRelation(null);
break;
default:
break;

View File

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

View File

@@ -29,13 +29,16 @@ export function computeMapBounds(map: MindMap, sizes?: Map<string, NodeSize>): M
const visible = map.nodes.filter((node) => !hidden.has(node.id));
if (visible.length === 0) return { x: 0, y: 0, width: 400, height: 300 };
// Le theme influence padding et typographie, donc la taille estimee.
const theme = getTheme(map.settings.theme);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of visible) {
const size = resolveNodeSize(node, sizes);
const size = resolveNodeSize(node, sizes, theme);
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + size.width);

View File

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

View File

@@ -0,0 +1,68 @@
import { Modal } from '@/components/Modal';
import { Button } from '@/components/Button';
import { MAP_TEMPLATES, type MapTemplate, type TemplateNode } from '@/features/templates/templates';
export interface TemplateGalleryProps {
open: boolean;
onClose: () => void;
onSelect: (template: MapTemplate) => void;
}
/** Apercu textuel : les premieres branches du modele, sans aucune image. */
function outline(node: TemplateNode): string {
const branches = (node.children ?? []).slice(0, 5).map((child) => child.text);
if (branches.length === 0) return 'Une racine, rien d autre.';
const extra = (node.children ?? []).length - branches.length;
return branches.join(' - ') + (extra > 0 ? ` - +${extra}` : '');
}
function countNodes(node: TemplateNode): number {
return 1 + (node.children ?? []).reduce((total, child) => total + countNodes(child), 0);
}
/**
* Galerie de modeles.
*
* Tout est local : aucun appel reseau, aucune image distante. Choisir un modele
* cree une copie ; le modele lui-meme n'est jamais modifie.
*/
export function TemplateGallery({ open, onClose, onSelect }: TemplateGalleryProps) {
return (
<Modal
open={open}
title="Nouvelle carte"
description="Partez d'une carte vide ou d'un modele. Le modele est copie : vous le modifiez librement."
onClose={onClose}
size="lg"
footer={<Button onClick={onClose}>Annuler</Button>}
>
<ul className="grid gap-2 sm:grid-cols-2" aria-label="Modeles disponibles">
{MAP_TEMPLATES.map((template) => (
<li key={template.id}>
<button
type="button"
onClick={() => onSelect(template)}
className="flex h-full w-full flex-col gap-1 rounded-lg border border-zinc-200 p-3 text-left transition-colors hover:border-indigo-300 hover:bg-indigo-50/50 dark:border-zinc-700 dark:hover:border-indigo-700 dark:hover:bg-indigo-950/30"
>
<span className="flex items-center gap-2">
<span aria-hidden="true" className="text-lg">
{template.icon}
</span>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{template.name}
</span>
<span className="ml-auto shrink-0 text-[11px] text-zinc-400">
{countNodes(template.root)} noeuds
</span>
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{template.description}
</span>
<span className="truncate text-[11px] text-zinc-400">{outline(template.root)}</span>
</button>
</li>
))}
</ul>
</Modal>
);
}

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { MAP_TEMPLATES, buildTemplateMap, getTemplate } from '@/features/templates/templates';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
import { getLevelOneBranches } from '@/features/editor/bilateral';
import { getOrderedChildren } from '@/utils/tree';
describe('galerie de modeles', () => {
it('propose les huit modeles annonces', () => {
expect(MAP_TEMPLATES.map((template) => template.id)).toEqual([
'blank',
'brainstorming',
'project',
'it-architecture',
'meeting',
'swot',
'study',
'personal',
]);
});
it('donne a chaque modele un nom et une description', () => {
for (const template of MAP_TEMPLATES) {
expect(template.name.length).toBeGreaterThan(0);
expect(template.description.length).toBeGreaterThan(0);
expect(template.icon.length).toBeGreaterThan(0);
}
});
it('retrouve un modele par son identifiant', () => {
expect(getTemplate('swot')?.name).toBe('SWOT');
expect(getTemplate('inexistant')).toBeUndefined();
});
});
describe('creation depuis un modele', () => {
it('produit une carte valide pour chaque modele', () => {
for (const template of MAP_TEMPLATES) {
const map = buildTemplateMap(template);
expect(map.nodes.length).toBeGreaterThan(0);
expect(map.nodes[0]?.parentId).toBeNull();
expect(map.nodes[0]?.type).toBe('root');
expect(map.settings.theme).toBe(template.theme);
expect(map.settings.layout).toBe(template.layout);
expect(map.relations).toEqual([]);
}
});
it('regenere des identifiants uniques a chaque creation', () => {
const template = getTemplate('it-architecture');
const first = buildTemplateMap(template as never);
const second = buildTemplateMap(template as never);
const firstIds = new Set(first.nodes.map((node) => node.id));
expect(firstIds.size).toBe(first.nodes.length);
for (const node of second.nodes) expect(firstIds.has(node.id)).toBe(false);
expect(first.id).not.toBe(second.id);
});
it('ne modifie jamais le modele source', () => {
const template = getTemplate('it-architecture') as never as {
root: { text: string; children?: unknown[] };
};
const before = JSON.stringify(template);
const map = buildTemplateMap(template as never);
const root = map.nodes[0];
if (root) root.text = 'Modifie';
expect(JSON.stringify(template)).toBe(before);
});
it("reproduit l'arborescence du modele Architecture IT", () => {
const map = buildTemplateMap(getTemplate('it-architecture') as never);
const root = map.nodes[0];
expect(root?.text).toBe('Architecture IT');
const branches = getOrderedChildren(map.nodes, root?.id as string).map((node) => node.text);
expect(branches).toEqual(['Reseau', 'Compute', 'Stockage', 'Services', 'Supervision']);
const reseau = map.nodes.find((node) => node.text === 'Reseau');
expect(getOrderedChildren(map.nodes, reseau?.id as string).map((node) => node.text)).toEqual([
'Firewall',
'Switch',
'VLAN',
'VPN',
]);
expect(map.nodes).toHaveLength(20);
});
it('n ecrit un cote que sur les branches de niveau 1', () => {
const map = buildTemplateMap(getTemplate('it-architecture') as never);
const branchIds = new Set(getLevelOneBranches(map.nodes).map((node) => node.id));
for (const node of map.nodes) {
if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
}
expect(branchIds.size).toBe(5);
});
it('positionne les noeuds sans les empiler', () => {
const map = buildTemplateMap(getTemplate('project') as never);
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
expect(distinct.size).toBe(map.nodes.length);
});
});
describe('modele et format de fichier', () => {
it('survit a un aller-retour de serialisation', () => {
const map = buildTemplateMap(getTemplate('swot') as never);
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
expect(again.nodes).toHaveLength(map.nodes.length);
expect(again.settings.layout).toBe(map.settings.layout);
const sides = new Map(again.nodes.map((node) => [node.text, node.side]));
for (const node of map.nodes) {
expect(sides.get(node.text)).toBe(node.side);
}
});
it('est ecrit au format courant', () => {
const map = buildTemplateMap(getTemplate('blank') as never);
expect(JSON.parse(serializeMap(map)).version).toBe(4);
});
});

View File

@@ -0,0 +1,395 @@
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MindMap,
type MindMapNode,
type NodeSide,
} from '@/types/map';
import { computeLayout } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes';
import { createNode, DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
/**
* Galerie de modeles, entierement locale.
*
* Un modele est une description figee : `buildTemplateMap` en fabrique une
* carte neuve, avec de nouveaux identifiants, a chaque appel. L'original n'est
* jamais modifie, et la carte creee est ensuite une carte comme les autres.
* Aucune ressource distante n'est chargee : tout est ecrit ici.
*/
export interface TemplateNode {
text: string;
icon?: string;
note?: string;
/** Cote impose pour une branche de niveau 1, en disposition bilaterale. */
side?: NodeSide;
children?: TemplateNode[];
}
export interface MapTemplate {
id: string;
name: string;
description: string;
/** Emoji d'illustration : aucune image distante. */
icon: string;
theme: string;
layout: LayoutKind;
branchStyle?: BranchStyle;
root: TemplateNode;
}
export const MAP_TEMPLATES: MapTemplate[] = [
{
id: 'blank',
name: 'Carte vide',
description: 'Une seule racine, a vous de construire.',
icon: '⬜',
theme: 'default',
layout: 'mindmap-right',
root: { text: 'Nouvelle carte' },
},
{
id: 'brainstorming',
name: 'Brainstorming',
description: 'Collecter des idees, les trier, decider.',
icon: '💡',
theme: 'sunset',
layout: 'mindmap-both',
root: {
text: 'Brainstorming',
icon: '💡',
children: [
{
text: 'Question de depart',
side: 'left',
children: [{ text: 'Contexte' }, { text: 'Contraintes' }],
},
{
text: 'Idees brutes',
side: 'right',
children: [{ text: 'Idee 1' }, { text: 'Idee 2' }, { text: 'Idee 3' }],
},
{
text: 'A creuser',
side: 'right',
children: [{ text: 'Pistes prometteuses' }, { text: 'Questions ouvertes' }],
},
{
text: 'Ecartees',
side: 'left',
children: [{ text: 'Trop couteux' }, { text: 'Hors sujet' }],
},
{ text: 'Prochaines etapes', side: 'right', children: [{ text: 'Qui fait quoi' }] },
],
},
},
{
id: 'project',
name: 'Gestion de projet',
description: 'Objectifs, lots de travail, jalons et risques.',
icon: '📋',
theme: 'ocean',
layout: 'mindmap-both',
root: {
text: 'Projet',
icon: '📋',
children: [
{
text: 'Objectifs',
icon: '🎯',
side: 'left',
children: [{ text: 'Resultat attendu' }, { text: 'Criteres de reussite' }],
},
{
text: 'Perimetre',
side: 'left',
children: [{ text: 'Inclus' }, { text: 'Exclu' }],
},
{
text: 'Lots de travail',
icon: '🧩',
side: 'right',
children: [
{ text: 'Cadrage' },
{ text: 'Realisation' },
{ text: 'Recette' },
{ text: 'Mise en production' },
],
},
{
text: 'Jalons',
icon: '📅',
side: 'right',
children: [{ text: 'Lancement' }, { text: 'Livraison' }],
},
{
text: 'Equipe',
side: 'left',
children: [{ text: 'Sponsor' }, { text: 'Chef de projet' }, { text: 'Contributeurs' }],
},
{
text: 'Risques',
icon: '⚠️',
side: 'right',
children: [{ text: 'Identifies' }, { text: 'Plan de repli' }],
},
],
},
},
{
id: 'it-architecture',
name: 'Architecture IT',
description: 'Reseau, compute, stockage, services et supervision.',
icon: '🖧',
theme: 'midnight',
layout: 'mindmap-both',
branchStyle: 'curved',
root: {
text: 'Architecture IT',
icon: '🖧',
note: "Vue d'ensemble d'un systeme d'information : adaptez chaque branche a votre contexte.",
children: [
{
text: 'Reseau',
icon: '🌐',
side: 'left',
children: [
{ text: 'Firewall', note: "Filtrage entrant et sortant, regles par zone." },
{ text: 'Switch' },
{ text: 'VLAN' },
{ text: 'VPN', note: "Acces distant des utilisateurs aux ressources internes." },
],
},
{
text: 'Compute',
icon: '🖥️',
side: 'right',
children: [
{ text: 'Hyperviseurs' },
{ text: 'VM' },
{ text: 'Conteneurs' },
],
},
{
text: 'Stockage',
icon: '💾',
side: 'left',
children: [{ text: 'NAS' }, { text: 'Sauvegarde' }],
},
{
text: 'Services',
icon: '⚙️',
side: 'right',
children: [{ text: 'DNS' }, { text: 'DHCP' }, { text: 'Applications' }],
},
{
text: 'Supervision',
icon: '📈',
side: 'left',
children: [{ text: 'Monitoring' }, { text: 'Logs' }],
},
],
},
},
{
id: 'meeting',
name: 'Reunion / prise de notes',
description: 'Ordre du jour, decisions, actions a suivre.',
icon: '📝',
theme: 'paper',
layout: 'mindmap-right',
root: {
text: 'Reunion',
icon: '📝',
children: [
{ text: 'Contexte', children: [{ text: 'Date' }, { text: 'Participants' }] },
{
text: 'Ordre du jour',
children: [{ text: 'Point 1' }, { text: 'Point 2' }, { text: 'Point 3' }],
},
{ text: 'Decisions', icon: '✅', children: [{ text: 'Decision 1' }] },
{
text: 'Actions',
icon: '➡️',
children: [{ text: 'Qui' }, { text: 'Quoi' }, { text: 'Pour quand' }],
},
{ text: 'Points en suspens' },
],
},
},
{
id: 'swot',
name: 'SWOT',
description: 'Forces, faiblesses, opportunites, menaces.',
icon: '🧭',
theme: 'forest',
layout: 'mindmap-both',
root: {
text: 'Analyse SWOT',
icon: '🧭',
children: [
{
text: 'Forces',
icon: '💪',
side: 'left',
children: [{ text: 'Interne, positif' }],
},
{
text: 'Faiblesses',
icon: '🪫',
side: 'left',
children: [{ text: 'Interne, a ameliorer' }],
},
{
text: 'Opportunites',
icon: '🚀',
side: 'right',
children: [{ text: 'Externe, favorable' }],
},
{
text: 'Menaces',
icon: '⛈️',
side: 'right',
children: [{ text: 'Externe, a surveiller' }],
},
],
},
},
{
id: 'study',
name: "Plan d'etude",
description: 'Programme de revision par matiere et par echeance.',
icon: '🎓',
theme: 'lavender',
layout: 'mindmap-right',
root: {
text: 'Plan de revision',
icon: '🎓',
children: [
{
text: 'Matieres',
children: [{ text: 'Matiere 1' }, { text: 'Matiere 2' }, { text: 'Matiere 3' }],
},
{
text: 'Chapitres a revoir',
children: [{ text: 'A maitriser' }, { text: 'A consolider' }],
},
{
text: 'Methodes',
children: [{ text: 'Fiches' }, { text: 'Exercices' }, { text: 'Annales' }],
},
{
text: 'Planning',
icon: '📅',
children: [{ text: 'Cette semaine' }, { text: 'Avant l examen' }],
},
{ text: 'Evaluation', children: [{ text: 'Points acquis' }, { text: 'Points fragiles' }] },
],
},
},
{
id: 'personal',
name: 'Planification personnelle',
description: 'Priorites, habitudes et projets personnels.',
icon: '🌱',
theme: 'default',
layout: 'mindmap-both',
root: {
text: 'Mon organisation',
icon: '🌱',
children: [
{
text: 'Priorites',
icon: '⭐',
side: 'right',
children: [{ text: 'Cette semaine' }, { text: 'Ce mois-ci' }],
},
{
text: 'Sante',
side: 'left',
children: [{ text: 'Sport' }, { text: 'Sommeil' }, { text: 'Alimentation' }],
},
{
text: 'Projets',
side: 'right',
children: [{ text: 'En cours' }, { text: 'A demarrer' }],
},
{
text: 'Habitudes',
side: 'left',
children: [{ text: 'A garder' }, { text: 'A changer' }],
},
{ text: 'Envies', icon: '✨', side: 'right', children: [{ text: 'A explorer' }] },
],
},
},
];
export function getTemplate(id: string): MapTemplate | undefined {
return MAP_TEMPLATES.find((template) => template.id === id);
}
/**
* Fabrique une carte a partir d'un modele.
*
* Chaque appel regenere tous les identifiants et recopie les objets : le modele
* d'origine, qui est une constante du module, n'est jamais modifie. La carte
* produite est ensuite une carte ordinaire, modifiable et supprimable comme
* n'importe quelle autre.
*
* Les positions initiales viennent de l'auto-layout, calcule sur les tailles
* estimees : le modele s'ouvre deja bien dispose, sans attendre une mesure du
* DOM qui n'existe pas encore.
*/
export function buildTemplateMap(template: MapTemplate, name?: string): MindMap {
const timestamp = new Date().toISOString();
const nodes: MindMapNode[] = [];
const walk = (spec: TemplateNode, parentId: string | null, order: number, depth: number): void => {
const node = createNode({
text: spec.text,
type: parentId === null ? 'root' : 'standard',
parentId,
order,
...(spec.icon ? { icon: spec.icon } : {}),
...(spec.note ? { note: spec.note } : {}),
// Le cote n'a de sens que sur une branche de niveau 1.
...(depth === 1 && spec.side ? { side: spec.side } : {}),
position: { x: 0, y: 0 },
});
nodes.push(node);
(spec.children ?? []).forEach((child, index) => walk(child, node.id, index, depth + 1));
};
walk(template.root, null, 0, 0);
const settings = {
...DEFAULT_SETTINGS,
theme: template.theme,
layout: template.layout,
...(template.branchStyle ? { branchStyle: template.branchStyle } : {}),
};
const positions = computeLayout(nodes, template.layout, { theme: getTheme(template.theme) });
const placed = nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
}));
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: name?.trim() || template.root.text || template.name,
createdAt: timestamp,
updatedAt: timestamp,
nodes: placed,
edges: rebuildEdges(placed),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings,
};
}

View File

@@ -1,4 +1,5 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { remapRelations } from '@/services/relations';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
@@ -20,6 +21,8 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
text: partial.text,
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 } : {}),
@@ -44,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 },
};
@@ -71,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 },
};
@@ -125,6 +132,7 @@ export function createSampleMap(): MindMap {
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { createEmptyMap } from '@/services/mapFactory';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
/** Carte v1 telle qu'elle etait exportee avant la phase 1.1. */
const LEGACY_V1 = {
version: 1,
id: '11111111-1111-4111-8111-111111111111',
name: 'Infrastructure v1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Infrastructure',
position: { x: 0, y: 0 },
parentId: null,
icon: '🖥️',
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
},
{ id: 'n1', type: 'standard', text: 'Reseau', position: { x: 260, y: 0 }, parentId: 'root' },
],
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
};
describe('compatibilite du format', () => {
it('importe une carte v1 sans propriete de mise en page', () => {
const map = normalizeMapDocument(LEGACY_V1);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(map.nodes).toHaveLength(2);
expect(map.nodes[1]?.layout).toBeUndefined();
expect(map.nodes[1]?.iconStyle).toBeUndefined();
expect(map.settings.layoutSpacing).toBeUndefined();
});
it('migre la largeur v1 vers layout.width', () => {
const map = normalizeMapDocument(LEGACY_V1);
const root = map.nodes[0];
expect(root?.layout?.widthMode).toBe('fixed');
expect(root?.layout?.width).toBe(240);
expect(root?.style?.width).toBeUndefined();
// Les autres personnalisations v1 sont conservees.
expect(root?.style?.backgroundColor).toBe('#eef2ff');
expect(root?.style?.textAlign).toBe('center');
expect(root?.icon).toBe('🖥️');
});
it('refuse une version plus recente que celle supportee', () => {
expect(() => normalizeMapDocument({ ...LEGACY_V1, version: MAP_FORMAT_VERSION + 1 })).toThrow(
/plus recente/i,
);
});
});
describe('aller-retour des nouvelles proprietes', () => {
function richMap(): MindMap {
const map = createEmptyMap('Mise en page');
const root = map.nodes[0] as MindMap['nodes'][number];
return {
...map,
nodes: [
{
...root,
icon: 'server',
iconStyle: { kind: 'builtin', position: 'top', size: 24, gap: 12 },
style: {
backgroundColor: '#112233',
textColor: '#ffffff',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: 1.6,
fontFamily: 'mono',
shadow: 'strong',
fontSize: 18,
bold: true,
italic: true,
shape: 'capsule',
},
layout: {
widthMode: 'fixed',
width: 320,
heightMode: 'fixed',
height: 140,
paddingX: 28,
paddingY: 18,
wrap: false,
maxTextWidth: 400,
},
branch: { style: 'straight', width: 4, color: '#ff8800' },
},
],
settings: {
...map.settings,
layoutSpacing: { parentGap: 140, siblingGap: 80 },
snapToGrid: true,
snapGrid: 10,
alignmentGuides: false,
},
};
}
it('conserve toute la mise en page apres export puis import', () => {
const original = richMap();
const restored = normalizeMapDocument(JSON.parse(serializeMap(original)));
const node = restored.nodes[0];
const source = original.nodes[0];
expect(node?.style).toEqual(source?.style);
expect(node?.layout).toEqual(source?.layout);
expect(node?.iconStyle).toEqual(source?.iconStyle);
expect(node?.branch).toEqual(source?.branch);
expect(node?.icon).toBe('server');
expect(node?.position).toEqual(source?.position);
expect(restored.settings.layoutSpacing).toEqual({ parentGap: 140, siblingGap: 80 });
expect(restored.settings.snapToGrid).toBe(true);
expect(restored.settings.snapGrid).toBe(10);
expect(restored.settings.alignmentGuides).toBe(false);
});
it('borne les valeurs aberrantes a l import', () => {
const map = normalizeMapDocument({
version: 2,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
layout: { width: 99999, paddingX: -20, maxTextWidth: 5 },
branch: { width: 500 },
iconStyle: { size: 900, gap: -5 },
},
],
});
const node = map.nodes[0];
expect(node?.layout?.width).toBeLessThanOrEqual(900);
expect(node?.layout?.paddingX).toBeGreaterThanOrEqual(0);
expect(node?.layout?.maxTextWidth).toBeGreaterThanOrEqual(80);
expect(node?.branch?.width).toBeLessThanOrEqual(10);
expect(node?.iconStyle?.size).toBeLessThanOrEqual(48);
expect(node?.iconStyle?.gap).toBeGreaterThanOrEqual(0);
});
it('rejette les valeurs de mise en page invalides', () => {
const map = normalizeMapDocument({
version: 2,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
layout: { widthMode: 'elastique', wrap: 'oui' },
iconStyle: { kind: 'svg', position: 'diagonale' },
style: { verticalAlign: 'flottant', fontFamily: 'comic-sans' },
},
],
});
expect(map.nodes[0]?.layout).toBeUndefined();
expect(map.nodes[0]?.iconStyle).toBeUndefined();
expect(map.nodes[0]?.style).toBeUndefined();
});
});

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { db } from '@/db/database';
import { createMap, getMap, importMap } from '@/services/mapRepository';
import {
MapFormatError,
normalizeMapDocument,
serializeMap,
} from '@/services/mapSerialization';
/** Carte v1 telle qu'elle pouvait etre enregistree en IndexedDB avant la v2. */
function legacyStoredMap(id: string): Record<string, unknown> {
return {
version: 1,
id,
name: 'Homelab v1',
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,
icon: '🖥️',
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
},
{
id: 'n1',
type: 'standard',
text: 'Reseau',
position: { x: 260, y: 0 },
parentId: 'root',
},
],
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
viewport: { x: 12, y: 34, zoom: 1.5 },
settings: { theme: 'ocean', layout: 'tree-vertical', branchStyle: 'curved', showGrid: false },
};
}
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('pipeline de normalisation', () => {
it('normalise une carte v1 venue d IndexedDB comme un fichier v1', async () => {
await db.maps.put(legacyStoredMap('stored-1') as unknown as MindMap);
const fromStorage = await getMap('stored-1');
const fromFile = normalizeMapDocument(legacyStoredMap('stored-1'), { source: 'file' });
expect(fromStorage).toBeDefined();
expect(fromStorage?.nodes).toEqual(fromFile.nodes);
expect(fromStorage?.settings).toEqual(fromFile.settings);
expect(fromStorage?.version).toBe(MAP_FORMAT_VERSION);
});
it('migre la largeur v1 d une carte stockee sans la reecrire en base', async () => {
await db.maps.put(legacyStoredMap('stored-2') as unknown as MindMap);
const opened = await getMap('stored-2');
expect(opened?.nodes[0]?.layout?.width).toBe(240);
expect(opened?.nodes[0]?.layout?.widthMode).toBe('fixed');
expect(opened?.nodes[0]?.style?.width).toBeUndefined();
// La base contient toujours le document d'origine : rien n'a ete reecrit.
const raw = (await db.maps.get('stored-2')) as unknown as Record<string, unknown>;
expect(raw.version).toBe(1);
expect((raw.nodes as Array<Record<string, unknown>>)[0]?.style).toMatchObject({ width: 240 });
});
it('conserve le contenu et les reglages d une carte v1', async () => {
await db.maps.put(legacyStoredMap('stored-3') as unknown as MindMap);
const opened = await getMap('stored-3');
expect(opened?.name).toBe('Homelab v1');
expect(opened?.createdAt).toBe('2026-01-01T00:00:00.000Z');
expect(opened?.updatedAt).toBe('2026-01-02T00:00:00.000Z');
expect(opened?.nodes[0]?.icon).toBe('🖥️');
expect(opened?.nodes[0]?.style?.textAlign).toBe('center');
expect(opened?.settings.theme).toBe('ocean');
expect(opened?.settings.layout).toBe('tree-vertical');
expect(opened?.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
});
it('ne modifie jamais le document fourni', () => {
const original = legacyStoredMap('immutable');
const copy = JSON.parse(JSON.stringify(original));
normalizeMapDocument(original, { source: 'storage' });
expect(original).toEqual(copy);
});
it('laisse une carte v2 inchangee', () => {
const v2 = normalizeMapDocument(legacyStoredMap('x'), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(v2)), { source: 'file' });
expect(again.nodes).toEqual(v2.nodes);
expect(again.settings).toEqual(v2.settings);
expect(again.version).toBe(MAP_FORMAT_VERSION);
});
it('accepte une carte locale videe de ses noeuds', async () => {
const map = await createMap('Carte videe');
await db.maps.put({ ...map, nodes: [], edges: [] });
const opened = await getMap(map.id);
expect(opened?.nodes).toHaveLength(0);
expect(opened?.name).toBe('Carte videe');
});
it('refuse un fichier sans noeud exploitable', () => {
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
});
it('adapte le message d erreur a la provenance', () => {
expect(() => normalizeMapDocument('pas un objet', { source: 'file' })).toThrow(/fichier/i);
expect(() => normalizeMapDocument('pas un objet', { source: 'storage' })).toThrow(
/enregistree/i,
);
expect(() => normalizeMapDocument('pas un objet', { source: 'share' })).toThrow(/partagee/i);
});
it('fait passer un import par le meme pipeline', async () => {
const normalised = normalizeMapDocument(legacyStoredMap('file-1'), { source: 'file' });
const stored = await importMap(normalised);
const reopened = await getMap(stored.id);
expect(reopened?.nodes).toEqual(normalised.nodes);
expect(reopened?.version).toBe(MAP_FORMAT_VERSION);
});
});

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
import { getOrderedChildren } from '@/utils/tree';
/** Carte v2 : pas de champ `order`, l'ordre est celui du tableau. */
const V2_MAP = {
version: 2,
id: 'v2-map',
name: 'Reseau v2',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
nodes: [
{ id: 'root', type: 'root', text: 'Reseau', position: { x: 0, y: 0 }, parentId: null },
{ id: 'dns', type: 'standard', text: 'DNS', position: { x: 200, y: 0 }, parentId: 'root' },
{ id: 'dhcp', type: 'standard', text: 'DHCP', position: { x: 200, y: 60 }, parentId: 'root' },
{ id: 'vlan', type: 'standard', text: 'VLAN', position: { x: 200, y: 120 }, parentId: 'root' },
],
edges: [
{ id: 'e-root-dns', source: 'root', target: 'dns' },
{ id: 'e-root-dhcp', source: 'root', target: 'dhcp' },
{ id: 'e-root-vlan', source: 'root', target: 'vlan' },
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
};
const V1_MAP = {
...V2_MAP,
version: 1,
id: 'v1-map',
nodes: V2_MAP.nodes.map((node) =>
node.id === 'root' ? { ...node, style: { width: 240, textAlign: 'center' } } : node,
),
};
function texts(map: MindMap, parentId: string | null): string[] {
return getOrderedChildren(map.nodes, parentId).map((node) => node.text);
}
describe('migration vers la v3', () => {
it('materialise l ordre d une carte v2 depuis la position dans le tableau', () => {
const map = normalizeMapDocument(V2_MAP);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
expect(map.nodes.map((node) => node.order)).toEqual([0, 0, 1, 2]);
});
it('migre une carte v1 jusqu a la v3 sans perdre la mise en page', () => {
const map = normalizeMapDocument(V1_MAP);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
expect(map.nodes[0]?.layout?.width).toBe(240);
expect(map.nodes[0]?.style?.textAlign).toBe('center');
});
it('respecte un ordre explicite plutot que la position dans le tableau', () => {
const desordonne = {
...V2_MAP,
version: 3,
nodes: [
V2_MAP.nodes[0],
{ ...V2_MAP.nodes[1], order: 2 },
{ ...V2_MAP.nodes[2], order: 1 },
{ ...V2_MAP.nodes[3], order: 0 },
],
};
expect(texts(normalizeMapDocument(desordonne), 'root')).toEqual(['VLAN', 'DHCP', 'DNS']);
});
it('refuse une version plus recente', () => {
expect(() => normalizeMapDocument({ ...V2_MAP, version: MAP_FORMAT_VERSION + 1 })).toThrow(
/plus recente/i,
);
});
});
describe('aller-retour de l ordre', () => {
it('conserve un ordre reordonne apres export puis import', () => {
const map = normalizeMapDocument(V2_MAP);
const reordonne: MindMap = {
...map,
nodes: map.nodes.map((node) => {
if (node.id === 'vlan') return { ...node, order: 0 };
if (node.id === 'dns') return { ...node, order: 1 };
if (node.id === 'dhcp') return { ...node, order: 2 };
return node;
}),
};
const restaure = normalizeMapDocument(JSON.parse(serializeMap(reordonne)));
expect(texts(restaure, 'root')).toEqual(['VLAN', 'DNS', 'DHCP']);
});
/** VLAN en tete, comme le ferait "Placer en premier". */
function withVlanFirst(): MindMap {
const map = normalizeMapDocument(V2_MAP);
const ordres: Record<string, number> = { vlan: 0, dns: 1, dhcp: 2 };
return {
...map,
nodes: map.nodes.map((node) =>
ordres[node.id] === undefined ? node : { ...node, order: ordres[node.id] as number },
),
};
}
it('ecrit les noeuds dans l ordre hierarchique', () => {
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
// Un lecteur anterieur a la v3, qui ignore `order`, retrouve le bon ordre.
expect(serialise.nodes.map((node) => node.id)).toEqual(['root', 'vlan', 'dns', 'dhcp']);
});
it('emet les liens dans l ordre de fratrie', () => {
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
expect(serialise.edges.map((edge) => edge.target)).toEqual(['vlan', 'dns', 'dhcp']);
});
it('departage deux freres de meme rang par leur position dans le tableau', () => {
const map = normalizeMapDocument(V2_MAP);
const ambigu: MindMap = {
...map,
nodes: map.nodes.map((node) => (node.id === 'vlan' ? { ...node, order: 0 } : node)),
};
// `dns` et `vlan` ont tous deux le rang 0 : le tableau tranche, sans erreur.
expect(texts(normalizeMapDocument(JSON.parse(serializeMap(ambigu))), 'root')).toEqual([
'DNS',
'VLAN',
'DHCP',
]);
});
it('conserve l ordre d une arborescence sur plusieurs niveaux', () => {
const map = normalizeMapDocument({
version: 3,
id: 'profond',
nodes: [
{ id: 'r', type: 'root', text: 'R', parentId: null, position: { x: 0, y: 0 } },
{ id: 'a', text: 'A', parentId: 'r', order: 1, position: { x: 0, y: 0 } },
{ id: 'b', text: 'B', parentId: 'r', order: 0, position: { x: 0, y: 0 } },
{ id: 'a1', text: 'A1', parentId: 'a', order: 1, position: { x: 0, y: 0 } },
{ id: 'a2', text: 'A2', parentId: 'a', order: 0, position: { x: 0, y: 0 } },
],
});
expect(texts(map, 'r')).toEqual(['B', 'A']);
expect(texts(map, 'a')).toEqual(['A2', 'A1']);
});
});

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { MapFormatError, normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
/** Document v4 minimal : deux branches opposees et une relation entre feuilles. */
function documentV4(): Record<string, unknown> {
return {
version: 4,
id: 'map-1',
name: 'Infrastructure',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
nodes: [
{ id: 'root', type: 'root', text: 'Infrastructure', position: { x: 0, y: 0 }, parentId: null },
{
id: 'reseau',
type: 'standard',
text: 'Reseau',
position: { x: 240, y: 0 },
parentId: 'root',
order: 0,
side: 'right',
},
{
id: 'firewall',
type: 'standard',
text: 'Firewall',
position: { x: 480, y: 0 },
parentId: 'reseau',
order: 0,
},
{
id: 'services',
type: 'standard',
text: 'Services',
position: { x: -240, y: 0 },
parentId: 'root',
order: 1,
side: 'left',
},
{
id: 'web',
type: 'standard',
text: 'Web',
position: { x: -480, y: 0 },
parentId: 'services',
order: 0,
},
],
edges: [],
relations: [
{
id: 'r-1',
sourceId: 'firewall',
targetId: 'web',
label: 'protege',
style: 'bezier',
color: '#a855f7',
width: 2,
dashed: true,
markerEnd: 'arrow',
},
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-both', branchStyle: 'bezier', showGrid: true },
};
}
describe('lecture d un document v4', () => {
it('conserve les cotes et les relations', () => {
const map = normalizeMapDocument(documentV4(), { source: 'file' });
expect(map.version).toBe(MAP_FORMAT_VERSION);
const sides = new Map(map.nodes.map((node) => [node.id, node.side]));
expect(sides.get('reseau')).toBe('right');
expect(sides.get('services')).toBe('left');
expect(map.relations).toHaveLength(1);
expect(map.relations[0]?.label).toBe('protege');
expect(map.relations[0]?.dashed).toBe(true);
expect(map.relations[0]?.markerEnd).toBe('arrow');
expect(map.settings.layout).toBe('mindmap-both');
});
it('ne modifie pas le document fourni', () => {
const raw = documentV4();
const before = JSON.stringify(raw);
normalizeMapDocument(raw, { source: 'file' });
expect(JSON.stringify(raw)).toBe(before);
});
it('est idempotent : renormaliser ne change plus rien', () => {
const once = normalizeMapDocument(documentV4(), { source: 'file' });
const twice = normalizeMapDocument(JSON.parse(JSON.stringify(once)), { source: 'file' });
expect(twice.nodes).toEqual(once.nodes);
expect(twice.relations).toEqual(once.relations);
});
it('survit a un aller-retour complet', () => {
const map = normalizeMapDocument(documentV4(), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
expect(again.relations).toEqual(map.relations);
expect(again.nodes.map((node) => [node.id, node.side])).toEqual(
map.nodes.map((node) => [node.id, node.side]),
);
});
});
describe('relations incoherentes', () => {
it('retire une relation dont la source n existe pas', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.sourceId = 'fantome';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('retire une relation dont la cible n existe pas', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'fantome';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('retire une relation bouclant sur elle-meme', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'firewall';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('ne garde qu une relation par couple source / cible', () => {
const raw = documentV4();
const relations = raw.relations as Array<Record<string, unknown>>;
relations.push({ ...relations[0], id: 'r-2' });
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(1);
});
it('ignore les champs de style invalides sans perdre la relation', () => {
const raw = documentV4();
const relation = (raw.relations as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
relation.style = 'zigzag';
relation.color = 'javascript:alert(1)';
relation.markerEnd = 'etoile';
const parsed = normalizeMapDocument(raw, { source: 'file' }).relations[0];
expect(parsed?.style).toBeUndefined();
expect(parsed?.color).toBeUndefined();
expect(parsed?.markerEnd).toBeUndefined();
expect(parsed?.label).toBe('protege');
});
it('ignore un cote invalide', () => {
const raw = documentV4();
(raw.nodes as Array<Record<string, unknown>>)[1]!.side = 'haut';
const map = normalizeMapDocument(raw, { source: 'file' });
expect(map.nodes.find((node) => node.id === 'reseau')?.side).toBeUndefined();
});
it('refuse un document depassant la limite de relations', () => {
const raw = documentV4();
raw.relations = Array.from({ length: 30 }, (_item, index) => ({
id: `r-${index}`,
sourceId: 'firewall',
targetId: 'web',
}));
expect(() => normalizeMapDocument(raw, { source: 'file', maxNodes: 10 })).toThrow(
MapFormatError,
);
});
});
describe('migrations vers la v4', () => {
function legacyDocument(version: number): Record<string, unknown> {
return {
version,
id: `map-v${version}`,
name: `Carte v${version}`,
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Racine',
position: { x: 0, y: 0 },
parentId: null,
style: version === 1 ? { width: 220, backgroundColor: '#ffffff' } : undefined,
},
{
id: 'a',
type: 'standard',
text: 'A',
position: { x: 200, y: 0 },
parentId: 'root',
},
{
id: 'b',
type: 'standard',
text: 'B',
position: { x: 200, y: 80 },
parentId: 'root',
},
],
edges: [
{ id: 'e-root-a', source: 'root', target: 'a' },
{ id: 'e-root-b', source: 'root', target: 'b' },
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier' },
};
}
it('ouvre une carte v1 et la ramene au format courant', () => {
const map = normalizeMapDocument(legacyDocument(1), { source: 'file' });
expect(map.version).toBe(4);
expect(map.relations).toEqual([]);
// La largeur fixe de la v1 a rejoint layout.width.
const root = map.nodes.find((node) => node.id === 'root');
expect(root?.layout?.width).toBe(220);
expect(root?.layout?.widthMode).toBe('fixed');
expect(root?.style?.backgroundColor).toBe('#ffffff');
});
it('ouvre une carte v2 en materialisant l ordre puis la v4', () => {
const map = normalizeMapDocument(legacyDocument(2), { source: 'file' });
expect(map.version).toBe(4);
expect(map.nodes.find((node) => node.id === 'a')?.order).toBe(0);
expect(map.nodes.find((node) => node.id === 'b')?.order).toBe(1);
expect(map.relations).toEqual([]);
});
it('ouvre une carte v3 sans rien inventer', () => {
const map = normalizeMapDocument(legacyDocument(3), { source: 'file' });
expect(map.version).toBe(4);
expect(map.relations).toEqual([]);
// Aucun cote : la carte reste exactement telle qu'elle etait.
for (const node of map.nodes) expect(node.side).toBeUndefined();
});
it('accepte une carte historique lue depuis IndexedDB', () => {
const stored = legacyDocument(2) as unknown as MindMap;
const map = normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
expect(map.version).toBe(4);
expect(map.nodes).toHaveLength(3);
expect(map.relations).toEqual([]);
});
it('refuse un document plus recent que le format connu', () => {
const raw = { ...documentV4(), version: 99 };
expect(() => normalizeMapDocument(raw, { source: 'file' })).toThrow(MapFormatError);
});
it('accepte une carte v4 sans champ relations', () => {
const raw = documentV4();
delete raw.relations;
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toEqual([]);
});
});

View File

@@ -8,6 +8,7 @@ import {
importMap,
listMaps,
renameMap,
saveMap,
seedSampleMapOnce,
} from '@/services/mapRepository';
import { saveShareRecord } from '@/services/shareRepository';
@@ -103,3 +104,49 @@ describe('stockage IndexedDB des cartes', () => {
expect((await getMap(map.id))?.name).toBe('Persistante');
});
});
describe('persistance de la mise en page', () => {
it('conserve taille, padding et alignement apres reouverture', async () => {
const map = await createMap('Mise en page');
const rootNodeId = map.nodes[0]?.id as string;
await saveMap({
...map,
nodes: map.nodes.map((node) =>
node.id === rootNodeId
? {
...node,
style: { textAlign: 'center', verticalAlign: 'middle', lineHeight: 1.6 },
layout: {
widthMode: 'fixed',
width: 280,
heightMode: 'fixed',
height: 120,
paddingX: 26,
paddingY: 16,
wrap: false,
},
iconStyle: { kind: 'builtin', position: 'top', size: 24 },
icon: 'server',
}
: node,
),
});
// Fermeture puis reouverture de la base, comme un redemarrage du navigateur.
db.close();
await db.open();
const stored = await getMap(map.id);
const node = stored?.nodes.find((candidate) => candidate.id === rootNodeId);
expect(node?.layout?.width).toBe(280);
expect(node?.layout?.height).toBe(120);
expect(node?.layout?.paddingX).toBe(26);
expect(node?.layout?.wrap).toBe(false);
expect(node?.style?.textAlign).toBe('center');
expect(node?.style?.verticalAlign).toBe('middle');
expect(node?.icon).toBe('server');
expect(node?.iconStyle?.position).toBe('top');
});
});

View File

@@ -3,6 +3,7 @@ import { type MindMap, type MapSummary } from '@/types/map';
import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository';
import { normalizeMapDocument } from '@/services/mapSerialization';
/** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */
export async function listMaps(): Promise<MapSummary[]> {
@@ -21,8 +22,17 @@ export async function listMaps(): Promise<MapSummary[]> {
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* Lit une carte locale et la fait passer par le pipeline de normalisation.
*
* Les cartes enregistrees avant une evolution de format sont ainsi ramenees au
* modele courant a l'ouverture, sans etre reecrites en base : la migration
* n'est persistee que si l'utilisateur modifie la carte.
*/
export async function getMap(id: string): Promise<MindMap | undefined> {
return db.maps.get(id);
const stored = await db.maps.get(id);
if (!stored) return undefined;
return normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
}
export async function saveMap(map: MindMap): Promise<void> {

View File

@@ -3,14 +3,14 @@ import { MAP_FORMAT_VERSION } from '@/types/map';
import { createSampleMap } from '@/services/mapFactory';
import {
MapFormatError,
parseMapDocument,
normalizeMapDocument,
serializeMap,
} from '@/services/mapSerialization';
describe('serialisation des cartes', () => {
it('effectue un aller-retour sans perte', () => {
const original = createSampleMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original)));
const restored = normalizeMapDocument(JSON.parse(serializeMap(original)));
expect(restored.name).toBe(original.name);
expect(restored.nodes).toHaveLength(original.nodes.length);
@@ -20,25 +20,25 @@ describe('serialisation des cartes', () => {
});
it('rejette un contenu qui n est pas un objet', () => {
expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => parseMapDocument(null)).toThrow(MapFormatError);
expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError);
expect(() => normalizeMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => normalizeMapDocument(null)).toThrow(MapFormatError);
expect(() => normalizeMapDocument([1, 2, 3])).toThrow(MapFormatError);
});
it('rejette une carte sans version', () => {
const map = createSampleMap() as unknown as Record<string, unknown>;
delete map.version;
expect(() => parseMapDocument(map)).toThrow(/version/i);
expect(() => normalizeMapDocument(map)).toThrow(/version/i);
});
it('rejette une version de format plus recente', () => {
const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 };
expect(() => parseMapDocument(map)).toThrow(/plus recente/i);
expect(() => normalizeMapDocument(map)).toThrow(/plus recente/i);
});
it('rejette une carte sans noeud exploitable', () => {
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
MapFormatError,
);
});
@@ -49,13 +49,13 @@ describe('serialisation des cartes', () => {
text: `Noeud ${index}`,
parentId: index === 0 ? null : 'n0',
}));
expect(() => parseMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
/limite/i,
);
});
it('neutralise les URL dangereuses', () => {
const parsed = parseMapDocument({
const parsed = normalizeMapDocument({
version: 1,
id: 'x',
name: 'Test',
@@ -72,7 +72,7 @@ describe('serialisation des cartes', () => {
});
it('conserve le texte tel quel sans interpreter le HTML', () => {
const parsed = parseMapDocument({
const parsed = normalizeMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }],
@@ -81,7 +81,7 @@ describe('serialisation des cartes', () => {
});
it('reconstruit la hierarchie a partir des liens', () => {
const parsed = parseMapDocument({
const parsed = normalizeMapDocument({
version: 1,
id: 'x',
nodes: [
@@ -97,7 +97,7 @@ describe('serialisation des cartes', () => {
});
it('ignore les liens pointant vers des noeuds inexistants', () => {
const parsed = parseMapDocument({
const parsed = normalizeMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: 'Racine' }],
@@ -107,7 +107,7 @@ describe('serialisation des cartes', () => {
});
it('ignore les styles invalides mais garde les styles valides', () => {
const parsed = parseMapDocument({
const parsed = normalizeMapDocument({
version: 1,
id: 'x',
nodes: [

View File

@@ -2,18 +2,37 @@ import { APP_CONFIG } from '@/config/app';
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type FontFamilyId,
type IconPosition,
type LayoutKind,
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,
type VerticalAlign,
} from '@/types/map';
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 { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
import {
flattenInOrder,
normalizeChildOrders,
rebuildEdges,
rebuildHierarchyFromEdges,
} from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
@@ -31,7 +50,16 @@ const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const VERTICAL_ALIGNS: VerticalAlign[] = ['top', 'middle', 'bottom'];
const SIZE_MODES: SizeMode[] = ['auto', 'fixed'];
const SHADOW_STYLES: ShadowStyle[] = ['theme', 'none', 'soft', 'strong'];
const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounded'];
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',
@@ -42,9 +70,16 @@ const LAYOUTS: LayoutKind[] = [
'freeform',
];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
/**
* Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable).
*
* Les noeuds sont ecrits en parcours de profondeur, freres dans l'ordre : le
* fichier reste lisible a l'oeil, et un lecteur anterieur a la v3, qui ignore
* `order`, retrouve malgre tout le bon ordre via la position dans le tableau.
*/
export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2);
const nodes = flattenInOrder(normalizeChildOrders(map.nodes));
return JSON.stringify({ ...map, nodes, edges: rebuildEdges(nodes) }, null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -95,10 +130,107 @@ function parseStyle(value: unknown): NodeStyle | undefined {
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign;
}
if (
typeof value.verticalAlign === 'string' &&
VERTICAL_ALIGNS.includes(value.verticalAlign as VerticalAlign)
) {
style.verticalAlign = value.verticalAlign as VerticalAlign;
}
if (
typeof value.fontFamily === 'string' &&
FONT_FAMILIES.includes(value.fontFamily as FontFamilyId)
) {
style.fontFamily = value.fontFamily as FontFamilyId;
}
if (typeof value.shadow === 'string' && SHADOW_STYLES.includes(value.shadow as ShadowStyle)) {
style.shadow = value.shadow as ShadowStyle;
}
if (typeof value.lineHeight === 'number' && Number.isFinite(value.lineHeight)) {
style.lineHeight = Math.min(
LAYOUT_LIMITS.lineHeight.max,
Math.max(LAYOUT_LIMITS.lineHeight.min, Math.round(value.lineHeight * 100) / 100),
);
}
return Object.keys(style).length > 0 ? style : undefined;
}
/** Mise en page d'un noeud (v2). Chaque valeur est bornee. */
function parseLayout(value: unknown): NodeLayout | undefined {
if (!isRecord(value)) return undefined;
const layout: NodeLayout = {};
if (typeof value.widthMode === 'string' && SIZE_MODES.includes(value.widthMode as SizeMode)) {
layout.widthMode = value.widthMode as SizeMode;
}
if (typeof value.heightMode === 'string' && SIZE_MODES.includes(value.heightMode as SizeMode)) {
layout.heightMode = value.heightMode as SizeMode;
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
layout.width = clampTo(value.width, LAYOUT_LIMITS.width);
}
if (typeof value.height === 'number' && Number.isFinite(value.height)) {
layout.height = clampTo(value.height, LAYOUT_LIMITS.height);
}
if (typeof value.paddingX === 'number' && Number.isFinite(value.paddingX)) {
layout.paddingX = clampTo(value.paddingX, LAYOUT_LIMITS.paddingX);
}
if (typeof value.paddingY === 'number' && Number.isFinite(value.paddingY)) {
layout.paddingY = clampTo(value.paddingY, LAYOUT_LIMITS.paddingY);
}
if (typeof value.maxTextWidth === 'number' && Number.isFinite(value.maxTextWidth)) {
layout.maxTextWidth = clampTo(value.maxTextWidth, LAYOUT_LIMITS.maxTextWidth);
}
if (typeof value.wrap === 'boolean') layout.wrap = value.wrap;
return Object.keys(layout).length > 0 ? layout : undefined;
}
function parseIconStyle(value: unknown): NodeIconStyle | undefined {
if (!isRecord(value)) return undefined;
const iconStyle: NodeIconStyle = {};
if (
typeof value.kind === 'string' &&
(ICON_KINDS as readonly string[]).includes(value.kind)
) {
iconStyle.kind = value.kind as NodeIconStyle['kind'];
}
if (
typeof value.position === 'string' &&
ICON_POSITIONS.includes(value.position as IconPosition)
) {
iconStyle.position = value.position as IconPosition;
}
if (typeof value.size === 'number' && Number.isFinite(value.size)) {
iconStyle.size = clampTo(value.size, LAYOUT_LIMITS.iconSize);
}
if (typeof value.gap === 'number' && Number.isFinite(value.gap)) {
iconStyle.gap = clampTo(value.gap, LAYOUT_LIMITS.iconGap);
}
return Object.keys(iconStyle).length > 0 ? iconStyle : undefined;
}
function parseBranch(value: unknown): NodeBranchStyle | undefined {
if (!isRecord(value)) return undefined;
const branch: NodeBranchStyle = {};
if (
typeof value.style === 'string' &&
BRANCH_STYLES.includes(value.style as BranchStyle)
) {
branch.style = value.style as BranchStyle;
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
branch.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
}
const color = asColor(value.color);
if (color) branch.color = color;
return Object.keys(branch).length > 0 ? branch : undefined;
}
function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null;
@@ -122,11 +254,23 @@ function parseNode(value: unknown): MindMapNode | null {
parentId: asString(value.parentId, 128) ?? null,
};
if (typeof value.order === 'number' && Number.isFinite(value.order)) {
node.order = Math.max(0, Math.round(value.order));
}
if (typeof value.side === 'string' && NODE_SIDES.includes(value.side as NodeSide)) {
node.side = value.side as NodeSide;
}
if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8);
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
const icon = asString(value.icon, 32);
if (icon) node.icon = icon;
const iconStyle = parseIconStyle(value.iconStyle);
if (iconStyle) node.iconStyle = iconStyle;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url;
@@ -137,11 +281,65 @@ function parseNode(value: unknown): MindMapNode | null {
const style = parseStyle(value.style);
if (style) node.style = style;
const layout = parseLayout(value.layout);
if (layout) node.layout = layout;
const branch = parseBranch(value.branch);
if (branch) node.branch = branch;
if (isRecord(value.meta)) node.meta = value.meta;
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;
@@ -157,35 +355,155 @@ function parseSettings(value: unknown): MapSettings {
settings.branchStyle = value.branchStyle as BranchStyle;
}
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid;
if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides;
if (typeof value.dragMovesBranch === 'boolean') settings.dragMovesBranch = value.dragMovesBranch;
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
}
if (isRecord(value.layoutSpacing)) {
const raw = value.layoutSpacing;
const parentGap = typeof raw.parentGap === 'number' ? raw.parentGap : undefined;
const siblingGap = typeof raw.siblingGap === 'number' ? raw.siblingGap : undefined;
if (parentGap !== undefined && siblingGap !== undefined) {
settings.layoutSpacing = {
parentGap: clampTo(parentGap, SPACING_LIMITS.parentGap),
siblingGap: clampTo(siblingGap, SPACING_LIMITS.siblingGap),
};
}
}
return settings;
}
/**
* Point d'extension des migrations de format.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
* recente, avant la normalisation finale.
* Migrations de format, appliquees de la plus ancienne a la plus recente.
*
* v1 -> v2 : la largeur fixe vivait dans `style.width`. Elle rejoint
* `layout.width` avec un `widthMode` explicite. Les autres nouveautes de la v2
* sont facultatives : leur absence signifie "herite du theme", il n'y a donc
* rien a ecrire dans le document.
*/
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
const document = raw;
const current = version;
function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(document.nodes)) return { ...document, version: 2 };
// Exemple d'evolution future :
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
const nodes = document.nodes.map((rawNode) => {
if (!isRecord(rawNode) || !isRecord(rawNode.style)) return rawNode;
const { width, ...styleRest } = rawNode.style;
if (typeof width !== 'number' || !Number.isFinite(width)) return rawNode;
const existingLayout = isRecord(rawNode.layout) ? rawNode.layout : {};
return {
...rawNode,
style: styleRest,
layout: { widthMode: 'fixed', width, ...existingLayout },
};
});
return { ...document, nodes, version: 2 };
}
/**
* v2 -> v3 : materialise l'ordre des freres depuis leur position dans le
* tableau `nodes`, ce qui reproduit exactement l'affichage de la v2.
*/
function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(document.nodes)) return { ...document, version: 3 };
const counters = new Map<string, number>();
const nodes = document.nodes.map((rawNode) => {
if (!isRecord(rawNode)) return rawNode;
if (typeof rawNode.order === 'number') return rawNode;
const parentId = typeof rawNode.parentId === 'string' ? rawNode.parentId : '';
const next = counters.get(parentId) ?? 0;
counters.set(parentId, next + 1);
return { ...rawNode, order: next };
});
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;
if (current === 1) {
document = migrateV1ToV2(document);
current = 2;
}
if (current === 2) {
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.
return { ...document, version: MAP_FORMAT_VERSION };
}
return document;
}
/** Provenance d'un document : ne sert qu'a formuler les messages d'erreur. */
export type MapDocumentSource = 'file' | 'storage' | 'share';
export interface NormalizeOptions {
maxNodes?: number;
source?: MapDocumentSource;
/**
* Autorise un document sans noeud. Vrai pour les cartes deja enregistrees :
* l'utilisateur a le droit d'avoir vide sa carte, et la rejeter reviendrait a
* la lui rendre inaccessible.
*/
allowEmpty?: boolean;
}
const INVALID_DOCUMENT_MESSAGE: Record<MapDocumentSource, string> = {
file: "Le fichier selectionne n'est pas une carte valide.",
storage: "Cette carte enregistree est illisible et n'a pas pu etre ouverte.",
share: "Cette carte partagee est illisible.",
};
/**
* Valide et normalise un document carte provenant d'un fichier ou du serveur.
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue,
* ni injecte en HTML.
* Point d'entree unique des documents carte.
*
* Toute carte qui entre dans l'editeur passe par ici, quelle que soit sa
* provenance : fichier .mmap, IndexedDB ou partage serveur. Le pipeline est
* toujours le meme :
*
* source -> validation -> migration de version -> normalisation -> modele courant
*
* La normalisation est non destructive : le document d'origine n'est jamais
* modifie, et rien n'est reecrit en base. Une carte v1 restee en IndexedDB
* continue donc de s'ouvrir exactement comme avant, et n'est reecrite que si
* l'utilisateur la modifie.
*
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue ni
* injecte en HTML.
*/
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap {
export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {}): MindMap {
const source = options.source ?? 'file';
if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide.");
throw new MapFormatError(INVALID_DOCUMENT_MESSAGE[source]);
}
const version = raw.version;
@@ -205,7 +523,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents).");
throw new MapFormatError(`${INVALID_DOCUMENT_MESSAGE[source]} (noeuds absents)`);
}
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
@@ -228,7 +546,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
nodes.push(node);
}
if (nodes.length === 0) {
if (nodes.length === 0 && !options.allowEmpty) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
}
@@ -249,25 +567,59 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
}));
// Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) {
if (linked.length > 0 && !linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null };
}
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString();
// Ordre de fratrie dense et tableau range en profondeur : le document sortant
// 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(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked,
edges: rebuildEdges(linked),
nodes: normalised,
edges: rebuildEdges(normalised),
relations,
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))),
zoom: Math.min(4, Math.max(0.02, asFiniteNumber(rawViewport.zoom, 1))),
},
settings: parseSettings(document.settings),
};
@@ -289,5 +641,5 @@ export async function readMapFile(
} catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
}
return parseMapDocument(raw);
return normalizeMapDocument(raw, { source: 'file' });
}

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest';
import {
MarkdownError,
buildMapFromMarkdown,
exportMarkdown,
parseMarkdown,
} from '@/services/markdown';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getOrderedChildren } from '@/utils/tree';
const SAMPLE = `# Infrastructure
## Reseau
- VLAN
- VPN
- Firewall
## Virtualisation
- Proxmox
- VM
- LXC
- Docker
`;
function texts(nodes: Array<{ text: string }>): string[] {
return nodes.map((node) => node.text);
}
describe('analyse Markdown', () => {
it('construit la hierarchie a partir des titres et des listes', () => {
const result = parseMarkdown(SAMPLE);
expect(result.title).toBe('Infrastructure');
expect(result.roots).toHaveLength(1);
const root = result.roots[0];
expect(texts(root?.children ?? [])).toEqual(['Reseau', 'Virtualisation']);
const reseau = root?.children[0];
expect(texts(reseau?.children ?? [])).toEqual(['VLAN', 'VPN', 'Firewall']);
const virtualisation = root?.children[1];
expect(texts(virtualisation?.children ?? [])).toEqual(['Proxmox', 'Docker']);
expect(texts(virtualisation?.children[0]?.children ?? [])).toEqual(['VM', 'LXC']);
expect(result.count).toBe(10);
});
it('gere trois niveaux de titres', () => {
const result = parseMarkdown('# A\n## B\n### C\n');
expect(result.roots[0]?.children[0]?.children[0]?.text).toBe('C');
});
it('accepte un niveau de titre saute', () => {
// Un H3 directement sous un H1 se rattache au titre ouvert le plus proche.
const result = parseMarkdown('# A\n### C\n');
expect(result.roots[0]?.children[0]?.text).toBe('C');
});
it('reprend un titre de niveau superieur apres un plus profond', () => {
const result = parseMarkdown('# A\n### C\n## B\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['C', 'B']);
});
it('lit les listes numerotees', () => {
const result = parseMarkdown('# A\n1. un\n2. deux\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
});
it('accepte les puces * et +', () => {
const result = parseMarkdown('# A\n* un\n+ deux\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
});
it('accepte un document fait uniquement de listes', () => {
const result = parseMarkdown('- un\n - un.a\n- deux\n');
expect(texts(result.roots)).toEqual(['un', 'deux']);
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un.a']);
expect(result.title).toBeUndefined();
});
it('conserve les caracteres Unicode', () => {
const result = parseMarkdown('# Sécurité réseau\n- Pare-feu «actif»\n- 日本語\n');
expect(result.roots[0]?.text).toBe('Sécurité réseau');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['Pare-feu «actif»', '日本語']);
});
});
describe('paragraphes et blocs de code', () => {
it('met un paragraphe dans la note du titre au-dessus', () => {
const result = parseMarkdown(
'# A\n\n## VPN\n\nLe VPN permet aux utilisateurs distants\nd acceder aux ressources internes.\n',
);
const vpn = result.roots[0]?.children[0];
expect(vpn?.text).toBe('VPN');
expect(vpn?.note).toContain('Le VPN permet aux utilisateurs distants');
expect(vpn?.note).toContain('ressources internes');
});
it('met un bloc de code dans la note, sans creer de noeuds', () => {
const result = parseMarkdown('# A\n\n## Docker\n\n```bash\ndocker compose up -d\n```\n');
const docker = result.roots[0]?.children[0];
expect(docker?.note).toContain('docker compose up -d');
expect(docker?.children).toHaveLength(0);
expect(result.count).toBe(2);
});
it('n interprete jamais le contenu : la note reste du texte', () => {
const result = parseMarkdown('# A\n\n<script>alert(1)</script>\n');
expect(result.roots[0]?.note).toBe('<script>alert(1)</script>');
});
it('attache un paragraphe qui suit une liste au dernier element cree', () => {
const result = parseMarkdown('# A\n- un\n\nDetail de un.\n');
expect(result.roots[0]?.children[0]?.note).toBe('Detail de un.');
});
});
describe('documents refuses', () => {
it('refuse un document vide', () => {
expect(() => parseMarkdown('')).toThrow(MarkdownError);
});
it('refuse un document sans titre ni liste', () => {
expect(() => parseMarkdown('juste du texte\net encore du texte')).toThrow(MarkdownError);
});
it('signale la ligne exacte d une indentation incoherente', () => {
const text = '# A\n- un\n - trop profond\n - incoherent\n';
try {
parseMarkdown(text);
throw new Error('aurait du echouer');
} catch (error) {
expect(error).toBeInstanceOf(MarkdownError);
expect((error as MarkdownError).line).toBe(4);
expect((error as MarkdownError).message).toContain('Ligne 4');
}
});
it('refuse un document au-dela de la limite de noeuds', () => {
const many = Array.from({ length: 12 }, (_item, index) => `- item ${index}`).join('\n');
expect(() => parseMarkdown(`# A\n${many}`, 5)).toThrow(MarkdownError);
});
});
describe('import comme nouvelle carte', () => {
it('fait du titre H1 la racine et le nom de la carte', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
expect(map.name).toBe('Infrastructure');
expect(map.nodes[0]?.text).toBe('Infrastructure');
expect(map.nodes).toHaveLength(10);
expect(map.relations).toEqual([]);
});
it('utilise le nom du fichier quand il n y a pas de H1 unique', () => {
const map = buildMapFromMarkdown('- un\n- deux\n', 'mes notes');
expect(map.name).toBe('mes notes');
expect(texts(getOrderedChildren(map.nodes, map.nodes[0]?.id as string))).toEqual([
'un',
'deux',
]);
});
it('reconstruit la hierarchie et positionne les noeuds', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const root = map.nodes[0];
const branches = getOrderedChildren(map.nodes, root?.id as string);
expect(texts(branches)).toEqual(['Reseau', 'Virtualisation']);
const proxmox = map.nodes.find((node) => node.text === 'Proxmox');
expect(texts(getOrderedChildren(map.nodes, proxmox?.id as string))).toEqual(['VM', 'LXC']);
// Les positions viennent de l'auto-layout : rien n'est empile a l'origine.
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
expect(distinct.size).toBe(map.nodes.length);
});
});
describe('import comme sous-arbre', () => {
it('insere le contenu sous le noeud vise en une seule entree d historique', () => {
const store = useEditorStore.getState();
store.loadMap(createEmptyMap('Carte'));
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
const parsed = parseMarkdown(SAMPLE);
const created = useEditorStore.getState().insertOutline(rootId, parsed.roots);
expect(created).toBe(10);
expect(useEditorStore.getState().map?.nodes).toHaveLength(11);
useEditorStore.getState().undo();
expect(useEditorStore.getState().map?.nodes).toHaveLength(1);
});
it('reporte les notes des paragraphes sur les noeuds crees', () => {
const store = useEditorStore.getState();
store.loadMap(createEmptyMap('Carte'));
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
const parsed = parseMarkdown('## VPN\n\nAcces distant securise.\n');
useEditorStore.getState().insertOutline(rootId, parsed.roots);
const vpn = useEditorStore.getState().map?.nodes.find((node) => node.text === 'VPN');
expect(vpn?.note).toBe('Acces distant securise.');
});
});
describe('export Markdown', () => {
it('ecrit les trois premiers niveaux en titres et la suite en listes', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const markdown = exportMarkdown(map);
expect(markdown).toContain('# Infrastructure');
expect(markdown).toContain('## Reseau');
expect(markdown).toContain('### VLAN');
expect(markdown).toContain('### Proxmox');
// VM et LXC sont freres sous Proxmox : meme niveau de liste.
expect(markdown).toContain('- VM');
expect(markdown).toContain('- LXC');
// Un titre est toujours precede d'une ligne vide.
expect(markdown).toContain('\n\n### Docker');
});
it('ecrit les notes sous leur titre', () => {
const map = buildMapFromMarkdown('# A\n\n## VPN\n\nAcces distant.\n', 'fichier');
expect(exportMarkdown(map)).toContain('Acces distant.');
});
it('ne represente pas les relations secondaires', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const firewall = map.nodes.find((node) => node.text === 'Firewall') as { id: string };
const docker = map.nodes.find((node) => node.text === 'Docker') as { id: string };
const withRelation = {
...map,
relations: [
{ id: 'r-1', sourceId: firewall.id, targetId: docker.id, label: 'protege' },
],
};
const markdown = exportMarkdown(withRelation);
expect(markdown).not.toContain('protege');
expect(markdown).toContain('# Infrastructure');
});
it('conserve la hierarchie lors d un aller-retour', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const again = buildMapFromMarkdown(exportMarkdown(map), 'fichier');
const flatten = (source: typeof map, parentId: string | null, depth: number): string[] =>
getOrderedChildren(source.nodes, parentId).flatMap((node) => [
`${depth}:${node.text}`,
...flatten(source, node.id, depth + 1),
]);
expect(flatten(again, null, 0)).toEqual(flatten(map, null, 0));
});
it('respecte l ordre des freres', () => {
const map = buildMapFromMarkdown('# A\n- premier\n- deuxieme\n- troisieme\n', 'fichier');
const markdown = exportMarkdown(map);
expect(markdown.indexOf('premier')).toBeLessThan(markdown.indexOf('deuxieme'));
expect(markdown.indexOf('deuxieme')).toBeLessThan(markdown.indexOf('troisieme'));
});
});

View File

@@ -0,0 +1,319 @@
import { APP_CONFIG } from '@/config/app';
import { type MindMap, type MindMapNode } from '@/types/map';
import {
OutlineError,
isListItem,
parseOutline,
type OutlineNode,
} from '@/services/outlineParser';
import { computeLayout } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes';
import { createEmptyMap, createNode } from '@/services/mapFactory';
import { getOrderedChildren, rebuildEdges } from '@/utils/tree';
/**
* Markdown <-> mind map.
*
* L'import ne cherche pas a etre un lecteur Markdown complet : il retient ce
* qui porte une hierarchie (titres et listes) et met le reste (paragraphes,
* blocs de code) dans la note du noeud courant. Rien n'est interprete comme du
* HTML, rien n'est execute : le texte reste du texte.
*
* Les listes sont deleguees a `parseOutline`, deja utilise par l'import
* d'outline : il n'y a qu'un seul analyseur d'indentation dans le projet.
*/
const MAX_NOTE_LENGTH = 20000;
const MAX_HEADING_DEPTH = 6;
export class MarkdownError extends Error {
readonly line: number;
constructor(message: string, line: number) {
super(message);
this.name = 'MarkdownError';
this.line = line;
}
}
export interface MarkdownResult {
roots: OutlineNode[];
/** Nombre total de noeuds produits. */
count: number;
/** Texte du premier titre de niveau 1, s'il existe. */
title?: string;
}
const HEADING = /^(#{1,6})\s+(.*)$/;
const FENCE = /^\s*(```|~~~)/;
function countNodes(nodes: OutlineNode[]): number {
let total = 0;
for (const node of nodes) total += 1 + countNodes(node.children);
return total;
}
/** Dernier noeud cree en profondeur : celui auquel rattacher la suite. */
function lastLeaf(nodes: OutlineNode[]): OutlineNode | null {
const last = nodes[nodes.length - 1];
if (!last) return null;
return lastLeaf(last.children) ?? last;
}
function appendNote(node: OutlineNode | null, block: string): void {
if (!node) return;
const text = block.trim();
if (!text) return;
const merged = node.note ? `${node.note}\n\n${text}` : text;
node.note = merged.slice(0, MAX_NOTE_LENGTH);
}
/**
* Analyse un document Markdown.
*
* Titres et listes construisent la hierarchie ; un titre de niveau saute est
* accepte et rattache au titre ouvert le plus proche, ce qui evite de rejeter
* un document par ailleurs parfaitement lisible.
*/
export function parseMarkdown(
text: string,
maxNodes: number = APP_CONFIG.maxNodes,
): MarkdownResult {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const roots: OutlineNode[] = [];
const headings: Array<{ level: number; node: OutlineNode }> = [];
let title: string | undefined;
let current: OutlineNode | null = null;
let paragraph: string[] = [];
let listBlock: Array<{ raw: string; line: number }> = [];
let fence: string | null = null;
let fenced: string[] = [];
const flushParagraph = (): void => {
if (paragraph.length === 0) return;
appendNote(current, paragraph.join('\n'));
paragraph = [];
};
const flushList = (): void => {
if (listBlock.length === 0) return;
const offset = (listBlock[0] as { line: number }).line - 1;
let parsed;
try {
parsed = parseOutline(listBlock.map((entry) => entry.raw).join('\n'), maxNodes);
} catch (error) {
if (error instanceof OutlineError) {
// On repositionne le numero de ligne sur le document complet.
throw new MarkdownError(
error.message.replace(/^Ligne \d+/, `Ligne ${error.line + offset}`),
error.line + offset,
);
}
throw error;
}
listBlock = [];
const parent = headings[headings.length - 1]?.node;
if (parent) parent.children.push(...parsed.roots);
else roots.push(...parsed.roots);
current = lastLeaf(parsed.roots) ?? current;
};
lines.forEach((raw, index) => {
const lineNumber = index + 1;
// Bloc de code : recopie telle quelle dans la note, jamais en noeuds.
if (fence !== null) {
fenced.push(raw);
if (raw.trimStart().startsWith(fence)) {
flushList();
appendNote(current, fenced.join('\n'));
fenced = [];
fence = null;
}
return;
}
const fenceMatch = FENCE.exec(raw);
if (fenceMatch) {
flushParagraph();
fence = fenceMatch[1] as string;
fenced = [raw];
return;
}
if (raw.trim().length === 0) {
flushParagraph();
return;
}
const heading = HEADING.exec(raw.trim());
if (heading) {
flushParagraph();
flushList();
const level = Math.min(MAX_HEADING_DEPTH, (heading[1] as string).length);
const label = (heading[2] as string).trim();
const node: OutlineNode = { text: label, children: [] };
while (headings.length > 0 && (headings[headings.length - 1] as { level: number }).level >= level) {
headings.pop();
}
const parent = headings[headings.length - 1]?.node;
if (parent) parent.children.push(node);
else roots.push(node);
headings.push({ level, node });
current = node;
if (level === 1 && !title) title = label;
return;
}
if (isListItem(raw)) {
flushParagraph();
listBlock.push({ raw, line: lineNumber });
return;
}
// Toute autre ligne est du texte : elle rejoint la note du noeud courant.
flushList();
paragraph.push(raw.trim());
});
if (fence !== null) appendNote(current, fenced.join('\n'));
flushList();
flushParagraph();
const count = countNodes(roots);
if (count === 0) {
throw new MarkdownError('Ce document Markdown ne contient aucun titre ni aucune liste.', 0);
}
if (count > maxNodes) {
throw new MarkdownError(
`Ce document produirait ${count} noeuds : la limite est de ${maxNodes}.`,
0,
);
}
return { roots, count, ...(title ? { title } : {}) };
}
const HEADING_DEPTH = 3;
/** Titre d'un noeud sur une seule ligne : un titre Markdown ne se coupe pas. */
function singleLine(text: string): string {
return text.replace(/\s*\n+\s*/g, ' ').trim();
}
function writeNode(
node: MindMapNode,
depth: number,
nodes: MindMapNode[],
out: string[],
): void {
const label = singleLine(node.text) || '(sans titre)';
if (depth < HEADING_DEPTH) {
out.push(`${'#'.repeat(depth + 1)} ${label}`, '');
} else {
out.push(`${' '.repeat(depth - HEADING_DEPTH)}- ${label}`);
}
if (node.note) {
// La note est ecrite sous son titre, separee par une ligne vide : elle
// revient au meme endroit lors d'un reimport.
if (depth >= HEADING_DEPTH) out.push('');
out.push(node.note.trim(), '');
}
for (const child of getOrderedChildren(nodes, node.id)) {
writeNode(child, depth + 1, nodes, out);
}
if (depth >= HEADING_DEPTH && node.note) out.push('');
}
/**
* Export Markdown : contenu et hierarchie uniquement.
*
* Ni positions, ni couleurs, ni dimensions, ni relations secondaires : ce
* format decrit ce que la carte dit, pas a quoi elle ressemble. Les trois
* premiers niveaux deviennent des titres, les suivants des listes imbriquees,
* ce qui redonne exactement la meme arborescence a la relecture.
*/
export function exportMarkdown(map: MindMap): string {
const out: string[] = [];
// `getOrderedChildren(nodes, null)` rend les racines dans l'ordre du document.
for (const root of getOrderedChildren(map.nodes, null)) {
writeNode(root, 0, map.nodes, out);
}
// Un titre est toujours precede d'une ligne vide : c'est ce qui separe une
// liste du titre suivant, et ce qui garde le fichier relisable tel quel.
const spaced: string[] = [];
for (const line of out) {
if (line.startsWith('#') && spaced.length > 0 && spaced[spaced.length - 1] !== '') {
spaced.push('');
}
spaced.push(line);
}
return `${spaced.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
}
/** Vrai si la carte contient des relations que l'export Markdown ne peut pas rendre. */
export function hasUnexportableRelations(map: MindMap): boolean {
return map.relations.length > 0;
}
/**
* Cree une carte complete a partir d'un document Markdown.
*
* Si le document commence par un unique titre de niveau 1, il devient la racine
* et le nom de la carte. Sinon, une racine est creee avec le nom fourni (celui
* du fichier) : rien n'est invente au-dela.
*/
export function buildMapFromMarkdown(
text: string,
fallbackName: string,
maxNodes: number = APP_CONFIG.maxNodes,
): MindMap {
const parsed = parseMarkdown(text, maxNodes);
const single = parsed.roots.length === 1 ? (parsed.roots[0] as OutlineNode) : null;
const rootSpec: OutlineNode = single ?? {
text: fallbackName,
children: parsed.roots,
};
const map = createEmptyMap(rootSpec.text || fallbackName);
const root = map.nodes[0] as MindMapNode;
const nodes: MindMapNode[] = [
{ ...root, text: rootSpec.text || fallbackName, ...(rootSpec.note ? { note: rootSpec.note } : {}) },
];
const walk = (children: OutlineNode[], parentId: string): void => {
children.forEach((child, index) => {
const node = createNode({
text: child.text,
parentId,
order: index,
...(child.note ? { note: child.note } : {}),
position: { x: 0, y: 0 },
});
nodes.push(node);
walk(child.children, node.id);
});
};
walk(rootSpec.children, root.id);
const positions = computeLayout(nodes, map.settings.layout, {
theme: getTheme(map.settings.theme),
});
const placed = nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
}));
return { ...map, nodes: placed, edges: rebuildEdges(placed) };
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
OutlineError,
looksIndented,
parseOutline,
toFlatLines,
} from '@/services/outlineParser';
describe('lecture d une liste simple', () => {
it('produit un noeud par ligne', () => {
const result = parseOutline('VLAN\nVPN\nFirewall');
expect(result.count).toBe(3);
expect(result.depth).toBe(1);
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall']);
expect(result.roots.every((node) => node.children.length === 0)).toBe(true);
});
it('ignore les lignes vides et les espaces superflus', () => {
const result = parseOutline('VLAN\n\n \nVPN\n\n');
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
});
it('retire les puces et les numerotations', () => {
const result = parseOutline('- VLAN\n* VPN\n1. Firewall\n2) Switch');
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall', 'Switch']);
});
it('refuse un texte sans ligne exploitable', () => {
expect(() => parseOutline(' \n\n')).toThrow(OutlineError);
});
it('applique la limite de noeuds', () => {
const texte = Array.from({ length: 12 }, (_unused, index) => `N${index}`).join('\n');
expect(() => parseOutline(texte, 5)).toThrow(/limite/i);
});
});
describe('lecture d une arborescence indentee', () => {
const OUTLINE = [
'Infrastructure',
' Reseau',
' VLAN',
' VPN',
' Firewall',
' Virtualisation',
' Proxmox',
' Docker',
' Services',
' GLPI',
' Grafana',
].join('\n');
it('reconstruit la hierarchie complete', () => {
const result = parseOutline(OUTLINE);
expect(result.count).toBe(11);
expect(result.depth).toBe(3);
expect(result.roots).toHaveLength(1);
const racine = result.roots[0];
expect(racine?.text).toBe('Infrastructure');
expect(racine?.children.map((node) => node.text)).toEqual([
'Reseau',
'Virtualisation',
'Services',
]);
expect(racine?.children[0]?.children.map((node) => node.text)).toEqual([
'VLAN',
'VPN',
'Firewall',
]);
expect(racine?.children[2]?.children).toHaveLength(2);
});
it('accepte les tabulations', () => {
const result = parseOutline('Reseau\n\tVLAN\n\tVPN\n\t\tSite A');
expect(result.roots[0]?.children.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
expect(result.roots[0]?.children[1]?.children[0]?.text).toBe('Site A');
});
it('accepte plusieurs racines', () => {
const result = parseOutline('Reseau\n VLAN\nServices\n GLPI');
expect(result.roots.map((node) => node.text)).toEqual(['Reseau', 'Services']);
});
it('tolere un texte entierement indente', () => {
const result = parseOutline(' Reseau\n VLAN');
expect(result.roots[0]?.text).toBe('Reseau');
expect(result.roots[0]?.children[0]?.text).toBe('VLAN');
});
it('refuse une indentation incoherente', () => {
// "C" ne correspond a aucun niveau ouvert : ni 0, ni 4.
expect(() => parseOutline('A\n B\n C')).toThrow(/incoherente/i);
});
it('refuse une ligne moins indentee que la premiere', () => {
expect(() => parseOutline(' A\nB')).toThrow(/moins indentee/i);
});
it('refuse une arborescence trop profonde', () => {
const profond = Array.from({ length: 25 }, (_unused, index) =>
`${' '.repeat(index * 2)}N${index}`,
).join('\n');
expect(() => parseOutline(profond)).toThrow(/profonde/i);
});
it('signale la ligne fautive', () => {
try {
parseOutline('A\n B\n C');
expect.unreachable('une erreur etait attendue');
} catch (error) {
expect(error).toBeInstanceOf(OutlineError);
expect((error as OutlineError).line).toBe(3);
}
});
});
describe('detection du mode', () => {
it('reconnait un texte indente', () => {
expect(looksIndented('A\n B')).toBe(true);
expect(looksIndented('A\n\tB')).toBe(true);
expect(looksIndented('A\nB')).toBe(false);
});
it('extrait les lignes utiles', () => {
expect(toFlatLines('- A\n\n \n2. B\r\nC')).toEqual(['A', 'B', 'C']);
});
});

View File

@@ -0,0 +1,186 @@
import { APP_CONFIG } from '@/config/app';
/**
* Lecture d'une arborescence indentee collee par l'utilisateur.
*
* Volontairement limite a un "outline" : une ligne = un noeud, l'indentation
* donne la profondeur. Ce n'est pas un lecteur Markdown ; seules les puces de
* liste les plus courantes sont retirees pour que coller une liste a puces
* fonctionne sans retouche.
*/
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 {
roots: OutlineNode[];
/** Nombre total de noeuds, tous niveaux confondus. */
count: number;
/** Profondeur maximale, la racine comptant pour 1. */
depth: number;
}
/** Erreur d'analyse, deja formulee pour l'utilisateur. */
export class OutlineError extends Error {
readonly line: number;
constructor(message: string, line: number) {
super(message);
this.name = 'OutlineError';
this.line = line;
}
}
const TAB_WIDTH = 4;
const MAX_DEPTH = 20;
const MAX_TEXT_LENGTH = 500;
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
const NUMBERED = /^\d{1,3}[.)]\s+/;
/** 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;
else if (character === '\t') width += TAB_WIDTH;
else break;
}
return width;
}
/** 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);
}
/**
* Analyse un texte indente.
*
* Une indentation incoherente (un niveau qui ne correspond a aucun niveau
* ouvert) est signalee plutot que devinee : mieux vaut un message clair qu'une
* hierarchie absurde.
*/
export function parseOutline(text: string, maxNodes: number = APP_CONFIG.maxNodes): OutlineResult {
const rawLines = text.replace(/\r\n?/g, '\n').split('\n');
const entries: Array<{ indent: number; text: string; line: number }> = [];
rawLines.forEach((raw, index) => {
if (raw.trim().length === 0) return; // les lignes vides sont ignorees
const content = cleanText(raw);
if (content.length === 0) return;
entries.push({ indent: indentWidth(raw), text: content, line: index + 1 });
});
if (entries.length === 0) {
throw new OutlineError('Aucune ligne exploitable dans le texte fourni.', 0);
}
if (entries.length > maxNodes) {
throw new OutlineError(
`Ce texte produirait ${entries.length} noeuds : la limite est de ${maxNodes}.`,
0,
);
}
const base = entries[0]?.indent ?? 0;
const roots: OutlineNode[] = [];
// Pile des niveaux ouverts : largeur d'indentation -> noeud parent.
const stack: Array<{ indent: number; node: OutlineNode | null }> = [{ indent: base, node: null }];
for (const entry of entries) {
if (entry.indent < base) {
throw new OutlineError(
`Ligne ${entry.line} : elle est moins indentee que la premiere ligne.`,
entry.line,
);
}
// On referme les niveaux plus profonds que la ligne courante.
let closedLevels = false;
while (
stack.length > 1 &&
entry.indent < (stack[stack.length - 1] as { indent: number }).indent
) {
stack.pop();
closedLevels = true;
}
const top = stack[stack.length - 1] as { indent: number; node: OutlineNode | null };
const node: OutlineNode = { text: entry.text, children: [] };
if (entry.indent === top.indent) {
const parent =
stack.length > 1 ? (stack[stack.length - 2] as { node: OutlineNode | null }).node : null;
if (parent) parent.children.push(node);
else roots.push(node);
stack[stack.length - 1] = { indent: entry.indent, node };
continue;
}
/*
* Ici l'indentation est superieure au niveau courant. Si l'on vient de
* refermer des niveaux, c'est qu'elle ne correspond a aucun niveau ouvert :
* l'outline est incoherent, on le signale plutot que de deviner.
*/
if (closedLevels) {
throw new OutlineError(
`Ligne ${entry.line} : indentation incoherente avec les lignes precedentes.`,
entry.line,
);
}
if (!top.node) {
throw new OutlineError(
`Ligne ${entry.line} : indentation inattendue, aucun element parent au-dessus.`,
entry.line,
);
}
if (stack.length >= MAX_DEPTH) {
throw new OutlineError(
`Ligne ${entry.line} : arborescence trop profonde (maximum ${MAX_DEPTH} niveaux).`,
entry.line,
);
}
top.node.children.push(node);
stack.push({ indent: entry.indent, node });
}
return { roots, count: entries.length, depth: measureDepth(roots) };
}
function measureDepth(nodes: OutlineNode[]): number {
let deepest = 0;
for (const node of nodes) {
deepest = Math.max(deepest, 1 + measureDepth(node.children));
}
return deepest;
}
/** Vrai si le texte contient au moins une indentation exploitable. */
export function looksIndented(text: string): boolean {
return text
.replace(/\r\n?/g, '\n')
.split('\n')
.some((line) => line.trim().length > 0 && /^[ \t]+/.test(line));
}
/** Lignes non vides d'un texte, sans puce ni numerotation. */
export function toFlatLines(text: string): string[] {
return text
.replace(/\r\n?/g, '\n')
.split('\n')
.map(cleanText)
.filter((line) => line.length > 0);
}

View File

@@ -0,0 +1,105 @@
import { type MindMapNode, type MindMapRelation } from '@/types/map';
import { createId } from '@/utils/id';
/**
* Regles des relations secondaires.
*
* Une relation est une connexion visuelle entre deux noeuds. Elle ne participe
* jamais a la structure : ni `parentId`, ni `order`, ni le repli, ni
* l'auto-layout ne la consultent. Ce module rassemble les seules regles qui la
* gouvernent, pour qu'elles ne soient pas reecrites a chaque appelant.
*/
/** Prefixe distinctif : un identifiant de relation ne ressemble a rien d'autre. */
export function createRelationId(): string {
return `r-${createId()}`;
}
export type RelationRejection = 'self' | 'duplicate' | 'unknown';
export const RELATION_REJECTION_MESSAGE: Record<RelationRejection, string> = {
self: "Une relation relie deux noeuds differents : elle ne peut pas boucler sur elle-meme.",
duplicate: 'Ces deux noeuds sont deja relies dans ce sens.',
unknown: "L'un des deux noeuds n'existe plus dans cette carte.",
};
/**
* Verifie qu'une relation peut etre creee.
*
* Deux refus volontaires :
* - source == target : un trait qui revient sur le meme noeud n'apporte rien
* et se dessine mal ;
* - doublon exact (meme source vers meme cible) : deux traits identiques se
* superposent, donc le second serait invisible et impossible a selectionner.
* Le sens inverse, lui, reste autorise : il porte une autre information.
*/
export function checkRelation(
nodes: MindMapNode[],
relations: MindMapRelation[],
sourceId: string,
targetId: string,
): RelationRejection | null {
if (sourceId === targetId) return 'self';
const known = new Set(nodes.map((node) => node.id));
if (!known.has(sourceId) || !known.has(targetId)) return 'unknown';
const exists = relations.some(
(relation) => relation.sourceId === sourceId && relation.targetId === targetId,
);
return exists ? 'duplicate' : null;
}
/**
* Retire les relations devenues invalides.
*
* Appelee a chaque modification structurelle : supprimer un noeud ne doit pas
* laisser derriere lui un trait vers le vide. C'est la contrepartie du fait que
* les relations ne sont pas derivees de la hierarchie : rien ne les nettoie
* automatiquement.
*/
export function pruneRelations(
relations: MindMapRelation[],
nodes: MindMapNode[],
): MindMapRelation[] {
if (relations.length === 0) return relations;
const known = new Set(nodes.map((node) => node.id));
const kept = relations.filter(
(relation) =>
relation.sourceId !== relation.targetId &&
known.has(relation.sourceId) &&
known.has(relation.targetId),
);
return kept.length === relations.length ? relations : kept;
}
/**
* Reporte des relations sur des identifiants renumerotes.
*
* Seules les relations dont les DEUX extremites font partie de la copie sont
* reportees. Une relation qui sort du sous-arbre copie n'est pas dupliquee :
* rien ne dit que la copie doit pointer vers la meme cible externe que
* l'original, et le deviner creerait des liens que l'utilisateur n'a pas
* demandes. Il reste libre de la recreer explicitement.
*/
export function remapRelations(
relations: MindMapRelation[],
idMapping: Map<string, string>,
): MindMapRelation[] {
const result: MindMapRelation[] = [];
for (const relation of relations) {
const source = idMapping.get(relation.sourceId);
const target = idMapping.get(relation.targetId);
if (!source || !target) continue;
result.push({ ...relation, id: createRelationId(), sourceId: source, targetId: target });
}
return result;
}
/** Relations entierement contenues dans un ensemble de noeuds. */
export function relationsWithin(
relations: MindMapRelation[],
nodeIds: Set<string>,
): MindMapRelation[] {
return relations.filter(
(relation) => nodeIds.has(relation.sourceId) && nodeIds.has(relation.targetId),
);
}

View File

@@ -1,5 +1,5 @@
import { type MindMap } from '@/types/map';
import { parseMapDocument } from '@/services/mapSerialization';
import { normalizeMapDocument } from '@/services/mapSerialization';
/** Erreur reseau/serveur deja traduite en message utilisateur. */
export class ShareApiError extends Error {
@@ -146,7 +146,7 @@ export async function fetchSharedMap(shareToken: string): Promise<SharedMapRespo
return {
shareToken: raw.share_token,
title: raw.title,
map: parseMapDocument(raw.map),
map: normalizeMapDocument(raw.map, { source: 'share' }),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};

View File

@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getDescendantIds, getHiddenNodeIds, getOrderedChildren } 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 childTexts(parentId: string): string[] {
return getOrderedChildren(store().map?.nodes ?? [], parentId).map((node) => node.text);
}
/** Serveurs > Production > (Web > nginx, DB), et Recette. */
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Serveurs'));
const initial = useEditorStore.getState();
initial.addChildren(rootId(), ['Production', 'Recette']);
initial.addChildren(idOf('Production'), ['Web', 'DB']);
initial.addChildren(idOf('Web'), ['nginx']);
});
describe('selection d une branche', () => {
it('selectionne le noeud et tous ses descendants', () => {
expect(store().selectBranch(idOf('Production'))).toBe(4);
const selection = new Set(store().selectedNodeIds);
expect(selection.has(idOf('Production'))).toBe(true);
expect(selection.has(idOf('nginx'))).toBe(true);
expect(selection.has(idOf('Recette'))).toBe(false);
});
it('permet d appliquer un style a toute la branche', () => {
store().selectBranch(idOf('Production'));
store().updateNodesStyle(store().selectedNodeIds, { backgroundColor: '#123456' });
for (const text of ['Production', 'Web', 'DB', 'nginx']) {
expect(store().map?.nodes.find((node) => node.text === text)?.style?.backgroundColor).toBe(
'#123456',
);
}
expect(store().map?.nodes.find((node) => node.text === 'Recette')?.style).toBeUndefined();
});
});
describe('duplication d une branche', () => {
it('copie tout le sous-arbre au meme niveau', () => {
expect(store().duplicateBranch(idOf('Production'))).toBe(4);
expect(childTexts(rootId())).toEqual(['Production', 'Recette', 'Production copie']);
const copie = idOf('Production copie');
expect(childTexts(copie)).toEqual(['Web', 'DB']);
expect(getDescendantIds(store().map?.nodes ?? [], copie)).toHaveLength(3);
});
it('genere des identifiants uniques', () => {
store().duplicateBranch(idOf('Production'));
const ids = (store().map?.nodes ?? []).map((node) => node.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('conserve les styles', () => {
store().updateNodesStyle([idOf('DB')], { backgroundColor: '#abcdef' });
store().duplicateBranch(idOf('Production'));
const copie = idOf('Production copie');
const dbCopie = getOrderedChildren(store().map?.nodes ?? [], copie)[1];
expect(dbCopie?.style?.backgroundColor).toBe('#abcdef');
});
it('ne cree qu une seule entree d historique', () => {
const before = store().past.length;
store().duplicateBranch(idOf('Production'));
expect(store().past.length).toBe(before + 1);
store().undo();
expect(childTexts(rootId())).toEqual(['Production', 'Recette']);
});
});
describe('copier / coller une branche', () => {
it('colle le sous-arbre complet sous un autre parent', () => {
expect(store().copyBranch(idOf('Production'))).toBe(4);
expect(store().pasteAsChild(idOf('Recette'))).toBe(4);
expect(childTexts(idOf('Recette'))).toEqual(['Production']);
const collee = getOrderedChildren(store().map?.nodes ?? [], idOf('Recette'))[0];
expect(getDescendantIds(store().map?.nodes ?? [], collee?.id as string)).toHaveLength(3);
});
it('ne modifie pas la branche d origine', () => {
store().copyBranch(idOf('Production'));
store().pasteAsChild(idOf('Recette'));
expect(childTexts(idOf('Production'))).toEqual(['Web', 'DB']);
});
it('ne cree qu une entree d historique et s annule d un coup', () => {
store().copyBranch(idOf('Production'));
const before = store().past.length;
store().pasteAsChild(idOf('Recette'));
expect(store().past.length).toBe(before + 1);
store().undo();
expect(childTexts(idOf('Recette'))).toEqual([]);
});
});
describe('repli et depli', () => {
it('replie un noeud et tous ses descendants', () => {
store().setBranchCollapsed(idOf('Production'), true);
const nodes = store().map?.nodes ?? [];
expect(nodes.find((node) => node.text === 'Production')?.collapsed).toBe(true);
expect(nodes.find((node) => node.text === 'Web')?.collapsed).toBe(true);
});
it('replie puis deplie toute la carte', () => {
store().setAllCollapsed(true);
expect(getHiddenNodeIds(store().map?.nodes ?? []).size).toBeGreaterThan(0);
store().setAllCollapsed(false);
expect(getHiddenNodeIds(store().map?.nodes ?? []).size).toBe(0);
});
it('replie au-dela d un niveau donne', () => {
store().collapseToLevel(2);
const nodes = store().map?.nodes ?? [];
expect(nodes.find((node) => node.id === rootId())?.collapsed).toBe(false);
expect(nodes.find((node) => node.text === 'Production')?.collapsed).toBe(true);
});
it('ne cree qu une entree d historique pour tout replier', () => {
const before = store().past.length;
store().setAllCollapsed(true);
expect(store().past.length).toBe(before + 1);
store().undo();
expect(getHiddenNodeIds(store().map?.nodes ?? []).size).toBe(0);
});
it('rend visible un resultat de recherche situe dans une branche repliee', () => {
store().setAllCollapsed(true);
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('nginx'))).toBe(true);
store().setSearchQuery('nginx');
expect(store().searchResults).toContain(idOf('nginx'));
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('nginx'))).toBe(false);
expect(store().selectedNodeIds).toEqual([idOf('nginx')]);
});
});
describe('deplacement de branche sur le canvas', () => {
it('entraine les descendants en conservant leurs positions relatives', () => {
const avant = (store().map?.nodes ?? []).map((node) => ({ id: node.id, ...node.position }));
const production = avant.find((node) => node.id === idOf('Production')) as {
x: number;
y: number;
};
store().pushHistory();
store().moveNodes(
[{ id: idOf('Production'), position: { x: production.x + 100, y: production.y + 50 } }],
{ withDescendants: true },
);
for (const text of ['Web', 'DB', 'nginx']) {
const id = idOf(text);
const source = avant.find((node) => node.id === id) as { x: number; y: number };
expect(store().map?.nodes.find((node) => node.id === id)?.position).toEqual({
x: source.x + 100,
y: source.y + 50,
});
}
const recette = avant.find((node) => node.id === idOf('Recette')) as { x: number };
expect(store().map?.nodes.find((node) => node.id === idOf('Recette'))?.position.x).toBe(
recette.x,
);
});
it('ne deplace que le noeud sans l option', () => {
const webAvant = store().map?.nodes.find((node) => node.text === 'Web')?.position;
store().moveNodes([{ id: idOf('Production'), position: { x: 999, y: 999 } }]);
expect(store().map?.nodes.find((node) => node.text === 'Web')?.position).toEqual(webAvant);
});
it('annule tout un deplacement de branche en une fois', () => {
const avant = store().map?.nodes.find((node) => node.text === 'nginx')?.position;
const production = store().map?.nodes.find((node) => node.text === 'Production')
?.position as { x: number; y: number };
store().pushHistory();
store().moveNodes([{ id: idOf('Production'), position: { x: production.x + 60, y: production.y } }], {
withDescendants: true,
});
store().moveNodes([{ id: idOf('Production'), position: { x: production.x + 120, y: production.y } }], {
withDescendants: true,
});
store().undo();
expect(store().map?.nodes.find((node) => node.text === 'nginx')?.position).toEqual(avant);
});
});

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { getTheme } from '@/features/editor/themes';
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
import { estimateNodeSize } from '@/utils/nodeSize';
import { useEditorStore } from '@/stores/editorStore';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function nodeById(id: string) {
return store().map?.nodes.find((node) => node.id === id);
}
function layoutOf(id: string) {
return nodeById(id)?.layout;
}
/** Cree un noeud circulaire et renvoie son identifiant. */
function circleNode(): string {
const id = store().addChild(rootId()) as string;
store().setEditingNode(null);
store().updateNodeText(id, 'Coeur de reseau');
store().updateNodesStyle([id], { shape: 'circle' });
return id;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Cercles'));
});
describe('noeud circulaire', () => {
it('reste carre en dimensionnement automatique', () => {
const id = circleNode();
const node = nodeById(id);
const size = estimateNodeSize(node!, getTheme('default'));
expect(size.width).toBe(size.height);
expect(resolveNodeAppearance(node!, 1, getTheme('default')).circle).toBe(true);
});
it('reste carre pendant un redimensionnement a la souris', () => {
const id = circleNode();
store().beginResize();
for (const [width, height] of [
[100, 100],
[150, 148],
[220, 90],
] as Array<[number, number]>) {
store().resizeNode(id, { width, height });
expect(layoutOf(id)?.width).toBe(layoutOf(id)?.height);
}
// Le cote retenu est le plus grand des deux, jamais un ovale.
expect(layoutOf(id)?.width).toBe(220);
expect(layoutOf(id)?.height).toBe(220);
});
it('fait suivre la hauteur quand la largeur change', () => {
const id = circleNode();
store().updateNodesLayout([id], { widthMode: 'fixed', width: 180 });
expect(layoutOf(id)?.width).toBe(180);
expect(layoutOf(id)?.height).toBe(180);
expect(layoutOf(id)?.heightMode).toBe('fixed');
});
it('fait suivre la largeur quand la hauteur change', () => {
const id = circleNode();
store().updateNodesLayout([id], { heightMode: 'fixed', height: 260 });
expect(layoutOf(id)?.width).toBe(260);
expect(layoutOf(id)?.height).toBe(260);
expect(layoutOf(id)?.widthMode).toBe('fixed');
});
it('carre un noeud rectangulaire converti en cercle', () => {
const id = store().addChild(rootId()) as string;
store().setEditingNode(null);
store().updateNodesLayout([id], {
widthMode: 'fixed',
width: 320,
heightMode: 'fixed',
height: 90,
});
store().updateNodesStyle([id], { shape: 'circle' });
expect(layoutOf(id)?.width).toBe(320);
expect(layoutOf(id)?.height).toBe(320);
});
it('annule tout un redimensionnement circulaire en une seule fois', () => {
const id = circleNode();
const before = store().past.length;
store().beginResize();
for (let side = 120; side <= 200; side += 20) {
store().resizeNode(id, { width: side, height: side });
}
expect(store().past.length).toBe(before + 1);
store().undo();
expect(layoutOf(id)?.width).toBeUndefined();
expect(layoutOf(id)?.height).toBeUndefined();
expect(nodeById(id)?.style?.shape).toBe('circle');
});
it('laisse les autres formes libres de leurs proportions', () => {
const id = store().addChild(rootId()) as string;
store().setEditingNode(null);
store().updateNodesStyle([id], { shape: 'rounded' });
store().beginResize();
store().resizeNode(id, { width: 300, height: 80 });
expect(layoutOf(id)?.width).toBe(300);
expect(layoutOf(id)?.height).toBe(80);
});
});

View File

@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { parseOutline } from '@/services/outlineParser';
import { useEditorStore } from '@/stores/editorStore';
import { getOrderedChildren } from '@/utils/tree';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function childTexts(parentId: string): string[] {
return getOrderedChildren(store().map?.nodes ?? [], parentId).map((node) => node.text);
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Creation rapide'));
});
describe('ajout de plusieurs enfants', () => {
it('cree un enfant par ligne, dans l ordre', () => {
const created = store().addChildren(rootId(), ['VLAN', 'VPN', 'Firewall']);
expect(created).toBe(3);
expect(childTexts(rootId())).toEqual(['VLAN', 'VPN', 'Firewall']);
});
it('ignore les lignes vides', () => {
expect(store().addChildren(rootId(), ['VLAN', '', ' ', 'VPN'])).toBe(2);
expect(childTexts(rootId())).toEqual(['VLAN', 'VPN']);
});
it('ne cree qu une seule entree d historique', () => {
const before = store().past.length;
store().addChildren(rootId(), ['A', 'B', 'C', 'D', 'E', 'F', 'G']);
expect(store().past.length).toBe(before + 1);
store().undo();
expect(store().map?.nodes).toHaveLength(1);
});
it('repartit les nouveaux noeuds au lieu de les superposer', () => {
store().addChildren(rootId(), ['A', 'B', 'C']);
const positions = getOrderedChildren(store().map?.nodes ?? [], rootId()).map(
(node) => `${node.position.x}:${node.position.y}`,
);
expect(new Set(positions).size).toBe(3);
});
it('se place a la suite des enfants existants', () => {
store().addChildren(rootId(), ['A', 'B']);
store().addChildren(rootId(), ['C']);
expect(childTexts(rootId())).toEqual(['A', 'B', 'C']);
});
it('deplie le parent replie', () => {
store().addChildren(rootId(), ['A']);
store().toggleCollapse(rootId());
expect(store().map?.nodes[0]?.collapsed).toBe(true);
store().addChildren(rootId(), ['B']);
expect(store().map?.nodes.find((node) => node.id === rootId())?.collapsed).toBe(false);
});
it('ne fait rien sans ligne exploitable', () => {
const before = store().past.length;
expect(store().addChildren(rootId(), ['', ' '])).toBe(0);
expect(store().past.length).toBe(before);
});
});
describe('import d une arborescence indentee', () => {
const OUTLINE = [
'Reseau',
' VLAN',
' VPN',
'Virtualisation',
' Proxmox',
' VM',
' LXC',
' Docker',
].join('\n');
it('reproduit la hierarchie sous le parent', () => {
const { roots } = parseOutline(OUTLINE);
const created = store().insertOutline(rootId(), roots);
expect(created).toBe(8);
expect(childTexts(rootId())).toEqual(['Reseau', 'Virtualisation']);
const nodes = store().map?.nodes ?? [];
const reseau = nodes.find((node) => node.text === 'Reseau') as { id: string };
expect(childTexts(reseau.id)).toEqual(['VLAN', 'VPN']);
const proxmox = nodes.find((node) => node.text === 'Proxmox') as { id: string };
expect(childTexts(proxmox.id)).toEqual(['VM', 'LXC']);
});
it('ne cree qu une seule entree d historique', () => {
const { roots } = parseOutline(OUTLINE);
const before = store().past.length;
store().insertOutline(rootId(), roots);
expect(store().past.length).toBe(before + 1);
store().undo();
expect(store().map?.nodes).toHaveLength(1);
});
it('genere des identifiants tous distincts', () => {
const { roots } = parseOutline(OUTLINE);
store().insertOutline(rootId(), roots);
const ids = (store().map?.nodes ?? []).map((node) => node.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('ajout avant et apres', () => {
it('insere un frere a la bonne place', () => {
store().addChildren(rootId(), ['A', 'B', 'C']);
const b = getOrderedChildren(store().map?.nodes ?? [], rootId())[1] as { id: string };
const avant = store().addSiblingAt(b.id, 'before') as string;
store().updateNodeText(avant, 'AvantB');
expect(childTexts(rootId())).toEqual(['A', 'AvantB', 'B', 'C']);
const apres = store().addSiblingAt(b.id, 'after') as string;
store().updateNodeText(apres, 'ApresB');
expect(childTexts(rootId())).toEqual(['A', 'AvantB', 'B', 'ApresB', 'C']);
});
it('ouvre directement l edition du nouveau noeud', () => {
store().addChildren(rootId(), ['A']);
const a = getOrderedChildren(store().map?.nodes ?? [], rootId())[0] as { id: string };
const nouveau = store().addSiblingAt(a.id, 'after');
expect(store().editingNodeId).toBe(nouveau);
expect(store().selectedNodeIds).toEqual([nouveau]);
});
});

View File

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getOrderedChildren } from '@/utils/tree';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function childTexts(parentId: string | null): string[] {
return getOrderedChildren(store().map?.nodes ?? [], parentId).map((node) => node.text);
}
function idOf(text: string): string {
return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Hierarchie'));
useEditorStore.getState().addChildren(rootId(), ['A', 'B', 'C', 'D']);
});
describe('ordre des freres', () => {
it('attribue un ordre dense a la creation', () => {
const orders = getOrderedChildren(store().map?.nodes ?? [], rootId()).map(
(node) => node.order,
);
expect(orders).toEqual([0, 1, 2, 3]);
});
it('monte un noeud d un cran', () => {
store().moveSibling(idOf('C'), 'up');
expect(childTexts(rootId())).toEqual(['A', 'C', 'B', 'D']);
});
it('descend un noeud d un cran', () => {
store().moveSibling(idOf('B'), 'down');
expect(childTexts(rootId())).toEqual(['A', 'C', 'B', 'D']);
});
it('place un noeud en premier et en dernier', () => {
store().moveSibling(idOf('D'), 'first');
expect(childTexts(rootId())).toEqual(['D', 'A', 'B', 'C']);
store().moveSibling(idOf('D'), 'last');
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
});
it('ne bouge pas aux extremites', () => {
const before = store().past.length;
store().moveSibling(idOf('A'), 'up');
store().moveSibling(idOf('D'), 'down');
expect(store().past.length).toBe(before);
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
});
it('cree une seule entree d historique par deplacement', () => {
const before = store().past.length;
store().moveSibling(idOf('C'), 'first');
expect(store().past.length).toBe(before + 1);
store().undo();
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
});
it('conserve l ordre a travers un ajout ulterieur', () => {
store().moveSibling(idOf('D'), 'first');
store().addChildren(rootId(), ['E']);
expect(childTexts(rootId())).toEqual(['D', 'A', 'B', 'C', 'E']);
});
});
describe('re-parentage', () => {
it('deplace un noeud sous un autre parent', () => {
expect(store().moveNodeToParent(idOf('D'), idOf('A'))).toBe(true);
expect(childTexts(rootId())).toEqual(['A', 'B', 'C']);
expect(childTexts(idOf('A'))).toEqual(['D']);
});
it('refuse un deplacement qui creerait une boucle', () => {
store().moveNodeToParent(idOf('B'), idOf('A'));
expect(store().moveNodeToParent(idOf('A'), idOf('B'))).toBe(false);
expect(childTexts(idOf('A'))).toEqual(['B']);
});
it('detache un noeud en le placant a la racine', () => {
expect(store().moveNodeToParent(idOf('B'), null)).toBe(true);
expect(store().map?.nodes.find((node) => node.text === 'B')?.parentId).toBeNull();
});
it('remonte un noeud d un niveau juste apres son parent', () => {
store().moveNodeToParent(idOf('D'), idOf('B'));
expect(store().outdentNode(idOf('D'))).toBe(true);
expect(childTexts(rootId())).toEqual(['A', 'B', 'D', 'C']);
});
it('ne remonte pas une racine', () => {
expect(store().outdentNode(rootId())).toBe(false);
});
});
describe('suppression en conservant les enfants', () => {
beforeEach(() => {
store().addChildren(idOf('B'), ['B1', 'B2']);
});
it('rattache les enfants au grand-parent, a la place du noeud supprime', () => {
store().deleteNodesKeepChildren([idOf('B')]);
expect(childTexts(rootId())).toEqual(['A', 'B1', 'B2', 'C', 'D']);
expect(store().map?.nodes.some((node) => node.text === 'B')).toBe(false);
});
it('cree une seule entree d historique', () => {
const before = store().past.length;
store().deleteNodesKeepChildren([idOf('B')]);
expect(store().past.length).toBe(before + 1);
store().undo();
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
});
it('promeut les enfants d une racine supprimee au rang de racines', () => {
const map = store().map;
const racine = map?.nodes.find((node) => node.id === rootId());
expect(racine).toBeDefined();
store().deleteNodesKeepChildren([rootId()]);
expect(childTexts(null)).toEqual(['A', 'B', 'C', 'D']);
expect(store().map?.nodes.every((node) => node.parentId === null || node.text !== 'A')).toBe(
true,
);
});
it('supprime toute la branche avec la suppression classique', () => {
store().deleteNodes([idOf('B')]);
expect(store().map?.nodes.some((node) => node.text.startsWith('B'))).toBe(false);
expect(childTexts(rootId())).toEqual(['A', 'C', 'D']);
});
});

View File

@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function nodeById(id: string) {
return store().map?.nodes.find((node) => node.id === id);
}
/** Cree trois noeuds a des positions connues, avec des tailles mesurees. */
function threeNodes(): string[] {
const ids = [rootId()];
for (let index = 0; index < 2; index += 1) {
ids.push(store().addChild(rootId()) as string);
}
store().setEditingNode(null);
const positions = [
{ x: 0, y: 0 },
{ x: 60, y: 200 },
{ x: 300, y: 500 },
];
ids.forEach((id, index) => {
store().moveNodes([{ id, position: positions[index] as { x: number; y: number } }]);
store().setMeasuredSize(id, { width: 100, height: 40 });
});
return ids;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Mise en page'));
});
describe('redimensionnement', () => {
it('passe le noeud en taille fixe et conserve les dimensions', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: 260, height: 120 });
const node = nodeById(id);
expect(node?.layout?.widthMode).toBe('fixed');
expect(node?.layout?.heightMode).toBe('fixed');
expect(node?.layout?.width).toBe(260);
expect(node?.layout?.height).toBe(120);
});
it('borne les dimensions saisies', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: -50, height: 100000 });
expect(nodeById(id)?.layout?.width).toBeGreaterThanOrEqual(60);
expect(nodeById(id)?.layout?.height).toBeLessThanOrEqual(900);
});
it('un resize continu ne cree qu une seule entree d historique', () => {
const id = rootId();
const before = store().past.length;
store().beginResize();
for (let width = 200; width <= 260; width += 10) {
store().resizeNode(id, { width, height: 80 });
}
expect(store().past.length).toBe(before + 1);
store().undo();
expect(nodeById(id)?.layout?.width).toBeUndefined();
});
it('met a jour la position quand la poignee haut-gauche est utilisee', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: 200, height: 90, position: { x: -40, y: -20 } });
expect(nodeById(id)?.position).toEqual({ x: -40, y: -20 });
});
it('repasse en taille automatique avec "adapter au contenu"', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: 300, height: 150 });
store().fitNodesToContent([id]);
const node = nodeById(id);
expect(node?.layout?.width).toBeUndefined();
expect(node?.layout?.height).toBeUndefined();
expect(node?.layout?.widthMode).toBe('auto');
});
});
describe('alignement multi-selection', () => {
it('aligne les bords gauches en une seule entree d historique', () => {
const ids = threeNodes();
store().setSelection(ids);
const before = store().past.length;
store().alignNodes('left');
expect(store().past.length).toBe(before + 1);
const xs = ids.map((id) => nodeById(id)?.position.x);
expect(new Set(xs).size).toBe(1);
expect(xs[0]).toBe(0);
});
it('aligne les bords droits en tenant compte de la largeur', () => {
const ids = threeNodes();
store().setMeasuredSize(ids[1] as string, { width: 200, height: 40 });
store().setSelection(ids);
store().alignNodes('right');
const rights = ids.map((id) => {
const node = nodeById(id);
const width = id === ids[1] ? 200 : 100;
return (node?.position.x ?? 0) + width;
});
expect(new Set(rights).size).toBe(1);
});
it('centre verticalement la selection', () => {
const ids = threeNodes();
store().setSelection(ids);
store().alignNodes('vcenter');
const centers = ids.map((id) => (nodeById(id)?.position.y ?? 0) + 20);
expect(new Set(centers).size).toBe(1);
});
it('ne fait rien avec un seul noeud selectionne', () => {
const ids = threeNodes();
store().setSelection([ids[0] as string]);
const before = store().past.length;
store().alignNodes('left');
expect(store().past.length).toBe(before);
});
});
describe('distribution', () => {
it('repartit les espaces verticaux en conservant les extremes', () => {
const ids = threeNodes();
store().setSelection(ids);
const before = store().past.length;
store().distributeNodes('vertical');
expect(store().past.length).toBe(before + 1);
const ys = ids.map((id) => nodeById(id)?.position.y ?? 0).sort((a, b) => a - b);
expect(ys[0]).toBe(0);
expect(ys[2]).toBe(500);
// Espaces egaux entre les bords : (500 + 40 - 0 - 120) / 2 = 210
expect((ys[1] as number) - ((ys[0] as number) + 40)).toBeCloseTo(210, 0);
});
it('repartit les espaces horizontaux', () => {
const ids = threeNodes();
store().setSelection(ids);
store().distributeNodes('horizontal');
const xs = ids.map((id) => nodeById(id)?.position.x ?? 0).sort((a, b) => a - b);
expect(xs[0]).toBe(0);
expect(xs[2]).toBe(300);
expect((xs[1] as number) - ((xs[0] as number) + 100)).toBeCloseTo(
(xs[2] as number) - ((xs[1] as number) + 100),
0,
);
});
it('exige au moins trois noeuds', () => {
const ids = threeNodes();
store().setSelection([ids[0] as string, ids[1] as string]);
const before = store().past.length;
store().distributeNodes('horizontal');
expect(store().past.length).toBe(before);
});
});

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { computeLayout } from '@/features/editor/layout';
import { getHiddenNodeIds } from '@/utils/tree';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function idOf(text: string): string {
return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
}
function relations() {
return store().map?.relations ?? [];
}
/** Infrastructure > (Reseau > Firewall, Services > Web). */
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Infrastructure'));
const initial = useEditorStore.getState();
initial.addChildren(rootId(), ['Reseau', 'Services']);
initial.addChildren(idOf('Reseau'), ['Firewall', 'Switch']);
initial.addChildren(idOf('Services'), ['Web']);
});
describe('creation d une relation', () => {
it('cree une relation entre deux noeuds', () => {
expect(store().addRelation(idOf('Firewall'), idOf('Web'))).toBeNull();
expect(relations()).toHaveLength(1);
expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
expect(relations()[0]?.targetId).toBe(idOf('Web'));
});
it('accepte un libelle libre', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
expect(relations()[0]?.label).toBe('protege');
});
it('accepte un style, une couleur, une epaisseur et des extremites', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), {
style: 'straight',
color: '#ff0000',
width: 3,
dashed: false,
markerEnd: 'none',
});
const relation = relations()[0];
expect(relation?.style).toBe('straight');
expect(relation?.color).toBe('#ff0000');
expect(relation?.width).toBe(3);
expect(relation?.dashed).toBe(false);
expect(relation?.markerEnd).toBe('none');
});
it('refuse une relation d un noeud vers lui-meme', () => {
expect(store().addRelation(idOf('Firewall'), idOf('Firewall'))).toBe('self');
expect(relations()).toHaveLength(0);
});
it('refuse une source inexistante', () => {
expect(store().addRelation('inconnu', idOf('Web'))).toBe('unknown');
expect(relations()).toHaveLength(0);
});
it('refuse une cible inexistante', () => {
expect(store().addRelation(idOf('Web'), 'inconnu')).toBe('unknown');
expect(relations()).toHaveLength(0);
});
it('refuse un doublon exact mais accepte le sens inverse', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
expect(store().addRelation(idOf('Firewall'), idOf('Web'))).toBe('duplicate');
expect(store().addRelation(idOf('Web'), idOf('Firewall'))).toBeNull();
expect(relations()).toHaveLength(2);
});
});
describe('independance vis-a-vis de la hierarchie', () => {
it('ne modifie ni parentId ni order ni les enfants', () => {
const before = (store().map?.nodes ?? []).map((node) => ({
id: node.id,
parentId: node.parentId,
order: node.order,
}));
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
const after = (store().map?.nodes ?? []).map((node) => ({
id: node.id,
parentId: node.parentId,
order: node.order,
}));
expect(after).toEqual(before);
});
it('ne cree aucun lien hierarchique', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const pairs = (store().map?.edges ?? []).map((edge) => `${edge.source}>${edge.target}`);
expect(pairs).not.toContain(`${idOf('Firewall')}>${idOf('Web')}`);
});
it('n influence pas l auto-layout', () => {
const nodes = store().map?.nodes ?? [];
const before = computeLayout(nodes, 'mindmap-right');
store().addRelation(idOf('Firewall'), idOf('Web'));
const after = computeLayout(store().map?.nodes ?? [], 'mindmap-right');
for (const [id, position] of before) expect(after.get(id)).toEqual(position);
});
});
describe('modification et suppression', () => {
it('modifie le libelle et le style sans toucher aux extremites', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const relation = relations()[0];
store().updateRelation(relation?.id as string, { label: 'utilise', width: 4 });
const updated = relations()[0];
expect(updated?.label).toBe('utilise');
expect(updated?.width).toBe(4);
expect(updated?.sourceId).toBe(relation?.sourceId);
expect(updated?.targetId).toBe(relation?.targetId);
});
it('ne laisse pas deplacer une extremite par une mise a jour', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const relation = relations()[0];
store().updateRelation(relation?.id as string, {
sourceId: idOf('Switch'),
} as { sourceId: string });
expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
});
it('supprime une relation sans supprimer de noeud', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const count = store().map?.nodes.length as number;
store().deleteRelation(relations()[0]?.id as string);
expect(relations()).toHaveLength(0);
expect(store().map?.nodes).toHaveLength(count);
});
it('annule et retablit une creation en une seule entree', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
expect(relations()).toHaveLength(1);
store().undo();
expect(relations()).toHaveLength(0);
store().redo();
expect(relations()).toHaveLength(1);
expect(relations()[0]?.label).toBe('protege');
});
it('annule une suppression de relation', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().deleteRelation(relations()[0]?.id as string);
store().undo();
expect(relations()).toHaveLength(1);
});
});
describe('relations et structure', () => {
it('disparait quand un de ses noeuds est supprime', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().deleteNodes([idOf('Web')]);
expect(relations()).toHaveLength(0);
});
it('revient si la suppression est annulee', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().deleteNodes([idOf('Web')]);
store().undo();
expect(relations()).toHaveLength(1);
});
it('reste dans le document quand une extremite est masquee par un repli', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().setBranchCollapsed(idOf('Services'), true);
// La relation est toujours la : seul l'affichage la masque.
expect(relations()).toHaveLength(1);
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('Web'))).toBe(true);
store().setBranchCollapsed(idOf('Services'), false);
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('Web'))).toBe(false);
expect(relations()).toHaveLength(1);
});
it('survit a un changement de parent des deux extremites', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().moveNodeToParent(idOf('Firewall'), idOf('Services'));
expect(relations()).toHaveLength(1);
expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
});
});
describe('duplication de branche', () => {
it('duplique une relation interne avec de nouveaux identifiants', () => {
store().addRelation(idOf('Firewall'), idOf('Switch'), { label: 'secours' });
store().duplicateBranch(idOf('Reseau'));
expect(relations()).toHaveLength(2);
const [original, copy] = relations();
expect(copy?.id).not.toBe(original?.id);
expect(copy?.sourceId).not.toBe(original?.sourceId);
expect(copy?.targetId).not.toBe(original?.targetId);
expect(copy?.label).toBe('secours');
// Les nouvelles extremites appartiennent bien a la copie.
const ids = new Set((store().map?.nodes ?? []).map((node) => node.id));
expect(ids.has(copy?.sourceId as string)).toBe(true);
expect(ids.has(copy?.targetId as string)).toBe(true);
});
it('ne duplique pas une relation sortant de la branche', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().duplicateBranch(idOf('Reseau'));
expect(relations()).toHaveLength(1);
});
it('reporte les relations internes lors d un copier / coller de branche', () => {
store().addRelation(idOf('Firewall'), idOf('Switch'));
store().copyBranch(idOf('Reseau'));
store().pasteAsChild(idOf('Services'));
expect(relations()).toHaveLength(2);
const copy = relations()[1];
expect(copy?.sourceId).not.toBe(idOf('Firewall'));
});
it('compte la duplication comme une seule entree d historique', () => {
store().addRelation(idOf('Firewall'), idOf('Switch'));
const nodeCount = store().map?.nodes.length as number;
store().duplicateBranch(idOf('Reseau'));
expect(relations()).toHaveLength(2);
store().undo();
expect(relations()).toHaveLength(1);
expect(store().map?.nodes).toHaveLength(nodeCount);
});
});
describe('recherche', () => {
it('retrouve les noeuds relies par un libelle de relation', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
store().setSearchQuery('protege');
const results = new Set(store().searchResults);
expect(results.has(idOf('Firewall'))).toBe(true);
expect(results.has(idOf('Web'))).toBe(true);
expect(results.has(idOf('Switch'))).toBe(false);
});
it('continue de chercher dans le texte des noeuds', () => {
store().setSearchQuery('Firewall');
expect(store().searchResults).toEqual([idOf('Firewall')]);
});
});

View File

@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getLevelOneBranches, resolveNodeSides } from '@/features/editor/bilateral';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function idOf(text: string): string {
return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
}
function sideOf(text: string) {
return store().map?.nodes.find((node) => node.text === text)?.side;
}
/** Infrastructure > (Reseau > Firewall, Services, Stockage, Supervision). */
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Infrastructure'));
const initial = useEditorStore.getState();
initial.addChildren(rootId(), ['Reseau', 'Services', 'Stockage', 'Supervision']);
initial.addChildren(idOf('Reseau'), ['Firewall', 'Switch']);
});
describe('cote manuel', () => {
it('place une branche de niveau 1 a gauche', () => {
expect(store().setNodeSide(idOf('Services'), 'left')).toBe(true);
expect(sideOf('Services')).toBe('left');
});
it('refuse de poser un cote sur un noeud plus profond', () => {
expect(store().setNodeSide(idOf('Firewall'), 'left')).toBe(false);
expect(sideOf('Firewall')).toBeUndefined();
});
it('refuse de poser un cote sur la racine', () => {
expect(store().setNodeSide(rootId(), 'left')).toBe(false);
});
it('ne deplace aucun noeud', () => {
const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
store().setNodeSide(idOf('Services'), 'left');
const after = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
expect(after).toEqual(before);
});
it('compte pour une seule entree d historique', () => {
store().setNodeSide(idOf('Services'), 'left');
store().undo();
expect(sideOf('Services')).toBeUndefined();
store().redo();
expect(sideOf('Services')).toBe('left');
});
it('fait suivre tous les descendants de la branche', () => {
store().setNodeSide(idOf('Reseau'), 'left');
const sides = resolveNodeSides(store().map?.nodes ?? []);
expect(sides.get(idOf('Firewall'))).toBe('left');
expect(sides.get(idOf('Switch'))).toBe('left');
});
});
describe('repartition automatique', () => {
it('materialise les cotes manquants en une seule entree', () => {
const changed = store().balanceSides();
expect(changed).toBe(4);
for (const branch of ['Reseau', 'Services', 'Stockage', 'Supervision']) {
expect(sideOf(branch)).toBeDefined();
}
store().undo();
expect(sideOf('Reseau')).toBeUndefined();
});
it('ne fait rien si tout est deja reparti', () => {
store().balanceSides();
expect(store().balanceSides()).toBe(0);
});
it('respecte un cote deja choisi a la main', () => {
store().setNodeSide(idOf('Supervision'), 'left');
store().balanceSides();
expect(sideOf('Supervision')).toBe('left');
});
it('n ecrit un cote que sur les branches de niveau 1', () => {
store().balanceSides();
const branchIds = new Set(getLevelOneBranches(store().map?.nodes ?? []).map((n) => n.id));
for (const node of store().map?.nodes ?? []) {
if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
}
});
});
describe('passage en disposition bilaterale', () => {
it('repartit les branches et deplace la carte en une seule entree', () => {
const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
store().applyAutoLayout('mindmap-both');
expect(store().map?.settings.layout).toBe('mindmap-both');
for (const branch of ['Reseau', 'Services', 'Stockage', 'Supervision']) {
expect(sideOf(branch)).toBeDefined();
}
const after = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
expect(after).not.toEqual(before);
store().undo();
expect(sideOf('Reseau')).toBeUndefined();
expect((store().map?.nodes ?? []).map((node) => ({ ...node.position }))).toEqual(before);
});
it('place la racine entre les deux cotes', () => {
store().applyAutoLayout('mindmap-both');
const nodes = store().map?.nodes ?? [];
const sides = resolveNodeSides(nodes);
const rootX = nodes.find((node) => node.id === rootId())?.position.x as number;
for (const branch of getLevelOneBranches(nodes)) {
if (sides.get(branch.id) === 'left') expect(branch.position.x).toBeLessThan(rootX);
else expect(branch.position.x).toBeGreaterThan(rootX);
}
});
it('choisir une disposition sans reorganiser ne deplace rien', () => {
const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
store().updateSettings({ layout: 'mindmap-both' });
expect(store().map?.settings.layout).toBe('mindmap-both');
expect((store().map?.nodes ?? []).map((node) => ({ ...node.position }))).toEqual(before);
});
});
describe('cote et duplication', () => {
it('conserve le cote quand une branche de niveau 1 est dupliquee', () => {
store().setNodeSide(idOf('Reseau'), 'left');
store().duplicateBranch(idOf('Reseau'));
const copy = (store().map?.nodes ?? []).find((node) => node.text.endsWith('copie'));
expect(copy?.side).toBe('left');
expect(copy?.parentId).toBe(rootId());
});
it('conserve le cote lors d une serialisation en memoire', () => {
store().balanceSides();
const sides = (store().map?.nodes ?? []).map((node) => [node.text, node.side]);
const reloaded = JSON.parse(JSON.stringify(store().map)) as { nodes: typeof sides };
expect((reloaded.nodes as unknown as Array<{ text: string; side?: string }>).map((n) => [n.text, n.side])).toEqual(sides);
});
});
describe('creation du cote gauche', () => {
it('fait naitre les enfants vers la gauche sur une branche de gauche', () => {
store().setNodeSide(idOf('Reseau'), 'left');
store().setNodeSide(idOf('Services'), 'right');
store().updateSettings({ layout: 'mindmap-both' });
const reseauX = store().map?.nodes.find((n) => n.id === idOf('Reseau'))?.position.x as number;
const servicesX = store().map?.nodes.find((n) => n.id === idOf('Services'))?.position
.x as number;
store().addChildren(idOf('Reseau'), ['Routeur']);
store().addChildren(idOf('Services'), ['LDAP']);
const routeurX = store().map?.nodes.find((n) => n.text === 'Routeur')?.position.x as number;
const ldapX = store().map?.nodes.find((n) => n.text === 'LDAP')?.position.x as number;
expect(routeurX).toBeLessThan(reseauX);
expect(ldapX).toBeGreaterThan(servicesX);
});
it('garde le comportement d origine dans les dispositions classiques', () => {
const reseauX = store().map?.nodes.find((n) => n.id === idOf('Reseau'))?.position.x as number;
store().addChildren(idOf('Reseau'), ['Routeur']);
const routeurX = store().map?.nodes.find((n) => n.text === 'Routeur')?.position.x as number;
expect(routeurX).toBeGreaterThan(reseauX);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function nodeById(id: string) {
return store().map?.nodes.find((node) => node.id === id);
}
function makeChildren(count: number): string[] {
const ids: string[] = [];
for (let index = 0; index < count; index += 1) {
ids.push(store().addChild(rootId()) as string);
}
store().setEditingNode(null);
return ids;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Styles'));
});
describe('application de style a plusieurs noeuds', () => {
it('applique une couleur a toute la selection en une entree d historique', () => {
const ids = makeChildren(3);
store().setSelection(ids);
const before = store().past.length;
store().updateNodesStyle(ids, { backgroundColor: '#112233' });
expect(store().past.length).toBe(before + 1);
for (const id of ids) {
expect(nodeById(id)?.style?.backgroundColor).toBe('#112233');
}
});
it('applique un padding a toute la selection', () => {
const ids = makeChildren(2);
store().updateNodesLayout(ids, { paddingX: 28, paddingY: 18 });
for (const id of ids) {
expect(nodeById(id)?.layout?.paddingX).toBe(28);
expect(nodeById(id)?.layout?.paddingY).toBe(18);
}
});
it('ne laisse pas d objet de style vide dans le document', () => {
const [id] = makeChildren(1);
store().updateNodesStyle([id as string], { bold: true });
store().updateNodesStyle([id as string], { bold: undefined });
expect(nodeById(id as string)?.style).toBeUndefined();
});
});
describe('copier / coller le style', () => {
it('copie uniquement les proprietes visuelles', () => {
const [source, target] = makeChildren(2) as [string, string];
store().updateNodeText(source, 'Source');
store().updateNode(source, { note: 'Note privee', url: 'https://exemple.fr/' });
store().updateNodesStyle([source], { backgroundColor: '#abcdef', textAlign: 'center' });
store().updateNodesLayout([source], { paddingX: 24 });
store().setNodesIcon([source], 'server', 'builtin');
store().updateNodeText(target, 'Cible');
store().setNodesIcon([target], '💾', 'emoji');
store().setSelection([source]);
expect(store().copyStyle()).toBe(true);
store().setSelection([target]);
expect(store().pasteStyle()).toBe(1);
const pasted = nodeById(target);
expect(pasted?.style?.backgroundColor).toBe('#abcdef');
expect(pasted?.style?.textAlign).toBe('center');
expect(pasted?.layout?.paddingX).toBe(24);
// La presentation de l'icone fait partie du style...
expect(pasted?.iconStyle?.kind).toBe('builtin');
// ...mais l'icone elle-meme est du contenu : elle n'est jamais remplacee.
expect(pasted?.icon).toBe('💾');
// Le reste du contenu de la cible n'est pas touche.
expect(pasted?.text).toBe('Cible');
expect(pasted?.note).toBeUndefined();
expect(pasted?.url).toBeUndefined();
expect(pasted?.parentId).toBe(rootId());
});
it("conserve l'icone de chaque noeud lors d'un collage groupe", () => {
const [reseau, stockage, securite] = makeChildren(3) as [string, string, string];
store().updateNodeText(reseau, 'Reseau');
store().setNodesIcon([reseau], '🌐', 'emoji');
store().updateNodesStyle([reseau], { backgroundColor: '#0ea5e9', shape: 'capsule' });
store().updateNodeText(stockage, 'Stockage');
store().setNodesIcon([stockage], '💾', 'emoji');
store().updateNodeText(securite, 'Securite');
store().setNodesIcon([securite], '🔒', 'emoji');
store().setSelection([reseau]);
store().copyStyle();
store().setSelection([stockage, securite]);
expect(store().pasteStyle()).toBe(2);
// Le style est bien applique...
for (const id of [stockage, securite]) {
expect(nodeById(id)?.style?.backgroundColor).toBe('#0ea5e9');
expect(nodeById(id)?.style?.shape).toBe('capsule');
}
// ...et chaque noeud garde son icone d'origine.
expect(nodeById(stockage)?.icon).toBe('💾');
expect(nodeById(securite)?.icon).toBe('🔒');
});
it("n'ajoute pas d'icone a un noeud qui n'en avait pas", () => {
const [source, target] = makeChildren(2) as [string, string];
store().setNodesIcon([source], '🌐', 'emoji');
store().updateNodesStyle([source], { textColor: '#111111' });
store().setSelection([source]);
store().copyStyle();
store().setSelection([target]);
store().pasteStyle();
expect(nodeById(target)?.style?.textColor).toBe('#111111');
expect(nodeById(target)?.icon).toBeUndefined();
});
it('colle le style sur plusieurs noeuds en une entree d historique', () => {
const ids = makeChildren(4) as string[];
const source = ids[0] as string;
store().updateNodesStyle([source], { borderColor: '#ff0000' });
store().setSelection([source]);
store().copyStyle();
store().setSelection(ids.slice(1));
const before = store().past.length;
expect(store().pasteStyle()).toBe(3);
expect(store().past.length).toBe(before + 1);
for (const id of ids.slice(1)) {
expect(nodeById(id)?.style?.borderColor).toBe('#ff0000');
}
});
it('ne colle rien sans style copie', () => {
const ids = makeChildren(1);
store().setSelection(ids);
expect(store().pasteStyle()).toBe(0);
});
});
describe('reinitialisation du style', () => {
it('remet le theme sans toucher au contenu ni a la hierarchie', () => {
const [id] = makeChildren(1) as [string];
store().updateNodeText(id, 'Firewall');
store().updateNode(id, { note: 'A verifier', url: 'https://exemple.fr/' });
store().updateNodesStyle([id], { backgroundColor: '#000000', shape: 'circle' });
store().updateNodesLayout([id], { paddingX: 30 });
store().updateNodesBranch([id], { color: '#00ff00' });
store().resetNodesStyle([id]);
const node = nodeById(id);
expect(node?.style).toBeUndefined();
expect(node?.layout).toBeUndefined();
expect(node?.branch).toBeUndefined();
expect(node?.text).toBe('Firewall');
expect(node?.note).toBe('A verifier');
expect(node?.url).toBe('https://exemple.fr/');
expect(node?.parentId).toBe(rootId());
});
});

View File

@@ -15,6 +15,14 @@ interface UiState {
propertiesPanelOpen: boolean;
searchPanelOpen: boolean;
toasts: Toast[];
/** Sections repliables du panneau Proprietes, conservees pendant la session. */
panelSections: Record<string, boolean>;
/**
* Demande d'ouverture du dialogue "ajouter plusieurs enfants".
* Permet a un raccourci clavier ou a un collage de l'ouvrir sans que les
* composants aient besoin de se connaitre.
*/
addChildrenRequest: { parentId: string | null; text: string; nonce: number } | null;
setColorMode: (mode: ColorMode) => void;
toggleColorMode: () => void;
@@ -22,9 +30,27 @@ interface UiState {
setSearchPanelOpen: (open: boolean) => void;
pushToast: (message: string, tone?: ToastTone) => void;
dismissToast: (id: string) => void;
togglePanelSection: (id: string) => void;
requestAddChildren: (parentId: string | null, text?: string) => void;
clearAddChildrenRequest: () => void;
}
const COLOR_MODE_KEY = 'openmind:color-mode';
const PANEL_SECTIONS_KEY = 'openmind:panel-sections';
function readPanelSections(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
const raw = window.sessionStorage.getItem(PANEL_SECTIONS_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : null;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, boolean>;
}
} catch {
// Stockage indisponible : toutes les sections restent ouvertes.
}
return {};
}
function readInitialColorMode(): ColorMode {
if (typeof window === 'undefined') return 'light';
@@ -52,6 +78,8 @@ export const useUiStore = create<UiState>((set, get) => ({
propertiesPanelOpen: true,
searchPanelOpen: false,
toasts: [],
panelSections: readPanelSections(),
addChildrenRequest: null,
setColorMode: (mode) => {
applyColorMode(mode);
@@ -73,6 +101,27 @@ export const useUiStore = create<UiState>((set, get) => ({
},
dismissToast: (id) => set({ toasts: get().toasts.filter((toast) => toast.id !== id) }),
requestAddChildren: (parentId, text = '') =>
set({
addChildrenRequest: {
parentId,
text,
nonce: (get().addChildrenRequest?.nonce ?? 0) + 1,
},
}),
clearAddChildrenRequest: () => set({ addChildrenRequest: null }),
togglePanelSection: (id) => {
const next = { ...get().panelSections, [id]: !(get().panelSections[id] ?? true) };
set({ panelSections: next });
try {
window.sessionStorage.setItem(PANEL_SECTIONS_KEY, JSON.stringify(next));
} catch {
// La preference restera valable pour l'affichage courant uniquement.
}
},
}));
/** Applique le mode couleur au chargement de l'application. */

View File

@@ -68,6 +68,21 @@
box-shadow: 0 0 0 2px #6366f1, 0 0 0 5px rgba(99, 102, 241, 0.22);
}
/* Poignees de redimensionnement : discretes, visibles au survol du noeud. */
.openmind-canvas .openmind-resize-handle {
width: 8px;
height: 8px;
border-radius: 2px;
border: 1.5px solid #ffffff;
background: #6366f1;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
}
.openmind-canvas .openmind-resize-line {
border-color: rgba(99, 102, 241, 0.55);
border-width: 1px;
}
.mind-node {
transition:
box-shadow 120ms ease-out,

View File

@@ -6,8 +6,24 @@
* evoluer le rendu sans toucher au format de fichier.
*/
/** Version du format de fichier. Incrementer en cas de changement cassant. */
export const MAP_FORMAT_VERSION = 1;
/**
* Version du format de fichier.
*
* v1 : format initial.
* v2 : mise en page des noeuds (layout, iconStyle, branch) et reglages
* d'espacement. Champs optionnels, resolus a l'affichage.
* v3 : ordre explicite des freres (`MindMapNode.order`). Le bump est justifie :
* l'ordre fait desormais partie de la semantique du document, et un
* 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 = 4;
/**
* Types de noeuds.
@@ -22,9 +38,25 @@ 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';
/** Personnalisations explicites d'un noeud : elles priment toujours sur le theme. */
export type VerticalAlign = 'top' | 'middle' | 'bottom';
/** Mode de dimensionnement : ajuste au contenu, ou taille imposee. */
export type SizeMode = 'auto' | 'fixed';
export type ShadowStyle = 'theme' | 'none' | 'soft' | 'strong';
/** Familles de polices proposees : uniquement des polices systeme, rien de distant. */
export type FontFamilyId = 'theme' | 'sans' | 'serif' | 'mono' | 'rounded';
/**
* Personnalisations explicites d'un noeud : elles priment toujours sur le theme.
* Un champ absent signifie "herite du theme".
*/
export interface NodeStyle {
backgroundColor?: string;
textColor?: string;
@@ -34,7 +66,40 @@ export interface NodeStyle {
bold?: boolean;
italic?: boolean;
textAlign?: TextAlign;
/**
* Largeur fixe heritee du format v1. Conservee en lecture pour ne pas casser
* les cartes existantes ; les nouvelles ecritures passent par `layout.width`.
*/
width?: number;
fontFamily?: FontFamilyId;
verticalAlign?: VerticalAlign;
lineHeight?: number;
shadow?: ShadowStyle;
}
/** Mise en page interne d'un noeud. Tous les champs sont optionnels (v2). */
export interface NodeLayout {
widthMode?: SizeMode;
width?: number;
heightMode?: SizeMode;
height?: number;
paddingX?: number;
paddingY?: number;
/** Retour a la ligne automatique du texte. */
wrap?: boolean;
/** Largeur maximale du texte avant retour a la ligne, en mode automatique. */
maxTextWidth?: number;
}
export type IconKind = 'emoji' | 'builtin';
export type IconPosition = 'left' | 'right' | 'top';
/** Presentation de l'icone. La valeur elle-meme reste dans `MindMapNode.icon`. */
export interface NodeIconStyle {
kind?: IconKind;
position?: IconPosition;
size?: number;
gap?: number;
}
export interface Position {
@@ -48,18 +113,51 @@ export interface MindMapNode {
/** Texte brut. Jamais interprete comme du HTML. */
text: string;
position: Position;
/** Parent hierarchique. `null` = racine (une carte peut en avoir plusieurs). */
/**
* Parent hierarchique, seule source de verite de la relation.
* `null` = racine. Une carte peut en compter plusieurs : le modele l'autorise
* depuis la v1 et les cartes existantes en dependent.
*/
parentId: string | null;
/**
* Position parmi les freres (v3). Absent = ordre d'apparition dans `nodes`.
* `parentId` reste la source de verite de la relation, `order` celle de la
* 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`. */
icon?: string;
url?: string;
note?: string;
style?: NodeStyle;
/** Mise en page (v2). Absent = entierement herite du theme. */
layout?: NodeLayout;
/** Presentation de l'icone (v2). */
iconStyle?: NodeIconStyle;
/** Personnalisation de la branche reliant ce noeud a son parent (v2). */
branch?: NodeBranchStyle;
/** Espace libre reserve aux futurs types de noeuds. */
meta?: Record<string, unknown>;
}
/** Personnalisation de la branche entrante d'un noeud. Absent = theme. */
export interface NodeBranchStyle {
style?: BranchStyle;
width?: number;
color?: string;
}
export interface MindMapEdge {
id: string;
source: string;
@@ -69,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
@@ -83,11 +209,32 @@ export type LayoutKind =
| 'organization'
| 'freeform';
/** Espacement applique par l'organisation automatique. */
export interface LayoutSpacing {
/** Distance entre un parent et ses enfants (rangs successifs). */
parentGap: number;
/** Espacement entre noeuds freres d'un meme rang. */
siblingGap: number;
}
export interface MapSettings {
theme: string;
layout: LayoutKind;
branchStyle: BranchStyle;
showGrid: boolean;
/** Espacement de l'auto-layout (v2). Absent = valeurs "Normal". */
layoutSpacing?: LayoutSpacing;
/** Aimantation des deplacements sur la grille (v2). */
snapToGrid?: boolean;
/** Pas de la grille d'aimantation, en pixels (v2). */
snapGrid?: number;
/** Guides d'alignement affiches pendant un deplacement (v2). */
alignmentGuides?: boolean;
/**
* Comportement par defaut du glisser-deposer (v3) : deplacer le noeud seul,
* ou toute sa branche. La touche Alt inverse ce choix le temps d'un drag.
*/
dragMovesBranch?: boolean;
}
export interface Viewport {
@@ -104,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;
}

View File

@@ -1,46 +1,92 @@
import { type MindMapNode } from '@/types/map';
export const NODE_MIN_WIDTH = 96;
export const NODE_MAX_WIDTH = 260;
export const NODE_LINE_HEIGHT = 20;
export const NODE_VERTICAL_PADDING = 20;
import { getTheme, type MapTheme } from '@/features/editor/themes';
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
import { countRenderedLines, measureLongestLine, type FontSpec } from '@/utils/textMeasure';
export interface NodeSize {
width: number;
height: number;
}
/** Marge interne reservee aux indicateurs (note, lien) affiches dans le noeud. */
const INDICATOR_WIDTH = 18;
/**
* Estimation de la taille d'un noeud, utilisee tant que le DOM n'a pas encore
* mesure le noeud (premier layout, export sur une carte importee).
* Estimation de la taille d'un noeud.
*
* Utilisee tant que le DOM n'a pas mesure le noeud : premier auto-layout,
* export d'une carte importee, calcul des bornes. Elle reproduit les regles
* appliquees par le rendu (padding, icone, retour a la ligne, largeur max).
*/
export function estimateNodeSize(node: MindMapNode): NodeSize {
const fontSize = node.style?.fontSize ?? 14;
const charWidth = fontSize * 0.58;
export function estimateNodeSize(node: MindMapNode, theme: MapTheme = getTheme(undefined)): NodeSize {
const appearance = resolveNodeAppearance(node, node.type === 'root' ? 0 : 1, theme);
const font: FontSpec = {
fontSize: appearance.fontSize,
fontFamily: appearance.fontFamily,
fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle,
};
const text = node.text || ' ';
const iconWidth = node.icon ? fontSize + 8 : 0;
const iconInline = Boolean(node.icon) && appearance.iconPosition !== 'top';
const iconAbove = Boolean(node.icon) && appearance.iconPosition === 'top';
const longestLine = text
.split('\n')
.reduce((longest, line) => Math.max(longest, line.length), 0);
const decorations =
(iconInline ? appearance.iconSize + appearance.iconGap : 0) +
(node.note ? INDICATOR_WIDTH : 0) +
(node.url ? INDICATOR_WIDTH : 0);
const rawWidth = longestLine * charWidth + 32 + iconWidth;
const width = node.style?.width
? node.style.width
: Math.min(NODE_MAX_WIDTH, Math.max(NODE_MIN_WIDTH, Math.round(rawWidth)));
const horizontalChrome = appearance.paddingX * 2 + decorations + appearance.borderWidth * 2;
const verticalChrome = appearance.paddingY * 2 + appearance.borderWidth * 2;
const charsPerLine = Math.max(1, Math.floor((width - 32 - iconWidth) / charWidth));
const lines = text
.split('\n')
.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
// Largeur
let width: number;
let textWidth: number;
const height = Math.max(38, lines * (fontSize + 6) + NODE_VERTICAL_PADDING);
return { width, height: Math.round(height) };
if (appearance.widthMode === 'fixed' && appearance.width) {
width = appearance.width;
textWidth = Math.max(16, width - horizontalChrome);
} else {
const naturalTextWidth = measureLongestLine(text, font);
const cap = appearance.wrap ? appearance.maxTextWidth : Number.POSITIVE_INFINITY;
textWidth = Math.min(naturalTextWidth, cap);
width = Math.max(appearance.minWidth, Math.ceil(textWidth + horizontalChrome));
}
// Hauteur
if (appearance.heightMode === 'fixed' && appearance.height) {
return { width: Math.round(width), height: Math.round(appearance.height) };
}
const lines = countRenderedLines(
text,
font,
appearance.wrap ? textWidth : Number.POSITIVE_INFINITY,
);
const textHeight = lines * appearance.fontSize * appearance.lineHeight;
const iconHeight = iconAbove ? appearance.iconSize + appearance.iconGap : 0;
const height = Math.max(
appearance.minHeight,
Math.ceil(textHeight + iconHeight + verticalChrome),
);
// Un cercle doit rester circulaire : on prend la plus grande des deux mesures.
if (appearance.circle && appearance.widthMode === 'auto') {
const side = Math.max(width, height);
return { width: side, height: side };
}
return { width: Math.round(width), height: Math.round(height) };
}
/** Taille reelle si elle a ete mesuree, sinon estimation. */
export function resolveNodeSize(node: MindMapNode, measured?: Map<string, NodeSize>): NodeSize {
/** Taille reelle si elle a ete mesuree dans le DOM, sinon estimation. */
export function resolveNodeSize(
node: MindMapNode,
measured?: Map<string, NodeSize>,
theme?: MapTheme,
): NodeSize {
const known = measured?.get(node.id);
if (known && known.width > 0 && known.height > 0) return known;
return estimateNodeSize(node);
return estimateNodeSize(node, theme);
}

View File

@@ -0,0 +1,94 @@
/**
* Mesure de texte pour l'estimation de taille des noeuds.
*
* Le rendu reel est fait par le navigateur : cette mesure ne sert qu'aux cas ou
* le noeud n'est pas encore dans le DOM (auto-layout d'une carte importee,
* export, calcul de bornes). Un canvas hors ecran donne un resultat bien plus
* fidele qu'un comptage de caracteres, tout en restant synchrone.
*/
let context: CanvasRenderingContext2D | null | undefined;
function getContext(): CanvasRenderingContext2D | null {
if (context !== undefined) return context;
if (typeof document === 'undefined') {
context = null;
return context;
}
context = document.createElement('canvas').getContext('2d');
return context;
}
export interface FontSpec {
fontSize: number;
fontFamily: string;
fontWeight: number;
fontStyle: 'normal' | 'italic';
}
function toCssFont(font: FontSpec): string {
return `${font.fontStyle} ${font.fontWeight} ${font.fontSize}px ${font.fontFamily}`;
}
/** Largeur d'une ligne en pixels. Repli sur une approximation hors navigateur. */
export function measureLineWidth(line: string, font: FontSpec): number {
const canvasContext = getContext();
if (canvasContext) {
canvasContext.font = toCssFont(font);
return canvasContext.measureText(line).width;
}
// Repli (tests Node) : largeur moyenne d'un caractere.
const ratio = font.fontFamily.includes('monospace') ? 0.6 : 0.52;
const bold = font.fontWeight >= 700 ? 1.05 : 1;
return line.length * font.fontSize * ratio * bold;
}
/**
* Nombre de lignes rendues pour un texte donne.
* `maxWidth` a `Infinity` desactive le retour a la ligne.
*/
export function countRenderedLines(text: string, font: FontSpec, maxWidth: number): number {
const paragraphs = text.split('\n');
if (!Number.isFinite(maxWidth) || maxWidth <= 0) return Math.max(1, paragraphs.length);
let total = 0;
for (const paragraph of paragraphs) {
total += countWrappedLines(paragraph, font, maxWidth);
}
return Math.max(1, total);
}
function countWrappedLines(paragraph: string, font: FontSpec, maxWidth: number): number {
if (paragraph.length === 0) return 1;
const words = paragraph.split(/\s+/).filter(Boolean);
if (words.length === 0) return 1;
let lines = 1;
let current = '';
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (measureLineWidth(candidate, font) <= maxWidth) {
current = candidate;
continue;
}
if (current) lines += 1;
// Un mot plus long que la ligne (URL, identifiant) est coupe par le CSS.
const wordWidth = measureLineWidth(word, font);
if (wordWidth > maxWidth) {
lines += Math.ceil(wordWidth / maxWidth) - 1;
current = '';
} else {
current = word;
}
}
return lines;
}
/** Largeur de la ligne la plus longue, sans retour a la ligne. */
export function measureLongestLine(text: string, font: FontSpec): number {
return text
.split('\n')
.reduce((widest, line) => Math.max(widest, measureLineWidth(line, font)), 0);
}

View File

@@ -1,17 +1,113 @@
import { type MindMapEdge, type MindMapNode } from '@/types/map';
import { type MindMapEdge, type MindMapNode, type Position } from '@/types/map';
/** Index enfant -> parents directs, construit une seule fois par operation. */
/**
* Compare deux freres.
*
* `order` fait foi ; a defaut, l'ordre d'apparition dans le tableau `nodes`
* sert de repli, ce qui reproduit exactement le comportement d'avant la v3 pour
* les cartes qui n'ont pas encore d'ordre explicite.
*/
export function compareSiblings(
a: MindMapNode,
b: MindMapNode,
indexById: Map<string, number>,
): number {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
}
function buildIndex(nodes: MindMapNode[]): Map<string, number> {
const index = new Map<string, number>();
nodes.forEach((node, position) => index.set(node.id, position));
return index;
}
/**
* Index parent -> enfants directs, tries par ordre de fratrie.
* Construit une seule fois par operation.
*/
export function buildChildrenMap(nodes: MindMapNode[]): Map<string, string[]> {
const byId = buildNodeMap(nodes);
const indexById = buildIndex(nodes);
const children = new Map<string, string[]>();
for (const node of nodes) {
if (!node.parentId) continue;
const list = children.get(node.parentId);
if (list) list.push(node.id);
else children.set(node.parentId, [node.id]);
}
for (const list of children.values()) {
if (list.length > 1) {
list.sort((a, b) =>
compareSiblings(byId.get(a) as MindMapNode, byId.get(b) as MindMapNode, indexById),
);
}
}
return children;
}
/** Enfants directs d'un noeud, dans l'ordre. `null` cible les racines. */
export function getOrderedChildren(
nodes: MindMapNode[],
parentId: string | null,
): MindMapNode[] {
const byId = buildNodeMap(nodes);
if (parentId === null) {
const indexById = buildIndex(nodes);
return getRootNodes(nodes).sort((a, b) => compareSiblings(a, b, indexById));
}
return (buildChildrenMap(nodes).get(parentId) ?? []).map((id) => byId.get(id) as MindMapNode);
}
/**
* Renumerote les freres en 0..n-1, pour chaque parent et pour les racines.
*
* Appelee apres toute modification structurelle : l'ordre reste ainsi dense et
* comparable, et les anciennes cartes recoivent un ordre explicite conforme a
* leur affichage precedent.
*/
export function normalizeChildOrders(nodes: MindMapNode[]): MindMapNode[] {
const indexById = buildIndex(nodes);
const groups = new Map<string | null, MindMapNode[]>();
const known = new Set(nodes.map((node) => node.id));
for (const node of nodes) {
// `null` regroupe les racines : aucune sentinelle textuelle a inventer.
const key = node.parentId && known.has(node.parentId) ? node.parentId : null;
const group = groups.get(key);
if (group) group.push(node);
else groups.set(key, [node]);
}
const orders = new Map<string, number>();
for (const group of groups.values()) {
group.sort((a, b) => compareSiblings(a, b, indexById));
group.forEach((node, position) => orders.set(node.id, position));
}
let changed = false;
const result = nodes.map((node) => {
const order = orders.get(node.id) ?? 0;
if (node.order === order) return node;
changed = true;
return { ...node, order };
});
return changed ? result : nodes;
}
/** Prochain rang libre parmi les enfants d'un parent. */
export function nextSiblingOrder(nodes: MindMapNode[], parentId: string | null): number {
const siblings = nodes.filter((node) =>
parentId === null ? !node.parentId : node.parentId === parentId,
);
if (siblings.length === 0) return 0;
return Math.max(...siblings.map((node) => node.order ?? 0)) + 1;
}
export function buildNodeMap(nodes: MindMapNode[]): Map<string, MindMapNode> {
return new Map(nodes.map((node) => [node.id, node]));
}
@@ -103,16 +199,45 @@ export function wouldCreateCycle(
return getDescendantIds(nodes, childId).includes(nextParentId);
}
/** Recalcule les liens a partir de la hierarchie : parentId fait autorite. */
/**
* Recalcule les liens a partir de la hierarchie : parentId fait autorite.
* Les liens sont emis dans l'ordre de fratrie, ce dont dagre se sert pour
* disposer les enfants dans le bon ordre.
*/
export function rebuildEdges(nodes: MindMapNode[]): MindMapEdge[] {
const ids = new Set(nodes.map((node) => node.id));
return nodes
.filter((node) => node.parentId && ids.has(node.parentId))
.map((node) => ({
id: `e-${node.parentId}-${node.id}`,
source: node.parentId as string,
target: node.id,
}));
const edges: MindMapEdge[] = [];
for (const [parentId, childIds] of buildChildrenMap(nodes)) {
for (const childId of childIds) {
edges.push({ id: `e-${parentId}-${childId}`, source: parentId, target: childId });
}
}
return edges;
}
/** Parcours en profondeur, racines et enfants dans l'ordre de fratrie. */
export function flattenInOrder(nodes: MindMapNode[]): MindMapNode[] {
const byId = buildNodeMap(nodes);
const children = buildChildrenMap(nodes);
const indexById = buildIndex(nodes);
const result: MindMapNode[] = [];
const seen = new Set<string>();
const visit = (node: MindMapNode): void => {
if (seen.has(node.id)) return;
seen.add(node.id);
result.push(node);
for (const childId of children.get(node.id) ?? []) {
const child = byId.get(childId);
if (child) visit(child);
}
};
for (const root of getRootNodes(nodes).sort((a, b) => compareSiblings(a, b, indexById))) {
visit(root);
}
// Securite : un noeud isole par une donnee incoherente reste present.
for (const node of nodes) if (!seen.has(node.id)) result.push(node);
return result;
}
/**
@@ -140,18 +265,43 @@ export function suggestChildPosition(
parent: MindMapNode,
horizontal: boolean,
direction: 1 | -1,
): { x: number; y: number } {
const siblings = nodes.filter((node) => node.parentId === parent.id);
const offset = siblings.length;
): Position {
return suggestChildPositions(nodes, parent, horizontal, direction, 1)[0] as Position;
}
if (horizontal) {
return {
x: parent.position.x + direction * 220,
y: parent.position.y + offset * 72 - (siblings.length > 0 ? 36 : 0),
};
const CHILD_GAP_Y = 72;
const CHILD_GAP_X = 200;
/**
* Positions de depart pour `count` nouveaux enfants.
*
* Elles ne remplacent pas l'auto-layout : elles evitent seulement que plusieurs
* noeuds crees d'un coup se superposent, en les repartissant autour du parent.
*/
export function suggestChildPositions(
nodes: MindMapNode[],
parent: MindMapNode,
horizontal: boolean,
direction: 1 | -1,
count: number,
): Position[] {
const existing = nodes.filter((node) => node.parentId === parent.id).length;
const total = existing + count;
const positions: Position[] = [];
for (let index = 0; index < count; index += 1) {
const slot = existing + index;
if (horizontal) {
positions.push({
x: parent.position.x + direction * 220,
y: parent.position.y + (slot - (total - 1) / 2) * CHILD_GAP_Y,
});
} else {
positions.push({
x: parent.position.x + (slot - (total - 1) / 2) * CHILD_GAP_X,
y: parent.position.y + direction * 140,
});
}
}
return {
x: parent.position.x + offset * 200 - (siblings.length > 0 ? 100 : 0),
y: parent.position.y + direction * 140,
};
return positions;
}