Files
openmind/frontend/src/services/mapSerialization.ts
snoopy a23a68629d Phase 1.1.1 : consolidation et coherence UX
Trois corrections ciblees, sans changement d'architecture ni d'interface.

1. Le style ne transporte plus l'icone
L'icone d'un noeud est du contenu, comme son texte ou sa note. Copier le style
de "Reseau" et le coller sur "Stockage" et "Securite" applique desormais les
couleurs, la forme et la mise en page en laissant chaque noeud avec sa propre
icone. Seule la presentation de l'icone (position, taille, espacement) reste
une propriete de style.

2. Le cercle conserve un rapport 1:1
`keepAspectRatio` sur le redimensionneur, et contrainte cote store pour les
trois autres chemins : redimensionnement direct, saisie d'une largeur ou d'une
hauteur dans le panneau, et conversion d'un noeud rectangulaire en cercle.
Un cercle ne peut plus devenir un ovale.

3. Point d'entree unique des documents
`normalizeMapDocument()` remplace `parseMapDocument()` et devient le seul
chemin par lequel une carte entre dans l'editeur, qu'elle vienne d'un fichier
.mmap, d'IndexedDB ou d'un partage. Le pipeline est toujours validation ->
migration -> normalisation. Les cartes locales passaient jusqu'ici a cote de la
migration : elles y passent maintenant a l'ouverture, sans etre reecrites en
base tant que l'utilisateur ne les modifie pas. Une carte locale videe de ses
noeuds reste ouvrable, et le message d'erreur s'adapte a la provenance.

Egalement
- Zoom minimum abaisse de 0.1 a 0.02 : "Adapter a l'ecran" butait sur ce
  plancher et ne montrait pas toute une carte de 181 noeuds.
- README : pipeline de normalisation, comportement du style et du cercle,
  limitations mesurees.

Tests : 103 vitest (85 -> 103) et 31 pytest, ruff, eslint et tsc au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:48:22 +02:00

495 lines
17 KiB
TypeScript

