From a23a68629deacb1e83cbedfde050aba92afda5cf Mon Sep 17 00:00:00 2001 From: snoopy Date: Thu, 10 Sep 2026 05:48:22 +0200 Subject: [PATCH] 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 --- README.md | 28 +++- frontend/src/features/editor/EditorPage.tsx | 23 +-- .../src/features/editor/MindMapCanvas.tsx | 4 +- frontend/src/features/editor/MindNodeView.tsx | 2 + .../services/mapLayoutSerialization.test.ts | 14 +- .../src/services/mapNormalization.test.ts | 137 ++++++++++++++++++ frontend/src/services/mapRepository.ts | 12 +- .../src/services/mapSerialization.test.ts | 30 ++-- frontend/src/services/mapSerialization.ts | 54 +++++-- frontend/src/services/shareApi.ts | 4 +- frontend/src/stores/editorCircle.test.ts | 126 ++++++++++++++++ frontend/src/stores/editorStore.ts | 85 ++++++++--- frontend/src/stores/editorStyle.test.ts | 49 ++++++- 13 files changed, 502 insertions(+), 66 deletions(-) create mode 100644 frontend/src/services/mapNormalization.test.ts create mode 100644 frontend/src/stores/editorCircle.test.ts diff --git a/README.md b/README.md index 8dbfa41..8c144e4 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,10 @@ avec un etat *Mixte* quand les valeurs different. annulation par redimensionnement), taille automatique qui epouse le contenu, alignement et distribution d'une multi-selection, aimantation sur la grille, 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 (gauche, droite, au-dessus), taille et espacement configurables. @@ -282,7 +285,7 @@ l'application chargee. | `Ctrl+C` | Copier | | `Ctrl+V` | Coller | | `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+A` | Tout selectionner | | `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 `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 { "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 que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical 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 est concue pour desktop et tablette. - **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de diff --git a/frontend/src/features/editor/EditorPage.tsx b/frontend/src/features/editor/EditorPage.tsx index 51762f1..bc3b9c3 100644 --- a/frontend/src/features/editor/EditorPage.tsx +++ b/frontend/src/features/editor/EditorPage.tsx @@ -39,15 +39,20 @@ function EditorPageContent({ mapId }: EditorPageProps) { let cancelled = false; setStatus('loading'); - void getMap(mapId).then((map) => { - if (cancelled) return; - if (!map) { - setStatus('missing'); - return; - } - loadMap(map); - setStatus('ready'); - }); + getMap(mapId) + .then((map) => { + if (cancelled) return; + if (!map) { + setStatus('missing'); + return; + } + loadMap(map); + setStatus('ready'); + }) + .catch(() => { + // Document illisible : message propre plutot qu'une erreur technique. + if (!cancelled) setStatus('missing'); + }); return () => { cancelled = true; diff --git a/frontend/src/features/editor/MindMapCanvas.tsx b/frontend/src/features/editor/MindMapCanvas.tsx index e29d87e..1733f94 100644 --- a/frontend/src/features/editor/MindMapCanvas.tsx +++ b/frontend/src/features/editor/MindMapCanvas.tsx @@ -235,7 +235,9 @@ export function MindMapCanvas({ setContextMenu(null); }} 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} multiSelectionKeyCode={['Shift', 'Meta', 'Control']} selectionKeyCode="Shift" diff --git a/frontend/src/features/editor/MindNodeView.tsx b/frontend/src/features/editor/MindNodeView.tsx index 2d60c1c..8616790 100644 --- a/frontend/src/features/editor/MindNodeView.tsx +++ b/frontend/src/features/editor/MindNodeView.tsx @@ -183,6 +183,8 @@ function MindNodeViewComponent({ data, id, selected }: NodeProps) { 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.nodes).toHaveLength(2); @@ -39,7 +39,7 @@ describe('compatibilite du format', () => { }); it('migre la largeur v1 vers layout.width', () => { - const map = parseMapDocument(LEGACY_V1); + const map = normalizeMapDocument(LEGACY_V1); const root = map.nodes[0]; expect(root?.layout?.widthMode).toBe('fixed'); @@ -52,7 +52,7 @@ describe('compatibilite du format', () => { }); 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, ); }); @@ -107,7 +107,7 @@ describe('aller-retour des nouvelles proprietes', () => { it('conserve toute la mise en page apres export puis import', () => { const original = richMap(); - const restored = parseMapDocument(JSON.parse(serializeMap(original))); + const restored = normalizeMapDocument(JSON.parse(serializeMap(original))); const node = restored.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', () => { - const map = parseMapDocument({ + const map = normalizeMapDocument({ version: 2, id: 'x', nodes: [ @@ -149,7 +149,7 @@ describe('aller-retour des nouvelles proprietes', () => { }); it('rejette les valeurs de mise en page invalides', () => { - const map = parseMapDocument({ + const map = normalizeMapDocument({ version: 2, id: 'x', nodes: [ diff --git a/frontend/src/services/mapNormalization.test.ts b/frontend/src/services/mapNormalization.test.ts new file mode 100644 index 0000000..180180d --- /dev/null +++ b/frontend/src/services/mapNormalization.test.ts @@ -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 { + 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; + expect(raw.version).toBe(1); + expect((raw.nodes as Array>)[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); + }); +}); diff --git a/frontend/src/services/mapRepository.ts b/frontend/src/services/mapRepository.ts index f3002ba..f0cb4de 100644 --- a/frontend/src/services/mapRepository.ts +++ b/frontend/src/services/mapRepository.ts @@ -3,6 +3,7 @@ import { type MindMap, type MapSummary } from '@/types/map'; import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory'; import { createId } from '@/utils/id'; 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. */ export async function listMaps(): Promise { @@ -21,8 +22,17 @@ export async function listMaps(): Promise { .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 { - 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 { diff --git a/frontend/src/services/mapSerialization.test.ts b/frontend/src/services/mapSerialization.test.ts index 7f4f6b7..e913a8c 100644 --- a/frontend/src/services/mapSerialization.test.ts +++ b/frontend/src/services/mapSerialization.test.ts @@ -3,14 +3,14 @@ import { MAP_FORMAT_VERSION } from '@/types/map'; import { createSampleMap } from '@/services/mapFactory'; import { MapFormatError, - parseMapDocument, + normalizeMapDocument, serializeMap, } from '@/services/mapSerialization'; describe('serialisation des cartes', () => { it('effectue un aller-retour sans perte', () => { 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.nodes).toHaveLength(original.nodes.length); @@ -20,25 +20,25 @@ describe('serialisation des cartes', () => { }); it('rejette un contenu qui n est pas un objet', () => { - expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError); - expect(() => parseMapDocument(null)).toThrow(MapFormatError); - expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError); + expect(() => normalizeMapDocument('pas une carte')).toThrow(MapFormatError); + expect(() => normalizeMapDocument(null)).toThrow(MapFormatError); + expect(() => normalizeMapDocument([1, 2, 3])).toThrow(MapFormatError); }); it('rejette une carte sans version', () => { const map = createSampleMap() as unknown as Record; delete map.version; - expect(() => parseMapDocument(map)).toThrow(/version/i); + expect(() => normalizeMapDocument(map)).toThrow(/version/i); }); it('rejette une version de format plus recente', () => { 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', () => { - expect(() => parseMapDocument({ 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: [] })).toThrow(MapFormatError); + expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow( MapFormatError, ); }); @@ -49,13 +49,13 @@ describe('serialisation des cartes', () => { text: `Noeud ${index}`, 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, ); }); it('neutralise les URL dangereuses', () => { - const parsed = parseMapDocument({ + const parsed = normalizeMapDocument({ version: 1, id: 'x', name: 'Test', @@ -72,7 +72,7 @@ describe('serialisation des cartes', () => { }); it('conserve le texte tel quel sans interpreter le HTML', () => { - const parsed = parseMapDocument({ + const parsed = normalizeMapDocument({ version: 1, id: 'x', nodes: [{ id: 'a', type: 'root', text: '' }], @@ -81,7 +81,7 @@ describe('serialisation des cartes', () => { }); it('reconstruit la hierarchie a partir des liens', () => { - const parsed = parseMapDocument({ + const parsed = normalizeMapDocument({ version: 1, id: 'x', nodes: [ @@ -97,7 +97,7 @@ describe('serialisation des cartes', () => { }); it('ignore les liens pointant vers des noeuds inexistants', () => { - const parsed = parseMapDocument({ + const parsed = normalizeMapDocument({ version: 1, id: 'x', 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', () => { - const parsed = parseMapDocument({ + const parsed = normalizeMapDocument({ version: 1, id: 'x', nodes: [ diff --git a/frontend/src/services/mapSerialization.ts b/frontend/src/services/mapSerialization.ts index f513487..70c1e67 100644 --- a/frontend/src/services/mapSerialization.ts +++ b/frontend/src/services/mapSerialization.ts @@ -345,14 +345,48 @@ function migrate(raw: Record, version: number): Record = { + 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. - * Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue, - * ni injecte en HTML. + * Point d'entree unique des documents carte. + * + * 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)) { - throw new MapFormatError("Le fichier selectionne n'est pas une carte valide."); + throw new MapFormatError(INVALID_DOCUMENT_MESSAGE[source]); } const version = raw.version; @@ -372,7 +406,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = const document = migrate(raw, version); 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; @@ -395,7 +429,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = nodes.push(node); } - if (nodes.length === 0) { + if (nodes.length === 0 && !options.allowEmpty) { 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. - 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 }; } @@ -434,7 +468,7 @@ export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = viewport: { x: asFiniteNumber(rawViewport.x, 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), }; @@ -456,5 +490,5 @@ export async function readMapFile( } catch { throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible)."); } - return parseMapDocument(raw); + return normalizeMapDocument(raw, { source: 'file' }); } diff --git a/frontend/src/services/shareApi.ts b/frontend/src/services/shareApi.ts index d19c1bb..5277fb2 100644 --- a/frontend/src/services/shareApi.ts +++ b/frontend/src/services/shareApi.ts @@ -1,5 +1,5 @@ import { type MindMap } from '@/types/map'; -import { parseMapDocument } from '@/services/mapSerialization'; +import { normalizeMapDocument } from '@/services/mapSerialization'; /** Erreur reseau/serveur deja traduite en message utilisateur. */ export class ShareApiError extends Error { @@ -146,7 +146,7 @@ export async function fetchSharedMap(shareToken: string): Promise 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); + }); +}); diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index 1562ef4..4470da0 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -16,7 +16,7 @@ import { } from '@/types/map'; import { createNode } from '@/services/mapFactory'; 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 { createId } from '@/utils/id'; import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize'; @@ -39,13 +39,18 @@ interface ClipboardPayload { 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 { style?: NodeStyle; layout?: NodeLayout; iconStyle?: NodeIconStyle; branch?: NodeBranchStyle; - icon?: string; } 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", * 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(value: T | undefined): T | undefined { if (!value) return undefined; const entries = Object.entries(value).filter(([, item]) => item !== undefined); @@ -499,12 +517,22 @@ export const useEditorStore = create((set, get) => { updateNodesStyle: (ids, patch) => { if (ids.length === 0) return; const target = new Set(ids); + commit((current) => withEdges( current, - current.nodes.map((node) => - target.has(node.id) ? { ...node, style: pruneEmpty({ ...node.style, ...patch }) } : node, - ), + current.nodes.map((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((set, get) => { updateNodesLayout: (ids, patch) => { if (ids.length === 0) return; const target = new Set(ids); + const theme = getTheme(get().map?.settings.theme); + commit((current) => withEdges( current, - current.nodes.map((node) => - target.has(node.id) - ? { ...node, layout: pruneEmpty({ ...node.layout, ...patch }) } - : node, - ), + current.nodes.map((node) => { + if (!target.has(node.id)) return 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((set, get) => { }, resizeNode: (id, size) => { - const width = clampTo(size.width, LAYOUT_LIMITS.width); - const height = clampTo(size.height, LAYOUT_LIMITS.height); + const state = get(); + const map = state.map; + if (!map) return; + const theme = getTheme(map.settings.theme); commit( (current) => @@ -651,6 +692,17 @@ export const useEditorStore = create((set, get) => { current, current.nodes.map((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. const { width: _legacyWidth, ...styleRest } = node.style ?? {}; return { @@ -750,14 +802,13 @@ export const useEditorStore = create((set, get) => { const node = map?.nodes.find((candidate) => candidate.id === state.selectedNodeIds[0]); 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({ styleClipboard: { ...(node.style ? { style: { ...node.style } } : {}), ...(node.layout ? { layout: { ...node.layout } } : {}), ...(node.iconStyle ? { iconStyle: { ...node.iconStyle } } : {}), ...(node.branch ? { branch: { ...node.branch } } : {}), - ...(node.icon ? { icon: node.icon } : {}), }, }); return true; @@ -774,17 +825,17 @@ export const useEditorStore = create((set, get) => { current, current.nodes.map((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 }; delete next.style; delete next.layout; delete next.iconStyle; delete next.branch; - delete next.icon; if (snapshot.style) next.style = { ...snapshot.style }; if (snapshot.layout) next.layout = { ...snapshot.layout }; if (snapshot.iconStyle) next.iconStyle = { ...snapshot.iconStyle }; if (snapshot.branch) next.branch = { ...snapshot.branch }; - if (snapshot.icon) next.icon = snapshot.icon; return next; }), ), diff --git a/frontend/src/stores/editorStyle.test.ts b/frontend/src/stores/editorStyle.test.ts index 3fc7e58..464f488 100644 --- a/frontend/src/stores/editorStyle.test.ts +++ b/frontend/src/stores/editorStyle.test.ts @@ -71,6 +71,7 @@ describe('copier / coller le style', () => { store().setNodesIcon([source], 'server', 'builtin'); store().updateNodeText(target, 'Cible'); + store().setNodesIcon([target], '💾', 'emoji'); store().setSelection([source]); expect(store().copyStyle()).toBe(true); @@ -81,16 +82,60 @@ describe('copier / coller le style', () => { expect(pasted?.style?.backgroundColor).toBe('#abcdef'); expect(pasted?.style?.textAlign).toBe('center'); 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'); - // 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?.note).toBeUndefined(); expect(pasted?.url).toBeUndefined(); 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', () => { const ids = makeChildren(4) as string[]; const source = ids[0] as string;