diff --git a/README.md b/README.md
index 674fe55..8dbfa41 100644
--- a/README.md
+++ b/README.md
@@ -226,11 +226,28 @@ 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.
+arbre horizontal, arbre vertical. L'espacement est configurable (distance
+parent/enfant et espacement entre branches), avec les presets Compact, Normal
+et Aere. Modifier l'espacement ne deplace rien tant que l'organisation n'est pas
+relancee.
-**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.
+**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.
+
+**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.
+
+**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
@@ -265,6 +282,8 @@ l'application chargee.
| `Ctrl+C` | Copier |
| `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer |
+| `Ctrl+Maj+C` | Copier le style du noeud |
+| `Ctrl+Maj+V` | Coller le style sur la selection |
| `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite |
| `Ctrl+F` | Rechercher dans la carte |
@@ -278,6 +297,12 @@ 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 2**. Les fichiers v1 restent lisibles : tous les
+champs ajoutes en v2 (`layout`, `iconStyle`, `branch`, reglages d'espacement)
+sont optionnels et un champ absent signifie simplement *herite du theme*. La
+seule transformation appliquee a l'import d'un fichier v1 est le deplacement de
+`style.width` vers `layout.width`.
+
```json
{
"version": 1,
@@ -296,7 +321,26 @@ carte. Il ne contient jamais de jeton de partage.
"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" }],
@@ -305,7 +349,11 @@ carte. Il ne contient jamais de jeton de partage.
"theme": "default",
"layout": "mindmap-right",
"branchStyle": "bezier",
- "showGrid": true
+ "showGrid": true,
+ "layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
+ "snapToGrid": false,
+ "snapGrid": 20,
+ "alignmentGuides": true
}
}
```
@@ -498,6 +546,13 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
- **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.
- **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 +575,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 |
diff --git a/backend/tests/test_layout_payload.py b/backend/tests/test_layout_payload.py
new file mode 100644
index 0000000..489bcfd
--- /dev/null
+++ b/backend/tests/test_layout_payload.py
@@ -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
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 22029bc..ee163fd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index a0ef9d5..c02a055 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
diff --git a/frontend/src/features/editor/AlignmentGuidesLayer.tsx b/frontend/src/features/editor/AlignmentGuidesLayer.tsx
new file mode 100644
index 0000000..345a07f
--- /dev/null
+++ b/frontend/src/features/editor/AlignmentGuidesLayer.tsx
@@ -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 (
+
+ {guides.map((guide) => {
+ const vertical = guide.orientation === 'vertical';
+ const margin = 24;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/features/editor/EditorToolbar.tsx b/frontend/src/features/editor/EditorToolbar.tsx
index a7b8aec..561d44e 100644
--- a/frontend/src/features/editor/EditorToolbar.tsx
+++ b/frontend/src/features/editor/EditorToolbar.tsx
@@ -24,7 +24,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';
@@ -71,6 +76,7 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const { zoomIn, zoomOut } = useReactFlow();
const [name, setName] = useState(map?.name ?? '');
+ const spacing = resolveSpacing(map?.settings.layoutSpacing);
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
@@ -160,6 +166,71 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
}}
/>
))}
+
+ Espacement
+
+ {SPACING_PRESETS.map((preset) => (
+
+ ))}
+
+
+
+
+
+ updateSettings({
+ layoutSpacing: { ...spacing, parentGap: Number(event.target.value) },
+ })
+ }
+ className="h-1.5 w-full accent-indigo-600"
+ />
+
+
+
+ updateSettings({
+ layoutSpacing: { ...spacing, siblingGap: Number(event.target.value) },
+ })
+ }
+ className="h-1.5 w-full accent-indigo-600"
+ />
+
+ Changer l'espacement ne deplace rien : relancez l'organisation.
+
+
+