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:
139
frontend/src/stores/editorStyle.test.ts
Normal file
139
frontend/src/stores/editorStyle.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function makeChildren(count: number): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
ids.push(store().addChild(rootId()) as string);
|
||||
}
|
||||
store().setEditingNode(null);
|
||||
return ids;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().loadMap(createEmptyMap('Styles'));
|
||||
});
|
||||
|
||||
describe('application de style a plusieurs noeuds', () => {
|
||||
it('applique une couleur a toute la selection en une entree d historique', () => {
|
||||
const ids = makeChildren(3);
|
||||
store().setSelection(ids);
|
||||
const before = store().past.length;
|
||||
|
||||
store().updateNodesStyle(ids, { backgroundColor: '#112233' });
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
for (const id of ids) {
|
||||
expect(nodeById(id)?.style?.backgroundColor).toBe('#112233');
|
||||
}
|
||||
});
|
||||
|
||||
it('applique un padding a toute la selection', () => {
|
||||
const ids = makeChildren(2);
|
||||
store().updateNodesLayout(ids, { paddingX: 28, paddingY: 18 });
|
||||
|
||||
for (const id of ids) {
|
||||
expect(nodeById(id)?.layout?.paddingX).toBe(28);
|
||||
expect(nodeById(id)?.layout?.paddingY).toBe(18);
|
||||
}
|
||||
});
|
||||
|
||||
it('ne laisse pas d objet de style vide dans le document', () => {
|
||||
const [id] = makeChildren(1);
|
||||
store().updateNodesStyle([id as string], { bold: true });
|
||||
store().updateNodesStyle([id as string], { bold: undefined });
|
||||
|
||||
expect(nodeById(id as string)?.style).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copier / coller le style', () => {
|
||||
it('copie uniquement les proprietes visuelles', () => {
|
||||
const [source, target] = makeChildren(2) as [string, string];
|
||||
|
||||
store().updateNodeText(source, 'Source');
|
||||
store().updateNode(source, { note: 'Note privee', url: 'https://exemple.fr/' });
|
||||
store().updateNodesStyle([source], { backgroundColor: '#abcdef', textAlign: 'center' });
|
||||
store().updateNodesLayout([source], { paddingX: 24 });
|
||||
store().setNodesIcon([source], 'server', 'builtin');
|
||||
|
||||
store().updateNodeText(target, 'Cible');
|
||||
store().setSelection([source]);
|
||||
expect(store().copyStyle()).toBe(true);
|
||||
|
||||
store().setSelection([target]);
|
||||
expect(store().pasteStyle()).toBe(1);
|
||||
|
||||
const pasted = nodeById(target);
|
||||
expect(pasted?.style?.backgroundColor).toBe('#abcdef');
|
||||
expect(pasted?.style?.textAlign).toBe('center');
|
||||
expect(pasted?.layout?.paddingX).toBe(24);
|
||||
expect(pasted?.icon).toBe('server');
|
||||
expect(pasted?.iconStyle?.kind).toBe('builtin');
|
||||
|
||||
// Le contenu de la cible n'est pas touche.
|
||||
expect(pasted?.text).toBe('Cible');
|
||||
expect(pasted?.note).toBeUndefined();
|
||||
expect(pasted?.url).toBeUndefined();
|
||||
expect(pasted?.parentId).toBe(rootId());
|
||||
});
|
||||
|
||||
it('colle le style sur plusieurs noeuds en une entree d historique', () => {
|
||||
const ids = makeChildren(4) as string[];
|
||||
const source = ids[0] as string;
|
||||
store().updateNodesStyle([source], { borderColor: '#ff0000' });
|
||||
|
||||
store().setSelection([source]);
|
||||
store().copyStyle();
|
||||
store().setSelection(ids.slice(1));
|
||||
|
||||
const before = store().past.length;
|
||||
expect(store().pasteStyle()).toBe(3);
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
for (const id of ids.slice(1)) {
|
||||
expect(nodeById(id)?.style?.borderColor).toBe('#ff0000');
|
||||
}
|
||||
});
|
||||
|
||||
it('ne colle rien sans style copie', () => {
|
||||
const ids = makeChildren(1);
|
||||
store().setSelection(ids);
|
||||
expect(store().pasteStyle()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinitialisation du style', () => {
|
||||
it('remet le theme sans toucher au contenu ni a la hierarchie', () => {
|
||||
const [id] = makeChildren(1) as [string];
|
||||
store().updateNodeText(id, 'Firewall');
|
||||
store().updateNode(id, { note: 'A verifier', url: 'https://exemple.fr/' });
|
||||
store().updateNodesStyle([id], { backgroundColor: '#000000', shape: 'circle' });
|
||||
store().updateNodesLayout([id], { paddingX: 30 });
|
||||
store().updateNodesBranch([id], { color: '#00ff00' });
|
||||
|
||||
store().resetNodesStyle([id]);
|
||||
|
||||
const node = nodeById(id);
|
||||
expect(node?.style).toBeUndefined();
|
||||
expect(node?.layout).toBeUndefined();
|
||||
expect(node?.branch).toBeUndefined();
|
||||
expect(node?.text).toBe('Firewall');
|
||||
expect(node?.note).toBe('A verifier');
|
||||
expect(node?.url).toBe('https://exemple.fr/');
|
||||
expect(node?.parentId).toBe(rootId());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user