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;