Phase 1.1.1 : consolidation et coherence UX

Trois corrections ciblees, sans changement d'architecture ni d'interface.

1. Le style ne transporte plus l'icone
L'icone d'un noeud est du contenu, comme son texte ou sa note. Copier le style
de "Reseau" et le coller sur "Stockage" et "Securite" applique desormais les
couleurs, la forme et la mise en page en laissant chaque noeud avec sa propre
icone. Seule la presentation de l'icone (position, taille, espacement) reste
une propriete de style.

2. Le cercle conserve un rapport 1:1
`keepAspectRatio` sur le redimensionneur, et contrainte cote store pour les
trois autres chemins : redimensionnement direct, saisie d'une largeur ou d'une
hauteur dans le panneau, et conversion d'un noeud rectangulaire en cercle.
Un cercle ne peut plus devenir un ovale.

3. Point d'entree unique des documents
`normalizeMapDocument()` remplace `parseMapDocument()` et devient le seul
chemin par lequel une carte entre dans l'editeur, qu'elle vienne d'un fichier
.mmap, d'IndexedDB ou d'un partage. Le pipeline est toujours validation ->
migration -> normalisation. Les cartes locales passaient jusqu'ici a cote de la
migration : elles y passent maintenant a l'ouverture, sans etre reecrites en
base tant que l'utilisateur ne les modifie pas. Une carte locale videe de ses
noeuds reste ouvrable, et le message d'erreur s'adapte a la provenance.

Egalement
- Zoom minimum abaisse de 0.1 a 0.02 : "Adapter a l'ecran" butait sur ce
  plancher et ne montrait pas toute une carte de 181 noeuds.
- README : pipeline de normalisation, comportement du style et du cercle,
  limitations mesurees.

Tests : 103 vitest (85 -> 103) et 31 pytest, ruff, eslint et tsc au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 05:48:22 +02:00
parent bf83292e65
commit a23a68629d
13 changed files with 502 additions and 66 deletions

View File

