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

@@ -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;

View File

@@ -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"

View File

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

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
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. */
const LEGACY_V1 = {
@@ -29,7 +29,7 @@ const LEGACY_V1 = {
describe('compatibilite du format', () => {
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: [

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 { 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<MapSummary[]> {
@@ -21,8 +22,17 @@ export async function listMaps(): Promise<MapSummary[]> {
.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> {
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> {

View File

@@ -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<string, unknown>;
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: '<img src=x onerror=alert(1)>' }],
@@ -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: [

View File

@@ -345,14 +345,48 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
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.
* 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' });
}

View File

@@ -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<SharedMapRespo
return {
shareToken: raw.share_token,
title: raw.title,
map: parseMapDocument(raw.map),
map: normalizeMapDocument(raw.map, { source: 'share' }),
createdAt: raw.created_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';
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<T extends object>(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<EditorState>((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<EditorState>((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<EditorState>((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<EditorState>((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<EditorState>((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<EditorState>((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;
}),
),

View File

@@ -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;