From 2afbf637ce19cba9525fe96461ce9db01941705d Mon Sep 17 00:00:00 2001 From: snoopy Date: Thu, 10 Sep 2026 06:35:57 +0200 Subject: [PATCH] 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 --- README.md | 48 +- backend/tests/test_order_payload.py | 111 ++++ frontend/src/components/Icons.tsx | 8 + .../src/features/editor/AddChildrenDialog.tsx | 177 +++++++ frontend/src/features/editor/EditorPage.tsx | 36 ++ .../src/features/editor/EditorToolbar.tsx | 52 ++ .../src/features/editor/MindMapCanvas.tsx | 58 ++- frontend/src/features/editor/MoveToDialog.tsx | 124 +++++ .../src/features/editor/NodeContextMenu.tsx | 274 +++++++--- .../src/features/editor/useEditorShortcuts.ts | 37 +- frontend/src/services/mapFactory.ts | 1 + .../services/mapOrderSerialization.test.ts | 151 ++++++ frontend/src/services/mapSerialization.ts | 56 +- frontend/src/services/outlineParser.test.ts | 131 +++++ frontend/src/services/outlineParser.ts | 173 +++++++ frontend/src/stores/editorBranch.test.ts | 213 ++++++++ frontend/src/stores/editorCreation.test.ts | 143 ++++++ frontend/src/stores/editorHierarchy.test.ts | 144 ++++++ frontend/src/stores/editorStore.ts | 486 +++++++++++++++++- frontend/src/stores/uiStore.ts | 20 + frontend/src/types/map.ts | 30 +- frontend/src/utils/tree.ts | 196 ++++++- 22 files changed, 2557 insertions(+), 112 deletions(-) create mode 100644 backend/tests/test_order_payload.py create mode 100644 frontend/src/features/editor/AddChildrenDialog.tsx create mode 100644 frontend/src/features/editor/MoveToDialog.tsx create mode 100644 frontend/src/services/mapOrderSerialization.test.ts create mode 100644 frontend/src/services/outlineParser.test.ts create mode 100644 frontend/src/services/outlineParser.ts create mode 100644 frontend/src/stores/editorBranch.test.ts create mode 100644 frontend/src/stores/editorCreation.test.ts create mode 100644 frontend/src/stores/editorHierarchy.test.ts diff --git a/README.md b/README.md index 8c144e4..f7cf422 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -240,6 +241,19 @@ 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, @@ -287,6 +301,11 @@ l'application chargee. | `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 | @@ -300,11 +319,20 @@ 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`. +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 | + +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 +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`. Toute carte qui entre dans l'editeur passe par le meme point d'entree, `normalizeMapDocument()`, quelle que soit sa provenance : @@ -573,6 +601,16 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL. - **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 diff --git a/backend/tests/test_order_payload.py b/backend/tests/test_order_payload.py new file mode 100644 index 0000000..318f4d5 --- /dev/null +++ b/backend/tests/test_order_payload.py @@ -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) diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx index 4527495..fe8ee3a 100644 --- a/frontend/src/components/Icons.tsx +++ b/frontend/src/components/Icons.tsx @@ -235,6 +235,14 @@ export const MapIcon = (p: IconProps) => ( ); +export const FoldIcon = (p: IconProps) => ( + + + + + +); + export const SpinnerIcon = ({ className }: IconProps) => ( 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 ( + + ); +} + +/** + * 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 ( + + + + + } + > +
+
+ +