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>
This commit is contained in:
2026-09-09 17:29:48 +02:00
parent 0b2e5e326d
commit af6fd519c5
37 changed files with 3896 additions and 439 deletions

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