diff --git a/frontend/src/features/editor/EditorToolbar.tsx b/frontend/src/features/editor/EditorToolbar.tsx
index 561d44e..6ecfd53 100644
--- a/frontend/src/features/editor/EditorToolbar.tsx
+++ b/frontend/src/features/editor/EditorToolbar.tsx
@@ -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
)}
+
+
+ Branches
+
+
+ }
+ >
+ {(close) => (
+ <>
+ {
+ setAllCollapsed(true);
+ close();
+ }}
+ />
+ {
+ setAllCollapsed(false);
+ close();
+ }}
+ />
+
+ Replier au-dela du niveau
+ {[1, 2, 3].map((level) => (
+ {
+ collapseToLevel(level);
+ close();
+ }}
+ />
+ ))}
+
+ updateSettings({ dragMovesBranch: !map.settings.dragMovesBranch })}
+ />
+ >
+ )}
+
+
} onClick={() => zoomIn()} />
} onClick={() => zoomOut()} />
diff --git a/frontend/src/features/editor/MindMapCanvas.tsx b/frontend/src/features/editor/MindMapCanvas.tsx
index 1733f94..be240ab 100644
--- a/frontend/src/features/editor/MindMapCanvas.tsx
+++ b/frontend/src/features/editor/MindMapCanvas.tsx
@@ -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
(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([]);
const [contextMenu, setContextMenu] = useState(null);
+ const [addChildrenFor, setAddChildrenFor] = useState<{ parentId: string | null; text: string } | null>(
+ null,
+ );
+ const [moveTarget, setMoveTarget] = useState(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({
{contextMenu ? (
- setContextMenu(null)} />
+ setContextMenu(null)}
+ onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
+ onMoveTo={(nodeId) => setMoveTarget(nodeId)}
+ />
) : null}
+
+ setAddChildrenFor(null)}
+ />
+
+ setMoveTarget(null)}
+ />
);
}
diff --git a/frontend/src/features/editor/MoveToDialog.tsx b/frontend/src/features/editor/MoveToDialog.tsx
new file mode 100644
index 0000000..1e02813
--- /dev/null
+++ b/frontend/src/features/editor/MoveToDialog.tsx
@@ -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 (
+
Annuler}
+ >
+
+
+
+ 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"
+ />
+
+
+
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
+
+
+ {candidates.length === 0 ? (
+
Aucun noeud ne correspond.
+ ) : (
+
+ {candidates.map(({ node: candidate, depth }) => (
+
+ 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',
+ )}
+ >
+
+ {depth === 0 ? '\u25cf' : '\u2514'}
+
+ {candidate.text || 'Sans titre'}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/features/editor/NodeContextMenu.tsx b/frontend/src/features/editor/NodeContextMenu.tsx
index cdcb83d..2d7e159 100644
--- a/frontend/src/features/editor/NodeContextMenu.tsx
+++ b/frontend/src/features/editor/NodeContextMenu.tsx
@@ -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;
- }> = [
- {
- label: 'Copier le style',
- hint: 'Ctrl+Maj+C',
- disabled: targets.length !== 1,
- run: () => {
- if (copyStyle()) pushToast('Style copie.', 'info');
+ const lines = useMemo
(() => {
+ 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),
},
- },
- {
- 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');
+ {
+ kind: 'item',
+ label: 'Ajouter avant',
+ disabled: !node.parentId,
+ run: () => store.addSiblingAt(node.id, 'before'),
},
- },
- { 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) },
- ];
+ {
+ 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 (store.copyStyle()) pushToast('Style copie.', 'info');
+ },
+ },
+ {
+ kind: 'item',
+ label: 'Coller le style',
+ hint: 'Ctrl+Maj+V',
+ disabled: !hasStyleClipboard,
+ 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),
+ },
+ ];
+ }, [
+ map,
+ state.nodeId,
+ targets,
+ hasStyleClipboard,
+ hasNodeClipboard,
+ onAddChildren,
+ onMoveTo,
+ pushToast,
+ ]);
return (
event.stopPropagation()}
>
- {items.map((item) => (
-
{
- item.run();
- onClose();
- }}
- className={cn(
- 'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
- 'disabled:cursor-not-allowed disabled:opacity-40',
- item.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',
- )}
- >
- {item.label}
- {item.hint ? (
- {item.hint}
- ) : null}
-
- ))}
+ {lines.map((line, index) =>
+ line.kind === 'heading' ? (
+
+ {line.label}
+
+ ) : (
+
{
+ line.run();
+ onClose();
+ }}
+ className={cn(
+ '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',
+ 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',
+ )}
+ >
+ {line.label}
+ {line.hint ? (
+
+ {line.hint}
+
+ ) : null}
+
+ ),
+ )}
);
}
diff --git a/frontend/src/features/editor/useEditorShortcuts.ts b/frontend/src/features/editor/useEditorShortcuts.ts
index 2709233..69e2d89 100644
--- a/frontend/src/features/editor/useEditorShortcuts.ts
+++ b/frontend/src/features/editor/useEditorShortcuts.ts
@@ -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':
- event.preventDefault();
- store.paste();
+ /*
+ * 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();
- store.addChild();
+ // 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();
- store.addSibling();
+ if (event.shiftKey) {
+ handlersRef.current.onAddChildren(store.selectedNodeIds[0] ?? null);
+ } else {
+ store.addSibling();
+ }
break;
case 'Delete':
case 'Backspace':
diff --git a/frontend/src/services/mapFactory.ts b/frontend/src/services/mapFactory.ts
index 043cb9a..bc285e1 100644
--- a/frontend/src/services/mapFactory.ts
+++ b/frontend/src/services/mapFactory.ts
@@ -20,6 +20,7 @@ export function createNode(partial: Partial & { 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 } : {}),
diff --git a/frontend/src/services/mapOrderSerialization.test.ts b/frontend/src/services/mapOrderSerialization.test.ts
new file mode 100644
index 0000000..d38bdfe
--- /dev/null
+++ b/frontend/src/services/mapOrderSerialization.test.ts
@@ -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 = { 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']);
+ });
+});
diff --git a/frontend/src/services/mapSerialization.ts b/frontend/src/services/mapSerialization.ts
index 70c1e67..39951fa 100644
--- a/frontend/src/services/mapSerialization.ts
+++ b/frontend/src/services/mapSerialization.ts
@@ -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 {
@@ -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): Record 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): Record {
+ if (!Array.isArray(document.nodes)) return { ...document, version: 3 };
+
+ const counters = new Map();
+ 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, version: number): Record {
let document = raw;
let current = version;
@@ -337,6 +375,10 @@ function migrate(raw: Record, version: number): Record {
+ 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']);
+ });
+});
diff --git a/frontend/src/services/outlineParser.ts b/frontend/src/services/outlineParser.ts
new file mode 100644
index 0000000..390ab7a
--- /dev/null
+++ b/frontend/src/services/outlineParser.ts
@@ -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);
+}
diff --git a/frontend/src/stores/editorBranch.test.ts b/frontend/src/stores/editorBranch.test.ts
new file mode 100644
index 0000000..8996a29
--- /dev/null
+++ b/frontend/src/stores/editorBranch.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/stores/editorCreation.test.ts b/frontend/src/stores/editorCreation.test.ts
new file mode 100644
index 0000000..b6b71c7
--- /dev/null
+++ b/frontend/src/stores/editorCreation.test.ts
@@ -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]);
+ });
+});
diff --git a/frontend/src/stores/editorHierarchy.test.ts b/frontend/src/stores/editorHierarchy.test.ts
new file mode 100644
index 0000000..8366e86
--- /dev/null
+++ b/frontend/src/stores/editorHierarchy.test.ts
@@ -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']);
+ });
+});
diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts
index 4470da0..116da66 100644
--- a/frontend/src/stores/editorStore.ts
+++ b/frontend/src/stores/editorStore.ts
@@ -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>) => 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((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((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((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((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((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;
diff --git a/frontend/src/stores/uiStore.ts b/frontend/src/stores/uiStore.ts
index 66a2178..74e4ad2 100644
--- a/frontend/src/stores/uiStore.ts
+++ b/frontend/src/stores/uiStore.ts
@@ -17,6 +17,12 @@ interface UiState {
toasts: Toast[];
/** Sections repliables du panneau Proprietes, conservees pendant la session. */
panelSections: Record;
+ /**
+ * 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((set, get) => ({
searchPanelOpen: false,
toasts: [],
panelSections: readPanelSections(),
+ addChildrenRequest: null,
setColorMode: (mode) => {
applyColorMode(mode);
@@ -93,6 +102,17 @@ export const useUiStore = create((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 });
diff --git a/frontend/src/types/map.ts b/frontend/src/types/map.ts
index 3cab610..49e1346 100644
--- a/frontend/src/types/map.ts
+++ b/frontend/src/types/map.ts
@@ -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 {
diff --git a/frontend/src/utils/tree.ts b/frontend/src/utils/tree.ts
index 0ada5a3..33a579e 100644
--- a/frontend/src/utils/tree.ts
+++ b/frontend/src/utils/tree.ts
@@ -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,
+): 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 {
+ const index = new Map();
+ 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 {
+ const byId = buildNodeMap(nodes);
+ const indexById = buildIndex(nodes);
const children = new Map();
+
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();
+ 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();
+ 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 {
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();
+
+ 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;
+}
- if (horizontal) {
- return {
- x: parent.position.x + direction * 220,
- y: parent.position.y + offset * 72 - (siblings.length > 0 ? 36 : 0),
- };
+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) {
+ positions.push({
+ x: parent.position.x + direction * 220,
+ 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;
}