Phase 2 : mind map bilaterale, relations, templates et Markdown

Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et
`MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les
cartes v1/v2/v3 restent ouvrables telles quelles.

Une relation est une connexion purement visuelle : elle ne touche jamais
parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue
de deriver les liens de parentId et se contente de retirer les relations dont
une extremite a disparu.

Mind map bilaterale : repartition gloutonne et deterministe des branches selon
la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre
recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul
Reorganiser applique.

Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou
texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant,
et l'export Markdown de la hierarchie.

Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles
changent de cote, ce qui faisait disparaitre les liens concernes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:12:00 +02:00
parent 2afbf637ce
commit e3c530348e
33 changed files with 4151 additions and 104 deletions

View File

@@ -71,9 +71,11 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const requestFit = useEditorStore((state) => state.requestFit);
const setAllCollapsed = useEditorStore((state) => state.setAllCollapsed);
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
const balanceSides = useEditorStore((state) => state.balanceSides);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
@@ -158,17 +160,22 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
{(close) => (
<>
<MenuLabel>Disposition</MenuLabel>
{/*
Choisir une disposition ne deplace rien : c'est "Reorganiser" qui
applique. Les positions placees a la main ne bougent donc jamais
sans que l'utilisateur le demande.
*/}
{LAYOUT_OPTIONS.map((option) => (
<MenuItem
key={option.id}
label={option.label}
active={map.settings.layout === option.id}
onSelect={() => {
applyAutoLayout(option.id);
close();
}}
onSelect={() => updateSettings({ layout: option.id })}
/>
))}
<p className="px-2.5 pb-2 pt-1 text-[11px] leading-snug text-zinc-400">
Choisir une disposition ne deplace rien : utilisez Reorganiser.
</p>
<MenuSeparator />
<MenuLabel>Espacement</MenuLabel>
<div className="flex gap-1 px-2.5 pb-2">
@@ -235,6 +242,20 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
</div>
<MenuSeparator />
<MenuItem
label="Repartir les branches sans cote"
hint="bilaterale"
onSelect={() => {
const changed = balanceSides();
pushToast(
changed > 0
? `${changed} branche(s) reparties. Relancez l'organisation pour les deplacer.`
: 'Toutes les branches ont deja un cote.',
changed > 0 ? 'success' : 'info',
);
close();
}}
/>
<MenuItem
label="Reorganiser avec la disposition actuelle"
onSelect={() => {

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { MarkdownError, parseMarkdown } from '@/services/markdown';
import { type OutlineNode } from '@/services/outlineParser';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
export interface MarkdownImportDialogProps {
open: boolean;
/** Noeud sous lequel importer. `null` = a la racine de la carte. */
parentId: string | null;
onClose: () => void;
}
interface Analysis {
roots: OutlineNode[];
count: number;
error: string | null;
}
function analyse(text: string, maxNodes: number): Analysis {
if (text.trim().length === 0) return { roots: [], count: 0, error: null };
try {
const result = parseMarkdown(text, maxNodes);
return { roots: result.roots, count: result.count, error: null };
} catch (error) {
return {
roots: [],
count: 0,
error:
error instanceof MarkdownError
? error.message
: "Ce document n'a pas pu etre analyse.",
};
}
}
function PreviewTree({ nodes, depth = 0 }: { nodes: OutlineNode[]; depth?: number }) {
return (
<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="text-xs text-zinc-600 dark:text-zinc-300">{node.text}</span>
{node.note ? (
<span className="ml-1 text-[10px] uppercase tracking-wide text-indigo-500">note</span>
) : null}
{node.children.length > 0 ? <PreviewTree nodes={node.children} depth={depth + 1} /> : null}
</li>
))}
</ul>
);
}
/**
* Import Markdown dans une carte ouverte.
*
* Le contenu devient un sous-arbre du noeud vise, en une seule entree
* d'historique. Les paragraphes rejoignent la note du noeud correspondant, les
* blocs de code aussi : rien n'est execute, rien n'est rendu en HTML.
*/
export function MarkdownImportDialog({ open, parentId, onClose }: MarkdownImportDialogProps) {
const insertOutline = useEditorStore((state) => state.insertOutline);
const parentName = useEditorStore(
(state) => state.map?.nodes.find((node) => node.id === parentId)?.text,
);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) setText('');
}, [open]);
const analysis = useMemo(() => analyse(text, maxNodes), [text, maxNodes]);
const canSubmit = analysis.count > 0 && analysis.error === null;
const submit = () => {
if (!canSubmit) return;
const created = insertOutline(parentId, analysis.roots);
if (created > 0) {
pushToast(`${created} noeud(s) importe(s) depuis le Markdown.`, 'success');
onClose();
}
};
const readFile = async (file: File) => {
const content = await file.text();
setText(content);
};
return (
<Modal
open={open}
title="Importer du Markdown"
description={
parentName
? `Le contenu deviendra un sous-arbre de "${parentName}".`
: 'Le contenu sera ajoute a la carte.'
}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
{analysis.count > 0 ? `Importer ${analysis.count} noeud(s)` : 'Importer'}
</Button>
</>
}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => fileInputRef.current?.click()}>
Choisir un fichier .md
</Button>
<span className="text-[11px] text-zinc-400">ou collez le contenu ci-dessous</span>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Choisir un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void readFile(file);
}}
/>
</div>
<textarea
id="markdown-import-text"
autoFocus
spellCheck={false}
className="field-input min-h-[200px] resize-y font-mono text-xs leading-relaxed"
placeholder={'# Infrastructure\n\n## Reseau\n- VLAN\n- VPN\n\n## Virtualisation\n- Proxmox\n - VM'}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
}}
aria-label="Contenu Markdown"
/>
<p className="text-[11px] text-zinc-400">
Titres (#, ##, ###) et listes construisent la hierarchie. Les paragraphes et les blocs de
code deviennent la note du noeud au-dessus. Ctrl+Entree valide.
</p>
{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}
{canSubmit ? (
<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.count} noeud(s) seront crees
</p>
<div className="max-h-48 overflow-y-auto">
<PreviewTree nodes={analysis.roots} />
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -12,7 +12,12 @@ import {
type NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters';
import {
type MindFlowNode,
toFlowEdges,
toFlowNodes,
toFlowRelationEdges,
} from '@/features/editor/flowAdapters';
import { MindNodeView } from '@/features/editor/MindNodeView';
import { getTheme } from '@/features/editor/themes';
import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer';
@@ -27,6 +32,8 @@ import {
} from '@/features/editor/NodeContextMenu';
import { AddChildrenDialog } from '@/features/editor/AddChildrenDialog';
import { MoveToDialog } from '@/features/editor/MoveToDialog';
import { RelationDialog } from '@/features/editor/RelationDialog';
import { MarkdownImportDialog } from '@/features/editor/MarkdownImportDialog';
import { SelectionToolbar } from '@/features/editor/SelectionToolbar';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
@@ -52,6 +59,7 @@ export function MindMapCanvas({
const focusRequest = useEditorStore((state) => state.focusRequest);
const fitRequest = useEditorStore((state) => state.fitRequest);
const sizeVersion = useEditorStore((state) => state.sizeVersion);
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const pushToast = useUiStore((state) => state.pushToast);
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
@@ -68,6 +76,8 @@ export function MindMapCanvas({
null,
);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const [relationSource, setRelationSource] = useState<string | null>(null);
const [markdownParent, setMarkdownParent] = useState<string | null>(null);
const addChildrenRequest = useUiStore((state) => state.addChildrenRequest);
const clearAddChildrenRequest = useUiStore((state) => state.clearAddChildrenRequest);
@@ -87,15 +97,35 @@ export function MindMapCanvas({
searchResults,
editingNodeId,
readOnly,
selectedRelationId,
measuredSizes: useEditorStore.getState().measuredSizes,
}),
// sizeVersion force le recalcul quand une taille de noeud a change.
// eslint-disable-next-line react-hooks/exhaustive-deps
[theme, selectedNodeIds, searchResults, editingNodeId, readOnly, sizeVersion],
[
theme,
selectedNodeIds,
searchResults,
editingNodeId,
readOnly,
sizeVersion,
selectedRelationId,
],
);
const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]);
const edges = useMemo(() => (map ? toFlowEdges(map, adapterOptions) : []), [map, adapterOptions]);
/*
* Les aretes affichees sont l'union de deux choses de nature differente : les
* liens hierarchiques, derives de `parentId`, et les relations secondaires,
* qui sont une donnee du document. Elles ne se melangent qu'ici, a
* l'affichage : le modele, lui, les garde separees.
*/
const edges = useMemo(
() =>
map ? [...toFlowEdges(map, adapterOptions), ...toFlowRelationEdges(map, adapterOptions)] : [],
[map, adapterOptions],
);
// Cadrage initial a l'ouverture d'une carte.
useEffect(() => {
@@ -156,10 +186,22 @@ export function MindMapCanvas({
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (readOnly) return;
const store = useEditorStore.getState();
for (const change of changes) {
if (change.type !== 'remove') continue;
if (change.type === 'select') {
// Une relation se selectionne ; un lien hierarchique, non : il n'a
// pas de proprietes propres, tout se regle sur le noeud enfant.
const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
if (relation) store.setSelectedRelation(change.selected ? relation.id : null);
continue;
}
if (change.type !== 'remove' || readOnly) continue;
const relation = store.map?.relations.find((candidate) => candidate.id === change.id);
if (relation) {
store.deleteRelation(relation.id);
continue;
}
const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
if (edge) store.detachNode(edge.target);
}
@@ -264,6 +306,7 @@ export function MindMapCanvas({
const store = useEditorStore.getState();
store.setSelection([]);
store.setEditingNode(null);
store.setSelectedRelation(null);
setContextMenu(null);
}}
defaultViewport={map.viewport}
@@ -313,6 +356,8 @@ export function MindMapCanvas({
onClose={() => setContextMenu(null)}
onAddChildren={(parentId) => setAddChildrenFor({ parentId, text: '' })}
onMoveTo={(nodeId) => setMoveTarget(nodeId)}
onAddRelation={(nodeId) => setRelationSource(nodeId)}
onImportMarkdown={(nodeId) => setMarkdownParent(nodeId)}
/>
) : null}
@@ -328,6 +373,18 @@ export function MindMapCanvas({
nodeId={moveTarget}
onClose={() => setMoveTarget(null)}
/>
<RelationDialog
open={relationSource !== null}
sourceId={relationSource}
onClose={() => setRelationSource(null)}
/>
<MarkdownImportDialog
open={markdownParent !== null}
parentId={markdownParent}
onClose={() => setMarkdownParent(null)}
/>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Handle, NodeResizer, type NodeProps } from '@xyflow/react';
import { Handle, NodeResizer, useUpdateNodeInternals, type NodeProps } from '@xyflow/react';
import { type MindFlowNode } from '@/features/editor/flowAdapters';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph';
@@ -42,6 +42,22 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [draft, setDraft] = useState(node.text);
/*
* React Flow mesure les poignees une seule fois, a leur apparition. Quand une
* branche change de cote, ou que la carte passe en bilaterale, les poignees
* changent d'identifiant et de bord : sans cette notification, React Flow
* garde les anciennes mesures et cesse simplement de dessiner les liens
* concernes.
*/
const updateNodeInternals = useUpdateNodeInternals();
const handleSignature = `${data.targetHandle.id}|${data.sourceHandles
.map((handle) => handle.id)
.join(',')}`;
useEffect(() => {
updateNodeInternals(id);
}, [id, handleSignature, updateNodeInternals]);
useEffect(() => {
if (isEditing) setDraft(node.text);
}, [isEditing, node.text]);
@@ -204,7 +220,8 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
<Handle
type="target"
position={data.targetPosition}
id={data.targetHandle.id}
position={data.targetHandle.position}
isConnectable={!readOnly}
aria-label="Point de connexion entrant"
/>
@@ -304,12 +321,18 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
</button>
) : null}
<Handle
type="source"
position={data.sourcePosition}
isConnectable={!readOnly}
aria-label="Point de connexion sortant"
/>
{/* En mind map bilaterale, la racine porte deux poignees sortantes :
ses branches partent a gauche comme a droite. */}
{data.sourceHandles.map((handle) => (
<Handle
key={handle.id}
type="source"
id={handle.id}
position={handle.position}
isConnectable={!readOnly}
aria-label="Point de connexion sortant"
/>
))}
</div>
);
}

View File

@@ -15,6 +15,8 @@ export interface NodeContextMenuProps {
onClose: () => void;
onAddChildren: (parentId: string) => void;
onMoveTo: (nodeId: string) => void;
onAddRelation: (nodeId: string) => void;
onImportMarkdown: (nodeId: string) => void;
}
interface MenuEntry {
@@ -34,7 +36,14 @@ interface MenuHeading {
type MenuLine = MenuEntry | MenuHeading;
/** Menu contextuel d'un noeud, organise par famille d'action. */
export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: NodeContextMenuProps) {
export function NodeContextMenu({
state,
onClose,
onAddChildren,
onMoveTo,
onAddRelation,
onImportMarkdown,
}: NodeContextMenuProps) {
const map = useEditorStore((store) => store.map);
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
@@ -72,6 +81,14 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
const hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id);
const notify = (message: string) => pushToast(message, 'success');
// Le cote ne se regle que sur une branche de niveau 1 : ailleurs, il est
// herite de la branche, il n'y a donc rien a choisir.
const parent = node.parentId
? map.nodes.find((candidate) => candidate.id === node.parentId)
: undefined;
const isBranchRoot = Boolean(parent && !parent.parentId);
const bilateral = map.settings.layout === 'mindmap-both';
return [
{ kind: 'heading', label: 'Ajouter' },
{ kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) },
@@ -94,6 +111,18 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
disabled: !node.parentId,
run: () => store.addSiblingAt(node.id, 'after'),
},
{
kind: 'item',
label: 'Importer Markdown ici...',
run: () => onImportMarkdown(node.id),
},
{ kind: 'heading', label: 'Relation' },
{
kind: 'item',
label: 'Ajouter une relation...',
run: () => onAddRelation(node.id),
},
{ kind: 'heading', label: 'Branche' },
{
@@ -125,6 +154,28 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
run: () => store.setBranchCollapsed(node.id, !node.collapsed),
},
{ kind: 'heading', label: 'Cote' },
{
kind: 'item',
label: 'Placer la branche a gauche',
hint: bilateral ? undefined : 'bilaterale',
disabled: !isBranchRoot || node.side === 'left',
run: () => {
store.setNodeSide(node.id, 'left');
notify('Branche placee a gauche. Relancez l organisation pour la deplacer.');
},
},
{
kind: 'item',
label: 'Placer la branche a droite',
hint: bilateral ? undefined : 'bilaterale',
disabled: !isBranchRoot || node.side === 'right',
run: () => {
store.setNodeSide(node.id, 'right');
notify('Branche placee a droite. Relancez l organisation pour la deplacer.');
},
},
{ kind: 'heading', label: 'Ordre' },
{
kind: 'item',
@@ -204,6 +255,8 @@ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo }: Nod
hasNodeClipboard,
onAddChildren,
onMoveTo,
onAddRelation,
onImportMarkdown,
pushToast,
]);

View File

@@ -28,6 +28,7 @@ import {
} from '@/features/editor/nodeStyleResolver';
import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
import { RelationProperties } from '@/features/editor/RelationProperties';
import {
ColorField,
NumberField,
@@ -90,6 +91,7 @@ const ICON_SIZES = [16, 20, 24, 32];
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
const selectedRelationId = useEditorStore((state) => state.selectedRelationId);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
@@ -135,6 +137,8 @@ export function PropertiesPanel() {
const palette = paletteForDepth(theme, referenceDepth);
const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined;
const relation = map?.relations.find((candidate) => candidate.id === selectedRelationId);
const header = (
<header className="flex items-center justify-between gap-2 border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
@@ -153,6 +157,20 @@ export function PropertiesPanel() {
</header>
);
// Une relation selectionnee prend la place du panneau de noeud : les deux
// selections s'excluent, le panneau ne montre donc jamais les deux a la fois.
if (nodes.length === 0 && relation) {
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<RelationProperties relationId={relation.id} />
</aside>
);
}
if (nodes.length === 0) {
return (
<aside
@@ -162,7 +180,8 @@ export function PropertiesPanel() {
{header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
Selectionnez un noeud pour modifier son contenu, sa typographie, son apparence et sa mise
en page. Selectionnez-en plusieurs pour appliquer un reglage a tout le lot.
en page. Selectionnez-en plusieurs pour appliquer un reglage a tout le lot. Cliquez une
relation pour en modifier le libelle et le trace.
</p>
</aside>
);

View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SearchIcon } from '@/components/Icons';
import { RELATION_REJECTION_MESSAGE } from '@/services/relations';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
export interface RelationDialogProps {
open: boolean;
sourceId: string | null;
onClose: () => void;
}
function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Creation d'une relation secondaire.
*
* Le choix passe par une recherche explicite, et non par un glissement depuis
* une poignee : tirer un lien depuis une poignee cree deja un rattachement
* parent / enfant. Deux gestes differents pour deux natures differentes, il n'y
* a donc aucune ambiguite sur ce que l'on est en train de creer.
*/
export function RelationDialog({ open, sourceId, onClose }: RelationDialogProps) {
const map = useEditorStore((state) => state.map);
const addRelation = useEditorStore((state) => state.addRelation);
const pushToast = useUiStore((state) => state.pushToast);
const [query, setQuery] = useState('');
const [label, setLabel] = useState('');
useEffect(() => {
if (open) {
setQuery('');
setLabel('');
}
}, [open]);
const source = map?.nodes.find((candidate) => candidate.id === sourceId);
const candidates = useMemo(() => {
if (!map || !source) return [];
const depths = computeDepths(map.nodes);
const needle = normalize(query.trim());
return map.nodes
.filter((candidate) => candidate.id !== source.id)
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
.slice(0, 60);
}, [map, source, query]);
const create = (targetId: string) => {
if (!source) return;
const trimmed = label.trim();
const rejection = addRelation(source.id, targetId, trimmed ? { label: trimmed } : undefined);
if (rejection) {
pushToast(RELATION_REJECTION_MESSAGE[rejection], 'error');
return;
}
pushToast('Relation creee. La hierarchie est inchangee.', 'success');
onClose();
};
return (
<Modal
open={open}
title="Ajouter une relation"
description={
source
? `Relation visuelle depuis "${source.text || 'Sans titre'}". Elle ne modifie ni le parent ni les enfants.`
: ''
}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<div>
<label className="field-label" htmlFor="relation-label">
Libelle (facultatif)
</label>
<input
id="relation-label"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise, protege..."
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<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 le noeud cible"
aria-label="Rechercher le noeud cible"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
{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="Cibles possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => create(candidate.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-zinc-700 transition-colors 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>
);
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { type BranchStyle, type RelationMarker } from '@/types/map';
import { Button } from '@/components/Button';
import { TrashIcon } from '@/components/Icons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
import {
ColorField,
NumberField,
SegmentedControl,
ToggleRow,
} from '@/features/editor/panel/controls';
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
import { RELATION_DEFAULTS } from '@/features/editor/flowAdapters';
import { getTheme, relationColor } from '@/features/editor/themes';
import { useEditorStore } from '@/stores/editorStore';
const LINE_STYLES: Array<{ value: BranchStyle; label: string }> = [
{ value: 'bezier', label: 'Bezier' },
{ value: 'curved', label: 'Courbe' },
{ value: 'straight', label: 'Droite' },
];
const MARKERS: Array<{ value: RelationMarker; label: string }> = [
{ value: 'none', label: 'Aucune' },
{ value: 'arrow', label: 'Fleche' },
];
/**
* Proprietes d'une relation secondaire.
*
* Volontairement limite a ce qui concerne le trait : une relation n'a ni
* parent, ni enfants, ni ordre. La supprimer ici ne supprime aucun noeud.
*/
export function RelationProperties({ relationId }: { relationId: string }) {
const map = useEditorStore((state) => state.map);
const updateRelation = useEditorStore((state) => state.updateRelation);
const deleteRelation = useEditorStore((state) => state.deleteRelation);
const relation = map?.relations.find((candidate) => candidate.id === relationId);
const [label, setLabel] = useState('');
useEffect(() => setLabel(relation?.label ?? ''), [relation?.id, relation?.label]);
if (!map || !relation) return null;
const theme = getTheme(map.settings.theme);
const sourceText = map.nodes.find((node) => node.id === relation.sourceId)?.text ?? '';
const targetText = map.nodes.find((node) => node.id === relation.targetId)?.text ?? '';
return (
<>
<PanelSection id="relation-content" title="Relation">
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{sourceText || 'Sans titre'}
</span>
{' → '}
<span className="font-medium text-zinc-700 dark:text-zinc-200">
{targetText || 'Sans titre'}
</span>
</p>
<p className="text-[11px] leading-snug text-zinc-400">
Connexion visuelle uniquement : la hierarchie de ces deux noeuds est inchangee.
</p>
<div>
<label className="field-label" htmlFor="relation-label-field">
Libelle
</label>
<input
id="relation-label-field"
className="field-input"
value={label}
maxLength={120}
placeholder="depend de, utilise..."
onChange={(event) => setLabel(event.target.value)}
onBlur={() => {
const trimmed = label.trim();
if (trimmed !== (relation.label ?? '')) {
updateRelation(relation.id, trimmed ? { label: trimmed } : { label: undefined });
}
}}
/>
</div>
</PanelSection>
<PanelSection id="relation-style" title="Apparence">
<SegmentedControl
legend="Trace"
selected={relation.style ?? RELATION_DEFAULTS.style}
options={LINE_STYLES}
onSelect={(value) => updateRelation(relation.id, { style: value })}
/>
<ColorField
id="relation-color"
label="Couleur"
shared={{ value: relation.color, mixed: false }}
fallback={relationColor(theme)}
onChange={(value) => updateRelation(relation.id, { color: value })}
/>
<NumberField
id="relation-width"
label="Epaisseur"
value={relation.width ?? RELATION_DEFAULTS.width}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
step={0.5}
onChange={(value) => updateRelation(relation.id, { width: value })}
/>
<ToggleRow
id="relation-dashed"
label="Trait pointille"
checked={relation.dashed ?? RELATION_DEFAULTS.dashed}
onChange={(checked) => updateRelation(relation.id, { dashed: checked })}
/>
<SegmentedControl
legend="Extremite d arrivee"
selected={relation.markerEnd ?? RELATION_DEFAULTS.markerEnd}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerEnd: value })}
/>
<SegmentedControl
legend="Extremite de depart"
selected={relation.markerStart ?? RELATION_DEFAULTS.markerStart}
options={MARKERS}
onSelect={(value) => updateRelation(relation.id, { markerStart: value })}
/>
</PanelSection>
<div className="px-4 py-3">
<Button size="sm" variant="danger" onClick={() => deleteRelation(relation.id)}>
<TrashIcon className="h-3.5 w-3.5" />
Supprimer la relation
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest';
import { type MindMapNode } from '@/types/map';
import {
assignSides,
findBranchRoot,
getLevelOneBranches,
materializeSides,
resolveNodeSides,
} from '@/features/editor/bilateral';
import { computeLayout } from '@/features/editor/layout';
function node(
id: string,
parentId: string | null,
order: number,
extra: Partial<MindMapNode> = {},
): MindMapNode {
return {
id,
type: parentId === null ? 'root' : 'standard',
text: id,
position: { x: 0, y: 0 },
parentId,
order,
...extra,
};
}
/**
* Racine avec quatre branches de poids tres differents :
* A = 25 noeuds, B = 3, C = 12, D = 5.
*/
function unbalancedMap(): MindMapNode[] {
const nodes: MindMapNode[] = [node('root', null, 0)];
const weights: Array<[string, number]> = [
['A', 24],
['B', 2],
['C', 11],
['D', 4],
];
weights.forEach(([branch, children], index) => {
nodes.push(node(branch, 'root', index));
for (let i = 0; i < children; i += 1) nodes.push(node(`${branch}${i}`, branch, i));
});
return nodes;
}
describe('attribution automatique des cotes', () => {
it('repartit les branches sans cote de part et d autre', () => {
const sides = resolveNodeSides(unbalancedMap());
const branches = ['A', 'B', 'C', 'D'].map((id) => sides.get(id));
expect(branches).toContain('left');
expect(branches).toContain('right');
});
it('equilibre les deux cotes en tenant compte de la taille des sous-arbres', () => {
const nodes = unbalancedMap();
const sides = resolveNodeSides(nodes);
const weights: Record<string, number> = { A: 25, B: 3, C: 12, D: 5 };
let left = 0;
let right = 0;
for (const [branch, weight] of Object.entries(weights)) {
if (sides.get(branch) === 'left') left += weight;
else right += weight;
}
// 25 d'un cote, 3 + 12 + 5 = 20 de l'autre : l'ecart reste faible.
expect(Math.abs(left - right)).toBeLessThanOrEqual(5);
});
it('est deterministe : deux appels donnent le meme resultat', () => {
const first = resolveNodeSides(unbalancedMap());
const second = resolveNodeSides(unbalancedMap());
for (const id of ['A', 'B', 'C', 'D']) expect(second.get(id)).toBe(first.get(id));
});
it('respecte un cote choisi a la main et equilibre le reste autour', () => {
const nodes = unbalancedMap().map((candidate) =>
candidate.id === 'A' ? { ...candidate, side: 'left' as const } : candidate,
);
const sides = resolveNodeSides(nodes);
expect(sides.get('A')).toBe('left');
// 25 a gauche : les trois autres branches vont a droite.
expect(sides.get('B')).toBe('right');
expect(sides.get('C')).toBe('right');
expect(sides.get('D')).toBe('right');
});
it('departage a poids egal par l ordre de fratrie', () => {
const result = assignSides([
{ id: 'x', weight: 4, order: 0 },
{ id: 'y', weight: 4, order: 1 },
]);
expect(result.get('x')).toBe('right');
expect(result.get('y')).toBe('left');
});
});
describe('heritage du cote', () => {
it('donne a tous les descendants le cote de leur branche de niveau 1', () => {
const nodes = unbalancedMap();
const sides = resolveNodeSides(nodes);
for (const branch of ['A', 'B', 'C', 'D']) {
const expected = sides.get(branch);
for (const candidate of nodes.filter((item) => item.parentId === branch)) {
expect(sides.get(candidate.id)).toBe(expected);
}
}
});
it('identifie la branche de niveau 1 dont depend un noeud profond', () => {
const nodes = unbalancedMap();
expect(findBranchRoot(nodes, 'A3')?.id).toBe('A');
expect(findBranchRoot(nodes, 'A')?.id).toBe('A');
expect(findBranchRoot(nodes, 'root')).toBeNull();
});
it('ne liste comme branches que les enfants directs des racines', () => {
const branches = getLevelOneBranches(unbalancedMap()).map((branch) => branch.id);
expect(branches).toEqual(['A', 'B', 'C', 'D']);
});
});
describe('materialisation des cotes', () => {
it('ecrit un cote uniquement sur les branches de niveau 1', () => {
const materialized = materializeSides(unbalancedMap());
for (const candidate of materialized) {
const isBranch = ['A', 'B', 'C', 'D'].includes(candidate.id);
expect(candidate.side !== undefined).toBe(isBranch);
}
});
it('ne modifie pas le tableau si tous les cotes sont deja definis', () => {
const once = materializeSides(unbalancedMap());
expect(materializeSides(once)).toBe(once);
});
it('n ecrase jamais un cote choisi a la main', () => {
const nodes = unbalancedMap().map((candidate) =>
candidate.id === 'B' ? { ...candidate, side: 'left' as const } : candidate,
);
const materialized = materializeSides(nodes);
expect(materialized.find((candidate) => candidate.id === 'B')?.side).toBe('left');
});
});
describe('auto-layout bilateral', () => {
it('place la racine entre les deux cotes', () => {
const nodes = materializeSides(unbalancedMap());
const sides = resolveNodeSides(nodes);
const positions = computeLayout(nodes, 'mindmap-both');
const rootX = positions.get('root')?.x as number;
const lefts = nodes
.filter((candidate) => sides.get(candidate.id) === 'left' && candidate.parentId === 'root')
.map((candidate) => positions.get(candidate.id)?.x as number);
const rights = nodes
.filter((candidate) => sides.get(candidate.id) === 'right' && candidate.parentId === 'root')
.map((candidate) => positions.get(candidate.id)?.x as number);
expect(lefts.length).toBeGreaterThan(0);
expect(rights.length).toBeGreaterThan(0);
for (const x of lefts) expect(x).toBeLessThan(rootX);
for (const x of rights) expect(x).toBeGreaterThan(rootX);
});
it('fait progresser chaque cote en s eloignant de la racine', () => {
const nodes = materializeSides(unbalancedMap());
const sides = resolveNodeSides(nodes);
const positions = computeLayout(nodes, 'mindmap-both');
const rootX = positions.get('root')?.x as number;
for (const branch of ['A', 'B', 'C', 'D']) {
const branchX = positions.get(branch)?.x as number;
const child = nodes.find((candidate) => candidate.parentId === branch);
if (!child) continue;
const childX = positions.get(child.id)?.x as number;
if (sides.get(branch) === 'left') {
expect(childX).toBeLessThan(branchX);
expect(branchX).toBeLessThan(rootX);
} else {
expect(childX).toBeGreaterThan(branchX);
expect(branchX).toBeGreaterThan(rootX);
}
}
});
it('respecte l ordre des freres a l interieur d un cote', () => {
const nodes: MindMapNode[] = [
node('root', null, 0),
node('a', 'root', 0, { side: 'right' }),
node('b', 'root', 1, { side: 'right' }),
node('c', 'root', 2, { side: 'right' }),
];
const positions = computeLayout(nodes, 'mindmap-both');
const y = (id: string) => positions.get(id)?.y as number;
expect(y('a')).toBeLessThan(y('b'));
expect(y('b')).toBeLessThan(y('c'));
});
it('deploie vers la droite ou vers la gauche selon la disposition classique', () => {
const nodes: MindMapNode[] = [node('root', null, 0), node('a', 'root', 0)];
const right = computeLayout(nodes, 'mindmap-right');
expect((right.get('a')?.x as number)).toBeGreaterThan(right.get('root')?.x as number);
const left = computeLayout(nodes, 'mindmap-left');
expect((left.get('a')?.x as number)).toBeLessThan(left.get('root')?.x as number);
});
it('ignore les relations secondaires : elles ne sont pas dans le graphe', () => {
// computeLayout ne recoit que des noeuds : aucune relation ne peut
// influencer dagre, par construction de sa signature.
const nodes = materializeSides(unbalancedMap());
const withoutRelations = computeLayout(nodes, 'mindmap-both');
const again = computeLayout(nodes, 'mindmap-both');
for (const [id, position] of withoutRelations) {
expect(again.get(id)).toEqual(position);
}
});
});

View File

@@ -0,0 +1,152 @@
import { type MindMapNode, type NodeSide } from '@/types/map';
import { buildChildrenMap, buildNodeMap, getOrderedChildren, getRootNodes } from '@/utils/tree';
/**
* Cotes gauche / droite d'une mind map bilaterale.
*
* Le modele est volontairement minimal : seul un enfant direct d'une racine
* porte un `side`. Tous ses descendants heritent du cote de leur branche de
* niveau 1, ce qui evite d'entretenir la meme information sur chaque noeud et
* garantit qu'une branche ne peut pas se retrouver a cheval sur les deux cotes.
*
* `order` reste la seule source de verite de l'ordre : on filtre les enfants
* par cote, puis on respecte leur ordre existant. Il n'y a jamais de
* `leftOrder` / `rightOrder` a maintenir en parallele.
*/
/** Poids d'une branche : elle-meme plus tous ses descendants. */
function branchWeight(children: Map<string, string[]>, rootId: string): number {
let total = 1;
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
total += 1;
const next = children.get(current);
if (next) queue.push(...next);
}
return total;
}
/**
* Repartition gloutonne et deterministe.
*
* Les branches sans cote sont traitees de la plus lourde a la plus legere et
* placees du cote le moins charge. C'est suffisant pour equilibrer visuellement
* une carte reelle, sans construire un solveur : a poids egal, l'ordre de
* fratrie tranche, donc le resultat est reproductible.
*/
export function assignSides(
branches: Array<{ id: string; weight: number; order: number; side?: NodeSide }>,
): Map<string, NodeSide> {
const result = new Map<string, NodeSide>();
let leftLoad = 0;
let rightLoad = 0;
for (const branch of branches) {
if (!branch.side) continue;
result.set(branch.id, branch.side);
if (branch.side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
const pending = branches
.filter((branch) => !branch.side)
.sort((a, b) => (b.weight !== a.weight ? b.weight - a.weight : a.order - b.order));
for (const branch of pending) {
// A charge egale, la droite d'abord : c'est le cote de lecture naturel.
const side: NodeSide = rightLoad <= leftLoad ? 'right' : 'left';
result.set(branch.id, side);
if (side === 'left') leftLoad += branch.weight;
else rightLoad += branch.weight;
}
return result;
}
/** Branches de niveau 1 : les enfants directs des racines, dans l'ordre. */
export function getLevelOneBranches(nodes: MindMapNode[]): MindMapNode[] {
const branches: MindMapNode[] = [];
for (const root of getRootNodes(nodes)) {
branches.push(...getOrderedChildren(nodes, root.id));
}
return branches;
}
/**
* Cote effectif de chaque noeud.
*
* Fonction pure : elle ne modifie pas le document. Une branche sans cote defini
* en recoit un ici aussi, par le meme algorithme que l'attribution explicite :
* une branche ajoutee apres coup se place donc immediatement du bon cote, sans
* qu'il faille reecrire la carte.
*/
export function resolveNodeSides(nodes: MindMapNode[]): Map<string, NodeSide> {
const children = buildChildrenMap(nodes);
const branches = getLevelOneBranches(nodes).map((node) => ({
id: node.id,
weight: branchWeight(children, node.id),
order: node.order ?? 0,
side: node.side,
}));
const byBranch = assignSides(branches);
const sides = new Map<string, NodeSide>();
// Les racines restent au centre : leur cote ne sert qu'aux cas degenres.
for (const root of getRootNodes(nodes)) sides.set(root.id, 'right');
for (const [branchId, side] of byBranch) {
sides.set(branchId, side);
const queue = [...(children.get(branchId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
sides.set(current, side);
const next = children.get(current);
if (next) queue.push(...next);
}
}
// Securite : un noeud orphelin d'une donnee incoherente reste positionnable.
for (const node of nodes) if (!sides.has(node.id)) sides.set(node.id, 'right');
return sides;
}
/**
* Materialise les cotes manquants dans le document.
*
* Seules les branches de niveau 1 sans cote sont completees : un cote choisi a
* la main n'est jamais ecrase. Retourne le tableau d'origine si rien ne change.
*/
export function materializeSides(nodes: MindMapNode[]): MindMapNode[] {
const branches = getLevelOneBranches(nodes);
if (branches.every((branch) => branch.side)) return nodes;
const resolved = resolveNodeSides(nodes);
const branchIds = new Set(branches.map((branch) => branch.id));
return nodes.map((node) =>
branchIds.has(node.id) && !node.side
? { ...node, side: resolved.get(node.id) ?? 'right' }
: node,
);
}
/**
* Branche de niveau 1 dont depend un noeud, c'est-a-dire le noeud dont le
* `side` fait foi pour lui. `null` pour une racine.
*/
export function findBranchRoot(nodes: MindMapNode[], nodeId: string): MindMapNode | null {
const byId = buildNodeMap(nodes);
let current = byId.get(nodeId);
if (!current) return null;
const guard = new Set<string>();
while (current.parentId && byId.has(current.parentId) && !guard.has(current.id)) {
guard.add(current.id);
const parent = byId.get(current.parentId) as MindMapNode;
if (!parent.parentId || !byId.has(parent.parentId)) return current;
current = parent;
}
return null;
}

View File

@@ -1,7 +1,13 @@
import { type Edge, type Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
import { type BranchStyle, type MindMap, type MindMapNode } from '@/types/map';
import { type MapTheme } from '@/features/editor/themes';
import { MarkerType, Position } from '@xyflow/react';
import {
type BranchStyle,
type MindMap,
type MindMapNode,
type NodeSide,
} from '@/types/map';
import { resolveNodeSides } from '@/features/editor/bilateral';
import { relationColor, type MapTheme } from '@/features/editor/themes';
import {
resolveNodeAppearance,
type NodeAppearance,
@@ -22,8 +28,19 @@ export interface MindNodeData extends Record<string, unknown> {
isSearchHit: boolean;
readOnly: boolean;
isEditing: boolean;
sourcePosition: Position;
targetPosition: Position;
/**
* Poignees sortantes. Une seule dans les dispositions classiques ; en mind map
* bilaterale, la racine en porte deux puisque ses branches partent des deux
* cotes.
*/
sourceHandles: HandleSpec[];
targetHandle: HandleSpec;
}
/** Une poignee de connexion : identifiant stable et cote d'ancrage. */
export interface HandleSpec {
id: string;
position: Position;
}
export type MindFlowNode = Node<MindNodeData, 'mind'>;
@@ -40,6 +57,81 @@ function handlePositions(layout: MindMap['settings']['layout']): {
: { source: Position.Right, target: Position.Left };
}
function handleSpec(type: 's' | 't', position: Position): HandleSpec {
return { id: `${type}-${position}`, position };
}
/** Poignees d'un noeud : une entrante, une ou deux sortantes. */
export interface HandlePlan {
sources: HandleSpec[];
target: HandleSpec;
/** Poignee sortante a utiliser pour rejoindre un enfant, par cote. */
sourceBySide: Map<NodeSide, HandleSpec>;
}
const SIDE_POSITION: Record<NodeSide, Position> = {
left: Position.Left,
right: Position.Right,
};
/**
* Repartition des poignees, calculee une fois par carte.
*
* Hors bilaterale, tous les noeuds partagent la meme orientation, comme avant.
* En bilaterale, chaque noeud regarde vers le centre et la racine porte les
* deux poignees sortantes : c'est ce qui permet aux branches de partir a gauche
* comme a droite sans que le trait fasse un detour.
*/
export function buildHandlePlans(map: MindMap): Map<string, HandlePlan> {
const plans = new Map<string, HandlePlan>();
const bilateral = map.settings.layout === 'mindmap-both';
const positions = handlePositions(map.settings.layout);
if (!bilateral) {
const source = handleSpec('s', positions.source);
const target = handleSpec('t', positions.target);
const plan: HandlePlan = {
sources: [source],
target,
sourceBySide: new Map([
['left', source],
['right', source],
]),
};
for (const node of map.nodes) plans.set(node.id, plan);
return plans;
}
const sides = resolveNodeSides(map.nodes);
const children = buildChildrenMap(map.nodes);
for (const node of map.nodes) {
const own = sides.get(node.id) ?? 'right';
// Le parent est toujours du cote du centre : l'entree lui fait face.
const target = handleSpec('t', own === 'left' ? Position.Right : Position.Left);
const childSides = new Set<NodeSide>();
for (const childId of children.get(node.id) ?? []) {
childSides.add(sides.get(childId) ?? 'right');
}
// Un noeud sans enfant garde une poignee : on doit pouvoir tirer un lien.
if (childSides.size === 0) childSides.add(own);
const sources = (['left', 'right'] as NodeSide[])
.filter((side) => childSides.has(side))
.map((side) => handleSpec('s', SIDE_POSITION[side]));
const sourceBySide = new Map<NodeSide, HandleSpec>();
for (const side of ['left', 'right'] as NodeSide[]) {
const match = sources.find((handle) => handle.position === SIDE_POSITION[side]);
sourceBySide.set(side, match ?? (sources[0] as HandleSpec));
}
plans.set(node.id, { sources, target, sourceBySide });
}
return plans;
}
const EDGE_TYPE_BY_BRANCH: Record<BranchStyle, string> = {
bezier: 'default',
curved: 'smoothstep',
@@ -62,12 +154,18 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults);
const positions = handlePositions(map.settings.layout);
const plans = buildHandlePlans(map);
const fallback: HandlePlan = {
sources: [handleSpec('s', Position.Right)],
target: handleSpec('t', Position.Left),
sourceBySide: new Map(),
};
return map.nodes.map((node) => {
const depth = depths.get(node.id) ?? 0;
const childIds = children.get(node.id) ?? [];
const measured = options.measuredSizes?.get(node.id);
const plan = plans.get(node.id) ?? fallback;
return {
id: node.id,
@@ -89,8 +187,8 @@ export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode
isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id,
sourcePosition: positions.source,
targetPosition: positions.target,
sourceHandles: plan.sources,
targetHandle: plan.target,
},
};
});
@@ -116,16 +214,21 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes);
const nodeMap = buildNodeMap(map.nodes);
const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
const plans = buildHandlePlans(map);
const sides = map.settings.layout === 'mindmap-both' ? resolveNodeSides(map.nodes) : null;
return map.edges.map((edge) => {
const target = nodeMap.get(edge.target);
const branch = target?.branch ?? {};
const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType;
const childSide: NodeSide = sides?.get(edge.target) ?? 'right';
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: plans.get(edge.source)?.sourceBySide.get(childSide)?.id,
targetHandle: plans.get(edge.target)?.target.id,
type,
hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly,
@@ -136,3 +239,95 @@ export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
};
});
}
/** Apparence par defaut d'une relation : pointille flechee, pour la distinguer. */
export const RELATION_DEFAULTS = {
style: 'bezier' as BranchStyle,
width: 2,
dashed: true,
markerEnd: 'arrow' as const,
markerStart: 'none' as const,
};
/** Prefixe des identifiants de relation : ils ne peuvent pas etre confondus
* avec un lien hierarchique, dont l'id est toujours `e-<parent>-<enfant>`. */
export const RELATION_ID_PREFIX = 'r-';
/**
* Relations secondaires converties en aretes React Flow.
*
* Elles ne sont jamais melangees a `map.edges` dans le document : les liens
* hierarchiques restent derives de `parentId`, les relations restent une donnee
* propre. C'est seulement a l'affichage que les deux se rejoignent.
*
* Une relation dont une extremite est masquee par un repli est masquee elle
* aussi : on ne trace pas un trait vers une position qui n'existe pas. Elle
* reste dans le document et reapparait au deploiement.
*/
export function toFlowRelationEdges(
map: MindMap,
options: AdapterOptions & { selectedRelationId?: string | null },
): Edge[] {
if (map.relations.length === 0) return [];
const hidden = getHiddenNodeIds(map.nodes);
const nodeMap = buildNodeMap(map.nodes);
const plans = buildHandlePlans(map);
return map.relations
.filter((relation) => nodeMap.has(relation.sourceId) && nodeMap.has(relation.targetId))
.map((relation) => {
const source = nodeMap.get(relation.sourceId) as MindMapNode;
const target = nodeMap.get(relation.targetId) as MindMapNode;
const type =
EDGE_TYPE_BY_BRANCH[relation.style ?? RELATION_DEFAULTS.style] ?? 'default';
const color = relation.color ?? relationColor(options.theme);
const width = relation.width ?? RELATION_DEFAULTS.width;
const dashed = relation.dashed ?? RELATION_DEFAULTS.dashed;
// La poignee sortante la plus proche de la cible evite un trait qui
// contourne le noeud avant de repartir.
const towards: NodeSide = target.position.x >= source.position.x ? 'right' : 'left';
const sourcePlan = plans.get(relation.sourceId);
const sourceHandle =
sourcePlan?.sources.find(
(handle) => handle.position === SIDE_POSITION[towards],
)?.id ?? sourcePlan?.sources[0]?.id;
return {
id: relation.id,
source: relation.sourceId,
target: relation.targetId,
sourceHandle,
targetHandle: plans.get(relation.targetId)?.target.id,
type,
selected: options.selectedRelationId === relation.id,
hidden: hidden.has(relation.sourceId) || hidden.has(relation.targetId),
deletable: !options.readOnly,
selectable: true,
...(relation.label ? { label: relation.label } : {}),
labelStyle: { fill: color, fontSize: 11, fontWeight: 500 },
labelBgStyle: { fill: options.theme.canvasBackground, fillOpacity: 0.85 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
markerEnd:
(relation.markerEnd ?? RELATION_DEFAULTS.markerEnd) === 'arrow'
? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
: undefined,
markerStart:
(relation.markerStart ?? RELATION_DEFAULTS.markerStart) === 'arrow'
? { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }
: undefined,
style: {
stroke: color,
strokeWidth: width,
...(dashed ? { strokeDasharray: '6 5' } : {}),
},
data: { relation },
};
});
}
/** Vrai si cette arete affichee est une relation secondaire. */
export function isRelationEdgeId(map: MindMap, edgeId: string): boolean {
return map.relations.some((relation) => relation.id === edgeId);
}

View File

@@ -1,6 +1,8 @@
import dagre from 'dagre';
import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
import { type MapTheme } from '@/features/editor/themes';
import { resolveNodeSides } from '@/features/editor/bilateral';
import { getRootNodes } from '@/utils/tree';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption {
@@ -10,9 +12,9 @@ export interface LayoutOption {
}
/**
* Dispositions proposees dans l'interface de la V1.
* `mindmap-both`, `organization` et `freeform` existent dans le modele mais ne
* sont pas encore implementees : elles restent disponibles pour une V2.
* Dispositions proposees dans l'interface.
* `organization` et `freeform` existent dans le modele mais ne sont pas encore
* implementees : elles restent disponibles pour une version ulterieure.
*/
export const LAYOUT_OPTIONS: LayoutOption[] = [
{
@@ -25,6 +27,11 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
label: 'Mind map gauche',
description: 'Les branches se deploient vers la gauche.',
},
{
id: 'mindmap-both',
label: 'Mind map bilaterale',
description: 'Racine centree, branches reparties a gauche et a droite.',
},
{
id: 'tree-horizontal',
label: 'Arbre horizontal',
@@ -70,8 +77,10 @@ const LAYOUT_PARAMS: Record<string, DagreParams> = {
'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
// Replis pour les dispositions reservees.
// La bilaterale ne passe pas par dagre directement : elle combine les deux
// dispositions ci-dessus. L'entree sert a l'orientation des poignees.
'mindmap-both': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
// Replis pour les dispositions reservees.
organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
};
@@ -111,11 +120,67 @@ export function computeLayout(
nodes: MindMapNode[],
layout: LayoutKind,
options: LayoutOptions = {},
): Map<string, Position> {
if (nodes.length === 0) return new Map<string, Position>();
if (layout === 'mindmap-both') return computeBilateralLayout(nodes, options);
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
return runDagre(nodes, params, options);
}
/**
* Mind map bilaterale : deux passes dagre, une par cote, puis recalage.
*
* Chaque passe contient les racines et les branches de son cote ; on aligne
* ensuite le graphe gauche sur le graphe droit en superposant la racine. La
* racine se retrouve donc au centre, avec ses branches de part et d'autre, et
* chaque cote reste centre verticalement sur elle.
*
* Les relations secondaires n'entrent jamais dans ces graphes : seul `parentId`
* construit les aretes, ici comme dans les autres dispositions.
*/
function computeBilateralLayout(
nodes: MindMapNode[],
options: LayoutOptions,
): Map<string, Position> {
const sides = resolveNodeSides(nodes);
const roots = getRootNodes(nodes);
const rootIds = new Set(roots.map((node) => node.id));
const rightNodes = nodes.filter(
(node) => rootIds.has(node.id) || sides.get(node.id) === 'right',
);
const leftNodes = nodes.filter((node) => rootIds.has(node.id) || sides.get(node.id) === 'left');
const rightParams = LAYOUT_PARAMS['mindmap-right'] as DagreParams;
const leftParams = LAYOUT_PARAMS['mindmap-left'] as DagreParams;
const right = runDagre(rightNodes, rightParams, options);
const left = runDagre(leftNodes, leftParams, options);
const anchor = roots[0];
const anchorRight = anchor ? right.get(anchor.id) : undefined;
const anchorLeft = anchor ? left.get(anchor.id) : undefined;
const shiftX = anchorRight && anchorLeft ? anchorRight.x - anchorLeft.x : 0;
const shiftY = anchorRight && anchorLeft ? anchorRight.y - anchorLeft.y : 0;
const positions = new Map<string, Position>(right);
for (const [id, position] of left) {
if (rootIds.has(id)) continue; // la racine garde la position du graphe droit
positions.set(id, { x: Math.round(position.x + shiftX), y: Math.round(position.y + shiftY) });
}
return positions;
}
/** Une passe dagre sur un sous-ensemble de noeuds. */
function runDagre(
nodes: MindMapNode[],
params: DagreParams,
options: LayoutOptions,
): Map<string, Position> {
const positions = new Map<string, Position>();
if (nodes.length === 0) return positions;
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
const spacing = resolveSpacing(options.spacing);
const graph = new dagre.graphlib.Graph();

View File

@@ -24,6 +24,12 @@ export interface MapTheme {
shadow: string;
branchColor: string;
branchWidth: number;
/**
* Couleur par defaut des relations secondaires (v4). Optionnelle : sans
* valeur, `relationColor()` choisit une teinte volontairement distincte des
* branches, pour qu'une relation ne se confonde jamais avec la hierarchie.
*/
relationColor?: string;
root: NodePalette;
levels: NodePalette[];
@@ -276,6 +282,15 @@ export function getTheme(themeId: string | undefined): MapTheme {
return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme);
}
/**
* Couleur d'une relation secondaire.
* Volontairement differente des branches : le lecteur doit voir au premier
* coup d'oeil qu'un trait n'appartient pas a la hierarchie.
*/
export function relationColor(theme: MapTheme): string {
return theme.relationColor ?? (theme.isDark ? '#f0abfc' : '#a855f7');
}
/** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */
export function paletteForDepth(theme: MapTheme, depth: number): NodePalette {
if (depth <= 0) return theme.root;

View File

@@ -135,7 +135,12 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
break;
case 'Delete':
case 'Backspace':
if (store.selectedNodeIds.length > 0) {
// Une relation selectionnee se supprime seule : aucun noeud n'est
// touche. C'est la contrepartie du fait qu'elle n'est pas structurelle.
if (store.selectedRelationId) {
event.preventDefault();
store.deleteRelation(store.selectedRelationId);
} else if (store.selectedNodeIds.length > 0) {
event.preventDefault();
store.deleteNodes(store.selectedNodeIds);
}
@@ -154,6 +159,7 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
case 'Escape':
store.setEditingNode(null);
store.setSelection([]);
store.setSelectedRelation(null);
break;
default:
break;

View File

@@ -14,6 +14,7 @@ import { PrintDialog } from '@/features/export/PrintDialog';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { serializeMap } from '@/services/mapSerialization';
import { exportMarkdown, hasUnexportableRelations } from '@/services/markdown';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ExportMenuProps {
@@ -37,6 +38,25 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
pushToast('Carte exportee.', 'success');
};
/**
* Export Markdown : contenu et hierarchie.
* Les relations secondaires n'y ont pas de representation fidele ; plutot que
* d'inventer une hierarchie, on previent l'utilisateur et on exporte le reste.
*/
const exportMarkdownFile = () => {
if (!map) return;
const blob = new Blob([exportMarkdown(map)], { type: 'text/markdown;charset=utf-8' });
downloadBlob(blob, toSafeFilename(map.name, '.md'));
if (hasUnexportableRelations(map)) {
pushToast(
'Export Markdown termine. Les relations visuelles secondaires n y sont pas representees.',
'info',
);
} else {
pushToast('Export Markdown termine.', 'success');
}
};
const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
if (!map || busy) return;
setBusy(true);
@@ -91,6 +111,17 @@ export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
</>
) : null}
<MenuLabel>Texte</MenuLabel>
<MenuItem
label="Markdown (.md)"
hint="hierarchie"
onSelect={() => {
exportMarkdownFile();
close();
}}
/>
<MenuSeparator />
<MenuLabel>Image</MenuLabel>
<MenuItem
label="PNG - resolution normale"

View File

@@ -5,10 +5,20 @@ import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Modal } from '@/components/Modal';
import { ImportIcon, MoonIcon, PlusIcon, SearchIcon, SunIcon } from '@/components/Icons';
import {
ChevronDownIcon,
ImportIcon,
MoonIcon,
PlusIcon,
SearchIcon,
SunIcon,
} from '@/components/Icons';
import { Menu, MenuItem, MenuLabel } from '@/components/Menu';
import { TemplateGallery } from '@/features/templates/TemplateGallery';
import { buildTemplateMap, type MapTemplate } from '@/features/templates/templates';
import { MarkdownError, buildMapFromMarkdown } from '@/services/markdown';
import { MapCard } from '@/features/maps/MapCard';
import {
createMap,
deleteMap,
duplicateMap,
getMap,
@@ -26,6 +36,7 @@ import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() {
const appName = useAppConfigStore((state) => state.name);
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
const maxNodes = useAppConfigStore((state) => state.maxNodes);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
@@ -36,7 +47,9 @@ export function DashboardPage() {
const [renaming, setRenaming] = useState<MapSummary | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleting, setDeleting] = useState<MapSummary | null>(null);
const [galleryOpen, setGalleryOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const markdownInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => {
setLoading(true);
@@ -56,9 +69,28 @@ export function DashboardPage() {
return maps.filter((map) => map.name.toLowerCase().includes(needle));
}, [maps, query]);
const handleCreate = async () => {
const map = await createMap('Nouvelle carte');
navigate(buildMapPath(map.id));
/** Un modele est toujours copie : l'original reste une constante du code. */
const handleChooseTemplate = async (template: MapTemplate) => {
setGalleryOpen(false);
const stored = await importMap(buildTemplateMap(template));
navigate(buildMapPath(stored.id));
};
const handleImportMarkdown = async (file: File) => {
try {
const name = file.name.replace(/\.(md|markdown|txt)$/i, '');
const map = buildMapFromMarkdown(await file.text(), name || 'Carte importee', maxNodes);
const stored = await importMap(map);
pushToast(`Carte "${stored.name}" creee depuis le Markdown.`, 'success');
navigate(buildMapPath(stored.id));
} catch (error) {
pushToast(
error instanceof MarkdownError
? error.message
: "Ce fichier Markdown n'a pas pu etre lu.",
'error',
);
}
};
const handleImportFile = async (file: File) => {
@@ -134,14 +166,43 @@ export function DashboardPage() {
</header>
<div className="mb-6 flex flex-wrap items-center gap-2">
<Button variant="primary" onClick={() => void handleCreate()}>
<Button variant="primary" onClick={() => setGalleryOpen(true)}>
<PlusIcon className="h-4 w-4" />
Nouvelle carte
</Button>
<Button onClick={() => fileInputRef.current?.click()}>
<ImportIcon className="h-4 w-4" />
Importer
</Button>
<Menu
label="Menu d'import"
trigger={
<Button>
<ImportIcon className="h-4 w-4" />
Importer
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{(close) => (
<>
<MenuLabel>Depuis un fichier</MenuLabel>
<MenuItem
label={`Carte ${APP_CONFIG.fileExtension}`}
onSelect={() => {
fileInputRef.current?.click();
close();
}}
/>
<MenuItem
label="Markdown (.md)"
hint="nouvelle carte"
onSelect={() => {
markdownInputRef.current?.click();
close();
}}
/>
</>
)}
</Menu>
<input
ref={fileInputRef}
type="file"
@@ -154,6 +215,18 @@ export function DashboardPage() {
if (file) void handleImportFile(file);
}}
/>
<input
ref={markdownInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="sr-only"
aria-label="Importer un fichier Markdown"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportMarkdown(file);
}}
/>
<div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
@@ -181,7 +254,7 @@ export function DashboardPage() {
: `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`}
</p>
{!query ? (
<Button variant="primary" onClick={() => void handleCreate()}>
<Button variant="primary" onClick={() => setGalleryOpen(true)}>
<PlusIcon className="h-4 w-4" />
Nouvelle carte
</Button>
@@ -213,6 +286,12 @@ export function DashboardPage() {
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
</footer>
<TemplateGallery
open={galleryOpen}
onClose={() => setGalleryOpen(false)}
onSelect={(template) => void handleChooseTemplate(template)}
/>
<Modal
open={renaming !== null}
title="Renommer la carte"

View File

@@ -0,0 +1,68 @@
import { Modal } from '@/components/Modal';
import { Button } from '@/components/Button';
import { MAP_TEMPLATES, type MapTemplate, type TemplateNode } from '@/features/templates/templates';
export interface TemplateGalleryProps {
open: boolean;
onClose: () => void;
onSelect: (template: MapTemplate) => void;
}
/** Apercu textuel : les premieres branches du modele, sans aucune image. */
function outline(node: TemplateNode): string {
const branches = (node.children ?? []).slice(0, 5).map((child) => child.text);
if (branches.length === 0) return 'Une racine, rien d autre.';
const extra = (node.children ?? []).length - branches.length;
return branches.join(' - ') + (extra > 0 ? ` - +${extra}` : '');
}
function countNodes(node: TemplateNode): number {
return 1 + (node.children ?? []).reduce((total, child) => total + countNodes(child), 0);
}
/**
* Galerie de modeles.
*
* Tout est local : aucun appel reseau, aucune image distante. Choisir un modele
* cree une copie ; le modele lui-meme n'est jamais modifie.
*/
export function TemplateGallery({ open, onClose, onSelect }: TemplateGalleryProps) {
return (
<Modal
open={open}
title="Nouvelle carte"
description="Partez d'une carte vide ou d'un modele. Le modele est copie : vous le modifiez librement."
onClose={onClose}
size="lg"
footer={<Button onClick={onClose}>Annuler</Button>}
>
<ul className="grid gap-2 sm:grid-cols-2" aria-label="Modeles disponibles">
{MAP_TEMPLATES.map((template) => (
<li key={template.id}>
<button
type="button"
onClick={() => onSelect(template)}
className="flex h-full w-full flex-col gap-1 rounded-lg border border-zinc-200 p-3 text-left transition-colors hover:border-indigo-300 hover:bg-indigo-50/50 dark:border-zinc-700 dark:hover:border-indigo-700 dark:hover:bg-indigo-950/30"
>
<span className="flex items-center gap-2">
<span aria-hidden="true" className="text-lg">
{template.icon}
</span>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{template.name}
</span>
<span className="ml-auto shrink-0 text-[11px] text-zinc-400">
{countNodes(template.root)} noeuds
</span>
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{template.description}
</span>
<span className="truncate text-[11px] text-zinc-400">{outline(template.root)}</span>
</button>
</li>
))}
</ul>
</Modal>
);
}

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { MAP_TEMPLATES, buildTemplateMap, getTemplate } from '@/features/templates/templates';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
import { getLevelOneBranches } from '@/features/editor/bilateral';
import { getOrderedChildren } from '@/utils/tree';
describe('galerie de modeles', () => {
it('propose les huit modeles annonces', () => {
expect(MAP_TEMPLATES.map((template) => template.id)).toEqual([
'blank',
'brainstorming',
'project',
'it-architecture',
'meeting',
'swot',
'study',
'personal',
]);
});
it('donne a chaque modele un nom et une description', () => {
for (const template of MAP_TEMPLATES) {
expect(template.name.length).toBeGreaterThan(0);
expect(template.description.length).toBeGreaterThan(0);
expect(template.icon.length).toBeGreaterThan(0);
}
});
it('retrouve un modele par son identifiant', () => {
expect(getTemplate('swot')?.name).toBe('SWOT');
expect(getTemplate('inexistant')).toBeUndefined();
});
});
describe('creation depuis un modele', () => {
it('produit une carte valide pour chaque modele', () => {
for (const template of MAP_TEMPLATES) {
const map = buildTemplateMap(template);
expect(map.nodes.length).toBeGreaterThan(0);
expect(map.nodes[0]?.parentId).toBeNull();
expect(map.nodes[0]?.type).toBe('root');
expect(map.settings.theme).toBe(template.theme);
expect(map.settings.layout).toBe(template.layout);
expect(map.relations).toEqual([]);
}
});
it('regenere des identifiants uniques a chaque creation', () => {
const template = getTemplate('it-architecture');
const first = buildTemplateMap(template as never);
const second = buildTemplateMap(template as never);
const firstIds = new Set(first.nodes.map((node) => node.id));
expect(firstIds.size).toBe(first.nodes.length);
for (const node of second.nodes) expect(firstIds.has(node.id)).toBe(false);
expect(first.id).not.toBe(second.id);
});
it('ne modifie jamais le modele source', () => {
const template = getTemplate('it-architecture') as never as {
root: { text: string; children?: unknown[] };
};
const before = JSON.stringify(template);
const map = buildTemplateMap(template as never);
const root = map.nodes[0];
if (root) root.text = 'Modifie';
expect(JSON.stringify(template)).toBe(before);
});
it("reproduit l'arborescence du modele Architecture IT", () => {
const map = buildTemplateMap(getTemplate('it-architecture') as never);
const root = map.nodes[0];
expect(root?.text).toBe('Architecture IT');
const branches = getOrderedChildren(map.nodes, root?.id as string).map((node) => node.text);
expect(branches).toEqual(['Reseau', 'Compute', 'Stockage', 'Services', 'Supervision']);
const reseau = map.nodes.find((node) => node.text === 'Reseau');
expect(getOrderedChildren(map.nodes, reseau?.id as string).map((node) => node.text)).toEqual([
'Firewall',
'Switch',
'VLAN',
'VPN',
]);
expect(map.nodes).toHaveLength(20);
});
it('n ecrit un cote que sur les branches de niveau 1', () => {
const map = buildTemplateMap(getTemplate('it-architecture') as never);
const branchIds = new Set(getLevelOneBranches(map.nodes).map((node) => node.id));
for (const node of map.nodes) {
if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
}
expect(branchIds.size).toBe(5);
});
it('positionne les noeuds sans les empiler', () => {
const map = buildTemplateMap(getTemplate('project') as never);
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
expect(distinct.size).toBe(map.nodes.length);
});
});
describe('modele et format de fichier', () => {
it('survit a un aller-retour de serialisation', () => {
const map = buildTemplateMap(getTemplate('swot') as never);
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
expect(again.nodes).toHaveLength(map.nodes.length);
expect(again.settings.layout).toBe(map.settings.layout);
const sides = new Map(again.nodes.map((node) => [node.text, node.side]));
for (const node of map.nodes) {
expect(sides.get(node.text)).toBe(node.side);
}
});
it('est ecrit au format courant', () => {
const map = buildTemplateMap(getTemplate('blank') as never);
expect(JSON.parse(serializeMap(map)).version).toBe(4);
});
});

View File

@@ -0,0 +1,395 @@
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MindMap,
type MindMapNode,
type NodeSide,
} from '@/types/map';
import { computeLayout } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes';
import { createNode, DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
/**
* Galerie de modeles, entierement locale.
*
* Un modele est une description figee : `buildTemplateMap` en fabrique une
* carte neuve, avec de nouveaux identifiants, a chaque appel. L'original n'est
* jamais modifie, et la carte creee est ensuite une carte comme les autres.
* Aucune ressource distante n'est chargee : tout est ecrit ici.
*/
export interface TemplateNode {
text: string;
icon?: string;
note?: string;
/** Cote impose pour une branche de niveau 1, en disposition bilaterale. */
side?: NodeSide;
children?: TemplateNode[];
}
export interface MapTemplate {
id: string;
name: string;
description: string;
/** Emoji d'illustration : aucune image distante. */
icon: string;
theme: string;
layout: LayoutKind;
branchStyle?: BranchStyle;
root: TemplateNode;
}
export const MAP_TEMPLATES: MapTemplate[] = [
{
id: 'blank',
name: 'Carte vide',
description: 'Une seule racine, a vous de construire.',
icon: '⬜',
theme: 'default',
layout: 'mindmap-right',
root: { text: 'Nouvelle carte' },
},
{
id: 'brainstorming',
name: 'Brainstorming',
description: 'Collecter des idees, les trier, decider.',
icon: '💡',
theme: 'sunset',
layout: 'mindmap-both',
root: {
text: 'Brainstorming',
icon: '💡',
children: [
{
text: 'Question de depart',
side: 'left',
children: [{ text: 'Contexte' }, { text: 'Contraintes' }],
},
{
text: 'Idees brutes',
side: 'right',
children: [{ text: 'Idee 1' }, { text: 'Idee 2' }, { text: 'Idee 3' }],
},
{
text: 'A creuser',
side: 'right',
children: [{ text: 'Pistes prometteuses' }, { text: 'Questions ouvertes' }],
},
{
text: 'Ecartees',
side: 'left',
children: [{ text: 'Trop couteux' }, { text: 'Hors sujet' }],
},
{ text: 'Prochaines etapes', side: 'right', children: [{ text: 'Qui fait quoi' }] },
],
},
},
{
id: 'project',
name: 'Gestion de projet',
description: 'Objectifs, lots de travail, jalons et risques.',
icon: '📋',
theme: 'ocean',
layout: 'mindmap-both',
root: {
text: 'Projet',
icon: '📋',
children: [
{
text: 'Objectifs',
icon: '🎯',
side: 'left',
children: [{ text: 'Resultat attendu' }, { text: 'Criteres de reussite' }],
},
{
text: 'Perimetre',
side: 'left',
children: [{ text: 'Inclus' }, { text: 'Exclu' }],
},
{
text: 'Lots de travail',
icon: '🧩',
side: 'right',
children: [
{ text: 'Cadrage' },
{ text: 'Realisation' },
{ text: 'Recette' },
{ text: 'Mise en production' },
],
},
{
text: 'Jalons',
icon: '📅',
side: 'right',
children: [{ text: 'Lancement' }, { text: 'Livraison' }],
},
{
text: 'Equipe',
side: 'left',
children: [{ text: 'Sponsor' }, { text: 'Chef de projet' }, { text: 'Contributeurs' }],
},
{
text: 'Risques',
icon: '⚠️',
side: 'right',
children: [{ text: 'Identifies' }, { text: 'Plan de repli' }],
},
],
},
},
{
id: 'it-architecture',
name: 'Architecture IT',
description: 'Reseau, compute, stockage, services et supervision.',
icon: '🖧',
theme: 'midnight',
layout: 'mindmap-both',
branchStyle: 'curved',
root: {
text: 'Architecture IT',
icon: '🖧',
note: "Vue d'ensemble d'un systeme d'information : adaptez chaque branche a votre contexte.",
children: [
{
text: 'Reseau',
icon: '🌐',
side: 'left',
children: [
{ text: 'Firewall', note: "Filtrage entrant et sortant, regles par zone." },
{ text: 'Switch' },
{ text: 'VLAN' },
{ text: 'VPN', note: "Acces distant des utilisateurs aux ressources internes." },
],
},
{
text: 'Compute',
icon: '🖥️',
side: 'right',
children: [
{ text: 'Hyperviseurs' },
{ text: 'VM' },
{ text: 'Conteneurs' },
],
},
{
text: 'Stockage',
icon: '💾',
side: 'left',
children: [{ text: 'NAS' }, { text: 'Sauvegarde' }],
},
{
text: 'Services',
icon: '⚙️',
side: 'right',
children: [{ text: 'DNS' }, { text: 'DHCP' }, { text: 'Applications' }],
},
{
text: 'Supervision',
icon: '📈',
side: 'left',
children: [{ text: 'Monitoring' }, { text: 'Logs' }],
},
],
},
},
{
id: 'meeting',
name: 'Reunion / prise de notes',
description: 'Ordre du jour, decisions, actions a suivre.',
icon: '📝',
theme: 'paper',
layout: 'mindmap-right',
root: {
text: 'Reunion',
icon: '📝',
children: [
{ text: 'Contexte', children: [{ text: 'Date' }, { text: 'Participants' }] },
{
text: 'Ordre du jour',
children: [{ text: 'Point 1' }, { text: 'Point 2' }, { text: 'Point 3' }],
},
{ text: 'Decisions', icon: '✅', children: [{ text: 'Decision 1' }] },
{
text: 'Actions',
icon: '➡️',
children: [{ text: 'Qui' }, { text: 'Quoi' }, { text: 'Pour quand' }],
},
{ text: 'Points en suspens' },
],
},
},
{
id: 'swot',
name: 'SWOT',
description: 'Forces, faiblesses, opportunites, menaces.',
icon: '🧭',
theme: 'forest',
layout: 'mindmap-both',
root: {
text: 'Analyse SWOT',
icon: '🧭',
children: [
{
text: 'Forces',
icon: '💪',
side: 'left',
children: [{ text: 'Interne, positif' }],
},
{
text: 'Faiblesses',
icon: '🪫',
side: 'left',
children: [{ text: 'Interne, a ameliorer' }],
},
{
text: 'Opportunites',
icon: '🚀',
side: 'right',
children: [{ text: 'Externe, favorable' }],
},
{
text: 'Menaces',
icon: '⛈️',
side: 'right',
children: [{ text: 'Externe, a surveiller' }],
},
],
},
},
{
id: 'study',
name: "Plan d'etude",
description: 'Programme de revision par matiere et par echeance.',
icon: '🎓',
theme: 'lavender',
layout: 'mindmap-right',
root: {
text: 'Plan de revision',
icon: '🎓',
children: [
{
text: 'Matieres',
children: [{ text: 'Matiere 1' }, { text: 'Matiere 2' }, { text: 'Matiere 3' }],
},
{
text: 'Chapitres a revoir',
children: [{ text: 'A maitriser' }, { text: 'A consolider' }],
},
{
text: 'Methodes',
children: [{ text: 'Fiches' }, { text: 'Exercices' }, { text: 'Annales' }],
},
{
text: 'Planning',
icon: '📅',
children: [{ text: 'Cette semaine' }, { text: 'Avant l examen' }],
},
{ text: 'Evaluation', children: [{ text: 'Points acquis' }, { text: 'Points fragiles' }] },
],
},
},
{
id: 'personal',
name: 'Planification personnelle',
description: 'Priorites, habitudes et projets personnels.',
icon: '🌱',
theme: 'default',
layout: 'mindmap-both',
root: {
text: 'Mon organisation',
icon: '🌱',
children: [
{
text: 'Priorites',
icon: '⭐',
side: 'right',
children: [{ text: 'Cette semaine' }, { text: 'Ce mois-ci' }],
},
{
text: 'Sante',
side: 'left',
children: [{ text: 'Sport' }, { text: 'Sommeil' }, { text: 'Alimentation' }],
},
{
text: 'Projets',
side: 'right',
children: [{ text: 'En cours' }, { text: 'A demarrer' }],
},
{
text: 'Habitudes',
side: 'left',
children: [{ text: 'A garder' }, { text: 'A changer' }],
},
{ text: 'Envies', icon: '✨', side: 'right', children: [{ text: 'A explorer' }] },
],
},
},
];
export function getTemplate(id: string): MapTemplate | undefined {
return MAP_TEMPLATES.find((template) => template.id === id);
}
/**
* Fabrique une carte a partir d'un modele.
*
* Chaque appel regenere tous les identifiants et recopie les objets : le modele
* d'origine, qui est une constante du module, n'est jamais modifie. La carte
* produite est ensuite une carte ordinaire, modifiable et supprimable comme
* n'importe quelle autre.
*
* Les positions initiales viennent de l'auto-layout, calcule sur les tailles
* estimees : le modele s'ouvre deja bien dispose, sans attendre une mesure du
* DOM qui n'existe pas encore.
*/
export function buildTemplateMap(template: MapTemplate, name?: string): MindMap {
const timestamp = new Date().toISOString();
const nodes: MindMapNode[] = [];
const walk = (spec: TemplateNode, parentId: string | null, order: number, depth: number): void => {
const node = createNode({
text: spec.text,
type: parentId === null ? 'root' : 'standard',
parentId,
order,
...(spec.icon ? { icon: spec.icon } : {}),
...(spec.note ? { note: spec.note } : {}),
// Le cote n'a de sens que sur une branche de niveau 1.
...(depth === 1 && spec.side ? { side: spec.side } : {}),
position: { x: 0, y: 0 },
});
nodes.push(node);
(spec.children ?? []).forEach((child, index) => walk(child, node.id, index, depth + 1));
};
walk(template.root, null, 0, 0);
const settings = {
...DEFAULT_SETTINGS,
theme: template.theme,
layout: template.layout,
...(template.branchStyle ? { branchStyle: template.branchStyle } : {}),
};
const positions = computeLayout(nodes, template.layout, { theme: getTheme(template.theme) });
const placed = nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
}));
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: name?.trim() || template.root.text || template.name,
createdAt: timestamp,
updatedAt: timestamp,
nodes: placed,
edges: rebuildEdges(placed),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings,
};
}

View File

@@ -1,4 +1,5 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { remapRelations } from '@/services/relations';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
@@ -21,6 +22,7 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null,
...(partial.order !== undefined ? { order: partial.order } : {}),
...(partial.side !== undefined ? { side: partial.side } : {}),
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}),
@@ -45,6 +47,7 @@ export function createEmptyMap(name: string): MindMap {
updatedAt: timestamp,
nodes: [root],
edges: [],
relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
@@ -72,6 +75,9 @@ export function cloneMap(map: MindMap, name?: string): MindMap {
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
// Les relations suivent la copie : leurs extremites pointent vers les
// nouveaux identifiants, jamais vers ceux de la carte d'origine.
relations: remapRelations(map.relations ?? [], idMapping),
viewport: { ...map.viewport },
settings: { ...map.settings },
};
@@ -126,6 +132,7 @@ export function createSampleMap(): MindMap {
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
relations: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { MapFormatError, normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
/** Document v4 minimal : deux branches opposees et une relation entre feuilles. */
function documentV4(): Record<string, unknown> {
return {
version: 4,
id: 'map-1',
name: 'Infrastructure',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
nodes: [
{ id: 'root', type: 'root', text: 'Infrastructure', position: { x: 0, y: 0 }, parentId: null },
{
id: 'reseau',
type: 'standard',
text: 'Reseau',
position: { x: 240, y: 0 },
parentId: 'root',
order: 0,
side: 'right',
},
{
id: 'firewall',
type: 'standard',
text: 'Firewall',
position: { x: 480, y: 0 },
parentId: 'reseau',
order: 0,
},
{
id: 'services',
type: 'standard',
text: 'Services',
position: { x: -240, y: 0 },
parentId: 'root',
order: 1,
side: 'left',
},
{
id: 'web',
type: 'standard',
text: 'Web',
position: { x: -480, y: 0 },
parentId: 'services',
order: 0,
},
],
edges: [],
relations: [
{
id: 'r-1',
sourceId: 'firewall',
targetId: 'web',
label: 'protege',
style: 'bezier',
color: '#a855f7',
width: 2,
dashed: true,
markerEnd: 'arrow',
},
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-both', branchStyle: 'bezier', showGrid: true },
};
}
describe('lecture d un document v4', () => {
it('conserve les cotes et les relations', () => {
const map = normalizeMapDocument(documentV4(), { source: 'file' });
expect(map.version).toBe(MAP_FORMAT_VERSION);
const sides = new Map(map.nodes.map((node) => [node.id, node.side]));
expect(sides.get('reseau')).toBe('right');
expect(sides.get('services')).toBe('left');
expect(map.relations).toHaveLength(1);
expect(map.relations[0]?.label).toBe('protege');
expect(map.relations[0]?.dashed).toBe(true);
expect(map.relations[0]?.markerEnd).toBe('arrow');
expect(map.settings.layout).toBe('mindmap-both');
});
it('ne modifie pas le document fourni', () => {
const raw = documentV4();
const before = JSON.stringify(raw);
normalizeMapDocument(raw, { source: 'file' });
expect(JSON.stringify(raw)).toBe(before);
});
it('est idempotent : renormaliser ne change plus rien', () => {
const once = normalizeMapDocument(documentV4(), { source: 'file' });
const twice = normalizeMapDocument(JSON.parse(JSON.stringify(once)), { source: 'file' });
expect(twice.nodes).toEqual(once.nodes);
expect(twice.relations).toEqual(once.relations);
});
it('survit a un aller-retour complet', () => {
const map = normalizeMapDocument(documentV4(), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
expect(again.relations).toEqual(map.relations);
expect(again.nodes.map((node) => [node.id, node.side])).toEqual(
map.nodes.map((node) => [node.id, node.side]),
);
});
});
describe('relations incoherentes', () => {
it('retire une relation dont la source n existe pas', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.sourceId = 'fantome';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('retire une relation dont la cible n existe pas', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'fantome';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('retire une relation bouclant sur elle-meme', () => {
const raw = documentV4();
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'firewall';
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
});
it('ne garde qu une relation par couple source / cible', () => {
const raw = documentV4();
const relations = raw.relations as Array<Record<string, unknown>>;
relations.push({ ...relations[0], id: 'r-2' });
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(1);
});
it('ignore les champs de style invalides sans perdre la relation', () => {
const raw = documentV4();
const relation = (raw.relations as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
relation.style = 'zigzag';
relation.color = 'javascript:alert(1)';
relation.markerEnd = 'etoile';
const parsed = normalizeMapDocument(raw, { source: 'file' }).relations[0];
expect(parsed?.style).toBeUndefined();
expect(parsed?.color).toBeUndefined();
expect(parsed?.markerEnd).toBeUndefined();
expect(parsed?.label).toBe('protege');
});
it('ignore un cote invalide', () => {
const raw = documentV4();
(raw.nodes as Array<Record<string, unknown>>)[1]!.side = 'haut';
const map = normalizeMapDocument(raw, { source: 'file' });
expect(map.nodes.find((node) => node.id === 'reseau')?.side).toBeUndefined();
});
it('refuse un document depassant la limite de relations', () => {
const raw = documentV4();
raw.relations = Array.from({ length: 30 }, (_item, index) => ({
id: `r-${index}`,
sourceId: 'firewall',
targetId: 'web',
}));
expect(() => normalizeMapDocument(raw, { source: 'file', maxNodes: 10 })).toThrow(
MapFormatError,
);
});
});
describe('migrations vers la v4', () => {
function legacyDocument(version: number): Record<string, unknown> {
return {
version,
id: `map-v${version}`,
name: `Carte v${version}`,
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Racine',
position: { x: 0, y: 0 },
parentId: null,
style: version === 1 ? { width: 220, backgroundColor: '#ffffff' } : undefined,
},
{
id: 'a',
type: 'standard',
text: 'A',
position: { x: 200, y: 0 },
parentId: 'root',
},
{
id: 'b',
type: 'standard',
text: 'B',
position: { x: 200, y: 80 },
parentId: 'root',
},
],
edges: [
{ id: 'e-root-a', source: 'root', target: 'a' },
{ id: 'e-root-b', source: 'root', target: 'b' },
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier' },
};
}
it('ouvre une carte v1 et la ramene au format courant', () => {
const map = normalizeMapDocument(legacyDocument(1), { source: 'file' });
expect(map.version).toBe(4);
expect(map.relations).toEqual([]);
// La largeur fixe de la v1 a rejoint layout.width.
const root = map.nodes.find((node) => node.id === 'root');
expect(root?.layout?.width).toBe(220);
expect(root?.layout?.widthMode).toBe('fixed');
expect(root?.style?.backgroundColor).toBe('#ffffff');
});
it('ouvre une carte v2 en materialisant l ordre puis la v4', () => {
const map = normalizeMapDocument(legacyDocument(2), { source: 'file' });
expect(map.version).toBe(4);
expect(map.nodes.find((node) => node.id === 'a')?.order).toBe(0);
expect(map.nodes.find((node) => node.id === 'b')?.order).toBe(1);
expect(map.relations).toEqual([]);
});
it('ouvre une carte v3 sans rien inventer', () => {
const map = normalizeMapDocument(legacyDocument(3), { source: 'file' });
expect(map.version).toBe(4);
expect(map.relations).toEqual([]);
// Aucun cote : la carte reste exactement telle qu'elle etait.
for (const node of map.nodes) expect(node.side).toBeUndefined();
});
it('accepte une carte historique lue depuis IndexedDB', () => {
const stored = legacyDocument(2) as unknown as MindMap;
const map = normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
expect(map.version).toBe(4);
expect(map.nodes).toHaveLength(3);
expect(map.relations).toEqual([]);
});
it('refuse un document plus recent que le format connu', () => {
const raw = { ...documentV4(), version: 99 };
expect(() => normalizeMapDocument(raw, { source: 'file' })).toThrow(MapFormatError);
});
it('accepte une carte v4 sans champ relations', () => {
const raw = documentV4();
delete raw.relations;
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toEqual([]);
});
});

View File

@@ -8,12 +8,15 @@ import {
type MapSettings,
type MindMap,
type MindMapNode,
type MindMapRelation,
type NodeBranchStyle,
type NodeIconStyle,
type NodeKind,
type NodeLayout,
type NodeShape,
type NodeSide,
type NodeStyle,
type RelationMarker,
type ShadowStyle,
type SizeMode,
type TextAlign,
@@ -22,6 +25,7 @@ import {
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
import { SPACING_LIMITS } from '@/features/editor/layout';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createRelationId, pruneRelations } from '@/services/relations';
import { createId } from '@/utils/id';
import {
flattenInOrder,
@@ -53,6 +57,9 @@ const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounde
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
const ICON_KINDS = ['emoji', 'builtin'] as const;
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const NODE_SIDES: NodeSide[] = ['left', 'right'];
const RELATION_MARKERS: RelationMarker[] = ['none', 'arrow'];
const MAX_LABEL_LENGTH = 120;
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
@@ -251,6 +258,10 @@ function parseNode(value: unknown): MindMapNode | null {
node.order = Math.max(0, Math.round(value.order));
}
if (typeof value.side === 'string' && NODE_SIDES.includes(value.side as NodeSide)) {
node.side = value.side as NodeSide;
}
if (value.collapsed === true) node.collapsed = true;
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
@@ -281,6 +292,54 @@ function parseNode(value: unknown): MindMapNode | null {
return node;
}
/**
* Relation secondaire (v4).
*
* Validee comme le reste du document : identifiants bornes, couleur filtree,
* enumerations verifiees. Une relation incoherente est ignoree plutot que
* corrigee au jugement.
*/
function parseRelation(value: unknown): MindMapRelation | null {
if (!isRecord(value)) return null;
const sourceId = asString(value.sourceId, 128);
const targetId = asString(value.targetId, 128);
if (!sourceId || !targetId || sourceId === targetId) return null;
const relation: MindMapRelation = {
id: asString(value.id, 128) ?? createRelationId(),
sourceId,
targetId,
};
const label = asString(value.label, MAX_LABEL_LENGTH)?.trim();
if (label) relation.label = label;
if (typeof value.style === 'string' && BRANCH_STYLES.includes(value.style as BranchStyle)) {
relation.style = value.style as BranchStyle;
}
const color = asColor(value.color);
if (color) relation.color = color;
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
relation.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
}
if (typeof value.dashed === 'boolean') relation.dashed = value.dashed;
if (
typeof value.markerStart === 'string' &&
RELATION_MARKERS.includes(value.markerStart as RelationMarker)
) {
relation.markerStart = value.markerStart as RelationMarker;
}
if (
typeof value.markerEnd === 'string' &&
RELATION_MARKERS.includes(value.markerEnd as RelationMarker)
) {
relation.markerEnd = value.markerEnd as RelationMarker;
}
return relation;
}
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
@@ -367,6 +426,18 @@ function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknow
return { ...document, nodes, version: 3 };
}
/**
* v3 -> v4 : rien a reecrire.
*
* Une carte v3 n'a ni cote ni relation. Absence de cote signifie "a repartir
* automatiquement", et absence de relation signifie "aucune" : la carte
* s'affiche donc exactement comme avant tant que l'utilisateur ne demande pas
* la disposition bilaterale. La migration se contente d'estampiller la version.
*/
function migrateV3ToV4(document: Record<string, unknown>): Record<string, unknown> {
return { ...document, version: 4 };
}
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
let document = raw;
let current = version;
@@ -379,6 +450,10 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
document = migrateV2ToV3(document);
current = 3;
}
if (current === 3) {
document = migrateV3ToV4(document);
current = 4;
}
if (current !== MAP_FORMAT_VERSION) {
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
@@ -503,6 +578,35 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
// est toujours dans la meme forme canonique, quelle que soit sa provenance.
const normalised = flattenInOrder(normalizeChildOrders(linked));
// Les relations sont une donnee propre : rien ne les deduit de la hierarchie,
// et rien dans la hierarchie ne doit dependre d'elles. On se contente de
// retirer celles dont une extremite n'existe plus.
const rawRelations = Array.isArray(document.relations) ? document.relations : [];
if (rawRelations.length > maxNodes * 2) {
throw new MapFormatError(
'Cette carte contient ' +
rawRelations.length +
' relations : la limite est de ' +
maxNodes * 2 +
'.',
);
}
const seenRelations = new Set<string>();
const relations = pruneRelations(
rawRelations
.map(parseRelation)
.filter((relation): relation is MindMapRelation => relation !== null)
.filter((relation) => {
// Un doublon exact serait invisible : le second trait se superposerait.
const key = `${relation.sourceId}>${relation.targetId}`;
if (seenRelations.has(key)) return false;
seenRelations.add(key);
return true;
}),
normalised,
);
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
@@ -511,6 +615,7 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: normalised,
edges: rebuildEdges(normalised),
relations,
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest';
import {
MarkdownError,
buildMapFromMarkdown,
exportMarkdown,
parseMarkdown,
} from '@/services/markdown';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getOrderedChildren } from '@/utils/tree';
const SAMPLE = `# Infrastructure
## Reseau
- VLAN
- VPN
- Firewall
## Virtualisation
- Proxmox
- VM
- LXC
- Docker
`;
function texts(nodes: Array<{ text: string }>): string[] {
return nodes.map((node) => node.text);
}
describe('analyse Markdown', () => {
it('construit la hierarchie a partir des titres et des listes', () => {
const result = parseMarkdown(SAMPLE);
expect(result.title).toBe('Infrastructure');
expect(result.roots).toHaveLength(1);
const root = result.roots[0];
expect(texts(root?.children ?? [])).toEqual(['Reseau', 'Virtualisation']);
const reseau = root?.children[0];
expect(texts(reseau?.children ?? [])).toEqual(['VLAN', 'VPN', 'Firewall']);
const virtualisation = root?.children[1];
expect(texts(virtualisation?.children ?? [])).toEqual(['Proxmox', 'Docker']);
expect(texts(virtualisation?.children[0]?.children ?? [])).toEqual(['VM', 'LXC']);
expect(result.count).toBe(10);
});
it('gere trois niveaux de titres', () => {
const result = parseMarkdown('# A\n## B\n### C\n');
expect(result.roots[0]?.children[0]?.children[0]?.text).toBe('C');
});
it('accepte un niveau de titre saute', () => {
// Un H3 directement sous un H1 se rattache au titre ouvert le plus proche.
const result = parseMarkdown('# A\n### C\n');
expect(result.roots[0]?.children[0]?.text).toBe('C');
});
it('reprend un titre de niveau superieur apres un plus profond', () => {
const result = parseMarkdown('# A\n### C\n## B\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['C', 'B']);
});
it('lit les listes numerotees', () => {
const result = parseMarkdown('# A\n1. un\n2. deux\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
});
it('accepte les puces * et +', () => {
const result = parseMarkdown('# A\n* un\n+ deux\n');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
});
it('accepte un document fait uniquement de listes', () => {
const result = parseMarkdown('- un\n - un.a\n- deux\n');
expect(texts(result.roots)).toEqual(['un', 'deux']);
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un.a']);
expect(result.title).toBeUndefined();
});
it('conserve les caracteres Unicode', () => {
const result = parseMarkdown('# Sécurité réseau\n- Pare-feu «actif»\n- 日本語\n');
expect(result.roots[0]?.text).toBe('Sécurité réseau');
expect(texts(result.roots[0]?.children ?? [])).toEqual(['Pare-feu «actif»', '日本語']);
});
});
describe('paragraphes et blocs de code', () => {
it('met un paragraphe dans la note du titre au-dessus', () => {
const result = parseMarkdown(
'# A\n\n## VPN\n\nLe VPN permet aux utilisateurs distants\nd acceder aux ressources internes.\n',
);
const vpn = result.roots[0]?.children[0];
expect(vpn?.text).toBe('VPN');
expect(vpn?.note).toContain('Le VPN permet aux utilisateurs distants');
expect(vpn?.note).toContain('ressources internes');
});
it('met un bloc de code dans la note, sans creer de noeuds', () => {
const result = parseMarkdown('# A\n\n## Docker\n\n```bash\ndocker compose up -d\n```\n');
const docker = result.roots[0]?.children[0];
expect(docker?.note).toContain('docker compose up -d');
expect(docker?.children).toHaveLength(0);
expect(result.count).toBe(2);
});
it('n interprete jamais le contenu : la note reste du texte', () => {
const result = parseMarkdown('# A\n\n<script>alert(1)</script>\n');
expect(result.roots[0]?.note).toBe('<script>alert(1)</script>');
});
it('attache un paragraphe qui suit une liste au dernier element cree', () => {
const result = parseMarkdown('# A\n- un\n\nDetail de un.\n');
expect(result.roots[0]?.children[0]?.note).toBe('Detail de un.');
});
});
describe('documents refuses', () => {
it('refuse un document vide', () => {
expect(() => parseMarkdown('')).toThrow(MarkdownError);
});
it('refuse un document sans titre ni liste', () => {
expect(() => parseMarkdown('juste du texte\net encore du texte')).toThrow(MarkdownError);
});
it('signale la ligne exacte d une indentation incoherente', () => {
const text = '# A\n- un\n - trop profond\n - incoherent\n';
try {
parseMarkdown(text);
throw new Error('aurait du echouer');
} catch (error) {
expect(error).toBeInstanceOf(MarkdownError);
expect((error as MarkdownError).line).toBe(4);
expect((error as MarkdownError).message).toContain('Ligne 4');
}
});
it('refuse un document au-dela de la limite de noeuds', () => {
const many = Array.from({ length: 12 }, (_item, index) => `- item ${index}`).join('\n');
expect(() => parseMarkdown(`# A\n${many}`, 5)).toThrow(MarkdownError);
});
});
describe('import comme nouvelle carte', () => {
it('fait du titre H1 la racine et le nom de la carte', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
expect(map.name).toBe('Infrastructure');
expect(map.nodes[0]?.text).toBe('Infrastructure');
expect(map.nodes).toHaveLength(10);
expect(map.relations).toEqual([]);
});
it('utilise le nom du fichier quand il n y a pas de H1 unique', () => {
const map = buildMapFromMarkdown('- un\n- deux\n', 'mes notes');
expect(map.name).toBe('mes notes');
expect(texts(getOrderedChildren(map.nodes, map.nodes[0]?.id as string))).toEqual([
'un',
'deux',
]);
});
it('reconstruit la hierarchie et positionne les noeuds', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const root = map.nodes[0];
const branches = getOrderedChildren(map.nodes, root?.id as string);
expect(texts(branches)).toEqual(['Reseau', 'Virtualisation']);
const proxmox = map.nodes.find((node) => node.text === 'Proxmox');
expect(texts(getOrderedChildren(map.nodes, proxmox?.id as string))).toEqual(['VM', 'LXC']);
// Les positions viennent de l'auto-layout : rien n'est empile a l'origine.
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
expect(distinct.size).toBe(map.nodes.length);
});
});
describe('import comme sous-arbre', () => {
it('insere le contenu sous le noeud vise en une seule entree d historique', () => {
const store = useEditorStore.getState();
store.loadMap(createEmptyMap('Carte'));
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
const parsed = parseMarkdown(SAMPLE);
const created = useEditorStore.getState().insertOutline(rootId, parsed.roots);
expect(created).toBe(10);
expect(useEditorStore.getState().map?.nodes).toHaveLength(11);
useEditorStore.getState().undo();
expect(useEditorStore.getState().map?.nodes).toHaveLength(1);
});
it('reporte les notes des paragraphes sur les noeuds crees', () => {
const store = useEditorStore.getState();
store.loadMap(createEmptyMap('Carte'));
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
const parsed = parseMarkdown('## VPN\n\nAcces distant securise.\n');
useEditorStore.getState().insertOutline(rootId, parsed.roots);
const vpn = useEditorStore.getState().map?.nodes.find((node) => node.text === 'VPN');
expect(vpn?.note).toBe('Acces distant securise.');
});
});
describe('export Markdown', () => {
it('ecrit les trois premiers niveaux en titres et la suite en listes', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const markdown = exportMarkdown(map);
expect(markdown).toContain('# Infrastructure');
expect(markdown).toContain('## Reseau');
expect(markdown).toContain('### VLAN');
expect(markdown).toContain('### Proxmox');
// VM et LXC sont freres sous Proxmox : meme niveau de liste.
expect(markdown).toContain('- VM');
expect(markdown).toContain('- LXC');
// Un titre est toujours precede d'une ligne vide.
expect(markdown).toContain('\n\n### Docker');
});
it('ecrit les notes sous leur titre', () => {
const map = buildMapFromMarkdown('# A\n\n## VPN\n\nAcces distant.\n', 'fichier');
expect(exportMarkdown(map)).toContain('Acces distant.');
});
it('ne represente pas les relations secondaires', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const firewall = map.nodes.find((node) => node.text === 'Firewall') as { id: string };
const docker = map.nodes.find((node) => node.text === 'Docker') as { id: string };
const withRelation = {
...map,
relations: [
{ id: 'r-1', sourceId: firewall.id, targetId: docker.id, label: 'protege' },
],
};
const markdown = exportMarkdown(withRelation);
expect(markdown).not.toContain('protege');
expect(markdown).toContain('# Infrastructure');
});
it('conserve la hierarchie lors d un aller-retour', () => {
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
const again = buildMapFromMarkdown(exportMarkdown(map), 'fichier');
const flatten = (source: typeof map, parentId: string | null, depth: number): string[] =>
getOrderedChildren(source.nodes, parentId).flatMap((node) => [
`${depth}:${node.text}`,
...flatten(source, node.id, depth + 1),
]);
expect(flatten(again, null, 0)).toEqual(flatten(map, null, 0));
});
it('respecte l ordre des freres', () => {
const map = buildMapFromMarkdown('# A\n- premier\n- deuxieme\n- troisieme\n', 'fichier');
const markdown = exportMarkdown(map);
expect(markdown.indexOf('premier')).toBeLessThan(markdown.indexOf('deuxieme'));
expect(markdown.indexOf('deuxieme')).toBeLessThan(markdown.indexOf('troisieme'));
});
});

View File

@@ -0,0 +1,319 @@
import { APP_CONFIG } from '@/config/app';
import { type MindMap, type MindMapNode } from '@/types/map';
import {
OutlineError,
isListItem,
parseOutline,
type OutlineNode,
} from '@/services/outlineParser';
import { computeLayout } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes';
import { createEmptyMap, createNode } from '@/services/mapFactory';
import { getOrderedChildren, rebuildEdges } from '@/utils/tree';
/**
* Markdown <-> mind map.
*
* L'import ne cherche pas a etre un lecteur Markdown complet : il retient ce
* qui porte une hierarchie (titres et listes) et met le reste (paragraphes,
* blocs de code) dans la note du noeud courant. Rien n'est interprete comme du
* HTML, rien n'est execute : le texte reste du texte.
*
* Les listes sont deleguees a `parseOutline`, deja utilise par l'import
* d'outline : il n'y a qu'un seul analyseur d'indentation dans le projet.
*/
const MAX_NOTE_LENGTH = 20000;
const MAX_HEADING_DEPTH = 6;
export class MarkdownError extends Error {
readonly line: number;
constructor(message: string, line: number) {
super(message);
this.name = 'MarkdownError';
this.line = line;
}
}
export interface MarkdownResult {
roots: OutlineNode[];
/** Nombre total de noeuds produits. */
count: number;
/** Texte du premier titre de niveau 1, s'il existe. */
title?: string;
}
const HEADING = /^(#{1,6})\s+(.*)$/;
const FENCE = /^\s*(```|~~~)/;
function countNodes(nodes: OutlineNode[]): number {
let total = 0;
for (const node of nodes) total += 1 + countNodes(node.children);
return total;
}
/** Dernier noeud cree en profondeur : celui auquel rattacher la suite. */
function lastLeaf(nodes: OutlineNode[]): OutlineNode | null {
const last = nodes[nodes.length - 1];
if (!last) return null;
return lastLeaf(last.children) ?? last;
}
function appendNote(node: OutlineNode | null, block: string): void {
if (!node) return;
const text = block.trim();
if (!text) return;
const merged = node.note ? `${node.note}\n\n${text}` : text;
node.note = merged.slice(0, MAX_NOTE_LENGTH);
}
/**
* Analyse un document Markdown.
*
* Titres et listes construisent la hierarchie ; un titre de niveau saute est
* accepte et rattache au titre ouvert le plus proche, ce qui evite de rejeter
* un document par ailleurs parfaitement lisible.
*/
export function parseMarkdown(
text: string,
maxNodes: number = APP_CONFIG.maxNodes,
): MarkdownResult {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const roots: OutlineNode[] = [];
const headings: Array<{ level: number; node: OutlineNode }> = [];
let title: string | undefined;
let current: OutlineNode | null = null;
let paragraph: string[] = [];
let listBlock: Array<{ raw: string; line: number }> = [];
let fence: string | null = null;
let fenced: string[] = [];
const flushParagraph = (): void => {
if (paragraph.length === 0) return;
appendNote(current, paragraph.join('\n'));
paragraph = [];
};
const flushList = (): void => {
if (listBlock.length === 0) return;
const offset = (listBlock[0] as { line: number }).line - 1;
let parsed;
try {
parsed = parseOutline(listBlock.map((entry) => entry.raw).join('\n'), maxNodes);
} catch (error) {
if (error instanceof OutlineError) {
// On repositionne le numero de ligne sur le document complet.
throw new MarkdownError(
error.message.replace(/^Ligne \d+/, `Ligne ${error.line + offset}`),
error.line + offset,
);
}
throw error;
}
listBlock = [];
const parent = headings[headings.length - 1]?.node;
if (parent) parent.children.push(...parsed.roots);
else roots.push(...parsed.roots);
current = lastLeaf(parsed.roots) ?? current;
};
lines.forEach((raw, index) => {
const lineNumber = index + 1;
// Bloc de code : recopie telle quelle dans la note, jamais en noeuds.
if (fence !== null) {
fenced.push(raw);
if (raw.trimStart().startsWith(fence)) {
flushList();
appendNote(current, fenced.join('\n'));
fenced = [];
fence = null;
}
return;
}
const fenceMatch = FENCE.exec(raw);
if (fenceMatch) {
flushParagraph();
fence = fenceMatch[1] as string;
fenced = [raw];
return;
}
if (raw.trim().length === 0) {
flushParagraph();
return;
}
const heading = HEADING.exec(raw.trim());
if (heading) {
flushParagraph();
flushList();
const level = Math.min(MAX_HEADING_DEPTH, (heading[1] as string).length);
const label = (heading[2] as string).trim();
const node: OutlineNode = { text: label, children: [] };
while (headings.length > 0 && (headings[headings.length - 1] as { level: number }).level >= level) {
headings.pop();
}
const parent = headings[headings.length - 1]?.node;
if (parent) parent.children.push(node);
else roots.push(node);
headings.push({ level, node });
current = node;
if (level === 1 && !title) title = label;
return;
}
if (isListItem(raw)) {
flushParagraph();
listBlock.push({ raw, line: lineNumber });
return;
}
// Toute autre ligne est du texte : elle rejoint la note du noeud courant.
flushList();
paragraph.push(raw.trim());
});
if (fence !== null) appendNote(current, fenced.join('\n'));
flushList();
flushParagraph();
const count = countNodes(roots);
if (count === 0) {
throw new MarkdownError('Ce document Markdown ne contient aucun titre ni aucune liste.', 0);
}
if (count > maxNodes) {
throw new MarkdownError(
`Ce document produirait ${count} noeuds : la limite est de ${maxNodes}.`,
0,
);
}
return { roots, count, ...(title ? { title } : {}) };
}
const HEADING_DEPTH = 3;
/** Titre d'un noeud sur une seule ligne : un titre Markdown ne se coupe pas. */
function singleLine(text: string): string {
return text.replace(/\s*\n+\s*/g, ' ').trim();
}
function writeNode(
node: MindMapNode,
depth: number,
nodes: MindMapNode[],
out: string[],
): void {
const label = singleLine(node.text) || '(sans titre)';
if (depth < HEADING_DEPTH) {
out.push(`${'#'.repeat(depth + 1)} ${label}`, '');
} else {
out.push(`${' '.repeat(depth - HEADING_DEPTH)}- ${label}`);
}
if (node.note) {
// La note est ecrite sous son titre, separee par une ligne vide : elle
// revient au meme endroit lors d'un reimport.
if (depth >= HEADING_DEPTH) out.push('');
out.push(node.note.trim(), '');
}
for (const child of getOrderedChildren(nodes, node.id)) {
writeNode(child, depth + 1, nodes, out);
}
if (depth >= HEADING_DEPTH && node.note) out.push('');
}
/**
* Export Markdown : contenu et hierarchie uniquement.
*
* Ni positions, ni couleurs, ni dimensions, ni relations secondaires : ce
* format decrit ce que la carte dit, pas a quoi elle ressemble. Les trois
* premiers niveaux deviennent des titres, les suivants des listes imbriquees,
* ce qui redonne exactement la meme arborescence a la relecture.
*/
export function exportMarkdown(map: MindMap): string {
const out: string[] = [];
// `getOrderedChildren(nodes, null)` rend les racines dans l'ordre du document.
for (const root of getOrderedChildren(map.nodes, null)) {
writeNode(root, 0, map.nodes, out);
}
// Un titre est toujours precede d'une ligne vide : c'est ce qui separe une
// liste du titre suivant, et ce qui garde le fichier relisable tel quel.
const spaced: string[] = [];
for (const line of out) {
if (line.startsWith('#') && spaced.length > 0 && spaced[spaced.length - 1] !== '') {
spaced.push('');
}
spaced.push(line);
}
return `${spaced.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
}
/** Vrai si la carte contient des relations que l'export Markdown ne peut pas rendre. */
export function hasUnexportableRelations(map: MindMap): boolean {
return map.relations.length > 0;
}
/**
* Cree une carte complete a partir d'un document Markdown.
*
* Si le document commence par un unique titre de niveau 1, il devient la racine
* et le nom de la carte. Sinon, une racine est creee avec le nom fourni (celui
* du fichier) : rien n'est invente au-dela.
*/
export function buildMapFromMarkdown(
text: string,
fallbackName: string,
maxNodes: number = APP_CONFIG.maxNodes,
): MindMap {
const parsed = parseMarkdown(text, maxNodes);
const single = parsed.roots.length === 1 ? (parsed.roots[0] as OutlineNode) : null;
const rootSpec: OutlineNode = single ?? {
text: fallbackName,
children: parsed.roots,
};
const map = createEmptyMap(rootSpec.text || fallbackName);
const root = map.nodes[0] as MindMapNode;
const nodes: MindMapNode[] = [
{ ...root, text: rootSpec.text || fallbackName, ...(rootSpec.note ? { note: rootSpec.note } : {}) },
];
const walk = (children: OutlineNode[], parentId: string): void => {
children.forEach((child, index) => {
const node = createNode({
text: child.text,
parentId,
order: index,
...(child.note ? { note: child.note } : {}),
position: { x: 0, y: 0 },
});
nodes.push(node);
walk(child.children, node.id);
});
};
walk(rootSpec.children, root.id);
const positions = computeLayout(nodes, map.settings.layout, {
theme: getTheme(map.settings.theme),
});
const placed = nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
}));
return { ...map, nodes: placed, edges: rebuildEdges(placed) };
}

View File

@@ -12,6 +12,11 @@ import { APP_CONFIG } from '@/config/app';
export interface OutlineNode {
text: string;
children: OutlineNode[];
/**
* Note attachee au noeud. Toujours du texte brut : elle n'est jamais
* interpretee comme du HTML. Alimentee par l'import Markdown.
*/
note?: string;
}
export interface OutlineResult {
@@ -39,7 +44,14 @@ const MAX_TEXT_LENGTH = 500;
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
const NUMBERED = /^\d{1,3}[.)]\s+/;
function indentWidth(line: string): number {
/** Vrai si la ligne est un element de liste : puce ou numerotation. */
export function isListItem(line: string): boolean {
const trimmed = line.trim();
return BULLET.test(trimmed) || NUMBERED.test(trimmed);
}
/** Largeur d'indentation d'une ligne, tabulation comptee pour TAB_WIDTH. */
export function indentWidth(line: string): number {
let width = 0;
for (const character of line) {
if (character === ' ') width += 1;
@@ -49,7 +61,8 @@ function indentWidth(line: string): number {
return width;
}
function cleanText(line: string): string {
/** Texte utile d'une ligne : sans indentation, sans puce ni numerotation. */
export function cleanText(line: string): string {
return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
}

View File

@@ -0,0 +1,105 @@
import { type MindMapNode, type MindMapRelation } from '@/types/map';
import { createId } from '@/utils/id';
/**
* Regles des relations secondaires.
*
* Une relation est une connexion visuelle entre deux noeuds. Elle ne participe
* jamais a la structure : ni `parentId`, ni `order`, ni le repli, ni
* l'auto-layout ne la consultent. Ce module rassemble les seules regles qui la
* gouvernent, pour qu'elles ne soient pas reecrites a chaque appelant.
*/
/** Prefixe distinctif : un identifiant de relation ne ressemble a rien d'autre. */
export function createRelationId(): string {
return `r-${createId()}`;
}
export type RelationRejection = 'self' | 'duplicate' | 'unknown';
export const RELATION_REJECTION_MESSAGE: Record<RelationRejection, string> = {
self: "Une relation relie deux noeuds differents : elle ne peut pas boucler sur elle-meme.",
duplicate: 'Ces deux noeuds sont deja relies dans ce sens.',
unknown: "L'un des deux noeuds n'existe plus dans cette carte.",
};
/**
* Verifie qu'une relation peut etre creee.
*
* Deux refus volontaires :
* - source == target : un trait qui revient sur le meme noeud n'apporte rien
* et se dessine mal ;
* - doublon exact (meme source vers meme cible) : deux traits identiques se
* superposent, donc le second serait invisible et impossible a selectionner.
* Le sens inverse, lui, reste autorise : il porte une autre information.
*/
export function checkRelation(
nodes: MindMapNode[],
relations: MindMapRelation[],
sourceId: string,
targetId: string,
): RelationRejection | null {
if (sourceId === targetId) return 'self';
const known = new Set(nodes.map((node) => node.id));
if (!known.has(sourceId) || !known.has(targetId)) return 'unknown';
const exists = relations.some(
(relation) => relation.sourceId === sourceId && relation.targetId === targetId,
);
return exists ? 'duplicate' : null;
}
/**
* Retire les relations devenues invalides.
*
* Appelee a chaque modification structurelle : supprimer un noeud ne doit pas
* laisser derriere lui un trait vers le vide. C'est la contrepartie du fait que
* les relations ne sont pas derivees de la hierarchie : rien ne les nettoie
* automatiquement.
*/
export function pruneRelations(
relations: MindMapRelation[],
nodes: MindMapNode[],
): MindMapRelation[] {
if (relations.length === 0) return relations;
const known = new Set(nodes.map((node) => node.id));
const kept = relations.filter(
(relation) =>
relation.sourceId !== relation.targetId &&
known.has(relation.sourceId) &&
known.has(relation.targetId),
);
return kept.length === relations.length ? relations : kept;
}
/**
* Reporte des relations sur des identifiants renumerotes.
*
* Seules les relations dont les DEUX extremites font partie de la copie sont
* reportees. Une relation qui sort du sous-arbre copie n'est pas dupliquee :
* rien ne dit que la copie doit pointer vers la meme cible externe que
* l'original, et le deviner creerait des liens que l'utilisateur n'a pas
* demandes. Il reste libre de la recreer explicitement.
*/
export function remapRelations(
relations: MindMapRelation[],
idMapping: Map<string, string>,
): MindMapRelation[] {
const result: MindMapRelation[] = [];
for (const relation of relations) {
const source = idMapping.get(relation.sourceId);
const target = idMapping.get(relation.targetId);
if (!source || !target) continue;
result.push({ ...relation, id: createRelationId(), sourceId: source, targetId: target });
}
return result;
}
/** Relations entierement contenues dans un ensemble de noeuds. */
export function relationsWithin(
relations: MindMapRelation[],
nodeIds: Set<string>,
): MindMapRelation[] {
return relations.filter(
(relation) => nodeIds.has(relation.sourceId) && nodeIds.has(relation.targetId),
);
}

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { computeLayout } from '@/features/editor/layout';
import { getHiddenNodeIds } 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 relations() {
return store().map?.relations ?? [];
}
/** Infrastructure > (Reseau > Firewall, Services > Web). */
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Infrastructure'));
const initial = useEditorStore.getState();
initial.addChildren(rootId(), ['Reseau', 'Services']);
initial.addChildren(idOf('Reseau'), ['Firewall', 'Switch']);
initial.addChildren(idOf('Services'), ['Web']);
});
describe('creation d une relation', () => {
it('cree une relation entre deux noeuds', () => {
expect(store().addRelation(idOf('Firewall'), idOf('Web'))).toBeNull();
expect(relations()).toHaveLength(1);
expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
expect(relations()[0]?.targetId).toBe(idOf('Web'));
});
it('accepte un libelle libre', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
expect(relations()[0]?.label).toBe('protege');
});
it('accepte un style, une couleur, une epaisseur et des extremites', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), {
style: 'straight',
color: '#ff0000',
width: 3,
dashed: false,
markerEnd: 'none',
});
const relation = relations()[0];
expect(relation?.style).toBe('straight');
expect(relation?.color).toBe('#ff0000');
expect(relation?.width).toBe(3);
expect(relation?.dashed).toBe(false);
expect(relation?.markerEnd).toBe('none');
});
it('refuse une relation d un noeud vers lui-meme', () => {
expect(store().addRelation(idOf('Firewall'), idOf('Firewall'))).toBe('self');
expect(relations()).toHaveLength(0);
});
it('refuse une source inexistante', () => {
expect(store().addRelation('inconnu', idOf('Web'))).toBe('unknown');
expect(relations()).toHaveLength(0);
});
it('refuse une cible inexistante', () => {
expect(store().addRelation(idOf('Web'), 'inconnu')).toBe('unknown');
expect(relations()).toHaveLength(0);
});
it('refuse un doublon exact mais accepte le sens inverse', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
expect(store().addRelation(idOf('Firewall'), idOf('Web'))).toBe('duplicate');
expect(store().addRelation(idOf('Web'), idOf('Firewall'))).toBeNull();
expect(relations()).toHaveLength(2);
});
});
describe('independance vis-a-vis de la hierarchie', () => {
it('ne modifie ni parentId ni order ni les enfants', () => {
const before = (store().map?.nodes ?? []).map((node) => ({
id: node.id,
parentId: node.parentId,
order: node.order,
}));
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
const after = (store().map?.nodes ?? []).map((node) => ({
id: node.id,
parentId: node.parentId,
order: node.order,
}));
expect(after).toEqual(before);
});
it('ne cree aucun lien hierarchique', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const pairs = (store().map?.edges ?? []).map((edge) => `${edge.source}>${edge.target}`);
expect(pairs).not.toContain(`${idOf('Firewall')}>${idOf('Web')}`);
});
it('n influence pas l auto-layout', () => {
const nodes = store().map?.nodes ?? [];
const before = computeLayout(nodes, 'mindmap-right');
store().addRelation(idOf('Firewall'), idOf('Web'));
const after = computeLayout(store().map?.nodes ?? [], 'mindmap-right');
for (const [id, position] of before) expect(after.get(id)).toEqual(position);
});
});
describe('modification et suppression', () => {
it('modifie le libelle et le style sans toucher aux extremites', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const relation = relations()[0];
store().updateRelation(relation?.id as string, { label: 'utilise', width: 4 });
const updated = relations()[0];
expect(updated?.label).toBe('utilise');
expect(updated?.width).toBe(4);
expect(updated?.sourceId).toBe(relation?.sourceId);
expect(updated?.targetId).toBe(relation?.targetId);
});
it('ne laisse pas deplacer une extremite par une mise a jour', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const relation = relations()[0];
store().updateRelation(relation?.id as string, {
sourceId: idOf('Switch'),
} as { sourceId: string });
expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
});
it('supprime une relation sans supprimer de noeud', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
const count = store().map?.nodes.length as number;
store().deleteRelation(relations()[0]?.id as string);
expect(relations()).toHaveLength(0);
expect(store().map?.nodes).toHaveLength(count);
});
it('annule et retablit une creation en une seule entree', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
expect(relations()).toHaveLength(1);
store().undo();
expect(relations()).toHaveLength(0);
store().redo();
expect(relations()).toHaveLength(1);
expect(relations()[0]?.label).toBe('protege');
});
it('annule une suppression de relation', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().deleteRelation(relations()[0]?.id as string);
store().undo();
expect(relations()).toHaveLength(1);
});
});
describe('relations et structure', () => {
it('disparait quand un de ses noeuds est supprime', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().deleteNodes([idOf('Web')]);
expect(relations()).toHaveLength(0);
});
it('revient si la suppression est annulee', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().deleteNodes([idOf('Web')]);
store().undo();
expect(relations()).toHaveLength(1);
});
it('reste dans le document quand une extremite est masquee par un repli', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().setBranchCollapsed(idOf('Services'), true);
// La relation est toujours la : seul l'affichage la masque.
expect(relations()).toHaveLength(1);
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('Web'))).toBe(true);
store().setBranchCollapsed(idOf('Services'), false);
expect(getHiddenNodeIds(store().map?.nodes ?? []).has(idOf('Web'))).toBe(false);
expect(relations()).toHaveLength(1);
});
it('survit a un changement de parent des deux extremites', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().moveNodeToParent(idOf('Firewall'), idOf('Services'));
expect(relations()).toHaveLength(1);
expect(relations()[0]?.sourceId).toBe(idOf('Firewall'));
});
});
describe('duplication de branche', () => {
it('duplique une relation interne avec de nouveaux identifiants', () => {
store().addRelation(idOf('Firewall'), idOf('Switch'), { label: 'secours' });
store().duplicateBranch(idOf('Reseau'));
expect(relations()).toHaveLength(2);
const [original, copy] = relations();
expect(copy?.id).not.toBe(original?.id);
expect(copy?.sourceId).not.toBe(original?.sourceId);
expect(copy?.targetId).not.toBe(original?.targetId);
expect(copy?.label).toBe('secours');
// Les nouvelles extremites appartiennent bien a la copie.
const ids = new Set((store().map?.nodes ?? []).map((node) => node.id));
expect(ids.has(copy?.sourceId as string)).toBe(true);
expect(ids.has(copy?.targetId as string)).toBe(true);
});
it('ne duplique pas une relation sortant de la branche', () => {
store().addRelation(idOf('Firewall'), idOf('Web'));
store().duplicateBranch(idOf('Reseau'));
expect(relations()).toHaveLength(1);
});
it('reporte les relations internes lors d un copier / coller de branche', () => {
store().addRelation(idOf('Firewall'), idOf('Switch'));
store().copyBranch(idOf('Reseau'));
store().pasteAsChild(idOf('Services'));
expect(relations()).toHaveLength(2);
const copy = relations()[1];
expect(copy?.sourceId).not.toBe(idOf('Firewall'));
});
it('compte la duplication comme une seule entree d historique', () => {
store().addRelation(idOf('Firewall'), idOf('Switch'));
const nodeCount = store().map?.nodes.length as number;
store().duplicateBranch(idOf('Reseau'));
expect(relations()).toHaveLength(2);
store().undo();
expect(relations()).toHaveLength(1);
expect(store().map?.nodes).toHaveLength(nodeCount);
});
});
describe('recherche', () => {
it('retrouve les noeuds relies par un libelle de relation', () => {
store().addRelation(idOf('Firewall'), idOf('Web'), { label: 'protege' });
store().setSearchQuery('protege');
const results = new Set(store().searchResults);
expect(results.has(idOf('Firewall'))).toBe(true);
expect(results.has(idOf('Web'))).toBe(true);
expect(results.has(idOf('Switch'))).toBe(false);
});
it('continue de chercher dans le texte des noeuds', () => {
store().setSearchQuery('Firewall');
expect(store().searchResults).toEqual([idOf('Firewall')]);
});
});

View File

@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
import { getLevelOneBranches, resolveNodeSides } from '@/features/editor/bilateral';
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 sideOf(text: string) {
return store().map?.nodes.find((node) => node.text === text)?.side;
}
/** Infrastructure > (Reseau > Firewall, Services, Stockage, Supervision). */
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Infrastructure'));
const initial = useEditorStore.getState();
initial.addChildren(rootId(), ['Reseau', 'Services', 'Stockage', 'Supervision']);
initial.addChildren(idOf('Reseau'), ['Firewall', 'Switch']);
});
describe('cote manuel', () => {
it('place une branche de niveau 1 a gauche', () => {
expect(store().setNodeSide(idOf('Services'), 'left')).toBe(true);
expect(sideOf('Services')).toBe('left');
});
it('refuse de poser un cote sur un noeud plus profond', () => {
expect(store().setNodeSide(idOf('Firewall'), 'left')).toBe(false);
expect(sideOf('Firewall')).toBeUndefined();
});
it('refuse de poser un cote sur la racine', () => {
expect(store().setNodeSide(rootId(), 'left')).toBe(false);
});
it('ne deplace aucun noeud', () => {
const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
store().setNodeSide(idOf('Services'), 'left');
const after = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
expect(after).toEqual(before);
});
it('compte pour une seule entree d historique', () => {
store().setNodeSide(idOf('Services'), 'left');
store().undo();
expect(sideOf('Services')).toBeUndefined();
store().redo();
expect(sideOf('Services')).toBe('left');
});
it('fait suivre tous les descendants de la branche', () => {
store().setNodeSide(idOf('Reseau'), 'left');
const sides = resolveNodeSides(store().map?.nodes ?? []);
expect(sides.get(idOf('Firewall'))).toBe('left');
expect(sides.get(idOf('Switch'))).toBe('left');
});
});
describe('repartition automatique', () => {
it('materialise les cotes manquants en une seule entree', () => {
const changed = store().balanceSides();
expect(changed).toBe(4);
for (const branch of ['Reseau', 'Services', 'Stockage', 'Supervision']) {
expect(sideOf(branch)).toBeDefined();
}
store().undo();
expect(sideOf('Reseau')).toBeUndefined();
});
it('ne fait rien si tout est deja reparti', () => {
store().balanceSides();
expect(store().balanceSides()).toBe(0);
});
it('respecte un cote deja choisi a la main', () => {
store().setNodeSide(idOf('Supervision'), 'left');
store().balanceSides();
expect(sideOf('Supervision')).toBe('left');
});
it('n ecrit un cote que sur les branches de niveau 1', () => {
store().balanceSides();
const branchIds = new Set(getLevelOneBranches(store().map?.nodes ?? []).map((n) => n.id));
for (const node of store().map?.nodes ?? []) {
if (node.side !== undefined) expect(branchIds.has(node.id)).toBe(true);
}
});
});
describe('passage en disposition bilaterale', () => {
it('repartit les branches et deplace la carte en une seule entree', () => {
const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
store().applyAutoLayout('mindmap-both');
expect(store().map?.settings.layout).toBe('mindmap-both');
for (const branch of ['Reseau', 'Services', 'Stockage', 'Supervision']) {
expect(sideOf(branch)).toBeDefined();
}
const after = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
expect(after).not.toEqual(before);
store().undo();
expect(sideOf('Reseau')).toBeUndefined();
expect((store().map?.nodes ?? []).map((node) => ({ ...node.position }))).toEqual(before);
});
it('place la racine entre les deux cotes', () => {
store().applyAutoLayout('mindmap-both');
const nodes = store().map?.nodes ?? [];
const sides = resolveNodeSides(nodes);
const rootX = nodes.find((node) => node.id === rootId())?.position.x as number;
for (const branch of getLevelOneBranches(nodes)) {
if (sides.get(branch.id) === 'left') expect(branch.position.x).toBeLessThan(rootX);
else expect(branch.position.x).toBeGreaterThan(rootX);
}
});
it('choisir une disposition sans reorganiser ne deplace rien', () => {
const before = (store().map?.nodes ?? []).map((node) => ({ ...node.position }));
store().updateSettings({ layout: 'mindmap-both' });
expect(store().map?.settings.layout).toBe('mindmap-both');
expect((store().map?.nodes ?? []).map((node) => ({ ...node.position }))).toEqual(before);
});
});
describe('cote et duplication', () => {
it('conserve le cote quand une branche de niveau 1 est dupliquee', () => {
store().setNodeSide(idOf('Reseau'), 'left');
store().duplicateBranch(idOf('Reseau'));
const copy = (store().map?.nodes ?? []).find((node) => node.text.endsWith('copie'));
expect(copy?.side).toBe('left');
expect(copy?.parentId).toBe(rootId());
});
it('conserve le cote lors d une serialisation en memoire', () => {
store().balanceSides();
const sides = (store().map?.nodes ?? []).map((node) => [node.text, node.side]);
const reloaded = JSON.parse(JSON.stringify(store().map)) as { nodes: typeof sides };
expect((reloaded.nodes as unknown as Array<{ text: string; side?: string }>).map((n) => [n.text, n.side])).toEqual(sides);
});
});
describe('creation du cote gauche', () => {
it('fait naitre les enfants vers la gauche sur une branche de gauche', () => {
store().setNodeSide(idOf('Reseau'), 'left');
store().setNodeSide(idOf('Services'), 'right');
store().updateSettings({ layout: 'mindmap-both' });
const reseauX = store().map?.nodes.find((n) => n.id === idOf('Reseau'))?.position.x as number;
const servicesX = store().map?.nodes.find((n) => n.id === idOf('Services'))?.position
.x as number;
store().addChildren(idOf('Reseau'), ['Routeur']);
store().addChildren(idOf('Services'), ['LDAP']);
const routeurX = store().map?.nodes.find((n) => n.text === 'Routeur')?.position.x as number;
const ldapX = store().map?.nodes.find((n) => n.text === 'LDAP')?.position.x as number;
expect(routeurX).toBeLessThan(reseauX);
expect(ldapX).toBeGreaterThan(servicesX);
});
it('garde le comportement d origine dans les dispositions classiques', () => {
const reseauX = store().map?.nodes.find((n) => n.id === idOf('Reseau'))?.position.x as number;
store().addChildren(idOf('Reseau'), ['Routeur']);
const routeurX = store().map?.nodes.find((n) => n.text === 'Routeur')?.position.x as number;
expect(routeurX).toBeGreaterThan(reseauX);
});
});

View File

@@ -7,14 +7,25 @@ import {
type MindMap,
type MindMapEdge,
type MindMapNode,
type MindMapRelation,
type NodeBranchStyle,
type NodeIconStyle,
type NodeLayout,
type NodeSide,
type NodeStyle,
type Position,
type Viewport,
} from '@/types/map';
import { createNode } from '@/services/mapFactory';
import {
checkRelation,
createRelationId,
pruneRelations,
relationsWithin,
remapRelations,
type RelationRejection,
} from '@/services/relations';
import { materializeSides, resolveNodeSides } from '@/features/editor/bilateral';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { getTheme, type MapTheme } from '@/features/editor/themes';
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
@@ -41,10 +52,14 @@ export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
interface HistorySnapshot {
nodes: MindMapNode[];
edges: MindMapEdge[];
/** Les relations font partie du document : elles suivent l'annulation. */
relations: MindMapRelation[];
}
interface ClipboardPayload {
nodes: MindMapNode[];
/** Relations internes au lot copie, reportees au collage. */
relations: MindMapRelation[];
}
/**
@@ -83,6 +98,12 @@ interface EditorState {
map: MindMap | null;
readOnly: boolean;
selectedNodeIds: string[];
/**
* Relation secondaire selectionnee. Distincte de la selection de noeuds :
* une relation n'est pas un noeud, et la supprimer ne doit jamais supprimer
* un noeud.
*/
selectedRelationId: string | null;
editingNodeId: string | null;
past: HistorySnapshot[];
future: HistorySnapshot[];
@@ -106,6 +127,21 @@ interface EditorState {
setSelection: (ids: string[]) => void;
setEditingNode: (id: string | null) => void;
setSelectedRelation: (id: string | null) => void;
/** Cree une relation secondaire. Ne touche jamais a `parentId` ni a `order`. */
addRelation: (
sourceId: string,
targetId: string,
patch?: Partial<Omit<MindMapRelation, 'id' | 'sourceId' | 'targetId'>>,
) => RelationRejection | null;
updateRelation: (id: string, patch: Partial<Omit<MindMapRelation, 'id'>>) => void;
deleteRelation: (id: string) => void;
/** Cote d'une branche de niveau 1 en mind map bilaterale. */
setNodeSide: (nodeId: string, side: NodeSide) => boolean;
/** Repartit les branches sans cote. Une seule entree d'historique. */
balanceSides: () => number;
pushHistory: () => void;
undo: () => void;
@@ -183,7 +219,7 @@ interface EditorState {
}
function snapshot(map: MindMap): HistorySnapshot {
return { nodes: map.nodes, edges: map.edges };
return { nodes: map.nodes, edges: map.edges, relations: map.relations };
}
/** Normalisation pour la recherche : insensible a la casse et aux accents. */
@@ -201,9 +237,33 @@ function normalizeText(value: string): string {
* 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 {
function withEdges(
map: MindMap,
nodes: MindMapNode[],
relations?: MindMapRelation[],
): MindMap {
const ordered = normalizeChildOrders(nodes);
return { ...map, nodes: ordered, edges: rebuildEdges(ordered) };
/*
* Les relations ne sont jamais recalculees ici : elles ne se deduisent pas de
* `parentId`. On les preserve telles quelles et on retire seulement celles
* dont une extremite vient de disparaitre. `withEdges` ne doit sous aucun
* pretexte devenir un endroit ou une relation se perd.
*/
const kept = pruneRelations(relations ?? map.relations, ordered);
return { ...map, nodes: ordered, edges: rebuildEdges(ordered), relations: kept };
}
/**
* Sens dans lequel placer les nouveaux enfants d'un parent.
*
* En mind map bilaterale, une branche de gauche fait naitre ses enfants vers la
* gauche : sans cela, tout ce qui est cree sur le cote gauche partirait vers la
* droite et traverserait la carte.
*/
function childDirection(map: MindMap, parent?: MindMapNode): 1 | -1 {
const base = layoutDirection(map.settings.layout);
if (map.settings.layout !== 'mindmap-both' || !parent) return base;
return resolveNodeSides(map.nodes).get(parent.id) === 'left' ? -1 : 1;
}
/**
@@ -230,6 +290,12 @@ function pruneEmpty<T extends object>(value: T | undefined): T | undefined {
return entries.length > 0 ? (Object.fromEntries(entries) as T) : undefined;
}
interface ClonedSubtrees {
nodes: MindMapNode[];
/** Ancien identifiant -> nouveau. Sert a reporter les relations internes. */
idMapping: Map<string, string>;
}
/**
* Copie un ensemble de sous-arbres en regenerant tous les identifiants.
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
@@ -238,11 +304,11 @@ function cloneSubtrees(
source: MindMapNode[],
offset: number,
parentOverride?: string | null,
): MindMapNode[] {
): ClonedSubtrees {
const idMapping = new Map<string, string>();
for (const node of source) idMapping.set(node.id, createId());
return source.map((node) => {
const nodes = source.map((node) => {
const mappedParent = node.parentId ? idMapping.get(node.parentId) : undefined;
const parentId =
mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null));
@@ -256,6 +322,8 @@ function cloneSubtrees(
...(node.style ? { style: { ...node.style } } : {}),
};
});
return { nodes, idMapping };
}
export const useEditorStore = create<EditorState>((set, get) => {
@@ -301,6 +369,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
...current,
nodes: target.nodes,
edges: target.edges,
relations: target.relations,
updatedAt: new Date().toISOString(),
},
selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)),
@@ -347,6 +416,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
map: null,
readOnly: false,
selectedNodeIds: [],
selectedRelationId: null,
editingNodeId: null,
past: [],
future: [],
@@ -367,6 +437,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
map,
readOnly: options?.readOnly ?? false,
selectedNodeIds: [],
selectedRelationId: null,
editingNodeId: null,
past: [],
future: [],
@@ -388,6 +459,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
map: null,
readOnly: false,
selectedNodeIds: [],
selectedRelationId: null,
editingNodeId: null,
past: [],
future: [],
@@ -400,8 +472,122 @@ export const useEditorStore = create<EditorState>((set, get) => {
});
},
setSelection: (ids) => set({ selectedNodeIds: ids }),
// Selectionner un noeud abandonne la relation selectionnee, et inversement :
// il n'y a jamais deux selections concurrentes a l'ecran.
setSelection: (ids) =>
set({ selectedNodeIds: ids, ...(ids.length > 0 ? { selectedRelationId: null } : {}) }),
setEditingNode: (id) => set({ editingNodeId: id }),
setSelectedRelation: (id) =>
set({ selectedRelationId: id, ...(id ? { selectedNodeIds: [], editingNodeId: null } : {}) }),
/**
* Cree une relation secondaire entre deux noeuds.
*
* Aucune ecriture sur `parentId`, `order`, `collapsed` ni sur les enfants :
* c'est la garantie qu'une relation ne devient jamais une deuxieme
* hierarchie. Retourne la raison du refus, ou `null` si la relation a bien
* ete creee.
*/
addRelation: (sourceId, targetId, patch) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return 'unknown';
const rejection = checkRelation(map.nodes, map.relations, sourceId, targetId);
if (rejection) return rejection;
const relation: MindMapRelation = {
id: createRelationId(),
sourceId,
targetId,
...(patch ?? {}),
};
commit((current) => ({ ...current, relations: [...current.relations, relation] }));
set({ selectedRelationId: relation.id, selectedNodeIds: [], editingNodeId: null });
return null;
},
updateRelation: (id, patch) => {
commit((current) => {
const index = current.relations.findIndex((relation) => relation.id === id);
if (index === -1) return current;
const relations = [...current.relations];
// `id`, `sourceId` et `targetId` restent maitres : on ne peut pas
// deplacer une extremite par une simple mise a jour de style.
const existing = relations[index] as MindMapRelation;
relations[index] = {
...existing,
...patch,
id: existing.id,
sourceId: existing.sourceId,
targetId: existing.targetId,
};
return { ...current, relations };
});
},
deleteRelation: (id) => {
const state = get();
if (!state.map || state.readOnly) return;
if (!state.map.relations.some((relation) => relation.id === id)) return;
// Supprimer une relation ne touche a aucun noeud.
commit((current) => ({
...current,
relations: current.relations.filter((relation) => relation.id !== id),
}));
set({ selectedRelationId: null });
},
/**
* Change le cote d'une branche de niveau 1.
*
* Rien ne bouge a l'ecran : conformement au reste de l'auto-layout, les
* positions manuelles ne sont jamais ecrasees sans que l'utilisateur
* demande explicitement une reorganisation.
*/
setNodeSide: (nodeId, side) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return false;
const node = map.nodes.find((candidate) => candidate.id === nodeId);
if (!node || !node.parentId) return false;
const parent = map.nodes.find((candidate) => candidate.id === node.parentId);
// Seule une branche de niveau 1 porte un cote : ailleurs il est herite.
if (!parent || parent.parentId) return false;
if (node.side === side) return true;
commit((current) =>
withEdges(
current,
current.nodes.map((candidate) =>
candidate.id === nodeId ? { ...candidate, side } : candidate,
),
),
);
return true;
},
/** Materialise les cotes manquants. Retourne le nombre de branches traitees. */
balanceSides: () => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return 0;
const nodes = materializeSides(map.nodes);
if (nodes === map.nodes) return 0;
let changed = 0;
for (let index = 0; index < nodes.length; index += 1) {
if (nodes[index] !== map.nodes[index]) changed += 1;
}
commit((current) => withEdges(current, nodes));
return changed;
},
pushHistory: () => {
const state = get();
@@ -443,7 +629,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout);
const direction = childDirection(map, parent);
const child = parent
? createNode({
@@ -523,7 +709,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout);
const direction = childDirection(map, parent);
const startOrder = nextSiblingOrder(map.nodes, parent?.id ?? null);
const positions = parent
@@ -561,7 +747,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const parent = parentId ? map.nodes.find((node) => node.id === parentId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout);
const direction = childDirection(map, parent);
const created: MindMapNode[] = [];
const insert = (
@@ -585,6 +771,8 @@ export const useEditorStore = create<EditorState>((set, get) => {
type: !anchorNode && map.nodes.length === 0 && created.length === 0 ? 'root' : 'standard',
order: baseOrder + index,
position,
// L'import Markdown range les paragraphes dans la note du noeud.
...(outline.note ? { note: outline.note } : {}),
});
created.push(node);
if (outline.children.length > 0) insert(outline.children, node, depth + 1, 0);
@@ -844,22 +1032,33 @@ export const useEditorStore = create<EditorState>((set, get) => {
const branchIds = new Set(withDescendants(map.nodes, [id]));
const source = map.nodes.filter((node) => branchIds.has(node.id));
const clones = cloneSubtrees(source, 40);
const { nodes: clones, idMapping } = cloneSubtrees(source, 40);
const cloneRoot = clones[0] as MindMapNode;
/*
* Les relations entierement contenues dans la branche sont dupliquees
* avec elle : la copie est autonome. Une relation qui sort de la branche
* ne l'est pas, faute de savoir si la copie doit viser la meme cible.
*/
const internal = remapRelations(relationsWithin(map.relations, branchIds), idMapping);
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,
),
]),
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,
),
],
[...current.relations, ...internal],
),
);
set({ selectedNodeIds: [cloneRoot.id], editingNodeId: null });
return clones.length;
@@ -873,7 +1072,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
const branchIds = new Set(withDescendants(map.nodes, [id]));
const nodes = map.nodes.filter((node) => branchIds.has(node.id)).map((node) => ({ ...node }));
set({ clipboard: { nodes } });
set({ clipboard: { nodes, relations: relationsWithin(map.relations, branchIds) } });
return nodes.length;
},
@@ -885,18 +1084,23 @@ export const useEditorStore = create<EditorState>((set, get) => {
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);
const { nodes: clones, idMapping } = cloneSubtrees(clipboard.nodes, 40, parentId);
const internal = remapRelations(clipboard.relations, idMapping);
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,
),
]),
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,
),
],
[...current.relations, ...internal],
),
);
set({ selectedNodeIds: [clones[0]?.id as string], editingNodeId: null });
return clones.length;
@@ -1407,7 +1611,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const nodes = map.nodes.filter((node) => ids.has(node.id));
set({ clipboard: { nodes: nodes.map((node) => ({ ...node })) } });
set({
clipboard: {
nodes: nodes.map((node) => ({ ...node })),
relations: relationsWithin(map.relations, ids),
},
});
return nodes.length;
},
@@ -1418,9 +1627,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
const target = state.selectedNodeIds[0] ?? null;
const clones = cloneSubtrees(clipboard.nodes, 40, target);
const { nodes: clones, idMapping } = cloneSubtrees(clipboard.nodes, 40, target);
const internal = remapRelations(clipboard.relations, idMapping);
commit((current) => withEdges(current, [...current.nodes, ...clones]));
commit((current) =>
withEdges(current, [...current.nodes, ...clones], [...current.relations, ...internal]),
);
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
@@ -1433,9 +1645,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const source = map.nodes.filter((node) => ids.has(node.id));
// parentOverride non fourni : chaque copie reste au meme niveau que l'original.
const clones = cloneSubtrees(source, 40);
const { nodes: clones, idMapping } = cloneSubtrees(source, 40);
const internal = remapRelations(relationsWithin(map.relations, ids), idMapping);
commit((current) => withEdges(current, [...current.nodes, ...clones]));
commit((current) =>
withEdges(current, [...current.nodes, ...clones], [...current.relations, ...internal]),
);
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
@@ -1447,13 +1662,23 @@ export const useEditorStore = create<EditorState>((set, get) => {
const target = layout ?? map.settings.layout;
const effectiveSpacing = spacing ?? map.settings.layoutSpacing;
const positions = computeLayout(map.nodes, target, {
/*
* Passage en bilaterale : les branches qui n'ont pas encore de cote en
* recoivent un ici, dans la meme entree d'historique que le deplacement.
* Un cote choisi a la main n'est jamais ecrase.
*/
const nodes = target === 'mindmap-both' ? materializeSides(map.nodes) : map.nodes;
const positions = computeLayout(nodes, target, {
measuredSizes: state.measuredSizes,
spacing: effectiveSpacing,
theme: getTheme(map.settings.theme),
});
if (positions.size === 0) return;
const byId = new Map(nodes.map((node) => [node.id, node]));
commit((current) => ({
...current,
settings: {
@@ -1462,8 +1687,9 @@ export const useEditorStore = create<EditorState>((set, get) => {
...(spacing ? { layoutSpacing: spacing } : {}),
},
nodes: current.nodes.map((node) => {
const withSide = byId.get(node.id) ?? node;
const position = positions.get(node.id);
return position ? { ...node, position } : node;
return position ? { ...withSide, position } : withSide;
}),
}));
set({ fitRequest: get().fitRequest + 1 });
@@ -1499,11 +1725,26 @@ export const useEditorStore = create<EditorState>((set, get) => {
}
const needle = normalizeText(trimmed);
/*
* Le libelle d'une relation est aussi du contenu : chercher "protege"
* doit ramener les deux noeuds qu'elle relie. La recherche continue de ne
* renvoyer que des noeuds, donc la navigation entre resultats, le
* deploiement des branches et le centrage restent inchanges.
*/
const fromRelations = new Set<string>();
for (const relation of map.relations) {
if (relation.label && normalizeText(relation.label).includes(needle)) {
fromRelations.add(relation.sourceId);
fromRelations.add(relation.targetId);
}
}
const results = map.nodes
.filter(
(node) =>
normalizeText(node.text).includes(needle) ||
(node.note ? normalizeText(node.note).includes(needle) : false),
(node.note ? normalizeText(node.note).includes(needle) : false) ||
fromRelations.has(node.id),
)
.map((node) => node.id);

View File

@@ -17,8 +17,13 @@
* 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.
* v4 : mind map bilaterale (`MindMapNode.side`) et relations secondaires
* (`MindMap.relations`). Le bump est justifie : un lecteur v3 ignorerait
* les relations, donc perdrait silencieusement une partie du document.
* La migration v3 -> v4 n'a rien a inventer : aucun cote defini signifie
* "reparti automatiquement", et aucune relation signifie "aucune".
*/
export const MAP_FORMAT_VERSION = 3;
export const MAP_FORMAT_VERSION = 4;
/**
* Types de noeuds.
@@ -33,6 +38,9 @@ export const EDITABLE_NODE_KINDS: NodeKind[] = ['root', 'standard'];
export type NodeShape = 'rectangle' | 'rounded' | 'capsule' | 'circle';
/** Cote d'une branche de niveau 1 en mind map bilaterale. */
export type NodeSide = 'left' | 'right';
export type TextAlign = 'left' | 'center' | 'right';
export type VerticalAlign = 'top' | 'middle' | 'bottom';
@@ -117,6 +125,15 @@ export interface MindMapNode {
* position : il n'y a jamais deux sources contradictoires.
*/
order?: number;
/**
* Cote d'une branche en mind map bilaterale (v4).
*
* N'a de sens que sur un enfant direct d'une racine : c'est la branche de
* niveau 1 qui choisit son cote, tous ses descendants en heritent. Le champ
* n'est donc jamais ecrit ailleurs, et il est ignore s'il s'y trouve.
* Absent = a repartir automatiquement.
*/
side?: NodeSide;
/** Branche repliee : les descendants sont masques. */
collapsed?: boolean;
/** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */
@@ -150,6 +167,34 @@ export interface MindMapEdge {
export type BranchStyle = 'straight' | 'curved' | 'bezier';
/** Extremite d'une relation secondaire. */
export type RelationMarker = 'none' | 'arrow';
/**
* Relation secondaire entre deux noeuds (v4).
*
* C'est une connexion purement visuelle : elle n'a aucun effet sur `parentId`,
* `order`, les enfants, le repli ou l'auto-layout. La hierarchie reste la seule
* structure de la carte ; une relation ne doit jamais devenir une deuxieme
* hierarchie parallele.
*/
export interface MindMapRelation {
id: string;
sourceId: string;
targetId: string;
/** Texte libre affiche au milieu du trait. Aucun vocabulaire impose. */
label?: string;
/** Trace du trait. Absent = bezier. */
style?: BranchStyle;
color?: string;
width?: number;
/** Trait discontinu. Absent = pointille : c'est le defaut d'une relation. */
dashed?: boolean;
markerStart?: RelationMarker;
/** Absent = fleche : une relation se lit par defaut de la source vers la cible. */
markerEnd?: RelationMarker;
}
/**
* Dispositions d'auto-layout.
* `mindmap-both`, `organization` et `freeform` sont reserves : ils sont
@@ -206,7 +251,16 @@ export interface MindMap {
createdAt: string;
updatedAt: string;
nodes: MindMapNode[];
/**
* Liens hierarchiques, entierement derives de `parentId`. Ils sont recalcules
* a chaque modification : ce ne sont pas une source de verite.
*/
edges: MindMapEdge[];
/**
* Relations secondaires (v4). Elles sont, elles, une donnee a part entiere :
* rien ne permet de les deduire de la hierarchie.
*/
relations: MindMapRelation[];
viewport: Viewport;
settings: MapSettings;
}