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

@@ -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),
);
}