diff --git a/README.md b/README.md index f7cf422..d9cccde 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,36 @@ double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique. Bezier, branches repliables avec compteur d'elements masques. **Auto-layout**, uniquement sur demande : mind map droite, mind map gauche, -arbre horizontal, arbre vertical. L'espacement est configurable (distance -parent/enfant et espacement entre branches), avec les presets Compact, Normal -et Aere. Modifier l'espacement ne deplace rien tant que l'organisation n'est pas -relancee. +mind map bilaterale, arbre horizontal, arbre vertical. L'espacement est +configurable (distance parent/enfant et espacement entre branches), avec les +presets Compact, Normal et Aere. Choisir une disposition ou modifier +l'espacement ne deplace rien : c'est *Reorganiser* qui applique, ce qui protege +les positions placees a la main. + +**Mind map bilaterale** : la racine reste au centre, les branches de niveau 1 se +repartissent a gauche et a droite, et tous les descendants heritent du cote de +leur branche. La repartition automatique equilibre les deux cotes selon la +taille des sous-arbres ; le cote se change ensuite a la main (*Placer la branche +a gauche / a droite*). + +**Relations secondaires** : une connexion visuelle entre deux noeuds +quelconques, independante de la hierarchie. Elle ne modifie ni le parent, ni +l'ordre, ni le repli, et l'auto-layout l'ignore totalement. Libelle libre, +trace (Bezier, courbe, droite), couleur, epaisseur, trait plein ou pointille, +fleches aux deux extremites. Une relation dont une extremite est masquee par un +repli disparait de l'affichage et revient au deploiement. + +**Modeles** : galerie locale de huit modeles (carte vide, brainstorming, gestion +de projet, architecture IT, reunion, SWOT, plan d'etude, planification +personnelle). Choisir un modele en cree une copie : l'original n'est jamais +modifie, et la carte obtenue est une carte comme les autres. + +**Markdown** : import d'un fichier `.md` ou d'un texte colle, comme nouvelle +carte depuis le tableau de bord ou comme sous-arbre d'un noeud existant. Les +titres et les listes construisent la hierarchie ; les paragraphes et les blocs +de code rejoignent la note du noeud, en texte brut. Export inverse en `.md` : +contenu et hierarchie uniquement, sans positions ni couleurs, avec un +avertissement si la carte contient des relations secondaires. **Proprietes du noeud**, regroupees en sections repliables (Contenu, Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ; @@ -274,7 +300,7 @@ theme ne supprime jamais une personnalisation explicite d'un noeud. et mise en evidence temporaire du noeud, depliage automatique des branches repliees. -**Exports** : `.mmap` (format natif), PNG (x1, x2, fond transparent), SVG, PDF +**Exports** : `.mmap` (format natif), Markdown (`.md`), PNG (x1, x2, fond transparent), SVG, PDF (A4/A3, portrait/paysage, marges, adapter a une page ou taille reelle, fond, titre, date), impression via `@media print`. @@ -326,14 +352,20 @@ Le format est en **version 3**. | v1 | format initial | `style.width` devient `layout.width` | | v2 | mise en page des noeuds (`layout`, `iconStyle`, `branch`) | l'ordre des freres est materialise depuis leur position dans `nodes` | | v3 | ordre explicite des freres (`node.order`) | aucune | +| v4 | cote des branches (`node.side`) et relations secondaires (`relations`) | aucune | -Les fichiers v1 et v2 restent lisibles, et les cartes deja enregistrees dans -IndexedDB s'ouvrent sans modification. Le passage en v3 est justifie : l'ordre -des freres fait desormais partie de la semantique du document, et un lecteur v2 +Les fichiers v1, v2 et v3 restent lisibles, et les cartes deja enregistrees dans +IndexedDB s'ouvrent sans modification. Le passage en v3 etait justifie : l'ordre +des freres fait partie de la semantique du document, et un lecteur v2 l'ignorerait en reordonnant silencieusement la carte. Les noeuds sont malgre tout ecrits en parcours de profondeur, freres dans l'ordre, ce qui donne le bon resultat meme a un lecteur qui ignore `order`. +Le passage en v4 l'est aussi : un lecteur v3 ignorerait `relations` et perdrait +donc une partie du document. La migration v3 -> v4 n'a en revanche rien a +inventer : aucun cote defini signifie "a repartir automatiquement", aucune +relation signifie "aucune". Une carte v3 s'affiche exactement comme avant. + Toute carte qui entre dans l'editeur passe par le meme point d'entree, `normalizeMapDocument()`, quelle que soit sa provenance : @@ -362,6 +394,8 @@ vous la modifiez. "text": "Infrastructure", "position": { "x": 0, "y": 0 }, "parentId": null, + "order": 0, + "side": "right", "collapsed": false, "icon": "\ud83d\udda5\ufe0f", "url": "https://exemple.fr", @@ -389,6 +423,19 @@ vous la modifiez. } ], "edges": [{ "id": "e-a-b", "source": "a", "target": "b" }], + "relations": [ + { + "id": "r-uuid", + "sourceId": "a", + "targetId": "b", + "label": "depend de", + "style": "bezier", + "color": "#a855f7", + "width": 2, + "dashed": true, + "markerEnd": "arrow" + } + ], "viewport": { "x": 0, "y": 0, "zoom": 1 }, "settings": { "theme": "default", @@ -398,11 +445,21 @@ vous la modifiez. "layoutSpacing": { "parentGap": 100, "siblingGap": 50 }, "snapToGrid": false, "snapGrid": 20, - "alignmentGuides": true + "alignmentGuides": true, + "dragMovesBranch": false } } ``` +Deux precisions sur ce document : + +- `edges` est **derive** de `parentId` : il est recalcule a chaque modification + et n'est jamais une source de verite. `relations`, au contraire, est une + donnee a part entiere, que rien ne permet de deduire de la hierarchie. +- `side` n'a de sens que sur un enfant direct d'une racine. Tous ses descendants + heritent du cote de leur branche, ce qui evite d'entretenir la meme + information sur chaque noeud. + A l'import, le fichier est **valide et normalise** : structure, version, champs essentiels, limite de noeuds, assainissement des URL (`javascript:` et `data:` sont supprimes), rejet des styles invalides. Le contenu n'est jamais execute ni @@ -488,12 +545,17 @@ docker run --rm -v "$PWD/frontend:/app" -w /app node:20-alpine sh -c "npm ci && Les tests backend couvrent : healthcheck, creation, lecture, mise a jour et suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans jeton, partage inexistant, payload invalide, limites de taille et de noeuds, -non-divulgation du jeton de gestion, repli SPA. +non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et +v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente. Les tests frontend couvrent : stockage IndexedDB, serialisation et validation -d'import, migrations, store Zustand (creation, suppression de branche, -undo/redo, groupement du glisser-deposer, copier/coller, duplication, detection -de boucle, recherche, auto-layout, lecture seule), assainissement des URL. +d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation, +store Zustand (creation, suppression de branche, undo/redo, groupement du +glisser-deposer, copier/coller, duplication, detection de boucle, recherche, +auto-layout, lecture seule), repartition et heritage des cotes, auto-layout +bilateral, relations secondaires (creation, refus, style, repli, duplication, +independance vis-a-vis de la hierarchie), analyse et export Markdown, galerie de +modeles, assainissement des URL. --- @@ -535,7 +597,8 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL. ├── hooks/ # routeur minimal, debounce ├── components/ # briques d'interface reutilisables ├── features/ - │ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, themes + │ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes + │ ├── templates/ # galerie et definitions des modeles │ ├── maps/ # tableau de bord │ ├── export/ # PNG, SVG, PDF, impression │ └── sharing/ # dialogue de partage, page publique @@ -583,9 +646,24 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL. - **Gestion d'un partage** : le jeton de gestion vit dans l'IndexedDB du navigateur d'origine. Depuis un autre appareil, un partage ne peut etre ni mis a jour ni supprime. -- **Dispositions reservees** : `mindmap-both`, `organization` et `freeform` - existent dans le modele de donnees mais ne sont pas proposees dans - l'interface. +- **Dispositions reservees** : `organization` et `freeform` existent dans le + modele de donnees mais ne sont pas proposees dans l'interface. +- **Relations et export Markdown** : l'export `.md` decrit le contenu et la + hierarchie. Les relations secondaires n'y ont pas de representation fidele et + ne sont pas inventees ; un message le signale au moment de l'export. +- **Relations et minimap** : la minimap n'affiche que les noeuds. Les relations + y sont invisibles, comme les branches hierarchiques. +- **Doublon de relation** : deux relations identiques (meme source vers meme + cible) se superposeraient, la seconde serait donc invisible et impossible a + selectionner. Une seule est acceptee par couple ; le sens inverse, lui, reste + autorise. +- **Relation dupliquee** : dupliquer ou coller une branche reporte les relations + dont les deux extremites sont dans la branche. Une relation qui sort vers un + noeud exterieur n'est pas recreee : rien ne dit que la copie doit viser la + meme cible, et le deviner creerait un lien non demande. +- **Bilaterale et racines multiples** : le recalage des deux cotes se fait sur + la premiere racine. Une carte a plusieurs racines reste affichee, mais les + racines suivantes ne sont pas recentrees individuellement. - **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont acceptes par le format, mais la V1 ne produit que `root` et `standard`. - **Migrations de schema SQLite** : la V1 initialise le schema avec diff --git a/backend/app/schemas/map.py b/backend/app/schemas/map.py index 8b78309..a430731 100644 --- a/backend/app/schemas/map.py +++ b/backend/app/schemas/map.py @@ -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 diff --git a/backend/tests/test_relation_payload.py b/backend/tests/test_relation_payload.py new file mode 100644 index 0000000..eafb030 --- /dev/null +++ b/backend/tests/test_relation_payload.py @@ -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 diff --git a/frontend/src/features/editor/EditorToolbar.tsx b/frontend/src/features/editor/EditorToolbar.tsx index 6ecfd53..c5f4ae3 100644 --- a/frontend/src/features/editor/EditorToolbar.tsx +++ b/frontend/src/features/editor/EditorToolbar.tsx @@ -71,9 +71,11 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp const requestFit = useEditorStore((state) => state.requestFit); const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed); const collapseToLevel = useEditorStore((state) => state.collapseToLevel); + const balanceSides = useEditorStore((state) => state.balanceSides); const colorMode = useUiStore((state) => state.colorMode); const toggleColorMode = useUiStore((state) => state.toggleColorMode); + const pushToast = useUiStore((state) => state.pushToast); const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen); const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen); @@ -158,17 +160,22 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp {(close) => ( <> Disposition + {/* + 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) => ( { - applyAutoLayout(option.id); - close(); - }} + onSelect={() => updateSettings({ layout: option.id })} /> ))} +

+ Choisir une disposition ne deplace rien : utilisez Reorganiser. +

Espacement
@@ -235,6 +242,20 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
+ { + 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(); + }} + /> { diff --git a/frontend/src/features/editor/MarkdownImportDialog.tsx b/frontend/src/features/editor/MarkdownImportDialog.tsx new file mode 100644 index 0000000..b799376 --- /dev/null +++ b/frontend/src/features/editor/MarkdownImportDialog.tsx @@ -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 ( +
    + {nodes.map((node, index) => ( +
  • + {node.text} + {node.note ? ( + note + ) : null} + {node.children.length > 0 ? : null} +
  • + ))} +
+ ); +} + +/** + * 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(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 ( + + + + + } + > +
+
+ + ou collez le contenu ci-dessous + { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) void readFile(file); + }} + /> +
+ +