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 <noreply@anthropic.com>
This commit is contained in:
48
README.md
48
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
|
||||
|
||||
111
backend/tests/test_order_payload.py
Normal file
111
backend/tests/test_order_payload.py
Normal file
@@ -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)
|
||||
@@ -235,6 +235,14 @@ export const MapIcon = (p: IconProps) => (
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const FoldIcon = (p: IconProps) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 6h16M4 18h16" />
|
||||
<path d="M12 9v-3l-2 2M12 6l2 2" />
|
||||
<path d="M12 15v3l-2-2M12 18l2-2" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const SpinnerIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
177
frontend/src/features/editor/AddChildrenDialog.tsx
Normal file
177
frontend/src/features/editor/AddChildrenDialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import {
|
||||
OutlineError,
|
||||
looksIndented,
|
||||
parseOutline,
|
||||
toFlatLines,
|
||||
type OutlineNode,
|
||||
} from '@/services/outlineParser';
|
||||
import { useAppConfigStore } from '@/stores/appConfigStore';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
|
||||
export interface AddChildrenDialogProps {
|
||||
open: boolean;
|
||||
parentId: string | null;
|
||||
/** Texte pre-rempli, par exemple le contenu colle. */
|
||||
initialText?: string;
|
||||
onClose: () => 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 (
|
||||
<ul className={depth === 0 ? 'space-y-0.5' : 'space-y-0.5 border-l border-zinc-200 pl-3 dark:border-zinc-700'}>
|
||||
{nodes.map((node, index) => (
|
||||
<li key={`${depth}-${index}-${node.text}`}>
|
||||
<span className="truncate text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
|
||||
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Ajouter plusieurs enfants"
|
||||
description={
|
||||
parentName
|
||||
? `Une ligne = un enfant de "${parentName}". Indentez pour creer une arborescence.`
|
||||
: 'Une ligne = un noeud. Indentez pour creer une arborescence.'
|
||||
}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Annuler</Button>
|
||||
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
|
||||
{analysis.count > 0 ? `Creer ${analysis.count} noeud(s)` : 'Creer'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="add-children-text">
|
||||
Contenu
|
||||
</label>
|
||||
<textarea
|
||||
id="add-children-text"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
className="field-input min-h-[180px] resize-y font-mono text-xs leading-relaxed"
|
||||
placeholder={'VLAN\nVPN\nFirewall\n\nou avec indentation :\nReseau\n VLAN\n VPN'}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-zinc-400">
|
||||
Les lignes vides sont ignorees, les puces (-, *, 1.) sont retirees. Ctrl+Entree valide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{analysis.error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300"
|
||||
>
|
||||
{analysis.error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analysis.count > 0 && !analysis.error ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/60">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-zinc-500">
|
||||
Apercu {analysis.mode === 'outline' ? '(arborescence)' : '(liste)'}
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<PreviewTree nodes={analysis.roots} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useAutosave } from '@/features/editor/useAutosave';
|
||||
import { useEditorShortcuts } from '@/features/editor/useEditorShortcuts';
|
||||
import { ShareDialog } from '@/features/sharing/ShareDialog';
|
||||
import { getMap } from '@/services/mapRepository';
|
||||
import { toFlatLines } from '@/services/outlineParser';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { navigate } from '@/hooks/useRoute';
|
||||
@@ -28,6 +29,7 @@ function EditorPageContent({ mapId }: EditorPageProps) {
|
||||
const searchPanelOpen = useUiStore((state) => state.searchPanelOpen);
|
||||
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
const requestAddChildren = useUiStore((state) => state.requestAddChildren);
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'missing'>('loading');
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
@@ -71,8 +73,42 @@ function EditorPageContent({ mapId }: EditorPageProps) {
|
||||
},
|
||||
onFit: requestFit,
|
||||
onFocusSearch: openSearch,
|
||||
onAddChildren: (parentId) => requestAddChildren(parentId),
|
||||
});
|
||||
|
||||
/*
|
||||
* Collage intelligent.
|
||||
*
|
||||
* On ecoute l'evenement `paste` plutot que de lire le presse-papiers :
|
||||
* l'evenement porte le texte sans demander de permission. Le presse-papiers
|
||||
* interne d'OpenMind reste prioritaire (gere par le raccourci Ctrl+V), et
|
||||
* l'edition d'un texte n'est jamais interceptee.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (status !== 'ready') return undefined;
|
||||
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const store = useEditorStore.getState();
|
||||
if (store.readOnly || store.editingNodeId !== null) return;
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return;
|
||||
}
|
||||
if ((store.clipboard?.nodes.length ?? 0) > 0) return;
|
||||
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
if (toFlatLines(text).length < 2) return;
|
||||
|
||||
event.preventDefault();
|
||||
requestAddChildren(store.selectedNodeIds[0] ?? null, text);
|
||||
};
|
||||
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [status, requestAddChildren]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ChevronDownIcon,
|
||||
FitIcon,
|
||||
FoldIcon,
|
||||
GridIcon,
|
||||
LayoutIcon,
|
||||
MoonIcon,
|
||||
@@ -68,6 +69,8 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
const updateSettings = useEditorStore((state) => state.updateSettings);
|
||||
const renameMap = useEditorStore((state) => state.renameMap);
|
||||
const requestFit = useEditorStore((state) => state.requestFit);
|
||||
const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed);
|
||||
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
|
||||
|
||||
const colorMode = useUiStore((state) => state.colorMode);
|
||||
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
|
||||
@@ -299,6 +302,55 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<Menu
|
||||
label="Affichage des branches"
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<FoldIcon className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">Branches</span>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
<MenuItem
|
||||
label="Tout replier"
|
||||
onSelect={() => {
|
||||
setAllCollapsed(true);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Tout developper"
|
||||
onSelect={() => {
|
||||
setAllCollapsed(false);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<MenuSeparator />
|
||||
<MenuLabel>Replier au-dela du niveau</MenuLabel>
|
||||
{[1, 2, 3].map((level) => (
|
||||
<MenuItem
|
||||
key={level}
|
||||
label={`Niveau ${level}`}
|
||||
onSelect={() => {
|
||||
collapseToLevel(level);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
label="Le glisser-deposer entraine la branche"
|
||||
active={map.settings.dragMovesBranch === true}
|
||||
hint="Alt inverse"
|
||||
onSelect={() => updateSettings({ dragMovesBranch: !map.settings.dragMovesBranch })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<IconButton label="Zoom avant" icon={<ZoomInIcon />} onClick={() => zoomIn()} />
|
||||
<IconButton label="Zoom arriere" icon={<ZoomOutIcon />} onClick={() => zoomOut()} />
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
NodeContextMenu,
|
||||
type NodeContextMenuState,
|
||||
} from '@/features/editor/NodeContextMenu';
|
||||
import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog';
|
||||
import { MoveToDialog } from '@/features/editor/MoveToDialog';
|
||||
import { SelectionToolbar } from '@/features/editor/SelectionToolbar';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
@@ -54,8 +56,27 @@ export function MindMapCanvas({
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
|
||||
const initialisedFor = useRef<string | null>(null);
|
||||
/**
|
||||
* Vrai pendant un drag qui doit entrainer toute la branche.
|
||||
* Le reglage de la carte donne le comportement par defaut ; la touche Alt
|
||||
* l'inverse le temps d'un deplacement.
|
||||
*/
|
||||
const branchDragRef = useRef(false);
|
||||
const [guides, setGuides] = useState<AlignmentGuide[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<NodeContextMenuState | null>(null);
|
||||
const [addChildrenFor, setAddChildrenFor] = useState<{ parentId: string | null; text: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
|
||||
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
|
||||
|
||||
// Ouverture demandee par un raccourci clavier ou par un collage multi-ligne.
|
||||
useEffect(() => {
|
||||
if (readOnly || !addChildrenRequest) return;
|
||||
setAddChildrenFor({ parentId: addChildrenRequest.parentId, text: addChildrenRequest.text });
|
||||
clearAddChildrenRequest();
|
||||
}, [addChildrenRequest, clearAddChildrenRequest, readOnly]);
|
||||
|
||||
const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]);
|
||||
|
||||
@@ -125,7 +146,9 @@ export function MindMapCanvas({
|
||||
}
|
||||
}
|
||||
|
||||
if (positions.length > 0 && !readOnly) store.moveNodes(positions);
|
||||
if (positions.length > 0 && !readOnly) {
|
||||
store.moveNodes(positions, { withDescendants: branchDragRef.current });
|
||||
}
|
||||
if (selection) store.setSelection([...selection]);
|
||||
},
|
||||
[readOnly],
|
||||
@@ -184,7 +207,10 @@ export function MindMapCanvas({
|
||||
[nodes, theme],
|
||||
);
|
||||
|
||||
const handleNodeDragStop = useCallback(() => setGuides([]), []);
|
||||
const handleNodeDragStop = useCallback(() => {
|
||||
setGuides([]);
|
||||
branchDragRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handlePaneDoubleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
@@ -215,7 +241,13 @@ export function MindMapCanvas({
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStart={() => useEditorStore.getState().pushHistory()}
|
||||
onNodeDragStart={(event) => {
|
||||
const store = useEditorStore.getState();
|
||||
const byDefault = store.map?.settings.dragMovesBranch === true;
|
||||
const altPressed = 'altKey' in event ? event.altKey : false;
|
||||
branchDragRef.current = byDefault !== altPressed;
|
||||
store.pushHistory();
|
||||
}}
|
||||
onNodeDrag={handleNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onNodeContextMenu={(event, node) => {
|
||||
@@ -276,8 +308,26 @@ export function MindMapCanvas({
|
||||
</ReactFlow>
|
||||
|
||||
{contextMenu ? (
|
||||
<NodeContextMenu state={contextMenu} onClose={() => setContextMenu(null)} />
|
||||
<NodeContextMenu
|
||||
state={contextMenu}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
|
||||
onMoveTo={(nodeId) => setMoveTarget(nodeId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AddChildrenDialog
|
||||
open={addChildrenFor !== null}
|
||||
parentId={addChildrenFor?.parentId ?? null}
|
||||
initialText={addChildrenFor?.text}
|
||||
onClose={() => setAddChildrenFor(null)}
|
||||
/>
|
||||
|
||||
<MoveToDialog
|
||||
open={moveTarget !== null}
|
||||
nodeId={moveTarget}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
124
frontend/src/features/editor/MoveToDialog.tsx
Normal file
124
frontend/src/features/editor/MoveToDialog.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { SearchIcon } from '@/components/Icons';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { computeDepths, getDescendantIds } from '@/utils/tree';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface MoveToDialogProps {
|
||||
open: boolean;
|
||||
nodeId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Choix d'un nouveau parent par recherche.
|
||||
*
|
||||
* Les cibles impossibles (le noeud lui-meme et ses descendants) sont exclues de
|
||||
* la liste : la detection de cycle de `reparentNode` reste la regle unique, ce
|
||||
* filtre ne fait qu'eviter de proposer un choix qui serait refuse.
|
||||
*/
|
||||
export function MoveToDialog({ open, nodeId, onClose }: MoveToDialogProps) {
|
||||
const map = useEditorStore((state) => state.map);
|
||||
const moveNodeToParent = useEditorStore((state) => state.moveNodeToParent);
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const node = map?.nodes.find((candidate) => candidate.id === nodeId);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (!map || !node) return [];
|
||||
const excluded = new Set([node.id, ...getDescendantIds(map.nodes, node.id)]);
|
||||
const depths = computeDepths(map.nodes);
|
||||
const needle = normalize(query.trim());
|
||||
|
||||
return map.nodes
|
||||
.filter((candidate) => !excluded.has(candidate.id) && candidate.id !== node.parentId)
|
||||
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
|
||||
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
|
||||
.slice(0, 60);
|
||||
}, [map, node, query]);
|
||||
|
||||
const move = (parentId: string | null) => {
|
||||
if (!node) return;
|
||||
if (moveNodeToParent(node.id, parentId)) {
|
||||
pushToast(
|
||||
parentId ? 'Branche deplacee.' : 'Noeud detache : il devient une racine.',
|
||||
'success',
|
||||
);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
pushToast('Deplacement impossible : cela creerait une boucle.', 'error');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Deplacer vers..."
|
||||
description={node ? `"${node.text || 'Sans titre'}" et sa branche changeront de parent.` : ''}
|
||||
onClose={onClose}
|
||||
footer={<Button onClick={onClose}>Annuler</Button>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
|
||||
<input
|
||||
autoFocus
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Rechercher un noeud parent"
|
||||
aria-label="Rechercher un noeud parent"
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(null)}
|
||||
className="w-full rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-left text-sm text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Detacher : en faire une racine
|
||||
</button>
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<p className="px-1 py-3 text-sm text-zinc-500">Aucun noeud ne correspond.</p>
|
||||
) : (
|
||||
<ul className="max-h-64 overflow-y-auto" aria-label="Nouveaux parents possibles">
|
||||
{candidates.map(({ node: candidate, depth }) => (
|
||||
<li key={candidate.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(candidate.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors',
|
||||
'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shrink-0 font-mono text-[11px] text-zinc-400"
|
||||
style={{ paddingLeft: depth * 10 }}
|
||||
>
|
||||
{depth === 0 ? '\u25cf' : '\u2514'}
|
||||
</span>
|
||||
<span className="truncate">{candidate.text || 'Sans titre'}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { getOrderedChildren } from '@/utils/tree';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface NodeContextMenuState {
|
||||
@@ -12,18 +13,32 @@ export interface NodeContextMenuState {
|
||||
export interface NodeContextMenuProps {
|
||||
state: NodeContextMenuState;
|
||||
onClose: () => void;
|
||||
onAddChildren: (parentId: string) => void;
|
||||
onMoveTo: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
/** Menu contextuel d'un noeud : actions de style et de mise en page. */
|
||||
export function NodeContextMenu({ state, onClose }: NodeContextMenuProps) {
|
||||
interface MenuEntry {
|
||||
kind: 'item';
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
interface MenuHeading {
|
||||
kind: 'heading';
|
||||
label: string;
|
||||
}
|
||||
|
||||
type MenuLine = MenuEntry | MenuHeading;
|
||||
|
||||
/** Menu contextuel d'un noeud, organise par famille d'action. */
|
||||
export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: NodeContextMenuProps) {
|
||||
const map = useEditorStore((store) => store.map);
|
||||
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
|
||||
const copyStyle = useEditorStore((store) => store.copyStyle);
|
||||
const pasteStyle = useEditorStore((store) => store.pasteStyle);
|
||||
const resetNodesStyle = useEditorStore((store) => store.resetNodesStyle);
|
||||
const fitNodesToContent = useEditorStore((store) => store.fitNodesToContent);
|
||||
const duplicateSelection = useEditorStore((store) => store.duplicateSelection);
|
||||
const deleteNodes = useEditorStore((store) => store.deleteNodes);
|
||||
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
|
||||
const hasNodeClipboard = useEditorStore((store) => (store.clipboard?.nodes.length ?? 0) > 0);
|
||||
const pushToast = useUiStore((store) => store.pushToast);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,70 +54,205 @@ export function NodeContextMenu({ state, onClose }: NodeContextMenuProps) {
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const targets = selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId];
|
||||
// Le clic droit agit sur la selection si le noeud vise en fait partie,
|
||||
// sinon sur ce seul noeud.
|
||||
const targets = useMemo(
|
||||
() => (selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId]),
|
||||
[selectedIds, state.nodeId],
|
||||
);
|
||||
|
||||
const items: Array<{
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
run: () => void;
|
||||
}> = [
|
||||
const lines = useMemo<MenuLine[]>(() => {
|
||||
const store = useEditorStore.getState();
|
||||
const node = map?.nodes.find((candidate) => candidate.id === state.nodeId);
|
||||
if (!node || !map) return [];
|
||||
|
||||
const siblings = getOrderedChildren(map.nodes, node.parentId);
|
||||
const position = siblings.findIndex((candidate) => candidate.id === node.id);
|
||||
const hasSiblings = siblings.length > 1;
|
||||
const hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id);
|
||||
const notify = (message: string) => pushToast(message, 'success');
|
||||
|
||||
return [
|
||||
{ kind: 'heading', label: 'Ajouter' },
|
||||
{ kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Ajouter plusieurs enfants...',
|
||||
hint: 'Maj+Entree',
|
||||
run: () => onAddChildren(node.id),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Ajouter avant',
|
||||
disabled: !node.parentId,
|
||||
run: () => store.addSiblingAt(node.id, 'before'),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Ajouter apres',
|
||||
hint: 'Entree',
|
||||
disabled: !node.parentId,
|
||||
run: () => store.addSiblingAt(node.id, 'after'),
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Branche' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Selectionner la branche',
|
||||
run: () => notify(`${store.selectBranch(node.id)} noeuds selectionnes.`),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Dupliquer la branche',
|
||||
run: () => notify(`Branche dupliquee (${store.duplicateBranch(node.id)} noeuds).`),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Copier la branche',
|
||||
run: () => notify(`${store.copyBranch(node.id)} noeuds copies.`),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Coller comme enfant',
|
||||
disabled: !hasNodeClipboard,
|
||||
run: () => notify(`${store.pasteAsChild(node.id)} noeuds colles.`),
|
||||
},
|
||||
{ kind: 'item', label: 'Deplacer vers...', run: () => onMoveTo(node.id) },
|
||||
{
|
||||
kind: 'item',
|
||||
label: node.collapsed ? 'Developper la branche' : 'Replier la branche',
|
||||
disabled: !hasChildren,
|
||||
run: () => store.setBranchCollapsed(node.id, !node.collapsed),
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Ordre' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Monter',
|
||||
hint: 'Alt+Haut',
|
||||
disabled: !hasSiblings || position <= 0,
|
||||
run: () => store.moveSibling(node.id, 'up'),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Descendre',
|
||||
hint: 'Alt+Bas',
|
||||
disabled: !hasSiblings || position >= siblings.length - 1,
|
||||
run: () => store.moveSibling(node.id, 'down'),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Placer en premier',
|
||||
disabled: !hasSiblings || position <= 0,
|
||||
run: () => store.moveSibling(node.id, 'first'),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Placer en dernier',
|
||||
disabled: !hasSiblings || position >= siblings.length - 1,
|
||||
run: () => store.moveSibling(node.id, 'last'),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Remonter d un niveau',
|
||||
hint: 'Maj+Tab',
|
||||
disabled: !node.parentId,
|
||||
run: () => store.outdentNode(node.id),
|
||||
},
|
||||
|
||||
{ kind: 'heading', label: 'Style' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Copier le style',
|
||||
hint: 'Ctrl+Maj+C',
|
||||
disabled: targets.length !== 1,
|
||||
run: () => {
|
||||
if (copyStyle()) pushToast('Style copie.', 'info');
|
||||
if (store.copyStyle()) pushToast('Style copie.', 'info');
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Coller le style',
|
||||
hint: 'Ctrl+Maj+V',
|
||||
disabled: !hasStyleClipboard,
|
||||
run: () => {
|
||||
const count = pasteStyle();
|
||||
if (count > 0) pushToast(`Style applique a ${count} noeud(s).`, 'success');
|
||||
run: () => notify(`Style applique a ${store.pasteStyle()} noeud(s).`),
|
||||
},
|
||||
{ kind: 'item', label: 'Reinitialiser le style', run: () => store.resetNodesStyle(targets) },
|
||||
{ kind: 'item', label: 'Adapter au contenu', run: () => store.fitNodesToContent(targets) },
|
||||
|
||||
{ kind: 'heading', label: 'Suppression' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Supprimer la branche',
|
||||
hint: 'Suppr',
|
||||
danger: true,
|
||||
run: () => store.deleteNodes(targets),
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Supprimer en conservant les enfants',
|
||||
danger: true,
|
||||
disabled: !hasChildren,
|
||||
run: () => store.deleteNodesKeepChildren(targets),
|
||||
},
|
||||
{ label: 'Reinitialiser le style', run: () => resetNodesStyle(targets) },
|
||||
{ label: 'Adapter au contenu', run: () => fitNodesToContent(targets) },
|
||||
{ label: 'Dupliquer', hint: 'Ctrl+D', run: () => duplicateSelection() },
|
||||
{ label: 'Supprimer', danger: true, run: () => deleteNodes(targets) },
|
||||
];
|
||||
}, [
|
||||
map,
|
||||
state.nodeId,
|
||||
targets,
|
||||
hasStyleClipboard,
|
||||
hasNodeClipboard,
|
||||
onAddChildren,
|
||||
onMoveTo,
|
||||
pushToast,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Actions du noeud"
|
||||
className="panel fixed z-50 min-w-[13rem] animate-fade-in p-1"
|
||||
className="panel fixed z-50 max-h-[80vh] min-w-[15rem] animate-fade-in overflow-y-auto p-1"
|
||||
style={{ left: state.x, top: state.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{items.map((item) => (
|
||||
{lines.map((line, index) =>
|
||||
line.kind === 'heading' ? (
|
||||
<div
|
||||
key={`heading-${line.label}`}
|
||||
className={cn(
|
||||
'px-2.5 pb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-400',
|
||||
index === 0 ? 'pt-1.5' : 'mt-1 border-t border-zinc-100 pt-2 dark:border-zinc-800',
|
||||
)}
|
||||
>
|
||||
{line.label}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
key={item.label}
|
||||
key={line.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
disabled={line.disabled}
|
||||
onClick={() => {
|
||||
item.run();
|
||||
line.run();
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
|
||||
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
item.danger
|
||||
line.danger
|
||||
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
|
||||
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.hint ? (
|
||||
<span className="text-[11px] uppercase tracking-wide text-zinc-400">{item.hint}</span>
|
||||
<span className="flex-1">{line.label}</span>
|
||||
{line.hint ? (
|
||||
<span className="shrink-0 text-[11px] uppercase tracking-wide text-zinc-400">
|
||||
{line.hint}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ShortcutHandlers {
|
||||
onSave: () => void;
|
||||
onFit: () => void;
|
||||
onFocusSearch: () => void;
|
||||
/** Ouvre le dialogue de creation multiple sur le parent donne. */
|
||||
onAddChildren: (parentId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,8 +81,15 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
|
||||
}
|
||||
return;
|
||||
case 'v':
|
||||
/*
|
||||
* Priorite au presse-papiers interne d'OpenMind. S'il est vide, on
|
||||
* laisse l'evenement `paste` du navigateur remonter : il porte le
|
||||
* texte du systeme et declenche la creation d'enfants.
|
||||
*/
|
||||
if ((store.clipboard?.nodes.length ?? 0) > 0) {
|
||||
event.preventDefault();
|
||||
store.paste();
|
||||
}
|
||||
return;
|
||||
case 'd':
|
||||
event.preventDefault();
|
||||
@@ -95,14 +104,34 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
|
||||
}
|
||||
}
|
||||
|
||||
// Alt + fleches : ordre du noeud parmi ses freres.
|
||||
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
const selected = store.selectedNodeIds[0];
|
||||
if (selected) {
|
||||
event.preventDefault();
|
||||
store.moveSibling(selected, event.key === 'ArrowUp' ? 'up' : 'down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Tab':
|
||||
event.preventDefault();
|
||||
// Maj+Tab remonte le noeud d'un niveau, Tab cree un enfant.
|
||||
if (event.shiftKey) {
|
||||
const selected = store.selectedNodeIds[0];
|
||||
if (selected) store.outdentNode(selected);
|
||||
} else {
|
||||
store.addChild();
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
handlersRef.current.onAddChildren(store.selectedNodeIds[0] ?? null);
|
||||
} else {
|
||||
store.addSibling();
|
||||
}
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
|
||||
text: partial.text,
|
||||
position: partial.position ?? { x: 0, y: 0 },
|
||||
parentId: partial.parentId ?? null,
|
||||
...(partial.order !== undefined ? { order: partial.order } : {}),
|
||||
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
|
||||
...(partial.icon ? { icon: partial.icon } : {}),
|
||||
...(partial.url ? { url: partial.url } : {}),
|
||||
|
||||
151
frontend/src/services/mapOrderSerialization.test.ts
Normal file
151
frontend/src/services/mapOrderSerialization.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
|
||||
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
|
||||
import { getOrderedChildren } from '@/utils/tree';
|
||||
|
||||
/** Carte v2 : pas de champ `order`, l'ordre est celui du tableau. */
|
||||
const V2_MAP = {
|
||||
version: 2,
|
||||
id: 'v2-map',
|
||||
name: 'Reseau v2',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
nodes: [
|
||||
{ id: 'root', type: 'root', text: 'Reseau', position: { x: 0, y: 0 }, parentId: null },
|
||||
{ id: 'dns', type: 'standard', text: 'DNS', position: { x: 200, y: 0 }, parentId: 'root' },
|
||||
{ id: 'dhcp', type: 'standard', text: 'DHCP', position: { x: 200, y: 60 }, parentId: 'root' },
|
||||
{ id: 'vlan', type: 'standard', text: 'VLAN', position: { x: 200, y: 120 }, parentId: 'root' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-root-dns', source: 'root', target: 'dns' },
|
||||
{ id: 'e-root-dhcp', source: 'root', target: 'dhcp' },
|
||||
{ id: 'e-root-vlan', source: 'root', target: 'vlan' },
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
|
||||
};
|
||||
|
||||
const V1_MAP = {
|
||||
...V2_MAP,
|
||||
version: 1,
|
||||
id: 'v1-map',
|
||||
nodes: V2_MAP.nodes.map((node) =>
|
||||
node.id === 'root' ? { ...node, style: { width: 240, textAlign: 'center' } } : node,
|
||||
),
|
||||
};
|
||||
|
||||
function texts(map: MindMap, parentId: string | null): string[] {
|
||||
return getOrderedChildren(map.nodes, parentId).map((node) => node.text);
|
||||
}
|
||||
|
||||
describe('migration vers la v3', () => {
|
||||
it('materialise l ordre d une carte v2 depuis la position dans le tableau', () => {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
|
||||
expect(map.version).toBe(MAP_FORMAT_VERSION);
|
||||
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
|
||||
expect(map.nodes.map((node) => node.order)).toEqual([0, 0, 1, 2]);
|
||||
});
|
||||
|
||||
it('migre une carte v1 jusqu a la v3 sans perdre la mise en page', () => {
|
||||
const map = normalizeMapDocument(V1_MAP);
|
||||
|
||||
expect(map.version).toBe(MAP_FORMAT_VERSION);
|
||||
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
|
||||
expect(map.nodes[0]?.layout?.width).toBe(240);
|
||||
expect(map.nodes[0]?.style?.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('respecte un ordre explicite plutot que la position dans le tableau', () => {
|
||||
const desordonne = {
|
||||
...V2_MAP,
|
||||
version: 3,
|
||||
nodes: [
|
||||
V2_MAP.nodes[0],
|
||||
{ ...V2_MAP.nodes[1], order: 2 },
|
||||
{ ...V2_MAP.nodes[2], order: 1 },
|
||||
{ ...V2_MAP.nodes[3], order: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(texts(normalizeMapDocument(desordonne), 'root')).toEqual(['VLAN', 'DHCP', 'DNS']);
|
||||
});
|
||||
|
||||
it('refuse une version plus recente', () => {
|
||||
expect(() => normalizeMapDocument({ ...V2_MAP, version: MAP_FORMAT_VERSION + 1 })).toThrow(
|
||||
/plus recente/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aller-retour de l ordre', () => {
|
||||
it('conserve un ordre reordonne apres export puis import', () => {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
const reordonne: MindMap = {
|
||||
...map,
|
||||
nodes: map.nodes.map((node) => {
|
||||
if (node.id === 'vlan') return { ...node, order: 0 };
|
||||
if (node.id === 'dns') return { ...node, order: 1 };
|
||||
if (node.id === 'dhcp') return { ...node, order: 2 };
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
|
||||
const restaure = normalizeMapDocument(JSON.parse(serializeMap(reordonne)));
|
||||
expect(texts(restaure, 'root')).toEqual(['VLAN', 'DNS', 'DHCP']);
|
||||
});
|
||||
|
||||
/** VLAN en tete, comme le ferait "Placer en premier". */
|
||||
function withVlanFirst(): MindMap {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
const ordres: Record<string, number> = { vlan: 0, dns: 1, dhcp: 2 };
|
||||
return {
|
||||
...map,
|
||||
nodes: map.nodes.map((node) =>
|
||||
ordres[node.id] === undefined ? node : { ...node, order: ordres[node.id] as number },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
it('ecrit les noeuds dans l ordre hierarchique', () => {
|
||||
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
|
||||
// Un lecteur anterieur a la v3, qui ignore `order`, retrouve le bon ordre.
|
||||
expect(serialise.nodes.map((node) => node.id)).toEqual(['root', 'vlan', 'dns', 'dhcp']);
|
||||
});
|
||||
|
||||
it('emet les liens dans l ordre de fratrie', () => {
|
||||
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
|
||||
expect(serialise.edges.map((edge) => edge.target)).toEqual(['vlan', 'dns', 'dhcp']);
|
||||
});
|
||||
|
||||
it('departage deux freres de meme rang par leur position dans le tableau', () => {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
const ambigu: MindMap = {
|
||||
...map,
|
||||
nodes: map.nodes.map((node) => (node.id === 'vlan' ? { ...node, order: 0 } : node)),
|
||||
};
|
||||
// `dns` et `vlan` ont tous deux le rang 0 : le tableau tranche, sans erreur.
|
||||
expect(texts(normalizeMapDocument(JSON.parse(serializeMap(ambigu))), 'root')).toEqual([
|
||||
'DNS',
|
||||
'VLAN',
|
||||
'DHCP',
|
||||
]);
|
||||
});
|
||||
|
||||
it('conserve l ordre d une arborescence sur plusieurs niveaux', () => {
|
||||
const map = normalizeMapDocument({
|
||||
version: 3,
|
||||
id: 'profond',
|
||||
nodes: [
|
||||
{ id: 'r', type: 'root', text: 'R', parentId: null, position: { x: 0, y: 0 } },
|
||||
{ id: 'a', text: 'A', parentId: 'r', order: 1, position: { x: 0, y: 0 } },
|
||||
{ id: 'b', text: 'B', parentId: 'r', order: 0, position: { x: 0, y: 0 } },
|
||||
{ id: 'a1', text: 'A1', parentId: 'a', order: 1, position: { x: 0, y: 0 } },
|
||||
{ id: 'a2', text: 'A2', parentId: 'a', order: 0, position: { x: 0, y: 0 } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(texts(map, 'r')).toEqual(['B', 'A']);
|
||||
expect(texts(map, 'a')).toEqual(['A2', 'A1']);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,12 @@ import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { SPACING_LIMITS } from '@/features/editor/layout';
|
||||
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
|
||||
import { createId } from '@/utils/id';
|
||||
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
|
||||
import {
|
||||
flattenInOrder,
|
||||
normalizeChildOrders,
|
||||
rebuildEdges,
|
||||
rebuildHierarchyFromEdges,
|
||||
} from '@/utils/tree';
|
||||
import { sanitizeUrl } from '@/utils/url';
|
||||
|
||||
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
|
||||
@@ -58,9 +63,16 @@ const LAYOUTS: LayoutKind[] = [
|
||||
'freeform',
|
||||
];
|
||||
|
||||
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
|
||||
/**
|
||||
* Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable).
|
||||
*
|
||||
* Les noeuds sont ecrits en parcours de profondeur, freres dans l'ordre : le
|
||||
* fichier reste lisible a l'oeil, et un lecteur anterieur a la v3, qui ignore
|
||||
* `order`, retrouve malgre tout le bon ordre via la position dans le tableau.
|
||||
*/
|
||||
export function serializeMap(map: MindMap): string {
|
||||
return JSON.stringify(map, null, 2);
|
||||
const nodes = flattenInOrder(normalizeChildOrders(map.nodes));
|
||||
return JSON.stringify({ ...map, nodes, edges: rebuildEdges(nodes) }, null, 2);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -235,6 +247,10 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
parentId: asString(value.parentId, 128) ?? null,
|
||||
};
|
||||
|
||||
if (typeof value.order === 'number' && Number.isFinite(value.order)) {
|
||||
node.order = Math.max(0, Math.round(value.order));
|
||||
}
|
||||
|
||||
if (value.collapsed === true) node.collapsed = true;
|
||||
|
||||
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
|
||||
@@ -282,6 +298,7 @@ function parseSettings(value: unknown): MapSettings {
|
||||
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
|
||||
if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid;
|
||||
if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides;
|
||||
if (typeof value.dragMovesBranch === 'boolean') settings.dragMovesBranch = value.dragMovesBranch;
|
||||
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
|
||||
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
|
||||
}
|
||||
@@ -329,6 +346,27 @@ function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknow
|
||||
return { ...document, nodes, version: 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 -> v3 : materialise l'ordre des freres depuis leur position dans le
|
||||
* tableau `nodes`, ce qui reproduit exactement l'affichage de la v2.
|
||||
*/
|
||||
function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!Array.isArray(document.nodes)) return { ...document, version: 3 };
|
||||
|
||||
const counters = new Map<string, number>();
|
||||
const nodes = document.nodes.map((rawNode) => {
|
||||
if (!isRecord(rawNode)) return rawNode;
|
||||
if (typeof rawNode.order === 'number') return rawNode;
|
||||
|
||||
const parentId = typeof rawNode.parentId === 'string' ? rawNode.parentId : '';
|
||||
const next = counters.get(parentId) ?? 0;
|
||||
counters.set(parentId, next + 1);
|
||||
return { ...rawNode, order: next };
|
||||
});
|
||||
|
||||
return { ...document, nodes, version: 3 };
|
||||
}
|
||||
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
let document = raw;
|
||||
let current = version;
|
||||
@@ -337,6 +375,10 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
|
||||
document = migrateV1ToV2(document);
|
||||
current = 2;
|
||||
}
|
||||
if (current === 2) {
|
||||
document = migrateV2ToV3(document);
|
||||
current = 3;
|
||||
}
|
||||
|
||||
if (current !== MAP_FORMAT_VERSION) {
|
||||
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
|
||||
@@ -457,14 +499,18 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
|
||||
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Ordre de fratrie dense et tableau range en profondeur : le document sortant
|
||||
// est toujours dans la meme forme canonique, quelle que soit sa provenance.
|
||||
const normalised = flattenInOrder(normalizeChildOrders(linked));
|
||||
|
||||
return {
|
||||
version: MAP_FORMAT_VERSION,
|
||||
id: asString(document.id, 128) ?? createId(),
|
||||
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
|
||||
createdAt: asString(document.createdAt, 40) ?? timestamp,
|
||||
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
|
||||
nodes: linked,
|
||||
edges: rebuildEdges(linked),
|
||||
nodes: normalised,
|
||||
edges: rebuildEdges(normalised),
|
||||
viewport: {
|
||||
x: asFiniteNumber(rawViewport.x, 0),
|
||||
y: asFiniteNumber(rawViewport.y, 0),
|
||||
|
||||
131
frontend/src/services/outlineParser.test.ts
Normal file
131
frontend/src/services/outlineParser.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
OutlineError,
|
||||
looksIndented,
|
||||
parseOutline,
|
||||
toFlatLines,
|
||||
} from '@/services/outlineParser';
|
||||
|
||||
describe('lecture d une liste simple', () => {
|
||||
it('produit un noeud par ligne', () => {
|
||||
const result = parseOutline('VLAN\nVPN\nFirewall');
|
||||
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.depth).toBe(1);
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall']);
|
||||
expect(result.roots.every((node) => node.children.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignore les lignes vides et les espaces superflus', () => {
|
||||
const result = parseOutline('VLAN\n\n \nVPN\n\n');
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
|
||||
});
|
||||
|
||||
it('retire les puces et les numerotations', () => {
|
||||
const result = parseOutline('- VLAN\n* VPN\n1. Firewall\n2) Switch');
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall', 'Switch']);
|
||||
});
|
||||
|
||||
it('refuse un texte sans ligne exploitable', () => {
|
||||
expect(() => parseOutline(' \n\n')).toThrow(OutlineError);
|
||||
});
|
||||
|
||||
it('applique la limite de noeuds', () => {
|
||||
const texte = Array.from({ length: 12 }, (_unused, index) => `N${index}`).join('\n');
|
||||
expect(() => parseOutline(texte, 5)).toThrow(/limite/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lecture d une arborescence indentee', () => {
|
||||
const OUTLINE = [
|
||||
'Infrastructure',
|
||||
' Reseau',
|
||||
' VLAN',
|
||||
' VPN',
|
||||
' Firewall',
|
||||
' Virtualisation',
|
||||
' Proxmox',
|
||||
' Docker',
|
||||
' Services',
|
||||
' GLPI',
|
||||
' Grafana',
|
||||
].join('\n');
|
||||
|
||||
it('reconstruit la hierarchie complete', () => {
|
||||
const result = parseOutline(OUTLINE);
|
||||
|
||||
expect(result.count).toBe(11);
|
||||
expect(result.depth).toBe(3);
|
||||
expect(result.roots).toHaveLength(1);
|
||||
|
||||
const racine = result.roots[0];
|
||||
expect(racine?.text).toBe('Infrastructure');
|
||||
expect(racine?.children.map((node) => node.text)).toEqual([
|
||||
'Reseau',
|
||||
'Virtualisation',
|
||||
'Services',
|
||||
]);
|
||||
expect(racine?.children[0]?.children.map((node) => node.text)).toEqual([
|
||||
'VLAN',
|
||||
'VPN',
|
||||
'Firewall',
|
||||
]);
|
||||
expect(racine?.children[2]?.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('accepte les tabulations', () => {
|
||||
const result = parseOutline('Reseau\n\tVLAN\n\tVPN\n\t\tSite A');
|
||||
|
||||
expect(result.roots[0]?.children.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
|
||||
expect(result.roots[0]?.children[1]?.children[0]?.text).toBe('Site A');
|
||||
});
|
||||
|
||||
it('accepte plusieurs racines', () => {
|
||||
const result = parseOutline('Reseau\n VLAN\nServices\n GLPI');
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['Reseau', 'Services']);
|
||||
});
|
||||
|
||||
it('tolere un texte entierement indente', () => {
|
||||
const result = parseOutline(' Reseau\n VLAN');
|
||||
expect(result.roots[0]?.text).toBe('Reseau');
|
||||
expect(result.roots[0]?.children[0]?.text).toBe('VLAN');
|
||||
});
|
||||
|
||||
it('refuse une indentation incoherente', () => {
|
||||
// "C" ne correspond a aucun niveau ouvert : ni 0, ni 4.
|
||||
expect(() => parseOutline('A\n B\n C')).toThrow(/incoherente/i);
|
||||
});
|
||||
|
||||
it('refuse une ligne moins indentee que la premiere', () => {
|
||||
expect(() => parseOutline(' A\nB')).toThrow(/moins indentee/i);
|
||||
});
|
||||
|
||||
it('refuse une arborescence trop profonde', () => {
|
||||
const profond = Array.from({ length: 25 }, (_unused, index) =>
|
||||
`${' '.repeat(index * 2)}N${index}`,
|
||||
).join('\n');
|
||||
expect(() => parseOutline(profond)).toThrow(/profonde/i);
|
||||
});
|
||||
|
||||
it('signale la ligne fautive', () => {
|
||||
try {
|
||||
parseOutline('A\n B\n C');
|
||||
expect.unreachable('une erreur etait attendue');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(OutlineError);
|
||||
expect((error as OutlineError).line).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detection du mode', () => {
|
||||
it('reconnait un texte indente', () => {
|
||||
expect(looksIndented('A\n B')).toBe(true);
|
||||
expect(looksIndented('A\n\tB')).toBe(true);
|
||||
expect(looksIndented('A\nB')).toBe(false);
|
||||
});
|
||||
|
||||
it('extrait les lignes utiles', () => {
|
||||
expect(toFlatLines('- A\n\n \n2. B\r\nC')).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
173
frontend/src/services/outlineParser.ts
Normal file
173
frontend/src/services/outlineParser.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { APP_CONFIG } from '@/config/app';
|
||||
|
||||
/**
|
||||
* Lecture d'une arborescence indentee collee par l'utilisateur.
|
||||
*
|
||||
* Volontairement limite a un "outline" : une ligne = un noeud, l'indentation
|
||||
* donne la profondeur. Ce n'est pas un lecteur Markdown ; seules les puces de
|
||||
* liste les plus courantes sont retirees pour que coller une liste a puces
|
||||
* fonctionne sans retouche.
|
||||
*/
|
||||
|
||||
export interface OutlineNode {
|
||||
text: string;
|
||||
children: OutlineNode[];
|
||||
}
|
||||
|
||||
export interface OutlineResult {
|
||||
roots: OutlineNode[];
|
||||
/** Nombre total de noeuds, tous niveaux confondus. */
|
||||
count: number;
|
||||
/** Profondeur maximale, la racine comptant pour 1. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Erreur d'analyse, deja formulee pour l'utilisateur. */
|
||||
export class OutlineError extends Error {
|
||||
readonly line: number;
|
||||
|
||||
constructor(message: string, line: number) {
|
||||
super(message);
|
||||
this.name = 'OutlineError';
|
||||
this.line = line;
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_WIDTH = 4;
|
||||
const MAX_DEPTH = 20;
|
||||
const MAX_TEXT_LENGTH = 500;
|
||||
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
|
||||
const NUMBERED = /^\d{1,3}[.)]\s+/;
|
||||
|
||||
function indentWidth(line: string): number {
|
||||
let width = 0;
|
||||
for (const character of line) {
|
||||
if (character === ' ') width += 1;
|
||||
else if (character === '\t') width += TAB_WIDTH;
|
||||
else break;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function cleanText(line: string): string {
|
||||
return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse un texte indente.
|
||||
*
|
||||
* Une indentation incoherente (un niveau qui ne correspond a aucun niveau
|
||||
* ouvert) est signalee plutot que devinee : mieux vaut un message clair qu'une
|
||||
* hierarchie absurde.
|
||||
*/
|
||||
export function parseOutline(text: string, maxNodes: number = APP_CONFIG.maxNodes): OutlineResult {
|
||||
const rawLines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
|
||||
const entries: Array<{ indent: number; text: string; line: number }> = [];
|
||||
rawLines.forEach((raw, index) => {
|
||||
if (raw.trim().length === 0) return; // les lignes vides sont ignorees
|
||||
const content = cleanText(raw);
|
||||
if (content.length === 0) return;
|
||||
entries.push({ indent: indentWidth(raw), text: content, line: index + 1 });
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
throw new OutlineError('Aucune ligne exploitable dans le texte fourni.', 0);
|
||||
}
|
||||
if (entries.length > maxNodes) {
|
||||
throw new OutlineError(
|
||||
`Ce texte produirait ${entries.length} noeuds : la limite est de ${maxNodes}.`,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const base = entries[0]?.indent ?? 0;
|
||||
const roots: OutlineNode[] = [];
|
||||
// Pile des niveaux ouverts : largeur d'indentation -> noeud parent.
|
||||
const stack: Array<{ indent: number; node: OutlineNode | null }> = [{ indent: base, node: null }];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.indent < base) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : elle est moins indentee que la premiere ligne.`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
|
||||
// On referme les niveaux plus profonds que la ligne courante.
|
||||
let closedLevels = false;
|
||||
while (
|
||||
stack.length > 1 &&
|
||||
entry.indent < (stack[stack.length - 1] as { indent: number }).indent
|
||||
) {
|
||||
stack.pop();
|
||||
closedLevels = true;
|
||||
}
|
||||
|
||||
const top = stack[stack.length - 1] as { indent: number; node: OutlineNode | null };
|
||||
const node: OutlineNode = { text: entry.text, children: [] };
|
||||
|
||||
if (entry.indent === top.indent) {
|
||||
const parent =
|
||||
stack.length > 1 ? (stack[stack.length - 2] as { node: OutlineNode | null }).node : null;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
stack[stack.length - 1] = { indent: entry.indent, node };
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ici l'indentation est superieure au niveau courant. Si l'on vient de
|
||||
* refermer des niveaux, c'est qu'elle ne correspond a aucun niveau ouvert :
|
||||
* l'outline est incoherent, on le signale plutot que de deviner.
|
||||
*/
|
||||
if (closedLevels) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : indentation incoherente avec les lignes precedentes.`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
if (!top.node) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : indentation inattendue, aucun element parent au-dessus.`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
if (stack.length >= MAX_DEPTH) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : arborescence trop profonde (maximum ${MAX_DEPTH} niveaux).`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
|
||||
top.node.children.push(node);
|
||||
stack.push({ indent: entry.indent, node });
|
||||
}
|
||||
|
||||
return { roots, count: entries.length, depth: measureDepth(roots) };
|
||||
}
|
||||
|
||||
function measureDepth(nodes: OutlineNode[]): number {
|
||||
let deepest = 0;
|
||||
for (const node of nodes) {
|
||||
deepest = Math.max(deepest, 1 + measureDepth(node.children));
|
||||
}
|
||||
return deepest;
|
||||
}
|
||||
|
||||
/** Vrai si le texte contient au moins une indentation exploitable. */
|
||||
export function looksIndented(text: string): boolean {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.some((line) => line.trim().length > 0 && /^[ \t]+/.test(line));
|
||||
}
|
||||
|
||||
/** Lignes non vides d'un texte, sans puce ni numerotation. */
|
||||
export function toFlatLines(text: string): string[] {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map(cleanText)
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
213
frontend/src/stores/editorBranch.test.ts
Normal file
213
frontend/src/stores/editorBranch.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { getDescendantIds, getHiddenNodeIds, getOrderedChildren } from '@/utils/tree';
|
||||
|
||||
function store() {
|
||||
return useEditorStore.getState();
|
||||
}
|
||||
|
||||
function rootId(): string {
|
||||
return store().map?.nodes[0]?.id as string;
|
||||
}
|
||||
|
||||
function idOf(text: string): string {
|
||||
return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
|
||||
}
|
||||
|
||||
function childTexts(parentId: string): string[] {
|
||||
return getOrderedChildren(store().map?.nodes ?? [], parentId).map((node) => node.text);
|
||||
}
|
||||
|
||||
/** Serveurs > Production > (Web > nginx, DB), et Recette. */
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().loadMap(createEmptyMap('Serveurs'));
|
||||
const initial = useEditorStore.getState();
|
||||
initial.addChildren(rootId(), ['Production', 'Recette']);
|
||||
initial.addChildren(idOf('Production'), ['Web', 'DB']);
|
||||
initial.addChildren(idOf('Web'), ['nginx']);
|
||||
});
|
||||
|
||||
describe('selection d une branche', () => {
|
||||
it('selectionne le noeud et tous ses descendants', () => {
|
||||
expect(store().selectBranch(idOf('Production'))).toBe(4);
|
||||
const selection = new Set(store().selectedNodeIds);
|
||||
|
||||
expect(selection.has(idOf('Production'))).toBe(true);
|
||||
expect(selection.has(idOf('nginx'))).toBe(true);
|
||||
expect(selection.has(idOf('Recette'))).toBe(false);
|
||||
});
|
||||
|
||||
it('permet d appliquer un style a toute la branche', () => {
|
||||
store().selectBranch(idOf('Production'));
|
||||
store().updateNodesStyle(store().selectedNodeIds, { backgroundColor: '#123456' });
|
||||
|
||||
for (const text of ['Production', 'Web', 'DB', 'nginx']) {
|
||||
expect(store().map?.nodes.find((node) => node.text === text)?.style?.backgroundColor).toBe(
|
||||
'#123456',
|
||||
);
|
||||
}
|
||||
expect(store().map?.nodes.find((node) => node.text === 'Recette')?.style).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplication d une branche', () => {
|
||||
it('copie tout le sous-arbre au meme niveau', () => {
|
||||
expect(store().duplicateBranch(idOf('Production'))).toBe(4);
|
||||
|
||||
expect(childTexts(rootId())).toEqual(['Production', 'Recette', 'Production copie']);
|
||||
const copie = idOf('Production copie');
|
||||
expect(childTexts(copie)).toEqual(['Web', 'DB']);
|
||||
expect(getDescendantIds(store().map?.nodes ?? [], copie)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('genere des identifiants uniques', () => {
|
||||
store().duplicateBranch(idOf('Production'));
|
||||
const ids = (store().map?.nodes ?? []).map((node) => node.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('conserve les styles', () => {
|
||||
store().updateNodesStyle([idOf('DB')], { backgroundColor: '#abcdef' });
|
||||
store().duplicateBranch(idOf('Production'));
|
||||
|
||||
const copie = idOf('Production copie');
|
||||
const dbCopie = getOrderedChildren(store().map?.nodes ?? [], copie)[1];
|
||||
expect(dbCopie?.style?.backgroundColor).toBe('#abcdef');
|
||||
});
|
||||
|
||||
it('ne cree qu une seule entree d historique', () => {
|
||||
const before = store().past.length;
|
||||
store().duplicateBranch(idOf('Production'));
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
store().undo();
|
||||
expect(childTexts(rootId())).toEqual(['Production', 'Recette']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copier / coller une branche', () => {
|
||||
it('colle le sous-arbre complet sous un autre parent', () => {
|
||||
expect(store().copyBranch(idOf('Production'))).toBe(4);
|
||||
expect(store().pasteAsChild(idOf('Recette'))).toBe(4);
|
||||
|
||||
expect(childTexts(idOf('Recette'))).toEqual(['Production']);
|
||||
const collee = getOrderedChildren(store().map?.nodes ?? [], idOf('Recette'))[0];
|
||||
expect(getDescendantIds(store().map?.nodes ?? [], collee?.id as string)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ne modifie pas la branche d origine', () => {
|
||||
store().copyBranch(idOf('Production'));
|
||||
store().pasteAsChild(idOf('Recette'));
|
||||
expect(childTexts(idOf('Production'))).toEqual(['Web', 'DB']);
|
||||
});
|
||||
|
||||
it('ne cree qu une entree d historique et s annule d un coup', () => {
|
||||
store().copyBranch(idOf('Production'));
|
||||
const before = store().past.length;
|
||||
store().pasteAsChild(idOf('Recette'));
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
store().undo();
|
||||
expect(childTexts(idOf('Recette'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repli et depli', () => {
|
||||
it('replie un noeud et tous ses descendants', () => {
|
||||
store().setBranchCollapsed(idOf('Production'), true);
|
||||
const nodes = store().map?.nodes ?? [];
|
||||
|
||||
expect(nodes.find((node) => node.text === 'Production')?.collapsed).toBe(true);
|
||||
expect(nodes.find((node) => node.text === 'Web')?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('replie puis deplie toute la carte', () => {
|
||||
store().setAllCollapsed(true);
|
||||
expect(getHiddenNodeIds(store().map?.nodes ?? []).size).toBeGreaterThan(0);
|
||||
|
||||
store().setAllCollapsed(false);
|
||||
expect(getHiddenNodeIds(store().map?.nodes ?? []).size).toBe(0);
|
||||
});
|
||||
|
||||
it('replie au-dela d un niveau donne', () => {
|
||||
store().collapseToLevel(2);
|
||||
const nodes = store().map?.nodes ?? [];
|
||||
|
||||
expect(nodes.find((node) => node.id === rootId())?.collapsed).toBe(false);
|
||||
expect(nodes.find((node) => node.text === 'Production')?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('ne cree qu une entree d historique pour tout replier', () => {
|
||||
const before = store().past.length;
|
||||
store().setAllCollapsed(true);
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
store().undo();
|
||||
expect(getHiddenNodeIds(store().map?.nodes ?? []).size).toBe(0);
|
||||
});
|
||||
|
||||
it('rend visible un resultat de recherche situe dans une branche repliee', () => {
|
||||
store().setAllCollapsed(true);
|
||||
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('nginx'))).toBe(true);
|
||||
|
||||
store().setSearchQuery('nginx');
|
||||
expect(store().searchResults).toContain(idOf('nginx'));
|
||||
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('nginx'))).toBe(false);
|
||||
expect(store().selectedNodeIds).toEqual([idOf('nginx')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deplacement de branche sur le canvas', () => {
|
||||
it('entraine les descendants en conservant leurs positions relatives', () => {
|
||||
const avant = (store().map?.nodes ?? []).map((node) => ({ id: node.id, ...node.position }));
|
||||
const production = avant.find((node) => node.id === idOf('Production')) as {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
store().pushHistory();
|
||||
store().moveNodes(
|
||||
[{ id: idOf('Production'), position: { x: production.x + 100, y: production.y + 50 } }],
|
||||
{ withDescendants: true },
|
||||
);
|
||||
|
||||
for (const text of ['Web', 'DB', 'nginx']) {
|
||||
const id = idOf(text);
|
||||
const source = avant.find((node) => node.id === id) as { x: number; y: number };
|
||||
expect(store().map?.nodes.find((node) => node.id === id)?.position).toEqual({
|
||||
x: source.x + 100,
|
||||
y: source.y + 50,
|
||||
});
|
||||
}
|
||||
|
||||
const recette = avant.find((node) => node.id === idOf('Recette')) as { x: number };
|
||||
expect(store().map?.nodes.find((node) => node.id === idOf('Recette'))?.position.x).toBe(
|
||||
recette.x,
|
||||
);
|
||||
});
|
||||
|
||||
it('ne deplace que le noeud sans l option', () => {
|
||||
const webAvant = store().map?.nodes.find((node) => node.text === 'Web')?.position;
|
||||
store().moveNodes([{ id: idOf('Production'), position: { x: 999, y: 999 } }]);
|
||||
|
||||
expect(store().map?.nodes.find((node) => node.text === 'Web')?.position).toEqual(webAvant);
|
||||
});
|
||||
|
||||
it('annule tout un deplacement de branche en une fois', () => {
|
||||
const avant = store().map?.nodes.find((node) => node.text === 'nginx')?.position;
|
||||
const production = store().map?.nodes.find((node) => node.text === 'Production')
|
||||
?.position as { x: number; y: number };
|
||||
|
||||
store().pushHistory();
|
||||
store().moveNodes([{ id: idOf('Production'), position: { x: production.x + 60, y: production.y } }], {
|
||||
withDescendants: true,
|
||||
});
|
||||
store().moveNodes([{ id: idOf('Production'), position: { x: production.x + 120, y: production.y } }], {
|
||||
withDescendants: true,
|
||||
});
|
||||
|
||||
store().undo();
|
||||
expect(store().map?.nodes.find((node) => node.text === 'nginx')?.position).toEqual(avant);
|
||||
});
|
||||
});
|
||||
143
frontend/src/stores/editorCreation.test.ts
Normal file
143
frontend/src/stores/editorCreation.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { parseOutline } from '@/services/outlineParser';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { getOrderedChildren } from '@/utils/tree';
|
||||
|
||||
function store() {
|
||||
return useEditorStore.getState();
|
||||
}
|
||||
|
||||
function rootId(): string {
|
||||
return store().map?.nodes[0]?.id as string;
|
||||
}
|
||||
|
||||
function childTexts(parentId: string): string[] {
|
||||
return getOrderedChildren(store().map?.nodes ?? [], parentId).map((node) => node.text);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().loadMap(createEmptyMap('Creation rapide'));
|
||||
});
|
||||
|
||||
describe('ajout de plusieurs enfants', () => {
|
||||
it('cree un enfant par ligne, dans l ordre', () => {
|
||||
const created = store().addChildren(rootId(), ['VLAN', 'VPN', 'Firewall']);
|
||||
|
||||
expect(created).toBe(3);
|
||||
expect(childTexts(rootId())).toEqual(['VLAN', 'VPN', 'Firewall']);
|
||||
});
|
||||
|
||||
it('ignore les lignes vides', () => {
|
||||
expect(store().addChildren(rootId(), ['VLAN', '', ' ', 'VPN'])).toBe(2);
|
||||
expect(childTexts(rootId())).toEqual(['VLAN', 'VPN']);
|
||||
});
|
||||
|
||||
it('ne cree qu une seule entree d historique', () => {
|
||||
const before = store().past.length;
|
||||
store().addChildren(rootId(), ['A', 'B', 'C', 'D', 'E', 'F', 'G']);
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
store().undo();
|
||||
expect(store().map?.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('repartit les nouveaux noeuds au lieu de les superposer', () => {
|
||||
store().addChildren(rootId(), ['A', 'B', 'C']);
|
||||
const positions = getOrderedChildren(store().map?.nodes ?? [], rootId()).map(
|
||||
(node) => `${node.position.x}:${node.position.y}`,
|
||||
);
|
||||
expect(new Set(positions).size).toBe(3);
|
||||
});
|
||||
|
||||
it('se place a la suite des enfants existants', () => {
|
||||
store().addChildren(rootId(), ['A', 'B']);
|
||||
store().addChildren(rootId(), ['C']);
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('deplie le parent replie', () => {
|
||||
store().addChildren(rootId(), ['A']);
|
||||
store().toggleCollapse(rootId());
|
||||
expect(store().map?.nodes[0]?.collapsed).toBe(true);
|
||||
|
||||
store().addChildren(rootId(), ['B']);
|
||||
expect(store().map?.nodes.find((node) => node.id === rootId())?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('ne fait rien sans ligne exploitable', () => {
|
||||
const before = store().past.length;
|
||||
expect(store().addChildren(rootId(), ['', ' '])).toBe(0);
|
||||
expect(store().past.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import d une arborescence indentee', () => {
|
||||
const OUTLINE = [
|
||||
'Reseau',
|
||||
' VLAN',
|
||||
' VPN',
|
||||
'Virtualisation',
|
||||
' Proxmox',
|
||||
' VM',
|
||||
' LXC',
|
||||
' Docker',
|
||||
].join('\n');
|
||||
|
||||
it('reproduit la hierarchie sous le parent', () => {
|
||||
const { roots } = parseOutline(OUTLINE);
|
||||
const created = store().insertOutline(rootId(), roots);
|
||||
|
||||
expect(created).toBe(8);
|
||||
expect(childTexts(rootId())).toEqual(['Reseau', 'Virtualisation']);
|
||||
|
||||
const nodes = store().map?.nodes ?? [];
|
||||
const reseau = nodes.find((node) => node.text === 'Reseau') as { id: string };
|
||||
expect(childTexts(reseau.id)).toEqual(['VLAN', 'VPN']);
|
||||
|
||||
const proxmox = nodes.find((node) => node.text === 'Proxmox') as { id: string };
|
||||
expect(childTexts(proxmox.id)).toEqual(['VM', 'LXC']);
|
||||
});
|
||||
|
||||
it('ne cree qu une seule entree d historique', () => {
|
||||
const { roots } = parseOutline(OUTLINE);
|
||||
const before = store().past.length;
|
||||
store().insertOutline(rootId(), roots);
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
store().undo();
|
||||
expect(store().map?.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('genere des identifiants tous distincts', () => {
|
||||
const { roots } = parseOutline(OUTLINE);
|
||||
store().insertOutline(rootId(), roots);
|
||||
|
||||
const ids = (store().map?.nodes ?? []).map((node) => node.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ajout avant et apres', () => {
|
||||
it('insere un frere a la bonne place', () => {
|
||||
store().addChildren(rootId(), ['A', 'B', 'C']);
|
||||
const b = getOrderedChildren(store().map?.nodes ?? [], rootId())[1] as { id: string };
|
||||
|
||||
const avant = store().addSiblingAt(b.id, 'before') as string;
|
||||
store().updateNodeText(avant, 'AvantB');
|
||||
expect(childTexts(rootId())).toEqual(['A', 'AvantB', 'B', 'C']);
|
||||
|
||||
const apres = store().addSiblingAt(b.id, 'after') as string;
|
||||
store().updateNodeText(apres, 'ApresB');
|
||||
expect(childTexts(rootId())).toEqual(['A', 'AvantB', 'B', 'ApresB', 'C']);
|
||||
});
|
||||
|
||||
it('ouvre directement l edition du nouveau noeud', () => {
|
||||
store().addChildren(rootId(), ['A']);
|
||||
const a = getOrderedChildren(store().map?.nodes ?? [], rootId())[0] as { id: string };
|
||||
const nouveau = store().addSiblingAt(a.id, 'after');
|
||||
|
||||
expect(store().editingNodeId).toBe(nouveau);
|
||||
expect(store().selectedNodeIds).toEqual([nouveau]);
|
||||
});
|
||||
});
|
||||
144
frontend/src/stores/editorHierarchy.test.ts
Normal file
144
frontend/src/stores/editorHierarchy.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { getOrderedChildren } from '@/utils/tree';
|
||||
|
||||
function store() {
|
||||
return useEditorStore.getState();
|
||||
}
|
||||
|
||||
function rootId(): string {
|
||||
return store().map?.nodes[0]?.id as string;
|
||||
}
|
||||
|
||||
function childTexts(parentId: string | null): string[] {
|
||||
return getOrderedChildren(store().map?.nodes ?? [], parentId).map((node) => node.text);
|
||||
}
|
||||
|
||||
function idOf(text: string): string {
|
||||
return (store().map?.nodes.find((node) => node.text === text) as { id: string }).id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().loadMap(createEmptyMap('Hierarchie'));
|
||||
useEditorStore.getState().addChildren(rootId(), ['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
describe('ordre des freres', () => {
|
||||
it('attribue un ordre dense a la creation', () => {
|
||||
const orders = getOrderedChildren(store().map?.nodes ?? [], rootId()).map(
|
||||
(node) => node.order,
|
||||
);
|
||||
expect(orders).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('monte un noeud d un cran', () => {
|
||||
store().moveSibling(idOf('C'), 'up');
|
||||
expect(childTexts(rootId())).toEqual(['A', 'C', 'B', 'D']);
|
||||
});
|
||||
|
||||
it('descend un noeud d un cran', () => {
|
||||
store().moveSibling(idOf('B'), 'down');
|
||||
expect(childTexts(rootId())).toEqual(['A', 'C', 'B', 'D']);
|
||||
});
|
||||
|
||||
it('place un noeud en premier et en dernier', () => {
|
||||
store().moveSibling(idOf('D'), 'first');
|
||||
expect(childTexts(rootId())).toEqual(['D', 'A', 'B', 'C']);
|
||||
|
||||
store().moveSibling(idOf('D'), 'last');
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('ne bouge pas aux extremites', () => {
|
||||
const before = store().past.length;
|
||||
store().moveSibling(idOf('A'), 'up');
|
||||
store().moveSibling(idOf('D'), 'down');
|
||||
expect(store().past.length).toBe(before);
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('cree une seule entree d historique par deplacement', () => {
|
||||
const before = store().past.length;
|
||||
store().moveSibling(idOf('C'), 'first');
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
store().undo();
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('conserve l ordre a travers un ajout ulterieur', () => {
|
||||
store().moveSibling(idOf('D'), 'first');
|
||||
store().addChildren(rootId(), ['E']);
|
||||
expect(childTexts(rootId())).toEqual(['D', 'A', 'B', 'C', 'E']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-parentage', () => {
|
||||
it('deplace un noeud sous un autre parent', () => {
|
||||
expect(store().moveNodeToParent(idOf('D'), idOf('A'))).toBe(true);
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'C']);
|
||||
expect(childTexts(idOf('A'))).toEqual(['D']);
|
||||
});
|
||||
|
||||
it('refuse un deplacement qui creerait une boucle', () => {
|
||||
store().moveNodeToParent(idOf('B'), idOf('A'));
|
||||
expect(store().moveNodeToParent(idOf('A'), idOf('B'))).toBe(false);
|
||||
expect(childTexts(idOf('A'))).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('detache un noeud en le placant a la racine', () => {
|
||||
expect(store().moveNodeToParent(idOf('B'), null)).toBe(true);
|
||||
expect(store().map?.nodes.find((node) => node.text === 'B')?.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('remonte un noeud d un niveau juste apres son parent', () => {
|
||||
store().moveNodeToParent(idOf('D'), idOf('B'));
|
||||
expect(store().outdentNode(idOf('D'))).toBe(true);
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'D', 'C']);
|
||||
});
|
||||
|
||||
it('ne remonte pas une racine', () => {
|
||||
expect(store().outdentNode(rootId())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suppression en conservant les enfants', () => {
|
||||
beforeEach(() => {
|
||||
store().addChildren(idOf('B'), ['B1', 'B2']);
|
||||
});
|
||||
|
||||
it('rattache les enfants au grand-parent, a la place du noeud supprime', () => {
|
||||
store().deleteNodesKeepChildren([idOf('B')]);
|
||||
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B1', 'B2', 'C', 'D']);
|
||||
expect(store().map?.nodes.some((node) => node.text === 'B')).toBe(false);
|
||||
});
|
||||
|
||||
it('cree une seule entree d historique', () => {
|
||||
const before = store().past.length;
|
||||
store().deleteNodesKeepChildren([idOf('B')]);
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
store().undo();
|
||||
expect(childTexts(rootId())).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('promeut les enfants d une racine supprimee au rang de racines', () => {
|
||||
const map = store().map;
|
||||
const racine = map?.nodes.find((node) => node.id === rootId());
|
||||
expect(racine).toBeDefined();
|
||||
|
||||
store().deleteNodesKeepChildren([rootId()]);
|
||||
expect(childTexts(null)).toEqual(['A', 'B', 'C', 'D']);
|
||||
expect(store().map?.nodes.every((node) => node.parentId === null || node.text !== 'A')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('supprime toute la branche avec la suppression classique', () => {
|
||||
store().deleteNodes([idOf('B')]);
|
||||
expect(store().map?.nodes.some((node) => node.text.startsWith('B'))).toBe(false);
|
||||
expect(childTexts(rootId())).toEqual(['A', 'C', 'D']);
|
||||
});
|
||||
});
|
||||
@@ -21,12 +21,20 @@ import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { createId } from '@/utils/id';
|
||||
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
|
||||
import {
|
||||
buildChildrenMap,
|
||||
buildNodeMap,
|
||||
computeDepths,
|
||||
getDescendantIds,
|
||||
getOrderedChildren,
|
||||
nextSiblingOrder,
|
||||
normalizeChildOrders,
|
||||
rebuildEdges,
|
||||
suggestChildPosition,
|
||||
suggestChildPositions,
|
||||
withDescendants,
|
||||
wouldCreateCycle,
|
||||
} from '@/utils/tree';
|
||||
import { type OutlineNode } from '@/services/outlineParser';
|
||||
|
||||
export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
||||
|
||||
@@ -55,6 +63,7 @@ export interface NodeStyleSnapshot {
|
||||
|
||||
export type AlignAxis = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
|
||||
export type DistributeAxis = 'horizontal' | 'vertical';
|
||||
export type SiblingMove = 'up' | 'down' | 'first' | 'last';
|
||||
|
||||
/** Rectangle occupe par un noeud, taille reelle comprise. */
|
||||
interface NodeBox {
|
||||
@@ -104,7 +113,26 @@ interface EditorState {
|
||||
|
||||
addChild: (parentId?: string | null) => string | null;
|
||||
addSibling: (nodeId?: string) => string | null;
|
||||
/** Cree plusieurs enfants d'un coup. Une seule entree d'historique. */
|
||||
addChildren: (parentId: string | null, texts: string[]) => number;
|
||||
/** Insere une arborescence indentee sous un parent. Une seule entree. */
|
||||
insertOutline: (parentId: string | null, roots: OutlineNode[]) => number;
|
||||
/** Insere un frere juste avant ou juste apres un noeud. */
|
||||
addSiblingAt: (nodeId: string, position: 'before' | 'after') => string | null;
|
||||
moveSibling: (id: string, direction: SiblingMove) => void;
|
||||
/** Remonte un noeud d'un niveau : il devient le frere de son parent. */
|
||||
outdentNode: (id: string) => boolean;
|
||||
moveNodeToParent: (childId: string, parentId: string | null) => boolean;
|
||||
deleteNodes: (ids: string[]) => void;
|
||||
/** Supprime des noeuds en rattachant leurs enfants au grand-parent. */
|
||||
deleteNodesKeepChildren: (ids: string[]) => void;
|
||||
selectBranch: (id: string) => number;
|
||||
duplicateBranch: (id: string) => number;
|
||||
copyBranch: (id: string) => number;
|
||||
pasteAsChild: (parentId: string) => number;
|
||||
setBranchCollapsed: (id: string, collapsed: boolean) => void;
|
||||
setAllCollapsed: (collapsed: boolean) => void;
|
||||
collapseToLevel: (level: number) => void;
|
||||
updateNodeText: (id: string, text: string) => void;
|
||||
updateNode: (id: string, patch: Partial<Omit<MindMapNode, 'id'>>) => void;
|
||||
updateNodeStyle: (id: string, patch: NodeStyle) => void;
|
||||
@@ -115,7 +143,10 @@ interface EditorState {
|
||||
setNodesIcon: (ids: string[], icon: string | undefined, kind?: NodeIconStyle['kind']) => void;
|
||||
resetNodesStyle: (ids: string[]) => void;
|
||||
fitNodesToContent: (ids: string[]) => void;
|
||||
moveNodes: (updates: Array<{ id: string; position: Position }>) => void;
|
||||
moveNodes: (
|
||||
updates: Array<{ id: string; position: Position }>,
|
||||
options?: { withDescendants?: boolean },
|
||||
) => void;
|
||||
beginResize: () => void;
|
||||
resizeNode: (
|
||||
id: string,
|
||||
@@ -163,8 +194,16 @@ function normalizeText(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Point de passage unique de toute modification structurelle.
|
||||
*
|
||||
* L'ordre de fratrie y est renumerote (dense, 0..n-1) et les liens recalcules
|
||||
* depuis `parentId`. Les anciennes cartes recoivent ainsi un ordre explicite
|
||||
* conforme a leur affichage, sans traitement special ailleurs.
|
||||
*/
|
||||
function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
|
||||
return { ...map, nodes, edges: rebuildEdges(nodes) };
|
||||
const ordered = normalizeChildOrders(nodes);
|
||||
return { ...map, nodes: ordered, edges: rebuildEdges(ordered) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -468,6 +507,139 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
return sibling.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cree plusieurs enfants d'un coup, un par ligne.
|
||||
*
|
||||
* Les nouveaux noeuds sont repartis autour du parent pour ne pas se
|
||||
* superposer, et l'ensemble ne represente qu'une seule entree d'historique.
|
||||
*/
|
||||
addChildren: (parentId, texts) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return 0;
|
||||
|
||||
const clean = texts.map((text) => text.trim()).filter((text) => text.length > 0);
|
||||
if (clean.length === 0) return 0;
|
||||
|
||||
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
|
||||
const horizontal = isHorizontalLayout(map.settings.layout);
|
||||
const direction = layoutDirection(map.settings.layout);
|
||||
const startOrder = nextSiblingOrder(map.nodes, parent?.id ?? null);
|
||||
|
||||
const positions = parent
|
||||
? suggestChildPositions(map.nodes, parent, horizontal, direction, clean.length)
|
||||
: clean.map((_text, index) => ({ x: 0, y: (map.nodes.length + index) * 70 }));
|
||||
|
||||
const created = clean.map((text, index) =>
|
||||
createNode({
|
||||
text,
|
||||
parentId: parent?.id ?? null,
|
||||
type: !parent && map.nodes.length === 0 && index === 0 ? 'root' : 'standard',
|
||||
order: startOrder + index,
|
||||
position: positions[index] as Position,
|
||||
}),
|
||||
);
|
||||
|
||||
commit((current) => {
|
||||
const nodes = current.nodes.map((node) =>
|
||||
parent && node.id === parent.id && node.collapsed ? { ...node, collapsed: false } : node,
|
||||
);
|
||||
return withEdges(current, [...nodes, ...created]);
|
||||
});
|
||||
|
||||
// On garde le parent selectionne : l'utilisateur enchaine souvent
|
||||
// plusieurs ajouts sur la meme branche.
|
||||
set({ selectedNodeIds: parent ? [parent.id] : [created[0]?.id as string], editingNodeId: null });
|
||||
return created.length;
|
||||
},
|
||||
|
||||
/** Insere une arborescence indentee. Une seule entree d'historique. */
|
||||
insertOutline: (parentId, roots) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly || roots.length === 0) return 0;
|
||||
|
||||
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
|
||||
const horizontal = isHorizontalLayout(map.settings.layout);
|
||||
const direction = layoutDirection(map.settings.layout);
|
||||
const created: MindMapNode[] = [];
|
||||
|
||||
const insert = (
|
||||
outlineNodes: OutlineNode[],
|
||||
targetParent: MindMapNode | undefined,
|
||||
depth: number,
|
||||
baseOrder: number,
|
||||
): void => {
|
||||
outlineNodes.forEach((outline, index) => {
|
||||
const anchorNode = targetParent ?? parent;
|
||||
const position = anchorNode
|
||||
? {
|
||||
x: anchorNode.position.x + (horizontal ? direction * 220 * (depth + 1) : index * 200),
|
||||
y: anchorNode.position.y + (horizontal ? index * 72 : 140 * (depth + 1)),
|
||||
}
|
||||
: { x: depth * 240, y: (created.length + index) * 70 };
|
||||
|
||||
const node = createNode({
|
||||
text: outline.text,
|
||||
parentId: anchorNode?.id ?? null,
|
||||
type: !anchorNode && map.nodes.length === 0 && created.length === 0 ? 'root' : 'standard',
|
||||
order: baseOrder + index,
|
||||
position,
|
||||
});
|
||||
created.push(node);
|
||||
if (outline.children.length > 0) insert(outline.children, node, depth + 1, 0);
|
||||
});
|
||||
};
|
||||
|
||||
insert(roots, parent, 0, nextSiblingOrder(map.nodes, parent?.id ?? null));
|
||||
|
||||
commit((current) => {
|
||||
const nodes = current.nodes.map((node) =>
|
||||
parent && node.id === parent.id && node.collapsed ? { ...node, collapsed: false } : node,
|
||||
);
|
||||
return withEdges(current, [...nodes, ...created]);
|
||||
});
|
||||
|
||||
set({ selectedNodeIds: parent ? [parent.id] : [created[0]?.id as string], editingNodeId: null });
|
||||
set({ fitRequest: get().fitRequest + 1 });
|
||||
return created.length;
|
||||
},
|
||||
|
||||
/** Insere un frere immediatement avant ou apres un noeud. */
|
||||
addSiblingAt: (nodeId, position) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return null;
|
||||
|
||||
const reference = map.nodes.find((node) => node.id === nodeId);
|
||||
if (!reference) return null;
|
||||
|
||||
const siblings = getOrderedChildren(map.nodes, reference.parentId);
|
||||
const referenceIndex = siblings.findIndex((node) => node.id === reference.id);
|
||||
const insertAt = position === 'before' ? referenceIndex : referenceIndex + 1;
|
||||
|
||||
const horizontal = isHorizontalLayout(map.settings.layout);
|
||||
const sibling = createNode({
|
||||
text: '',
|
||||
parentId: reference.parentId,
|
||||
position: horizontal
|
||||
? { x: reference.position.x, y: reference.position.y + (position === 'before' ? -70 : 70) }
|
||||
: { x: reference.position.x + (position === 'before' ? -200 : 200), y: reference.position.y },
|
||||
});
|
||||
|
||||
commit((current) => {
|
||||
const reordered = current.nodes.map((node) => {
|
||||
const index = siblings.findIndex((candidate) => candidate.id === node.id);
|
||||
if (index === -1) return node;
|
||||
return { ...node, order: index < insertAt ? index : index + 1 };
|
||||
});
|
||||
return withEdges(current, [...reordered, { ...sibling, order: insertAt }]);
|
||||
});
|
||||
|
||||
set({ selectedNodeIds: [sibling.id], editingNodeId: sibling.id });
|
||||
return sibling.id;
|
||||
},
|
||||
|
||||
deleteNodes: (ids) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
@@ -485,6 +657,92 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
});
|
||||
},
|
||||
|
||||
/** Change la position d'un noeud parmi ses freres. Une entree d'historique. */
|
||||
moveSibling: (id, direction) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return;
|
||||
|
||||
const node = map.nodes.find((candidate) => candidate.id === id);
|
||||
if (!node) return;
|
||||
|
||||
const siblings = getOrderedChildren(map.nodes, node.parentId);
|
||||
const from = siblings.findIndex((candidate) => candidate.id === id);
|
||||
if (from === -1 || siblings.length < 2) return;
|
||||
|
||||
const to =
|
||||
direction === 'up'
|
||||
? from - 1
|
||||
: direction === 'down'
|
||||
? from + 1
|
||||
: direction === 'first'
|
||||
? 0
|
||||
: siblings.length - 1;
|
||||
if (to === from || to < 0 || to >= siblings.length) return;
|
||||
|
||||
const reordered = [...siblings];
|
||||
reordered.splice(to, 0, ...reordered.splice(from, 1));
|
||||
const orderById = new Map(reordered.map((candidate, index) => [candidate.id, index]));
|
||||
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((candidate) => {
|
||||
const order = orderById.get(candidate.id);
|
||||
return order === undefined ? candidate : { ...candidate, order };
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remonte un noeud d'un niveau : il devient le frere de son parent, place
|
||||
* juste apres lui. Sans effet sur une racine.
|
||||
*/
|
||||
outdentNode: (id) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return false;
|
||||
|
||||
const node = map.nodes.find((candidate) => candidate.id === id);
|
||||
const parent = node?.parentId
|
||||
? map.nodes.find((candidate) => candidate.id === node.parentId)
|
||||
: undefined;
|
||||
if (!node || !parent) return false;
|
||||
|
||||
const uncles = getOrderedChildren(map.nodes, parent.parentId);
|
||||
const parentIndex = uncles.findIndex((candidate) => candidate.id === parent.id);
|
||||
const insertAt = parentIndex + 1;
|
||||
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((candidate) => {
|
||||
if (candidate.id === node.id) {
|
||||
return { ...candidate, parentId: parent.parentId, order: insertAt, type: 'standard' };
|
||||
}
|
||||
const index = uncles.findIndex((uncle) => uncle.id === candidate.id);
|
||||
if (index === -1) return candidate;
|
||||
return { ...candidate, order: index < insertAt ? index : index + 1 };
|
||||
}),
|
||||
),
|
||||
);
|
||||
set({ selectedNodeIds: [node.id] });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Deplace un noeud (et donc sa branche) sous un autre parent.
|
||||
* Reutilise `reparentNode`, seul detenteur de la detection de cycle.
|
||||
*/
|
||||
moveNodeToParent: (childId, parentId) => {
|
||||
if (parentId === null) {
|
||||
get().detachNode(childId);
|
||||
return true;
|
||||
}
|
||||
return get().reparentNode(childId, parentId);
|
||||
},
|
||||
|
||||
updateNodeText: (id, text) => {
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
@@ -514,6 +772,136 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime des noeuds en rattachant leurs enfants au grand-parent, a la
|
||||
* place qu'occupait le noeud supprime.
|
||||
*/
|
||||
deleteNodesKeepChildren: (ids) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly || ids.length === 0) return;
|
||||
|
||||
const doomed = new Set(ids.filter((id) => map.nodes.some((node) => node.id === id)));
|
||||
if (doomed.size === 0) return;
|
||||
|
||||
let nodes = map.nodes;
|
||||
const promoted: string[] = [];
|
||||
|
||||
// Traitement du plus profond au moins profond : un parent supprime apres
|
||||
// son enfant retrouve bien les petits-enfants deja remontes.
|
||||
const depths = computeDepths(nodes);
|
||||
const ordered = [...doomed].sort((a, b) => (depths.get(b) ?? 0) - (depths.get(a) ?? 0));
|
||||
|
||||
for (const id of ordered) {
|
||||
const target = nodes.find((node) => node.id === id);
|
||||
if (!target) continue;
|
||||
|
||||
const children = getOrderedChildren(nodes, id);
|
||||
const siblings = getOrderedChildren(nodes, target.parentId);
|
||||
const insertAt = siblings.findIndex((node) => node.id === id);
|
||||
|
||||
nodes = nodes
|
||||
.filter((node) => node.id !== id)
|
||||
.map((node) => {
|
||||
if (node.parentId === id) {
|
||||
const childIndex = children.findIndex((child) => child.id === node.id);
|
||||
promoted.push(node.id);
|
||||
return {
|
||||
...node,
|
||||
parentId: target.parentId,
|
||||
order: insertAt + childIndex,
|
||||
type: target.parentId === null ? node.type : 'standard',
|
||||
} as MindMapNode;
|
||||
}
|
||||
const siblingIndex = siblings.findIndex((sibling) => sibling.id === node.id);
|
||||
if (siblingIndex === -1 || siblingIndex <= insertAt) return node;
|
||||
return { ...node, order: siblingIndex + children.length - 1 };
|
||||
});
|
||||
}
|
||||
|
||||
commit((current) => withEdges(current, nodes));
|
||||
set({ selectedNodeIds: promoted, editingNodeId: null });
|
||||
},
|
||||
|
||||
/** Selectionne un noeud et tous ses descendants. */
|
||||
selectBranch: (id) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map) return 0;
|
||||
const branch = withDescendants(map.nodes, [id]);
|
||||
set({ selectedNodeIds: branch, editingNodeId: null });
|
||||
return branch.length;
|
||||
},
|
||||
|
||||
/** Duplique un noeud avec tout son sous-arbre, au meme niveau. */
|
||||
duplicateBranch: (id) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return 0;
|
||||
|
||||
const root = map.nodes.find((node) => node.id === id);
|
||||
if (!root) return 0;
|
||||
|
||||
const branchIds = new Set(withDescendants(map.nodes, [id]));
|
||||
const source = map.nodes.filter((node) => branchIds.has(node.id));
|
||||
const clones = cloneSubtrees(source, 40);
|
||||
const cloneRoot = clones[0] as MindMapNode;
|
||||
|
||||
commit((current) =>
|
||||
withEdges(current, [
|
||||
...current.nodes,
|
||||
...clones.map((node) =>
|
||||
node.id === cloneRoot.id
|
||||
? {
|
||||
...node,
|
||||
text: node.text ? `${node.text} copie` : node.text,
|
||||
order: nextSiblingOrder(current.nodes, root.parentId),
|
||||
}
|
||||
: node,
|
||||
),
|
||||
]),
|
||||
);
|
||||
set({ selectedNodeIds: [cloneRoot.id], editingNodeId: null });
|
||||
return clones.length;
|
||||
},
|
||||
|
||||
/** Copie un noeud et son sous-arbre dans le presse-papiers interne. */
|
||||
copyBranch: (id) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map) return 0;
|
||||
|
||||
const branchIds = new Set(withDescendants(map.nodes, [id]));
|
||||
const nodes = map.nodes.filter((node) => branchIds.has(node.id)).map((node) => ({ ...node }));
|
||||
set({ clipboard: { nodes } });
|
||||
return nodes.length;
|
||||
},
|
||||
|
||||
/** Colle le presse-papiers interne comme enfant du noeud vise. */
|
||||
pasteAsChild: (parentId) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
const clipboard = state.clipboard;
|
||||
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
|
||||
if (!map.nodes.some((node) => node.id === parentId)) return 0;
|
||||
|
||||
const clones = cloneSubtrees(clipboard.nodes, 40, parentId);
|
||||
let order = nextSiblingOrder(map.nodes, parentId);
|
||||
|
||||
commit((current) =>
|
||||
withEdges(current, [
|
||||
...current.nodes.map((node) =>
|
||||
node.id === parentId && node.collapsed ? { ...node, collapsed: false } : node,
|
||||
),
|
||||
...clones.map((node) =>
|
||||
node.parentId === parentId ? { ...node, order: order++ } : node,
|
||||
),
|
||||
]),
|
||||
);
|
||||
set({ selectedNodeIds: [clones[0]?.id as string], editingNodeId: null });
|
||||
return clones.length;
|
||||
},
|
||||
|
||||
updateNodesStyle: (ids, patch) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
@@ -656,9 +1044,41 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
moveNodes: (updates) => {
|
||||
moveNodes: (updates, options) => {
|
||||
if (updates.length === 0) return;
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map) return;
|
||||
|
||||
const byId = new Map(updates.map((update) => [update.id, update.position]));
|
||||
|
||||
/*
|
||||
* Deplacement de branche : on applique aux descendants le meme decalage
|
||||
* que celui subi par le noeud tire, ce qui preserve leurs positions
|
||||
* relatives. Les noeuds deja deplaces par React Flow (multi-selection)
|
||||
* sont exclus pour ne pas cumuler deux fois le decalage.
|
||||
*/
|
||||
if (options?.withDescendants) {
|
||||
const nodeById = buildNodeMap(map.nodes);
|
||||
for (const update of updates) {
|
||||
const source = nodeById.get(update.id);
|
||||
if (!source) continue;
|
||||
const deltaX = update.position.x - source.position.x;
|
||||
const deltaY = update.position.y - source.position.y;
|
||||
if (deltaX === 0 && deltaY === 0) continue;
|
||||
|
||||
for (const descendantId of getDescendantIds(map.nodes, update.id)) {
|
||||
if (byId.has(descendantId)) continue;
|
||||
const descendant = nodeById.get(descendantId);
|
||||
if (!descendant) continue;
|
||||
byId.set(descendantId, {
|
||||
x: descendant.position.x + deltaX,
|
||||
y: descendant.position.y + deltaY,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pas d'entree d'historique ici : `pushHistory` est appele au debut du drag.
|
||||
commit(
|
||||
(current) => ({
|
||||
@@ -890,6 +1310,66 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
commit((current) => withEdges(current, nodes));
|
||||
},
|
||||
|
||||
/** Replie ou deplie un noeud et tous ses descendants. Une entree d'historique. */
|
||||
setBranchCollapsed: (id, collapsed) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map) return;
|
||||
|
||||
const branch = new Set([id, ...getDescendantIds(map.nodes, id)]);
|
||||
const nodes = map.nodes.map((node) =>
|
||||
branch.has(node.id) ? { ...node, collapsed } : node,
|
||||
);
|
||||
|
||||
if (state.readOnly) {
|
||||
set({ map: { ...map, nodes } });
|
||||
return;
|
||||
}
|
||||
commit((current) => withEdges(current, nodes));
|
||||
},
|
||||
|
||||
/** Replie ou deplie toute la carte. Une entree d'historique. */
|
||||
setAllCollapsed: (collapsed) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map) return;
|
||||
|
||||
const children = buildChildrenMap(map.nodes);
|
||||
const nodes = map.nodes.map((node) =>
|
||||
(children.get(node.id)?.length ?? 0) > 0 ? { ...node, collapsed } : node,
|
||||
);
|
||||
|
||||
if (state.readOnly) {
|
||||
set({ map: { ...map, nodes } });
|
||||
return;
|
||||
}
|
||||
commit((current) => withEdges(current, nodes));
|
||||
},
|
||||
|
||||
/**
|
||||
* Replie tout ce qui se trouve au-dela d'un niveau donne.
|
||||
* Niveau 1 = seules les racines restent visibles.
|
||||
*/
|
||||
collapseToLevel: (level) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map) return;
|
||||
|
||||
const depths = computeDepths(map.nodes);
|
||||
const children = buildChildrenMap(map.nodes);
|
||||
const nodes = map.nodes.map((node) => {
|
||||
if ((children.get(node.id)?.length ?? 0) === 0) return node;
|
||||
const depth = depths.get(node.id) ?? 0;
|
||||
return { ...node, collapsed: depth >= level - 1 };
|
||||
});
|
||||
|
||||
if (state.readOnly) {
|
||||
set({ map: { ...map, nodes } });
|
||||
return;
|
||||
}
|
||||
commit((current) => withEdges(current, nodes));
|
||||
},
|
||||
|
||||
expandAncestors: (id) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
|
||||
@@ -17,6 +17,12 @@ interface UiState {
|
||||
toasts: Toast[];
|
||||
/** Sections repliables du panneau Proprietes, conservees pendant la session. */
|
||||
panelSections: Record<string, boolean>;
|
||||
/**
|
||||
* Demande d'ouverture du dialogue "ajouter plusieurs enfants".
|
||||
* Permet a un raccourci clavier ou a un collage de l'ouvrir sans que les
|
||||
* composants aient besoin de se connaitre.
|
||||
*/
|
||||
addChildrenRequest: { parentId: string | null; text: string; nonce: number } | null;
|
||||
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
toggleColorMode: () => void;
|
||||
@@ -25,6 +31,8 @@ interface UiState {
|
||||
pushToast: (message: string, tone?: ToastTone) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
togglePanelSection: (id: string) => void;
|
||||
requestAddChildren: (parentId: string | null, text?: string) => void;
|
||||
clearAddChildrenRequest: () => void;
|
||||
}
|
||||
|
||||
const COLOR_MODE_KEY = 'openmind:color-mode';
|
||||
@@ -71,6 +79,7 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
searchPanelOpen: false,
|
||||
toasts: [],
|
||||
panelSections: readPanelSections(),
|
||||
addChildrenRequest: null,
|
||||
|
||||
setColorMode: (mode) => {
|
||||
applyColorMode(mode);
|
||||
@@ -93,6 +102,17 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
|
||||
dismissToast: (id) => set({ toasts: get().toasts.filter((toast) => toast.id !== id) }),
|
||||
|
||||
requestAddChildren: (parentId, text = '') =>
|
||||
set({
|
||||
addChildrenRequest: {
|
||||
parentId,
|
||||
text,
|
||||
nonce: (get().addChildrenRequest?.nonce ?? 0) + 1,
|
||||
},
|
||||
}),
|
||||
|
||||
clearAddChildrenRequest: () => set({ addChildrenRequest: null }),
|
||||
|
||||
togglePanelSection: (id) => {
|
||||
const next = { ...get().panelSections, [id]: !(get().panelSections[id] ?? true) };
|
||||
set({ panelSections: next });
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
* Version du format de fichier.
|
||||
*
|
||||
* v1 : format initial.
|
||||
* v2 : ajout de la mise en page des noeuds (layout, iconStyle, branch) et des
|
||||
* reglages d'espacement. Tous les champs ajoutes sont optionnels : un
|
||||
* document v1 reste lisible sans transformation, et les valeurs par defaut
|
||||
* sont resolues a l'affichage (voir `nodeStyleResolver`).
|
||||
* v2 : mise en page des noeuds (layout, iconStyle, branch) et reglages
|
||||
* d'espacement. Champs optionnels, resolus a l'affichage.
|
||||
* v3 : ordre explicite des freres (`MindMapNode.order`). Le bump est justifie :
|
||||
* l'ordre fait desormais partie de la semantique du document, et un
|
||||
* lecteur v2 l'ignorerait silencieusement en reordonnant la carte.
|
||||
* La migration v2 -> v3 materialise l'ordre depuis la position dans le
|
||||
* tableau `nodes`, ce qui reproduit exactement l'affichage precedent.
|
||||
*/
|
||||
export const MAP_FORMAT_VERSION = 2;
|
||||
export const MAP_FORMAT_VERSION = 3;
|
||||
|
||||
/**
|
||||
* Types de noeuds.
|
||||
@@ -102,8 +105,18 @@ export interface MindMapNode {
|
||||
/** Texte brut. Jamais interprete comme du HTML. */
|
||||
text: string;
|
||||
position: Position;
|
||||
/** Parent hierarchique. `null` = racine (une carte peut en avoir plusieurs). */
|
||||
/**
|
||||
* Parent hierarchique, seule source de verite de la relation.
|
||||
* `null` = racine. Une carte peut en compter plusieurs : le modele l'autorise
|
||||
* depuis la v1 et les cartes existantes en dependent.
|
||||
*/
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Position parmi les freres (v3). Absent = ordre d'apparition dans `nodes`.
|
||||
* `parentId` reste la source de verite de la relation, `order` celle de la
|
||||
* position : il n'y a jamais deux sources contradictoires.
|
||||
*/
|
||||
order?: number;
|
||||
/** Branche repliee : les descendants sont masques. */
|
||||
collapsed?: boolean;
|
||||
/** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */
|
||||
@@ -172,6 +185,11 @@ export interface MapSettings {
|
||||
snapGrid?: number;
|
||||
/** Guides d'alignement affiches pendant un deplacement (v2). */
|
||||
alignmentGuides?: boolean;
|
||||
/**
|
||||
* Comportement par defaut du glisser-deposer (v3) : deplacer le noeud seul,
|
||||
* ou toute sa branche. La touche Alt inverse ce choix le temps d'un drag.
|
||||
*/
|
||||
dragMovesBranch?: boolean;
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
|
||||
@@ -1,17 +1,113 @@
|
||||
import { type MindMapEdge, type MindMapNode } from '@/types/map';
|
||||
import { type MindMapEdge, type MindMapNode, type Position } from '@/types/map';
|
||||
|
||||
/** Index enfant -> parents directs, construit une seule fois par operation. */
|
||||
/**
|
||||
* Compare deux freres.
|
||||
*
|
||||
* `order` fait foi ; a defaut, l'ordre d'apparition dans le tableau `nodes`
|
||||
* sert de repli, ce qui reproduit exactement le comportement d'avant la v3 pour
|
||||
* les cartes qui n'ont pas encore d'ordre explicite.
|
||||
*/
|
||||
export function compareSiblings(
|
||||
a: MindMapNode,
|
||||
b: MindMapNode,
|
||||
indexById: Map<string, number>,
|
||||
): number {
|
||||
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
|
||||
}
|
||||
|
||||
function buildIndex(nodes: MindMapNode[]): Map<string, number> {
|
||||
const index = new Map<string, number>();
|
||||
nodes.forEach((node, position) => index.set(node.id, position));
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index parent -> enfants directs, tries par ordre de fratrie.
|
||||
* Construit une seule fois par operation.
|
||||
*/
|
||||
export function buildChildrenMap(nodes: MindMapNode[]): Map<string, string[]> {
|
||||
const byId = buildNodeMap(nodes);
|
||||
const indexById = buildIndex(nodes);
|
||||
const children = new Map<string, string[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node.parentId) continue;
|
||||
const list = children.get(node.parentId);
|
||||
if (list) list.push(node.id);
|
||||
else children.set(node.parentId, [node.id]);
|
||||
}
|
||||
|
||||
for (const list of children.values()) {
|
||||
if (list.length > 1) {
|
||||
list.sort((a, b) =>
|
||||
compareSiblings(byId.get(a) as MindMapNode, byId.get(b) as MindMapNode, indexById),
|
||||
);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/** Enfants directs d'un noeud, dans l'ordre. `null` cible les racines. */
|
||||
export function getOrderedChildren(
|
||||
nodes: MindMapNode[],
|
||||
parentId: string | null,
|
||||
): MindMapNode[] {
|
||||
const byId = buildNodeMap(nodes);
|
||||
if (parentId === null) {
|
||||
const indexById = buildIndex(nodes);
|
||||
return getRootNodes(nodes).sort((a, b) => compareSiblings(a, b, indexById));
|
||||
}
|
||||
return (buildChildrenMap(nodes).get(parentId) ?? []).map((id) => byId.get(id) as MindMapNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renumerote les freres en 0..n-1, pour chaque parent et pour les racines.
|
||||
*
|
||||
* Appelee apres toute modification structurelle : l'ordre reste ainsi dense et
|
||||
* comparable, et les anciennes cartes recoivent un ordre explicite conforme a
|
||||
* leur affichage precedent.
|
||||
*/
|
||||
export function normalizeChildOrders(nodes: MindMapNode[]): MindMapNode[] {
|
||||
const indexById = buildIndex(nodes);
|
||||
const groups = new Map<string | null, MindMapNode[]>();
|
||||
const known = new Set(nodes.map((node) => node.id));
|
||||
|
||||
for (const node of nodes) {
|
||||
// `null` regroupe les racines : aucune sentinelle textuelle a inventer.
|
||||
const key = node.parentId && known.has(node.parentId) ? node.parentId : null;
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(node);
|
||||
else groups.set(key, [node]);
|
||||
}
|
||||
|
||||
const orders = new Map<string, number>();
|
||||
for (const group of groups.values()) {
|
||||
group.sort((a, b) => compareSiblings(a, b, indexById));
|
||||
group.forEach((node, position) => orders.set(node.id, position));
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const result = nodes.map((node) => {
|
||||
const order = orders.get(node.id) ?? 0;
|
||||
if (node.order === order) return node;
|
||||
changed = true;
|
||||
return { ...node, order };
|
||||
});
|
||||
return changed ? result : nodes;
|
||||
}
|
||||
|
||||
/** Prochain rang libre parmi les enfants d'un parent. */
|
||||
export function nextSiblingOrder(nodes: MindMapNode[], parentId: string | null): number {
|
||||
const siblings = nodes.filter((node) =>
|
||||
parentId === null ? !node.parentId : node.parentId === parentId,
|
||||
);
|
||||
if (siblings.length === 0) return 0;
|
||||
return Math.max(...siblings.map((node) => node.order ?? 0)) + 1;
|
||||
}
|
||||
|
||||
export function buildNodeMap(nodes: MindMapNode[]): Map<string, MindMapNode> {
|
||||
return new Map(nodes.map((node) => [node.id, node]));
|
||||
}
|
||||
@@ -103,16 +199,45 @@ export function wouldCreateCycle(
|
||||
return getDescendantIds(nodes, childId).includes(nextParentId);
|
||||
}
|
||||
|
||||
/** Recalcule les liens a partir de la hierarchie : parentId fait autorite. */
|
||||
/**
|
||||
* Recalcule les liens a partir de la hierarchie : parentId fait autorite.
|
||||
* Les liens sont emis dans l'ordre de fratrie, ce dont dagre se sert pour
|
||||
* disposer les enfants dans le bon ordre.
|
||||
*/
|
||||
export function rebuildEdges(nodes: MindMapNode[]): MindMapEdge[] {
|
||||
const ids = new Set(nodes.map((node) => node.id));
|
||||
return nodes
|
||||
.filter((node) => node.parentId && ids.has(node.parentId))
|
||||
.map((node) => ({
|
||||
id: `e-${node.parentId}-${node.id}`,
|
||||
source: node.parentId as string,
|
||||
target: node.id,
|
||||
}));
|
||||
const edges: MindMapEdge[] = [];
|
||||
for (const [parentId, childIds] of buildChildrenMap(nodes)) {
|
||||
for (const childId of childIds) {
|
||||
edges.push({ id: `e-${parentId}-${childId}`, source: parentId, target: childId });
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Parcours en profondeur, racines et enfants dans l'ordre de fratrie. */
|
||||
export function flattenInOrder(nodes: MindMapNode[]): MindMapNode[] {
|
||||
const byId = buildNodeMap(nodes);
|
||||
const children = buildChildrenMap(nodes);
|
||||
const indexById = buildIndex(nodes);
|
||||
const result: MindMapNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const visit = (node: MindMapNode): void => {
|
||||
if (seen.has(node.id)) return;
|
||||
seen.add(node.id);
|
||||
result.push(node);
|
||||
for (const childId of children.get(node.id) ?? []) {
|
||||
const child = byId.get(childId);
|
||||
if (child) visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of getRootNodes(nodes).sort((a, b) => compareSiblings(a, b, indexById))) {
|
||||
visit(root);
|
||||
}
|
||||
// Securite : un noeud isole par une donnee incoherente reste present.
|
||||
for (const node of nodes) if (!seen.has(node.id)) result.push(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,18 +265,43 @@ export function suggestChildPosition(
|
||||
parent: MindMapNode,
|
||||
horizontal: boolean,
|
||||
direction: 1 | -1,
|
||||
): { x: number; y: number } {
|
||||
const siblings = nodes.filter((node) => node.parentId === parent.id);
|
||||
const offset = siblings.length;
|
||||
): Position {
|
||||
return suggestChildPositions(nodes, parent, horizontal, direction, 1)[0] as Position;
|
||||
}
|
||||
|
||||
const CHILD_GAP_Y = 72;
|
||||
const CHILD_GAP_X = 200;
|
||||
|
||||
/**
|
||||
* Positions de depart pour `count` nouveaux enfants.
|
||||
*
|
||||
* Elles ne remplacent pas l'auto-layout : elles evitent seulement que plusieurs
|
||||
* noeuds crees d'un coup se superposent, en les repartissant autour du parent.
|
||||
*/
|
||||
export function suggestChildPositions(
|
||||
nodes: MindMapNode[],
|
||||
parent: MindMapNode,
|
||||
horizontal: boolean,
|
||||
direction: 1 | -1,
|
||||
count: number,
|
||||
): Position[] {
|
||||
const existing = nodes.filter((node) => node.parentId === parent.id).length;
|
||||
const total = existing + count;
|
||||
const positions: Position[] = [];
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const slot = existing + index;
|
||||
if (horizontal) {
|
||||
return {
|
||||
positions.push({
|
||||
x: parent.position.x + direction * 220,
|
||||
y: parent.position.y + offset * 72 - (siblings.length > 0 ? 36 : 0),
|
||||
};
|
||||
y: parent.position.y + (slot - (total - 1) / 2) * CHILD_GAP_Y,
|
||||
});
|
||||
} else {
|
||||
positions.push({
|
||||
x: parent.position.x + (slot - (total - 1) / 2) * CHILD_GAP_X,
|
||||
y: parent.position.y + direction * 140,
|
||||
});
|
||||
}
|
||||
return {
|
||||
x: parent.position.x + offset * 200 - (siblings.length > 0 ? 100 : 0),
|
||||
y: parent.position.y + direction * 140,
|
||||
};
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user