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:
187
frontend/src/stores/editorLayout.test.ts
Normal file
187
frontend/src/stores/editorLayout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user