import { APP_CONFIG } from '@/config/app';
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type FontFamilyId,
type IconPosition,
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapNode,
type NodeBranchStyle,
type NodeIconStyle,
type NodeKind,
type NodeLayout,
type NodeShape,
type NodeStyle,
type ShadowStyle,
type SizeMode,
type TextAlign,
type VerticalAlign,
} from '@/types/map';
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
import { SPACING_LIMITS } from '@/features/editor/layout';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
export class MapFormatError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapFormatError';
}
}
const MAX_TEXT_LENGTH = 5000;
const MAX_NOTE_LENGTH = 20000;
const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const VERTICAL_ALIGNS: VerticalAlign[] = ['top', 'middle', 'bottom'];
const SIZE_MODES: SizeMode[] = ['auto', 'fixed'];
const SHADOW_STYLES: ShadowStyle[] = ['theme', 'none', 'soft', 'strong'];
const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounded'];
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
const ICON_KINDS = ['emoji', 'builtin'] as const;
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
'tree-horizontal',
'tree-vertical',
'mindmap-both',
'organization',
'freeform',
];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.slice(0, maxLength);
}
function asFiniteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/** Une couleur CSS acceptee : hex, rgb(a) ou nom simple. Rien d'autre. */
function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim().slice(0, 40);
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
if (/^rgba?\(\s*[\d.\s,%/]+\)$/.test(trimmed)) return trimmed;
if (/^[a-zA-Z]{3,20}$/.test(trimmed)) return trimmed;
return undefined;
}
function parseStyle(value: unknown): NodeStyle | undefined {
if (!isRecord(value)) return undefined;
const style: NodeStyle = {};
const background = asColor(value.backgroundColor);
if (background) style.backgroundColor = background;
const text = asColor(value.textColor);
if (text) style.textColor = text;
const border = asColor(value.borderColor);
if (border) style.borderColor = border;
if (typeof value.shape === 'string' && NODE_SHAPES.includes(value.shape as NodeShape)) {
style.shape = value.shape as NodeShape;
}
if (typeof value.fontSize === 'number' && Number.isFinite(value.fontSize)) {
style.fontSize = Math.min(48, Math.max(10, Math.round(value.fontSize)));
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
style.width = Math.min(600, Math.max(80, Math.round(value.width)));
}
if (typeof value.bold === 'boolean') style.bold = value.bold;
if (typeof value.italic === 'boolean') style.italic = value.italic;
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign;
}
if (
typeof value.verticalAlign === 'string' &&
VERTICAL_ALIGNS.includes(value.verticalAlign as VerticalAlign)
) {
style.verticalAlign = value.verticalAlign as VerticalAlign;
}
if (
typeof value.fontFamily === 'string' &&
FONT_FAMILIES.includes(value.fontFamily as FontFamilyId)
) {
style.fontFamily = value.fontFamily as FontFamilyId;
}
if (typeof value.shadow === 'string' && SHADOW_STYLES.includes(value.shadow as ShadowStyle)) {
style.shadow = value.shadow as ShadowStyle;
}
if (typeof value.lineHeight === 'number' && Number.isFinite(value.lineHeight)) {
style.lineHeight = Math.min(
LAYOUT_LIMITS.lineHeight.max,
Math.max(LAYOUT_LIMITS.lineHeight.min, Math.round(value.lineHeight * 100) / 100),
);
}
return Object.keys(style).length > 0 ? style : undefined;
}
/** Mise en page d'un noeud (v2). Chaque valeur est bornee. */
function parseLayout(value: unknown): NodeLayout | undefined {
if (!isRecord(value)) return undefined;
const layout: NodeLayout = {};
if (typeof value.widthMode === 'string' && SIZE_MODES.includes(value.widthMode as SizeMode)) {
layout.widthMode = value.widthMode as SizeMode;
}
if (typeof value.heightMode === 'string' && SIZE_MODES.includes(value.heightMode as SizeMode)) {
layout.heightMode = value.heightMode as SizeMode;
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
layout.width = clampTo(value.width, LAYOUT_LIMITS.width);
}
if (typeof value.height === 'number' && Number.isFinite(value.height)) {
layout.height = clampTo(value.height, LAYOUT_LIMITS.height);
}
if (typeof value.paddingX === 'number' && Number.isFinite(value.paddingX)) {
layout.paddingX = clampTo(value.paddingX, LAYOUT_LIMITS.paddingX);
}
if (typeof value.paddingY === 'number' && Number.isFinite(value.paddingY)) {
layout.paddingY = clampTo(value.paddingY, LAYOUT_LIMITS.paddingY);
}
if (typeof value.maxTextWidth === 'number' && Number.isFinite(value.maxTextWidth)) {
layout.maxTextWidth = clampTo(value.maxTextWidth, LAYOUT_LIMITS.maxTextWidth);
}
if (typeof value.wrap === 'boolean') layout.wrap = value.wrap;
return Object.keys(layout).length > 0 ? layout : undefined;
}
function parseIconStyle(value: unknown): NodeIconStyle | undefined {
if (!isRecord(value)) return undefined;
const iconStyle: NodeIconStyle = {};
if (
typeof value.kind === 'string' &&
(ICON_KINDS as readonly string[]).includes(value.kind)
) {
iconStyle.kind = value.kind as NodeIconStyle['kind'];
}
if (
typeof value.position === 'string' &&
ICON_POSITIONS.includes(value.position as IconPosition)
) {
iconStyle.position = value.position as IconPosition;
}
if (typeof value.size === 'number' && Number.isFinite(value.size)) {
iconStyle.size = clampTo(value.size, LAYOUT_LIMITS.iconSize);
}
if (typeof value.gap === 'number' && Number.isFinite(value.gap)) {
iconStyle.gap = clampTo(value.gap, LAYOUT_LIMITS.iconGap);
}
return Object.keys(iconStyle).length > 0 ? iconStyle : undefined;
}
function parseBranch(value: unknown): NodeBranchStyle | undefined {
if (!isRecord(value)) return undefined;
const branch: NodeBranchStyle = {};
if (
typeof value.style === 'string' &&
BRANCH_STYLES.includes(value.style as BranchStyle)
) {
branch.style = value.style as BranchStyle;
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
branch.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
}
const color = asColor(value.color);
if (color) branch.color = color;
return Object.keys(branch).length > 0 ? branch : undefined;
}
function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null;
const id = asString(value.id, 128);
if (!id) return null;
const type =
typeof value.type === 'string' && NODE_KINDS.includes(value.type as NodeKind)
? (value.type as NodeKind)
: 'standard';
const rawPosition = isRecord(value.position) ? value.position : {};
const node: MindMapNode = {
id,
type,
text: asString(value.text, MAX_TEXT_LENGTH) ?? '',
position: {
x: asFiniteNumber(rawPosition.x, 0),
y: asFiniteNumber(rawPosition.y, 0),
},
parentId: asString(value.parentId, 128) ?? null,
};
if (value.collapsed === true) node.collapsed = true;
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
const icon = asString(value.icon, 32);
if (icon) node.icon = icon;
const iconStyle = parseIconStyle(value.iconStyle);
if (iconStyle) node.iconStyle = iconStyle;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url;
const note = asString(value.note, MAX_NOTE_LENGTH);
if (note) node.note = note;
const style = parseStyle(value.style);
if (style) node.style = style;
const layout = parseLayout(value.layout);
if (layout) node.layout = layout;
const branch = parseBranch(value.branch);
if (branch) node.branch = branch;
if (isRecord(value.meta)) node.meta = value.meta;
return node;
}
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
if (typeof value.theme === 'string') settings.theme = value.theme.slice(0, 40);
if (typeof value.layout === 'string' && LAYOUTS.includes(value.layout as LayoutKind)) {
settings.layout = value.layout as LayoutKind;
}
if (
typeof value.branchStyle === 'string' &&
BRANCH_STYLES.includes(value.branchStyle as BranchStyle)
) {
settings.branchStyle = value.branchStyle as BranchStyle;
}
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid;
if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides;
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
}
if (isRecord(value.layoutSpacing)) {
const raw = value.layoutSpacing;
const parentGap = typeof raw.parentGap === 'number' ? raw.parentGap : undefined;
const siblingGap = typeof raw.siblingGap === 'number' ? raw.siblingGap : undefined;
if (parentGap !== undefined && siblingGap !== undefined) {
settings.layoutSpacing = {
parentGap: clampTo(parentGap, SPACING_LIMITS.parentGap),
siblingGap: clampTo(siblingGap, SPACING_LIMITS.siblingGap),
};
}
}
return settings;
}
/**
* Migrations de format, appliquees de la plus ancienne a la plus recente.
*
* v1 -> v2 : la largeur fixe vivait dans `style.width`. Elle rejoint
* `layout.width` avec un `widthMode` explicite. Les autres nouveautes de la v2
* sont facultatives : leur absence signifie "herite du theme", il n'y a donc
* rien a ecrire dans le document.
*/
function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(document.nodes)) return { ...document, version: 2 };
const nodes = document.nodes.map((rawNode) => {
if (!isRecord(rawNode) || !isRecord(rawNode.style)) return rawNode;
const { width, ...styleRest } = rawNode.style;
if (typeof width !== 'number' || !Number.isFinite(width)) return rawNode;
const existingLayout = isRecord(rawNode.layout) ? rawNode.layout : {};
return {
...rawNode,
style: styleRest,
layout: { widthMode: 'fixed', width, ...existingLayout },
};
});
return { ...document, nodes, version: 2 };
}
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
let document = raw;
let current = version;
if (current === 1) {
document = migrateV1ToV2(document);
current = 2;
}
if (current !== MAP_FORMAT_VERSION) {
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
return { ...document, version: MAP_FORMAT_VERSION };
}
return document;
}
/** Provenance d'un document : ne sert qu'a formuler les messages d'erreur. */
export type MapDocumentSource = 'file' | 'storage' | 'share';
export interface NormalizeOptions {
maxNodes?: number;
source?: MapDocumentSource;
/**
* Autorise un document sans noeud. Vrai pour les cartes deja enregistrees :
* l'utilisateur a le droit d'avoir vide sa carte, et la rejeter reviendrait a
* la lui rendre inaccessible.
*/
allowEmpty?: boolean;
}
const INVALID_DOCUMENT_MESSAGE: Record<MapDocumentSource, string> = {
file: "Le fichier selectionne n'est pas une carte valide.",
storage: "Cette carte enregistree est illisible et n'a pas pu etre ouverte.",
share: "Cette carte partagee est illisible.",
};
/**
* Point d'entree unique des documents carte.
*
* Toute carte qui entre dans l'editeur passe par ici, quelle que soit sa
* provenance : fichier .mmap, IndexedDB ou partage serveur. Le pipeline est
* toujours le meme :
*
* source -> validation -> migration de version -> normalisation -> modele courant
*
* La normalisation est non destructive : le document d'origine n'est jamais
* modifie, et rien n'est reecrit en base. Une carte v1 restee en IndexedDB
* continue donc de s'ouvrir exactement comme avant, et n'est reecrite que si
* l'utilisateur la modifie.
*
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue ni
* injecte en HTML.
*/
export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {}): MindMap {
const source = options.source ?? 'file';
if (!isRecord(raw)) {
throw new MapFormatError(INVALID_DOCUMENT_MESSAGE[source]);
}
const version = raw.version;
if (typeof version !== 'number' || !Number.isFinite(version) || version < 1) {
throw new MapFormatError(
"Ce fichier ne precise aucune version de format : il n'est pas reconnu comme une carte.",
);
}
if (version > MAP_FORMAT_VERSION) {
throw new MapFormatError(
'Cette carte a ete creee avec une version plus recente du format (v' +
version +
"). Mettez a jour l'application pour l'ouvrir.",
);
}
const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) {
throw new MapFormatError(`${INVALID_DOCUMENT_MESSAGE[source]} (noeuds absents)`);
}
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
if (document.nodes.length > maxNodes) {
throw new MapFormatError(
'Cette carte contient ' +
document.nodes.length +
' noeuds : la limite est de ' +
maxNodes +
'.',
);
}
const seen = new Set<string>();
const nodes: MindMapNode[] = [];
for (const rawNode of document.nodes) {
const node = parseNode(rawNode);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
}
if (nodes.length === 0 && !options.allowEmpty) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
}
const rawEdges = Array.isArray(document.edges) ? document.edges : [];
const parsedEdges = rawEdges
.filter(isRecord)
.map((edge) => ({
id: asString(edge.id, 256) ?? createId(),
source: asString(edge.source, 128) ?? '',
target: asString(edge.target, 128) ?? '',
}))
.filter((edge) => seen.has(edge.source) && seen.has(edge.target));
// La hierarchie fait autorite ; les liens en sont deduits.
const linked = rebuildHierarchyFromEdges(nodes, parsedEdges).map((node) => ({
...node,
parentId: node.parentId && seen.has(node.parentId) ? node.parentId : null,
}));
// Au moins une racine explicite pour que l'editeur reste coherent.
if (linked.length > 0 && !linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null };
}
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString();
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked,
edges: rebuildEdges(linked),
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.02, asFiniteNumber(rawViewport.zoom, 1))),
},
settings: parseSettings(document.settings),
};
}
/** Lit un fichier .mmap choisi par l'utilisateur et retourne la carte validee. */
export async function readMapFile(
file: File,
maxSizeMb: number = APP_CONFIG.maxMapSizeMb,
): Promise<MindMap> {
if (file.size > maxSizeMb * 1024 * 1024) {
throw new MapFormatError('Ce fichier depasse la taille maximale de ' + maxSizeMb + ' Mo.');
}
const text = await file.text();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
}
return normalizeMapDocument(raw, { source: 'file' });
}