@@ -244,7 +244,10 @@ avec un etat *Mixte* quand les valeurs different.
annulation par redimensionnement), taille automatique qui epouse le contenu, annulation par redimensionnement), taille automatique qui epouse le contenu,
alignement et distribution d'une multi-selection, aimantation sur la grille, alignement et distribution d'une multi-selection, aimantation sur la grille,
guides d'alignement pendant le deplacement, copier / coller / reinitialiser le guides d'alignement pendant le deplacement, copier / coller / reinitialiser le
style (Ctrl+Maj+C, Ctrl+Maj+V), menu contextuel par clic droit. style (Ctrl+Maj+C, Ctrl+Maj+V), menu contextuel par clic droit. Le style couvre
les couleurs, la forme, la typographie, la mise en page, la presentation de
l'icone et la branche ; l'icone elle-meme, comme le texte, la note et l'URL,
appartient au contenu du noeud et n'est jamais remplacee par un collage.
**Bibliotheque d'icones** : emojis et icones integrees (Lucide), avec position **Bibliotheque d'icones** : emojis et icones integrees (Lucide), avec position
(gauche, droite, au-dessus), taille et espacement configurables. (gauche, droite, au-dessus), taille et espacement configurables.
@@ -282,7 +285,7 @@ l'application chargee.
| `Ctrl+C` | Copier | | `Ctrl+C` | Copier |
| `Ctrl+V` | Coller | | `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer | | `Ctrl+D` | Dupliquer |
| `Ctrl+Maj+C` | Copier le style du noeud | | `Ctrl+Maj+C` | Copier le style du noeud (sans son icone) |
| `Ctrl+Maj+V` | Coller le style sur la selection | | `Ctrl+Maj+V` | Coller le style sur la selection |
| `Ctrl+A` | Tout selectionner | | `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite | | `Ctrl+S` | Sauvegarde explicite |
@@ -303,6 +306,20 @@ sont optionnels et un champ absent signifie simplement *herite du theme*. La
seule transformation appliquee a l'import d'un fichier v1 est le deplacement de seule transformation appliquee a l'import d'un fichier v1 est le deplacement de
`style.width` vers `layout.width`. `style.width` vers `layout.width`.
Toute carte qui entre dans l'editeur passe par le meme point d'entree,
`normalizeMapDocument()`, quelle que soit sa provenance :
```
fichier .mmap ┐
IndexedDB ├─> validation -> migration de version -> normalisation -> modele courant
partage ┘
```
La normalisation est **non destructive** : le document fourni n'est jamais
modifie et rien n'est reecrit en base. Une carte v1 restee en IndexedDB s'ouvre
donc exactement comme avant, et n'est reenregistree au format courant que si
vous la modifiez.
```json ```json
{ {
"version": 1, "version": 1,
@@ -553,6 +570,13 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
- **Alignement vertical** : il n'a d'effet visible que si le noeud est plus haut - **Alignement vertical** : il n'a d'effet visible que si le noeud est plus haut
que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical
important. important.
- **Cercle** : un noeud circulaire garde toujours un rapport 1:1. Modifier sa
largeur ajuste sa hauteur et inversement ; convertir un noeud rectangulaire en
cercle le ramene a un carre. Il n'est donc pas possible d'obtenir un ovale.
- **Deplacement sur une tres grande carte** : sur une carte de ~180 noeuds, une
frame de glisser-deposer coute environ 60 a 80 ms car React Flow recalcule
l'ensemble des noeuds controles. Le deplacement reste utilisable mais moins
fluide que sur une carte de taille courante.
- **Mobile** : consultation et edition basique fonctionnent, mais l'interface - **Mobile** : consultation et edition basique fonctionnent, mais l'interface
est concue pour desktop et tablette. est concue pour desktop et tablette.
- **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de - **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de

View File

@@ -39,15 +39,20 @@ function EditorPageContent({ mapId }: EditorPageProps) {
let cancelled = false; let cancelled = false;
setStatus('loading'); setStatus('loading');
void getMap(mapId).then((map) => { getMap(mapId)
if (cancelled) return; .then((map) => {
if (!map) { if (cancelled) return;
setStatus('missing'); if (!map) {
return; setStatus('missing');
} return;
loadMap(map); }
setStatus('ready'); loadMap(map);
}); setStatus('ready');
})
.catch(() => {
// Document illisible : message propre plutot qu'une erreur technique.
if (!cancelled) setStatus('missing');
});
return () => { return () => {
cancelled = true; cancelled = true;

View File

@@ -235,7 +235,9 @@ export function MindMapCanvas({
setContextMenu(null); setContextMenu(null);
}} }}
defaultViewport={map.viewport} defaultViewport={map.viewport}
minZoom={0.1} // Plancher bas volontairement : sur une grande carte, "Adapter a
// l'ecran" doit pouvoir descendre assez pour tout montrer.
minZoom={0.02}
maxZoom={3} maxZoom={3}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']} multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode="Shift" selectionKeyCode="Shift"

View File

@@ -183,6 +183,8 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>)
<NodeResizer <NodeResizer
isVisible isVisible
color="#6366f1" color="#6366f1"
// Un cercle reste circulaire pendant tout le redimensionnement.
keepAspectRatio={appearance.circle}
minWidth={appearance.minWidth} minWidth={appearance.minWidth}
minHeight={appearance.minHeight} minHeight={appearance.minHeight}
maxWidth={LAYOUT_LIMITS.width.max} maxWidth={LAYOUT_LIMITS.width.max}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map'; import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { createEmptyMap } from '@/services/mapFactory'; import { createEmptyMap } from '@/services/mapFactory';
import { parseMapDocument, serializeMap } from '@/services/mapSerialization'; import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
/** Carte v1 telle qu'elle etait exportee avant la phase 1.1. */ /** Carte v1 telle qu'elle etait exportee avant la phase 1.1. */
const LEGACY_V1 = { const LEGACY_V1 = {
@@ -29,7 +29,7 @@ const LEGACY_V1 = {
describe('compatibilite du format', () => { describe('compatibilite du format', () => {
it('importe une carte v1 sans propriete de mise en page', () => { it('importe une carte v1 sans propriete de mise en page', () => {
const map = parseMapDocument(LEGACY_V1); const map = normalizeMapDocument(LEGACY_V1);
expect(map.version).toBe(MAP_FORMAT_VERSION); expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(map.nodes).toHaveLength(2); expect(map.nodes).toHaveLength(2);
@@ -39,7 +39,7 @@ describe('compatibilite du format', () => {
}); });
it('migre la largeur v1 vers layout.width', () => { it('migre la largeur v1 vers layout.width', () => {
const map = parseMapDocument(LEGACY_V1); const map = normalizeMapDocument(LEGACY_V1);
const root = map.nodes[0]; const root = map.nodes[0];
expect(root?.layout?.widthMode).toBe('fixed'); expect(root?.layout?.widthMode).toBe('fixed');
@@ -52,7 +52,7 @@ describe('compatibilite du format', () => {
}); });
it('refuse une version plus recente que celle supportee', () => { it('refuse une version plus recente que celle supportee', () => {
expect(() => parseMapDocument({ ...LEGACY_V1, version: MAP_FORMAT_VERSION + 1 })).toThrow( expect(() => normalizeMapDocument({ ...LEGACY_V1, version: MAP_FORMAT_VERSION + 1 })).toThrow(
/plus recente/i, /plus recente/i,
); );
}); });
@@ -107,7 +107,7 @@ describe('aller-retour des nouvelles proprietes', () => {
it('conserve toute la mise en page apres export puis import', () => { it('conserve toute la mise en page apres export puis import', () => {
const original = richMap(); const original = richMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original))); const restored = normalizeMapDocument(JSON.parse(serializeMap(original)));
const node = restored.nodes[0]; const node = restored.nodes[0];
const source = original.nodes[0]; const source = original.nodes[0];
@@ -124,7 +124,7 @@ describe('aller-retour des nouvelles proprietes', () => {
}); });
it('borne les valeurs aberrantes a l import', () => { it('borne les valeurs aberrantes a l import', () => {
const map = parseMapDocument({ const map = normalizeMapDocument({
version: 2, version: 2,
id: 'x', id: 'x',
nodes: [ nodes: [
@@ -149,7 +149,7 @@ describe('aller-retour des nouvelles proprietes', () => {
}); });
it('rejette les valeurs de mise en page invalides', () => { it('rejette les valeurs de mise en page invalides', () => {
const map = parseMapDocument({ const map = normalizeMapDocument({
version: 2, version: 2,
id: 'x', id: 'x',
nodes: [ nodes: [

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { db } from '@/db/database';
import { createMap, getMap, importMap } from '@/services/mapRepository';
import {
MapFormatError,
normalizeMapDocument,
serializeMap,
} from '@/services/mapSerialization';
/** Carte v1 telle qu'elle pouvait etre enregistree en IndexedDB avant la v2. */
function legacyStoredMap(id: string): Record<string, unknown> {
return {
version: 1,
id,
name: 'Homelab v1',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
nodes: [
{
id: 'root',
type: 'root',
text: 'Infrastructure',
position: { x: 0, y: 0 },
parentId: null,
icon: '🖥️',
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
},
{
id: 'n1',
type: 'standard',
text: 'Reseau',
position: { x: 260, y: 0 },
parentId: 'root',
},
],
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
viewport: { x: 12, y: 34, zoom: 1.5 },
settings: { theme: 'ocean', layout: 'tree-vertical', branchStyle: 'curved', showGrid: false },
};
}
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('pipeline de normalisation', () => {
it('normalise une carte v1 venue d IndexedDB comme un fichier v1', async () => {
await db.maps.put(legacyStoredMap('stored-1') as unknown as MindMap);
const fromStorage = await getMap('stored-1');
const fromFile = normalizeMapDocument(legacyStoredMap('stored-1'), { source: 'file' });
expect(fromStorage).toBeDefined();
expect(fromStorage?.nodes).toEqual(fromFile.nodes);
expect(fromStorage?.settings).toEqual(fromFile.settings);
expect(fromStorage?.version).toBe(MAP_FORMAT_VERSION);
});
it('migre la largeur v1 d une carte stockee sans la reecrire en base', async () => {
await db.maps.put(legacyStoredMap('stored-2') as unknown as MindMap);
const opened = await getMap('stored-2');
expect(opened?.nodes[0]?.layout?.width).toBe(240);
expect(opened?.nodes[0]?.layout?.widthMode).toBe('fixed');
expect(opened?.nodes[0]?.style?.width).toBeUndefined();
// La base contient toujours le document d'origine : rien n'a ete reecrit.
const raw = (await db.maps.get('stored-2')) as unknown as Record<string, unknown>;
expect(raw.version).toBe(1);
expect((raw.nodes as Array<Record<string, unknown>>)[0]?.style).toMatchObject({ width: 240 });
});
it('conserve le contenu et les reglages d une carte v1', async () => {
await db.maps.put(legacyStoredMap('stored-3') as unknown as MindMap);
const opened = await getMap('stored-3');
expect(opened?.name).toBe('Homelab v1');
expect(opened?.createdAt).toBe('2026-01-01T00:00:00.000Z');
expect(opened?.updatedAt).toBe('2026-01-02T00:00:00.000Z');
expect(opened?.nodes[0]?.icon).toBe('🖥️');
expect(opened?.nodes[0]?.style?.textAlign).toBe('center');
expect(opened?.settings.theme).toBe('ocean');
expect(opened?.settings.layout).toBe('tree-vertical');
expect(opened?.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
});
it('ne modifie jamais le document fourni', () => {
const original = legacyStoredMap('immutable');
const copy = JSON.parse(JSON.stringify(original));
normalizeMapDocument(original, { source: 'storage' });
expect(original).toEqual(copy);
});
it('laisse une carte v2 inchangee', () => {
const v2 = normalizeMapDocument(legacyStoredMap('x'), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(v2)), { source: 'file' });
expect(again.nodes).toEqual(v2.nodes);
expect(again.settings).toEqual(v2.settings);
expect(again.version).toBe(MAP_FORMAT_VERSION);
});
it('accepte une carte locale videe de ses noeuds', async () => {
const map = await createMap('Carte videe');
await db.maps.put({ ...map, nodes: [], edges: [] });
const opened = await getMap(map.id);
expect(opened?.nodes).toHaveLength(0);
expect(opened?.name).toBe('Carte videe');
});
it('refuse un fichier sans noeud exploitable', () => {
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
});
it('adapte le message d erreur a la provenance', () => {
expect(() => normalizeMapDocument('pas un objet', { source: 'file' })).toThrow(/fichier/i);
expect(() => normalizeMapDocument('pas un objet', { source: 'storage' })).toThrow(
/enregistree/i,
);
expect(() => normalizeMapDocument('pas un objet', { source: 'share' })).toThrow(/partagee/i);
});
it('fait passer un import par le meme pipeline', async () => {
const normalised = normalizeMapDocument(legacyStoredMap('file-1'), { source: 'file' });
const stored = await importMap(normalised);
const reopened = await getMap(stored.id);
expect(reopened?.nodes).toEqual(normalised.nodes);
expect(reopened?.version).toBe(MAP_FORMAT_VERSION);
});
});

View File

@@ -3,6 +3,7 @@ import { type MindMap, type MapSummary } from '@/types/map';
import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory'; import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository'; import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository';
import { normalizeMapDocument } from '@/services/mapSerialization';
/** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */ /** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */
export async function listMaps(): Promise<MapSummary[]> { export async function listMaps(): Promise<MapSummary[]> {
@@ -21,8 +22,17 @@ export async function listMaps(): Promise<MapSummary[]> {
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} }
/**
* Lit une carte locale et la fait passer par le pipeline de normalisation.
*
* Les cartes enregistrees avant une evolution de format sont ainsi ramenees au
* modele courant a l'ouverture, sans etre reecrites en base : la migration
* n'est persistee que si l'utilisateur modifie la carte.
*/
export async function getMap(id: string): Promise<MindMap | undefined> { export async function getMap(id: string): Promise<MindMap | undefined> {
return db.maps.get(id); const stored = await db.maps.get(id);
if (!stored) return undefined;
return normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
} }
export async function saveMap(map: MindMap): Promise<void> { export async function saveMap(map: MindMap): Promise<void> {

View File

@@ -3,14 +3,14 @@ import { MAP_FORMAT_VERSION } from '@/types/map';
import { createSampleMap } from '@/services/mapFactory'; import { createSampleMap } from '@/services/mapFactory';
import { import {
MapFormatError, MapFormatError,
parseMapDocument, normalizeMapDocument,
serializeMap, serializeMap,
} from '@/services/mapSerialization'; } from '@/services/mapSerialization';
describe('serialisation des cartes', () => { describe('serialisation des cartes', () => {
it('effectue un aller-retour sans perte', () => { it('effectue un aller-retour sans perte', () => {
const original = createSampleMap(); const original = createSampleMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original))); const restored = normalizeMapDocument(JSON.parse(serializeMap(original)));
expect(restored.name).toBe(original.name); expect(restored.name).toBe(original.name);
expect(restored.nodes).toHaveLength(original.nodes.length); expect(restored.nodes).toHaveLength(original.nodes.length);
@@ -20,25 +20,25 @@ describe('serialisation des cartes', () => {
}); });
it('rejette un contenu qui n est pas un objet', () => { it('rejette un contenu qui n est pas un objet', () => {
expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError); expect(() => normalizeMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => parseMapDocument(null)).toThrow(MapFormatError); expect(() => normalizeMapDocument(null)).toThrow(MapFormatError);
expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError); expect(() => normalizeMapDocument([1, 2, 3])).toThrow(MapFormatError);
}); });
it('rejette une carte sans version', () => { it('rejette une carte sans version', () => {
const map = createSampleMap() as unknown as Record<string, unknown>; const map = createSampleMap() as unknown as Record<string, unknown>;
delete map.version; delete map.version;
expect(() => parseMapDocument(map)).toThrow(/version/i); expect(() => normalizeMapDocument(map)).toThrow(/version/i);
}); });
it('rejette une version de format plus recente', () => { it('rejette une version de format plus recente', () => {
const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 }; const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 };
expect(() => parseMapDocument(map)).toThrow(/plus recente/i); expect(() => normalizeMapDocument(map)).toThrow(/plus recente/i);
}); });
it('rejette une carte sans noeud exploitable', () => { it('rejette une carte sans noeud exploitable', () => {
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError); expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow( expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
MapFormatError, MapFormatError,
); );
}); });
@@ -49,13 +49,13 @@ describe('serialisation des cartes', () => {
text: `Noeud ${index}`, text: `Noeud ${index}`,
parentId: index === 0 ? null : 'n0', parentId: index === 0 ? null : 'n0',
})); }));
expect(() => parseMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow( expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
/limite/i, /limite/i,
); );
}); });
it('neutralise les URL dangereuses', () => { it('neutralise les URL dangereuses', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
name: 'Test', name: 'Test',
@@ -72,7 +72,7 @@ describe('serialisation des cartes', () => {
}); });
it('conserve le texte tel quel sans interpreter le HTML', () => { it('conserve le texte tel quel sans interpreter le HTML', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }], nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }],
@@ -81,7 +81,7 @@ describe('serialisation des cartes', () => {
}); });
it('reconstruit la hierarchie a partir des liens', () => { it('reconstruit la hierarchie a partir des liens', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [ nodes: [
@@ -97,7 +97,7 @@ describe('serialisation des cartes', () => {
}); });
it('ignore les liens pointant vers des noeuds inexistants', () => { it('ignore les liens pointant vers des noeuds inexistants', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [{ id: 'a', type: 'root', text: 'Racine' }], nodes: [{ id: 'a', type: 'root', text: 'Racine' }],
@@ -107,7 +107,7 @@ describe('serialisation des cartes', () => {
}); });
it('ignore les styles invalides mais garde les styles valides', () => { it('ignore les styles invalides mais garde les styles valides', () => {
const parsed = parseMapDocument({ const parsed = normalizeMapDocument({
version: 1, version: 1,
id: 'x', id: 'x',
nodes: [ nodes: [

View File

@@ -345,14 +345,48 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
return document; return document;
} }
/** Provenance d'un document : ne sert qu'a formuler les messages d'erreur. */
export type MapDocumentSource = 'file' | 'storage' | 'share';
export interface NormalizeOptions {
maxNodes?: number;
source?: MapDocumentSource;
/**
* Autorise un document sans noeud. Vrai pour les cartes deja enregistrees :
* l'utilisateur a le droit d'avoir vide sa carte, et la rejeter reviendrait a
* la lui rendre inaccessible.
*/
allowEmpty?: boolean;
}
const INVALID_DOCUMENT_MESSAGE: Record<MapDocumentSource, string> = {
file: "Le fichier selectionne n'est pas une carte valide.",
storage: "Cette carte enregistree est illisible et n'a pas pu etre ouverte.",
share: "Cette carte partagee est illisible.",
};
/** /**
* Valide et normalise un document carte provenant d'un fichier ou du serveur. * Point d'entree unique des documents carte.
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue, *
* ni injecte en HTML. * Toute carte qui entre dans l'editeur passe par ici, quelle que soit sa
* provenance : fichier .mmap, IndexedDB ou partage serveur. Le pipeline est
* toujours le meme :
*
* source -> validation -> migration de version -> normalisation -> modele courant
*
* La normalisation est non destructive : le document d'origine n'est jamais
* modifie, et rien n'est reecrit en base. Une carte v1 restee en IndexedDB
* continue donc de s'ouvrir exactement comme avant, et n'est reecrite que si
* l'utilisateur la modifie.
*
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue ni
* injecte en HTML.
*/ */
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap { export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {}): MindMap {
const source = options.source ?? 'file';
if (!isRecord(raw)) { if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide."); throw new MapFormatError(INVALID_DOCUMENT_MESSAGE[source]);
} }
const version = raw.version; const version = raw.version;
@@ -372,7 +406,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
const document = migrate(raw, version); const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) { if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents)."); throw new MapFormatError(`${INVALID_DOCUMENT_MESSAGE[source]} (noeuds absents)`);
} }
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes; const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
@@ -395,7 +429,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
nodes.push(node); nodes.push(node);
} }
if (nodes.length === 0) { if (nodes.length === 0 && !options.allowEmpty) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.'); throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
} }
@@ -416,7 +450,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
})); }));
// Au moins une racine explicite pour que l'editeur reste coherent. // Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) { if (linked.length > 0 && !linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null }; linked[0] = { ...linked[0], type: 'root', parentId: null };
} }
@@ -434,7 +468,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } =
viewport: { viewport: {
x: asFiniteNumber(rawViewport.x, 0), x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0), y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))), zoom: Math.min(4, Math.max(0.02, asFiniteNumber(rawViewport.zoom, 1))),
}, },
settings: parseSettings(document.settings), settings: parseSettings(document.settings),
}; };
@@ -456,5 +490,5 @@ export async function readMapFile(
} catch { } catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible)."); throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
} }
return parseMapDocument(raw); return normalizeMapDocument(raw, { source: 'file' });
} }

View File

@@ -1,5 +1,5 @@
import { type MindMap } from '@/types/map'; import { type MindMap } from '@/types/map';
import { parseMapDocument } from '@/services/mapSerialization'; import { normalizeMapDocument } from '@/services/mapSerialization';
/** Erreur reseau/serveur deja traduite en message utilisateur. */ /** Erreur reseau/serveur deja traduite en message utilisateur. */
export class ShareApiError extends Error { export class ShareApiError extends Error {
@@ -146,7 +146,7 @@ export async function fetchSharedMap(shareToken: string): Promise<SharedMapRespo
return { return {
shareToken: raw.share_token, shareToken: raw.share_token,
title: raw.title, title: raw.title,
map: parseMapDocument(raw.map), map: normalizeMapDocument(raw.map, { source: 'share' }),
createdAt: raw.created_at, createdAt: raw.created_at,
updatedAt: raw.updated_at, updatedAt: raw.updated_at,
}; };

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { getTheme } from '@/features/editor/themes';
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
import { estimateNodeSize } from '@/utils/nodeSize';
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 layoutOf(id: string) {
return nodeById(id)?.layout;
}
/** Cree un noeud circulaire et renvoie son identifiant. */
function circleNode(): string {
const id = store().addChild(rootId()) as string;
store().setEditingNode(null);
store().updateNodeText(id, 'Coeur de reseau');
store().updateNodesStyle([id], { shape: 'circle' });
return id;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Cercles'));
});
describe('noeud circulaire', () => {
it('reste carre en dimensionnement automatique', () => {
const id = circleNode();
const node = nodeById(id);
const size = estimateNodeSize(node!, getTheme('default'));
expect(size.width).toBe(size.height);
expect(resolveNodeAppearance(node!, 1, getTheme('default')).circle).toBe(true);
});
it('reste carre pendant un redimensionnement a la souris', () => {
const id = circleNode();
store().beginResize();
for (const [width, height] of [
[100, 100],
[150, 148],
[220, 90],
] as Array<[number, number]>) {
store().resizeNode(id, { width, height });
expect(layoutOf(id)?.width).toBe(layoutOf(id)?.height);
}
// Le cote retenu est le plus grand des deux, jamais un ovale.
expect(layoutOf(id)?.width).toBe(220);
expect(layoutOf(id)?.height).toBe(220);
});
it('fait suivre la hauteur quand la largeur change', () => {
const id = circleNode();
store().updateNodesLayout([id], { widthMode: 'fixed', width: 180 });
expect(layoutOf(id)?.width).toBe(180);
expect(layoutOf(id)?.height).toBe(180);
expect(layoutOf(id)?.heightMode).toBe('fixed');
});
it('fait suivre la largeur quand la hauteur change', () => {
const id = circleNode();
store().updateNodesLayout([id], { heightMode: 'fixed', height: 260 });
expect(layoutOf(id)?.width).toBe(260);
expect(layoutOf(id)?.height).toBe(260);
expect(layoutOf(id)?.widthMode).toBe('fixed');
});
it('carre un noeud rectangulaire converti en cercle', () => {
const id = store().addChild(rootId()) as string;
store().setEditingNode(null);
store().updateNodesLayout([id], {
widthMode: 'fixed',
width: 320,
heightMode: 'fixed',
height: 90,
});
store().updateNodesStyle([id], { shape: 'circle' });
expect(layoutOf(id)?.width).toBe(320);
expect(layoutOf(id)?.height).toBe(320);
});
it('annule tout un redimensionnement circulaire en une seule fois', () => {
const id = circleNode();
const before = store().past.length;
store().beginResize();
for (let side = 120; side <= 200; side += 20) {
store().resizeNode(id, { width: side, height: side });
}
expect(store().past.length).toBe(before + 1);
store().undo();
expect(layoutOf(id)?.width).toBeUndefined();
expect(layoutOf(id)?.height).toBeUndefined();
expect(nodeById(id)?.style?.shape).toBe('circle');
});
it('laisse les autres formes libres de leurs proportions', () => {
const id = store().addChild(rootId()) as string;
store().setEditingNode(null);
store().updateNodesStyle([id], { shape: 'rounded' });
store().beginResize();
store().resizeNode(id, { width: 300, height: 80 });
expect(layoutOf(id)?.width).toBe(300);
expect(layoutOf(id)?.height).toBe(80);
});
});

View File

@@ -16,7 +16,7 @@ import {
} from '@/types/map'; } from '@/types/map';
import { createNode } from '@/services/mapFactory'; import { createNode } from '@/services/mapFactory';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout'; import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { getTheme } from '@/features/editor/themes'; import { getTheme, type MapTheme } from '@/features/editor/themes';
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver'; import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
import { createId } from '@/utils/id'; import { createId } from '@/utils/id';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize'; import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
@@ -39,13 +39,18 @@ interface ClipboardPayload {
nodes: MindMapNode[]; nodes: MindMapNode[];
} }
/** Proprietes purement visuelles, copiees par "Copier le style". */ /**
* Proprietes purement visuelles, copiees par "Copier le style".
*
* L'icone elle-meme (`MindMapNode.icon`) est du contenu, au meme titre que le
* texte ou la note : elle n'est jamais copiee. Seule sa presentation
* (`iconStyle` : position, taille, espacement) fait partie du style.
*/
export interface NodeStyleSnapshot { export interface NodeStyleSnapshot {
style?: NodeStyle; style?: NodeStyle;
layout?: NodeLayout; layout?: NodeLayout;
iconStyle?: NodeIconStyle; iconStyle?: NodeIconStyle;
branch?: NodeBranchStyle; branch?: NodeBranchStyle;
icon?: string;
} }
export type AlignAxis = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom'; export type AlignAxis = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
@@ -167,6 +172,19 @@ function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
* Le document reste ainsi minimal : un champ absent signifie "herite du theme", * Le document reste ainsi minimal : un champ absent signifie "herite du theme",
* ce qui est exactement la semantique attendue par le resolveur de style. * ce qui est exactement la semantique attendue par le resolveur de style.
*/ */
/**
* Un noeud circulaire doit rester circulaire : largeur et hauteur sont liees,
* en taille automatique comme en taille fixe.
*/
function isCircleShape(node: MindMapNode, theme: MapTheme): boolean {
return (node.style?.shape ?? theme.nodeShape) === 'circle';
}
/** Force un cote unique sur une mise en page circulaire. */
function squareLayout(layout: NodeLayout, side: number): NodeLayout {
return { ...layout, width: side, height: side };
}
function pruneEmpty<T extends object>(value: T | undefined): T | undefined { function pruneEmpty<T extends object>(value: T | undefined): T | undefined {
if (!value) return undefined; if (!value) return undefined;
const entries = Object.entries(value).filter(([, item]) => item !== undefined); const entries = Object.entries(value).filter(([, item]) => item !== undefined);
@@ -499,12 +517,22 @@ export const useEditorStore = create<EditorState>((set, get) => {
updateNodesStyle: (ids, patch) => { updateNodesStyle: (ids, patch) => {
if (ids.length === 0) return; if (ids.length === 0) return;
const target = new Set(ids); const target = new Set(ids);
commit((current) => commit((current) =>
withEdges( withEdges(
current, current,
current.nodes.map((node) => current.nodes.map((node) => {
target.has(node.id) ? { ...node, style: pruneEmpty({ ...node.style, ...patch }) } : node, if (!target.has(node.id)) return node;
),
const next: MindMapNode = { ...node, style: pruneEmpty({ ...node.style, ...patch }) };
// Passer en cercle avec une taille fixe rectangulaire : on carre.
if (patch.shape === 'circle' && (node.layout?.width || node.layout?.height)) {
const side = Math.max(node.layout?.width ?? 0, node.layout?.height ?? 0);
next.layout = pruneEmpty(squareLayout(node.layout ?? {}, side));
}
return next;
}),
), ),
); );
}, },
@@ -512,14 +540,25 @@ export const useEditorStore = create<EditorState>((set, get) => {
updateNodesLayout: (ids, patch) => { updateNodesLayout: (ids, patch) => {
if (ids.length === 0) return; if (ids.length === 0) return;
const target = new Set(ids); const target = new Set(ids);
const theme = getTheme(get().map?.settings.theme);
commit((current) => commit((current) =>
withEdges( withEdges(
current, current,
current.nodes.map((node) => current.nodes.map((node) => {
target.has(node.id) if (!target.has(node.id)) return node;
? { ...node, layout: pruneEmpty({ ...node.layout, ...patch }) }
: node, let layout: NodeLayout = { ...node.layout, ...patch };
), if (isCircleShape(node, theme)) {
// Modifier un cote d'un cercle entraine l'autre.
if (patch.width !== undefined) layout = squareLayout(layout, patch.width);
else if (patch.height !== undefined) layout = squareLayout(layout, patch.height);
if (patch.widthMode === 'fixed' || patch.heightMode === 'fixed') {
layout = { ...layout, widthMode: 'fixed', heightMode: 'fixed' };
}
}
return { ...node, layout: pruneEmpty(layout) };
}),
), ),
); );
}, },
@@ -642,8 +681,10 @@ export const useEditorStore = create<EditorState>((set, get) => {
}, },
resizeNode: (id, size) => { resizeNode: (id, size) => {
const width = clampTo(size.width, LAYOUT_LIMITS.width); const state = get();
const height = clampTo(size.height, LAYOUT_LIMITS.height); const map = state.map;
if (!map) return;
const theme = getTheme(map.settings.theme);
commit( commit(
(current) => (current) =>
@@ -651,6 +692,17 @@ export const useEditorStore = create<EditorState>((set, get) => {
current, current,
current.nodes.map((node) => { current.nodes.map((node) => {
if (node.id !== id) return node; if (node.id !== id) return node;
let width = clampTo(size.width, LAYOUT_LIMITS.width);
let height = clampTo(size.height, LAYOUT_LIMITS.height);
if (isCircleShape(node, theme)) {
// Le resizer conserve deja le ratio ; ce garde-fou couvre les
// arrondis et les appels directs.
const side = Math.max(width, height);
width = side;
height = side;
}
// La legacy `style.width` disparait des qu'un resize explicite a lieu. // La legacy `style.width` disparait des qu'un resize explicite a lieu.
const { width: _legacyWidth, ...styleRest } = node.style ?? {}; const { width: _legacyWidth, ...styleRest } = node.style ?? {};
return { return {
@@ -750,14 +802,13 @@ export const useEditorStore = create<EditorState>((set, get) => {
const node = map?.nodes.find((candidate) => candidate.id === state.selectedNodeIds[0]); const node = map?.nodes.find((candidate) => candidate.id === state.selectedNodeIds[0]);
if (!node) return false; if (!node) return false;
// Uniquement l'apparence : ni texte, ni note, ni URL, ni hierarchie. // Uniquement l'apparence : ni texte, ni note, ni URL, ni icone, ni hierarchie.
set({ set({
styleClipboard: { styleClipboard: {
...(node.style ? { style: { ...node.style } } : {}), ...(node.style ? { style: { ...node.style } } : {}),
...(node.layout ? { layout: { ...node.layout } } : {}), ...(node.layout ? { layout: { ...node.layout } } : {}),
...(node.iconStyle ? { iconStyle: { ...node.iconStyle } } : {}), ...(node.iconStyle ? { iconStyle: { ...node.iconStyle } } : {}),
...(node.branch ? { branch: { ...node.branch } } : {}), ...(node.branch ? { branch: { ...node.branch } } : {}),
...(node.icon ? { icon: node.icon } : {}),
}, },
}); });
return true; return true;
@@ -774,17 +825,17 @@ export const useEditorStore = create<EditorState>((set, get) => {
current, current,
current.nodes.map((node) => { current.nodes.map((node) => {
if (!target.has(node.id)) return node; if (!target.has(node.id)) return node;
// `icon`, `text`, `note`, `url`, `position` et la hierarchie ne
// sont jamais touches : seul l'habillage visuel est remplace.
const next: MindMapNode = { ...node }; const next: MindMapNode = { ...node };
delete next.style; delete next.style;
delete next.layout; delete next.layout;
delete next.iconStyle; delete next.iconStyle;
delete next.branch; delete next.branch;
delete next.icon;
if (snapshot.style) next.style = { ...snapshot.style }; if (snapshot.style) next.style = { ...snapshot.style };
if (snapshot.layout) next.layout = { ...snapshot.layout }; if (snapshot.layout) next.layout = { ...snapshot.layout };
if (snapshot.iconStyle) next.iconStyle = { ...snapshot.iconStyle }; if (snapshot.iconStyle) next.iconStyle = { ...snapshot.iconStyle };
if (snapshot.branch) next.branch = { ...snapshot.branch }; if (snapshot.branch) next.branch = { ...snapshot.branch };
if (snapshot.icon) next.icon = snapshot.icon;
return next; return next;
}), }),
), ),

View File

@@ -71,6 +71,7 @@ describe('copier / coller le style', () => {
store().setNodesIcon([source], 'server', 'builtin'); store().setNodesIcon([source], 'server', 'builtin');
store().updateNodeText(target, 'Cible'); store().updateNodeText(target, 'Cible');
store().setNodesIcon([target], '💾', 'emoji');
store().setSelection([source]); store().setSelection([source]);
expect(store().copyStyle()).toBe(true); expect(store().copyStyle()).toBe(true);
@@ -81,16 +82,60 @@ describe('copier / coller le style', () => {
expect(pasted?.style?.backgroundColor).toBe('#abcdef'); expect(pasted?.style?.backgroundColor).toBe('#abcdef');
expect(pasted?.style?.textAlign).toBe('center'); expect(pasted?.style?.textAlign).toBe('center');
expect(pasted?.layout?.paddingX).toBe(24); expect(pasted?.layout?.paddingX).toBe(24);
expect(pasted?.icon).toBe('server'); // La presentation de l'icone fait partie du style...
expect(pasted?.iconStyle?.kind).toBe('builtin'); expect(pasted?.iconStyle?.kind).toBe('builtin');
// Le contenu de la cible n'est pas touche. // ...mais l'icone elle-meme est du contenu : elle n'est jamais remplacee.
expect(pasted?.icon).toBe('💾');
// Le reste du contenu de la cible n'est pas touche.
expect(pasted?.text).toBe('Cible'); expect(pasted?.text).toBe('Cible');
expect(pasted?.note).toBeUndefined(); expect(pasted?.note).toBeUndefined();
expect(pasted?.url).toBeUndefined(); expect(pasted?.url).toBeUndefined();
expect(pasted?.parentId).toBe(rootId()); expect(pasted?.parentId).toBe(rootId());
}); });
it("conserve l'icone de chaque noeud lors d'un collage groupe", () => {
const [reseau, stockage, securite] = makeChildren(3) as [string, string, string];
store().updateNodeText(reseau, 'Reseau');
store().setNodesIcon([reseau], '🌐', 'emoji');
store().updateNodesStyle([reseau], { backgroundColor: '#0ea5e9', shape: 'capsule' });
store().updateNodeText(stockage, 'Stockage');
store().setNodesIcon([stockage], '💾', 'emoji');
store().updateNodeText(securite, 'Securite');
store().setNodesIcon([securite], '🔒', 'emoji');
store().setSelection([reseau]);
store().copyStyle();
store().setSelection([stockage, securite]);
expect(store().pasteStyle()).toBe(2);
// Le style est bien applique...
for (const id of [stockage, securite]) {
expect(nodeById(id)?.style?.backgroundColor).toBe('#0ea5e9');
expect(nodeById(id)?.style?.shape).toBe('capsule');
}
// ...et chaque noeud garde son icone d'origine.
expect(nodeById(stockage)?.icon).toBe('💾');
expect(nodeById(securite)?.icon).toBe('🔒');
});
it("n'ajoute pas d'icone a un noeud qui n'en avait pas", () => {
const [source, target] = makeChildren(2) as [string, string];
store().setNodesIcon([source], '🌐', 'emoji');
store().updateNodesStyle([source], { textColor: '#111111' });
store().setSelection([source]);
store().copyStyle();
store().setSelection([target]);
store().pasteStyle();
expect(nodeById(target)?.style?.textColor).toBe('#111111');
expect(nodeById(target)?.icon).toBeUndefined();
});
it('colle le style sur plusieurs noeuds en une entree d historique', () => { it('colle le style sur plusieurs noeuds en une entree d historique', () => {
const ids = makeChildren(4) as string[]; const ids = makeChildren(4) as string[];
const source = ids[0] as string; const source = ids[0] as string;