Phase 1.1 : mise en page et edition visuelle des noeuds
Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite retiree, compatibilite .mmap v1 et IndexedDB preservee. Modele (format v2) - MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels. - NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow. - MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides. - Un champ absent signifie "herite du theme" : rien n'est ecrit dans le document pour materialiser un defaut, donc les cartes deja stockees restent lisibles telles quelles. Migration v1 -> v2 : style.width devient layout.width, toujours lu en repli par le resolveur. Mise en page - Nouveau resolveur de style qui fusionne theme et personnalisations. - Taille automatique fidele au contenu, taille personnalisee bornee, redimensionnement a la souris via NodeResizer. - Padding horizontal et vertical, retour a la ligne, largeur maximale du texte, interligne, police, alignement horizontal et vertical, ombre. - Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et espacement configurables. Multi-selection - Barre contextuelle d'alignement (6 axes) et de distribution (2 axes). - Application d'un reglage a toute la selection, avec etat "Mixte". Style - Copier / coller / reinitialiser le style, au clavier et par clic droit. Auto-layout - Espacement parent/enfant et entre branches configurable, presets Compact, Normal et Aere. Le layout reste declenche uniquement a la demande. Historique - Une seule entree par redimensionnement complet, par alignement, par distribution, par application multiple et par collage de style. Panneau Proprietes - Reorganise en cinq sections repliables dont l'etat est conserve pendant la session. Vraies icones d'alignement, tooltips et aria-labels partout. Corrections - Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la largeur min-content a un caractere, ce qui faisait s'effondrer tous les noeuds automatiques. - Largeur auto en max-content bornee, pour qu'une phrase longue occupe la largeur maximale au lieu de former une colonne etroite. Tests - 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,25 @@ import { APP_CONFIG } from '@/config/app';
|
||||
import {
|
||||
MAP_FORMAT_VERSION,
|
||||
type BranchStyle,
|
||||
type FontFamilyId,
|
||||
type IconPosition,
|
||||
type LayoutKind,
|
||||
type MapSettings,
|
||||
type MindMap,
|
||||
type MindMapNode,
|
||||
type NodeBranchStyle,
|
||||
type NodeIconStyle,
|
||||
type NodeKind,
|
||||
type NodeLayout,
|
||||
type NodeShape,
|
||||
type NodeStyle,
|
||||
type ShadowStyle,
|
||||
type SizeMode,
|
||||
type TextAlign,
|
||||
type VerticalAlign,
|
||||
} from '@/types/map';
|
||||
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { SPACING_LIMITS } from '@/features/editor/layout';
|
||||
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
|
||||
import { createId } from '@/utils/id';
|
||||
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
|
||||
@@ -31,6 +41,12 @@ const MAX_NAME_LENGTH = 200;
|
||||
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
|
||||
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
|
||||
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
|
||||
const VERTICAL_ALIGNS: VerticalAlign[] = ['top', 'middle', 'bottom'];
|
||||
const SIZE_MODES: SizeMode[] = ['auto', 'fixed'];
|
||||
const SHADOW_STYLES: ShadowStyle[] = ['theme', 'none', 'soft', 'strong'];
|
||||
const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounded'];
|
||||
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
|
||||
const ICON_KINDS = ['emoji', 'builtin'] as const;
|
||||
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
|
||||
const LAYOUTS: LayoutKind[] = [
|
||||
'mindmap-right',
|
||||
@@ -95,10 +111,107 @@ function parseStyle(value: unknown): NodeStyle | undefined {
|
||||
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
|
||||
style.textAlign = value.textAlign as TextAlign;
|
||||
}
|
||||
if (
|
||||
typeof value.verticalAlign === 'string' &&
|
||||
VERTICAL_ALIGNS.includes(value.verticalAlign as VerticalAlign)
|
||||
) {
|
||||
style.verticalAlign = value.verticalAlign as VerticalAlign;
|
||||
}
|
||||
if (
|
||||
typeof value.fontFamily === 'string' &&
|
||||
FONT_FAMILIES.includes(value.fontFamily as FontFamilyId)
|
||||
) {
|
||||
style.fontFamily = value.fontFamily as FontFamilyId;
|
||||
}
|
||||
if (typeof value.shadow === 'string' && SHADOW_STYLES.includes(value.shadow as ShadowStyle)) {
|
||||
style.shadow = value.shadow as ShadowStyle;
|
||||
}
|
||||
if (typeof value.lineHeight === 'number' && Number.isFinite(value.lineHeight)) {
|
||||
style.lineHeight = Math.min(
|
||||
LAYOUT_LIMITS.lineHeight.max,
|
||||
Math.max(LAYOUT_LIMITS.lineHeight.min, Math.round(value.lineHeight * 100) / 100),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.keys(style).length > 0 ? style : undefined;
|
||||
}
|
||||
|
||||
/** Mise en page d'un noeud (v2). Chaque valeur est bornee. */
|
||||
function parseLayout(value: unknown): NodeLayout | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const layout: NodeLayout = {};
|
||||
if (typeof value.widthMode === 'string' && SIZE_MODES.includes(value.widthMode as SizeMode)) {
|
||||
layout.widthMode = value.widthMode as SizeMode;
|
||||
}
|
||||
if (typeof value.heightMode === 'string' && SIZE_MODES.includes(value.heightMode as SizeMode)) {
|
||||
layout.heightMode = value.heightMode as SizeMode;
|
||||
}
|
||||
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
|
||||
layout.width = clampTo(value.width, LAYOUT_LIMITS.width);
|
||||
}
|
||||
if (typeof value.height === 'number' && Number.isFinite(value.height)) {
|
||||
layout.height = clampTo(value.height, LAYOUT_LIMITS.height);
|
||||
}
|
||||
if (typeof value.paddingX === 'number' && Number.isFinite(value.paddingX)) {
|
||||
layout.paddingX = clampTo(value.paddingX, LAYOUT_LIMITS.paddingX);
|
||||
}
|
||||
if (typeof value.paddingY === 'number' && Number.isFinite(value.paddingY)) {
|
||||
layout.paddingY = clampTo(value.paddingY, LAYOUT_LIMITS.paddingY);
|
||||
}
|
||||
if (typeof value.maxTextWidth === 'number' && Number.isFinite(value.maxTextWidth)) {
|
||||
layout.maxTextWidth = clampTo(value.maxTextWidth, LAYOUT_LIMITS.maxTextWidth);
|
||||
}
|
||||
if (typeof value.wrap === 'boolean') layout.wrap = value.wrap;
|
||||
|
||||
return Object.keys(layout).length > 0 ? layout : undefined;
|
||||
}
|
||||
|
||||
function parseIconStyle(value: unknown): NodeIconStyle | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const iconStyle: NodeIconStyle = {};
|
||||
if (
|
||||
typeof value.kind === 'string' &&
|
||||
(ICON_KINDS as readonly string[]).includes(value.kind)
|
||||
) {
|
||||
iconStyle.kind = value.kind as NodeIconStyle['kind'];
|
||||
}
|
||||
if (
|
||||
typeof value.position === 'string' &&
|
||||
ICON_POSITIONS.includes(value.position as IconPosition)
|
||||
) {
|
||||
iconStyle.position = value.position as IconPosition;
|
||||
}
|
||||
if (typeof value.size === 'number' && Number.isFinite(value.size)) {
|
||||
iconStyle.size = clampTo(value.size, LAYOUT_LIMITS.iconSize);
|
||||
}
|
||||
if (typeof value.gap === 'number' && Number.isFinite(value.gap)) {
|
||||
iconStyle.gap = clampTo(value.gap, LAYOUT_LIMITS.iconGap);
|
||||
}
|
||||
|
||||
return Object.keys(iconStyle).length > 0 ? iconStyle : undefined;
|
||||
}
|
||||
|
||||
function parseBranch(value: unknown): NodeBranchStyle | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const branch: NodeBranchStyle = {};
|
||||
if (
|
||||
typeof value.style === 'string' &&
|
||||
BRANCH_STYLES.includes(value.style as BranchStyle)
|
||||
) {
|
||||
branch.style = value.style as BranchStyle;
|
||||
}
|
||||
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
|
||||
branch.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
|
||||
}
|
||||
const color = asColor(value.color);
|
||||
if (color) branch.color = color;
|
||||
|
||||
return Object.keys(branch).length > 0 ? branch : undefined;
|
||||
}
|
||||
|
||||
function parseNode(value: unknown): MindMapNode | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
@@ -124,9 +237,13 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
|
||||
if (value.collapsed === true) node.collapsed = true;
|
||||
|
||||
const icon = asString(value.icon, 8);
|
||||
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
|
||||
const icon = asString(value.icon, 32);
|
||||
if (icon) node.icon = icon;
|
||||
|
||||
const iconStyle = parseIconStyle(value.iconStyle);
|
||||
if (iconStyle) node.iconStyle = iconStyle;
|
||||
|
||||
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
|
||||
const url = sanitizeUrl(asString(value.url, 2048));
|
||||
if (url) node.url = url;
|
||||
@@ -137,6 +254,12 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
const style = parseStyle(value.style);
|
||||
if (style) node.style = style;
|
||||
|
||||
const layout = parseLayout(value.layout);
|
||||
if (layout) node.layout = layout;
|
||||
|
||||
const branch = parseBranch(value.branch);
|
||||
if (branch) node.branch = branch;
|
||||
|
||||
if (isRecord(value.meta)) node.meta = value.meta;
|
||||
|
||||
return node;
|
||||
@@ -157,22 +280,66 @@ function parseSettings(value: unknown): MapSettings {
|
||||
settings.branchStyle = value.branchStyle as BranchStyle;
|
||||
}
|
||||
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
|
||||
if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid;
|
||||
if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides;
|
||||
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
|
||||
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
|
||||
}
|
||||
|
||||
if (isRecord(value.layoutSpacing)) {
|
||||
const raw = value.layoutSpacing;
|
||||
const parentGap = typeof raw.parentGap === 'number' ? raw.parentGap : undefined;
|
||||
const siblingGap = typeof raw.siblingGap === 'number' ? raw.siblingGap : undefined;
|
||||
if (parentGap !== undefined && siblingGap !== undefined) {
|
||||
settings.layoutSpacing = {
|
||||
parentGap: clampTo(parentGap, SPACING_LIMITS.parentGap),
|
||||
siblingGap: clampTo(siblingGap, SPACING_LIMITS.siblingGap),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point d'extension des migrations de format.
|
||||
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
|
||||
* recente, avant la normalisation finale.
|
||||
* Migrations de format, appliquees de la plus ancienne a la plus recente.
|
||||
*
|
||||
* v1 -> v2 : la largeur fixe vivait dans `style.width`. Elle rejoint
|
||||
* `layout.width` avec un `widthMode` explicite. Les autres nouveautes de la v2
|
||||
* sont facultatives : leur absence signifie "herite du theme", il n'y a donc
|
||||
* rien a ecrire dans le document.
|
||||
*/
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
const document = raw;
|
||||
const current = version;
|
||||
function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!Array.isArray(document.nodes)) return { ...document, version: 2 };
|
||||
|
||||
// Exemple d'evolution future :
|
||||
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
|
||||
const nodes = document.nodes.map((rawNode) => {
|
||||
if (!isRecord(rawNode) || !isRecord(rawNode.style)) return rawNode;
|
||||
|
||||
const { width, ...styleRest } = rawNode.style;
|
||||
if (typeof width !== 'number' || !Number.isFinite(width)) return rawNode;
|
||||
|
||||
const existingLayout = isRecord(rawNode.layout) ? rawNode.layout : {};
|
||||
return {
|
||||
...rawNode,
|
||||
style: styleRest,
|
||||
layout: { widthMode: 'fixed', width, ...existingLayout },
|
||||
};
|
||||
});
|
||||
|
||||
return { ...document, nodes, version: 2 };
|
||||
}
|
||||
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
let document = raw;
|
||||
let current = version;
|
||||
|
||||
if (current === 1) {
|
||||
document = migrateV1ToV2(document);
|
||||
current = 2;
|
||||
}
|
||||
|
||||
if (current !== MAP_FORMAT_VERSION) {
|
||||
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
|
||||
return { ...document, version: MAP_FORMAT_VERSION };
|
||||
}
|
||||
return document;
|
||||
|
||||
Reference in New Issue
Block a user