Phase 1.1 : mise en page et edition visuelle des noeuds

Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite
retiree, compatibilite .mmap v1 et IndexedDB preservee.

Modele (format v2)
- MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels.
- NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow.
- MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides.
- Un champ absent signifie "herite du theme" : rien n'est ecrit dans le
  document pour materialiser un defaut, donc les cartes deja stockees restent
  lisibles telles quelles. Migration v1 -> v2 : style.width devient
  layout.width, toujours lu en repli par le resolveur.

Mise en page
- Nouveau resolveur de style qui fusionne theme et personnalisations.
- Taille automatique fidele au contenu, taille personnalisee bornee,
  redimensionnement a la souris via NodeResizer.
- Padding horizontal et vertical, retour a la ligne, largeur maximale du texte,
  interligne, police, alignement horizontal et vertical, ombre.
- Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et
  espacement configurables.

Multi-selection
- Barre contextuelle d'alignement (6 axes) et de distribution (2 axes).
- Application d'un reglage a toute la selection, avec etat "Mixte".

Style
- Copier / coller / reinitialiser le style, au clavier et par clic droit.

Auto-layout
- Espacement parent/enfant et entre branches configurable, presets Compact,
  Normal et Aere. Le layout reste declenche uniquement a la demande.

Historique
- Une seule entree par redimensionnement complet, par alignement, par
  distribution, par application multiple et par collage de style.

Panneau Proprietes
- Reorganise en cinq sections repliables dont l'etat est conserve pendant la
  session. Vraies icones d'alignement, tooltips et aria-labels partout.

Corrections
- Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la
  largeur min-content a un caractere, ce qui faisait s'effondrer tous les
  noeuds automatiques.
- Largeur auto en max-content bornee, pour qu'une phrase longue occupe la
  largeur maximale au lieu de former une colonne etroite.

Tests
- 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 17:29:48 +02:00
parent 0b2e5e326d
commit af6fd519c5
37 changed files with 3896 additions and 439 deletions

View File

@@ -2,15 +2,25 @@ 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';
@@ -31,6 +41,12 @@ 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',
@@ -95,10 +111,107 @@ function parseStyle(value: unknown): NodeStyle | undefined {
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;
@@ -124,9 +237,13 @@ function parseNode(value: unknown): MindMapNode | null {
if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8);
// 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;
@@ -137,6 +254,12 @@ function parseNode(value: unknown): MindMapNode | null {
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;
@@ -157,22 +280,66 @@ function parseSettings(value: unknown): MapSettings {
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;
}
/**
* Point d'extension des migrations de format.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
* recente, avant la normalisation finale.
* 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 migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
const document = raw;
const current = version;
function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(document.nodes)) return { ...document, version: 2 };
// Exemple d'evolution future :
// if (current === 1) { document = migrateV1toV2(document); current = 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;