OpenMind v1 : cartes mentales libres, local-first et dockerisees
Premiere version fonctionnelle, demarrable avec `docker compose up -d --build` sur http://localhost:8080. Architecture - Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react, etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre. - Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les fichiers statiques du frontend compile. - Image Docker multi-stage, un seul service, bind mount ./data pour SQLite. Local-first Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur demande explicite de partage. Partage Deux secrets distincts : share_token public en lecture seule dans l'URL, et management_token conserve uniquement cote client, transmis en en-tete pour les modifications et stocke hashe en base. Fonctionnalites Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis, branches repliables, recherche), 9 themes, 4 dispositions automatiques, panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression, page publique en lecture seule, PWA. Qualite 25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur, TypeScript strict sans erreur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
182
frontend/src/features/editor/flowAdapters.ts
Normal file
182
frontend/src/features/editor/flowAdapters.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { type Edge, type Node } from '@xyflow/react';
|
||||
import { Position } from '@xyflow/react';
|
||||
import { type BranchStyle, type MindMap, type MindMapNode, type NodeShape } from '@/types/map';
|
||||
import { type MapTheme, paletteForDepth } from '@/features/editor/themes';
|
||||
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
|
||||
import { buildChildrenMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
|
||||
import { type NodeSize } from '@/utils/nodeSize';
|
||||
|
||||
/** Style CSS resolu d'un noeud : theme + personnalisations explicites. */
|
||||
export interface NodeAppearance {
|
||||
background: string;
|
||||
color: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
borderRadius: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: number;
|
||||
fontStyle: 'normal' | 'italic';
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
boxShadow: string;
|
||||
width?: number;
|
||||
circle: boolean;
|
||||
}
|
||||
|
||||
export interface MindNodeData extends Record<string, unknown> {
|
||||
node: MindMapNode;
|
||||
depth: number;
|
||||
appearance: NodeAppearance;
|
||||
childCount: number;
|
||||
hiddenCount: number;
|
||||
isSearchHit: boolean;
|
||||
readOnly: boolean;
|
||||
isEditing: boolean;
|
||||
sourcePosition: Position;
|
||||
targetPosition: Position;
|
||||
}
|
||||
|
||||
export type MindFlowNode = Node<MindNodeData, 'mind'>;
|
||||
|
||||
function radiusForShape(shape: NodeShape, themeRadius: number): string {
|
||||
switch (shape) {
|
||||
case 'rectangle':
|
||||
return '3px';
|
||||
case 'capsule':
|
||||
return '999px';
|
||||
case 'circle':
|
||||
return '50%';
|
||||
case 'rounded':
|
||||
default:
|
||||
return `${themeRadius}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne le theme et les personnalisations du noeud.
|
||||
* Une valeur definie explicitement sur le noeud gagne toujours : changer de
|
||||
* theme ne detruit donc jamais une personnalisation.
|
||||
*/
|
||||
export function resolveNodeAppearance(
|
||||
node: MindMapNode,
|
||||
depth: number,
|
||||
theme: MapTheme,
|
||||
): NodeAppearance {
|
||||
const palette = paletteForDepth(theme, depth);
|
||||
const style = node.style ?? {};
|
||||
const shape = style.shape ?? theme.nodeShape;
|
||||
|
||||
return {
|
||||
background: style.backgroundColor ?? palette.background,
|
||||
color: style.textColor ?? palette.text,
|
||||
borderColor: style.borderColor ?? palette.border,
|
||||
borderWidth: theme.borderWidth,
|
||||
borderRadius: radiusForShape(shape, theme.nodeRadius),
|
||||
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
|
||||
fontFamily: theme.fontFamily,
|
||||
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
|
||||
fontStyle: style.italic ? 'italic' : 'normal',
|
||||
textAlign: style.textAlign ?? 'left',
|
||||
boxShadow: theme.shadow,
|
||||
...(style.width ? { width: style.width } : {}),
|
||||
circle: shape === 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 positions = handlePositions(map.settings.layout);
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
sourcePosition: positions.source,
|
||||
targetPosition: positions.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;
|
||||
}
|
||||
|
||||
export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
|
||||
const hidden = getHiddenNodeIds(map.nodes);
|
||||
const edgeType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
|
||||
|
||||
return map.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edgeType,
|
||||
hidden: hidden.has(edge.source) || hidden.has(edge.target),
|
||||
deletable: !options.readOnly,
|
||||
style: {
|
||||
stroke: edge.color ?? options.theme.branchColor,
|
||||
strokeWidth: options.theme.branchWidth,
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user