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

@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
function nodeById(id: string) {
return store().map?.nodes.find((node) => node.id === id);
}
/** Cree trois noeuds a des positions connues, avec des tailles mesurees. */
function threeNodes(): string[] {
const ids = [rootId()];
for (let index = 0; index < 2; index += 1) {
ids.push(store().addChild(rootId()) as string);
}
store().setEditingNode(null);
const positions = [
{ x: 0, y: 0 },
{ x: 60, y: 200 },
{ x: 300, y: 500 },
];
ids.forEach((id, index) => {
store().moveNodes([{ id, position: positions[index] as { x: number; y: number } }]);
store().setMeasuredSize(id, { width: 100, height: 40 });
});
return ids;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Mise en page'));
});
describe('redimensionnement', () => {
it('passe le noeud en taille fixe et conserve les dimensions', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: 260, height: 120 });
const node = nodeById(id);
expect(node?.layout?.widthMode).toBe('fixed');
expect(node?.layout?.heightMode).toBe('fixed');
expect(node?.layout?.width).toBe(260);
expect(node?.layout?.height).toBe(120);
});
it('borne les dimensions saisies', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: -50, height: 100000 });
expect(nodeById(id)?.layout?.width).toBeGreaterThanOrEqual(60);
expect(nodeById(id)?.layout?.height).toBeLessThanOrEqual(900);
});
it('un resize continu ne cree qu une seule entree d historique', () => {
const id = rootId();
const before = store().past.length;
store().beginResize();
for (let width = 200; width <= 260; width += 10) {
store().resizeNode(id, { width, height: 80 });
}
expect(store().past.length).toBe(before + 1);
store().undo();
expect(nodeById(id)?.layout?.width).toBeUndefined();
});
it('met a jour la position quand la poignee haut-gauche est utilisee', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: 200, height: 90, position: { x: -40, y: -20 } });
expect(nodeById(id)?.position).toEqual({ x: -40, y: -20 });
});
it('repasse en taille automatique avec "adapter au contenu"', () => {
const id = rootId();
store().beginResize();
store().resizeNode(id, { width: 300, height: 150 });
store().fitNodesToContent([id]);
const node = nodeById(id);
expect(node?.layout?.width).toBeUndefined();
expect(node?.layout?.height).toBeUndefined();
expect(node?.layout?.widthMode).toBe('auto');
});
});
describe('alignement multi-selection', () => {
it('aligne les bords gauches en une seule entree d historique', () => {
const ids = threeNodes();
store().setSelection(ids);
const before = store().past.length;
store().alignNodes('left');
expect(store().past.length).toBe(before + 1);
const xs = ids.map((id) => nodeById(id)?.position.x);
expect(new Set(xs).size).toBe(1);
expect(xs[0]).toBe(0);
});
it('aligne les bords droits en tenant compte de la largeur', () => {
const ids = threeNodes();
store().setMeasuredSize(ids[1] as string, { width: 200, height: 40 });
store().setSelection(ids);
store().alignNodes('right');
const rights = ids.map((id) => {
const node = nodeById(id);
const width = id === ids[1] ? 200 : 100;
return (node?.position.x ?? 0) + width;
});
expect(new Set(rights).size).toBe(1);
});
it('centre verticalement la selection', () => {
const ids = threeNodes();
store().setSelection(ids);
store().alignNodes('vcenter');
const centers = ids.map((id) => (nodeById(id)?.position.y ?? 0) + 20);
expect(new Set(centers).size).toBe(1);
});
it('ne fait rien avec un seul noeud selectionne', () => {
const ids = threeNodes();
store().setSelection([ids[0] as string]);
const before = store().past.length;
store().alignNodes('left');
expect(store().past.length).toBe(before);
});
});
describe('distribution', () => {
it('repartit les espaces verticaux en conservant les extremes', () => {
const ids = threeNodes();
store().setSelection(ids);
const before = store().past.length;
store().distributeNodes('vertical');
expect(store().past.length).toBe(before + 1);
const ys = ids.map((id) => nodeById(id)?.position.y ?? 0).sort((a, b) => a - b);
expect(ys[0]).toBe(0);
expect(ys[2]).toBe(500);
// Espaces egaux entre les bords : (500 + 40 - 0 - 120) / 2 = 210
expect((ys[1] as number) - ((ys[0] as number) + 40)).toBeCloseTo(210, 0);
});
it('repartit les espaces horizontaux', () => {
const ids = threeNodes();
store().setSelection(ids);
store().distributeNodes('horizontal');
const xs = ids.map((id) => nodeById(id)?.position.x ?? 0).sort((a, b) => a - b);
expect(xs[0]).toBe(0);
expect(xs[2]).toBe(300);
expect((xs[1] as number) - ((xs[0] as number) + 100)).toBeCloseTo(
(xs[2] as number) - ((xs[1] as number) + 100),
0,
);
});
it('exige au moins trois noeuds', () => {
const ids = threeNodes();
store().setSelection([ids[0] as string, ids[1] as string]);
const before = store().past.length;
store().distributeNodes('horizontal');
expect(store().past.length).toBe(before);
});
});