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 ( + + + + + } + > +
+
+ +