Files
openmind/frontend/src/stores/editorStyle.test.ts
snoopy af6fd519c5 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>
2026-09-09 17:29:48 +02:00

140 lines
4.5 KiB
TypeScript

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());
});
});