Files
openmind/frontend/src/features/editor/flowAdapters.ts
snoopy e3c530348e 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>
2026-09-10 08:12:00 +02:00

334 lines
12 KiB
TypeScript

import { type Edge, type Node } from '@xyflow/react';
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,
} from '@/features/editor/nodeStyleResolver';
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { buildChildrenMap, buildNodeMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
import { type NodeSize } from '@/utils/nodeSize';
export { resolveNodeAppearance };
export type { NodeAppearance };
export interface MindNodeData extends Record<string, unknown> {
node: MindMapNode;
depth: number;
appearance: NodeAppearance;
childCount: number;
hiddenCount: number;
isSearchHit: boolean;
readOnly: boolean;
isEditing: boolean;
/**
* 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'>;
function handlePositions(layout: MindMap['settings']['layout']): {
source: Position;
target: Position;
} {
if (!isHorizontalLayout(layout)) {
return { source: Position.Bottom, target: Position.Top };
}
return layoutDirection(layout) === -1
? { source: Position.Left, target: Position.Right }
: { 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',
straight: 'straight',
};
export interface AdapterOptions {
theme: MapTheme;
selectedIds: string[];
searchResults: string[];
editingNodeId: string | null;
readOnly: boolean;
/** Tailles reelles mesurees dans le DOM, requises par la minimap. */
measuredSizes?: Map<string, NodeSize>;
}
export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode[] {
const depths = computeDepths(map.nodes);
const hidden = getHiddenNodeIds(map.nodes);
const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults);
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,
type: 'mind' as const,
position: node.position,
...(measured ? { measured } : {}),
selected: selected.has(node.id),
hidden: hidden.has(node.id),
draggable: !options.readOnly,
selectable: true,
connectable: !options.readOnly,
deletable: !options.readOnly,
data: {
node,
depth,
appearance: resolveNodeAppearance(node, depth, options.theme),
childCount: childIds.length,
hiddenCount: node.collapsed ? countBranch(children, node.id) : 0,
isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id,
sourceHandles: plan.sources,
targetHandle: plan.target,
},
};
});
}
function countBranch(children: Map<string, string[]>, rootId: string): number {
let total = 0;
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;
}
/**
* Les liens heritent du style de branche de la carte, sauf si le noeud enfant
* definit sa propre personnalisation (`node.branch`).
*/
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,
style: {
stroke: branch.color ?? edge.color ?? options.theme.branchColor,
strokeWidth: branch.width ?? options.theme.branchWidth,
},
};
});
}
/** 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);
}