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>
188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import {
|
|
type FontFamilyId,
|
|
type IconPosition,
|
|
type MindMapNode,
|
|
type NodeShape,
|
|
type ShadowStyle,
|
|
type TextAlign,
|
|
type VerticalAlign,
|
|
} from '@/types/map';
|
|
import { type MapTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
|
|
|
|
/**
|
|
* Fusion theme + personnalisations explicites du noeud.
|
|
*
|
|
* Regle unique : un champ absent du noeud herite du theme. Rien n'est jamais
|
|
* ecrit dans le document pour "materialiser" un defaut, ce qui garantit que les
|
|
* cartes v1 deja stockees restent lisibles et que changer de theme n'ecrase
|
|
* aucune personnalisation.
|
|
*/
|
|
|
|
export const FONT_FAMILIES: Record<Exclude<FontFamilyId, 'theme'>, string> = {
|
|
sans: "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, Arial, sans-serif",
|
|
serif: "Georgia, 'Times New Roman', Times, serif",
|
|
mono: "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace",
|
|
rounded: "'Segoe UI', 'Trebuchet MS', ui-rounded, system-ui, sans-serif",
|
|
};
|
|
|
|
export const FONT_FAMILY_OPTIONS: Array<{ id: FontFamilyId; label: string }> = [
|
|
{ id: 'theme', label: 'Theme' },
|
|
{ id: 'sans', label: 'Sans-serif' },
|
|
{ id: 'serif', label: 'Serif' },
|
|
{ id: 'mono', label: 'Monospace' },
|
|
{ id: 'rounded', label: 'Arrondie' },
|
|
];
|
|
|
|
export const SHADOW_OPTIONS: Array<{ id: ShadowStyle; label: string }> = [
|
|
{ id: 'theme', label: 'Theme' },
|
|
{ id: 'none', label: 'Aucune' },
|
|
{ id: 'soft', label: 'Douce' },
|
|
{ id: 'strong', label: 'Marquee' },
|
|
];
|
|
|
|
const SHADOWS: Record<Exclude<ShadowStyle, 'theme'>, string> = {
|
|
none: 'none',
|
|
soft: '0 1px 2px rgba(15,23,42,0.06), 0 6px 16px -10px rgba(15,23,42,0.35)',
|
|
strong: '0 2px 4px rgba(15,23,42,0.10), 0 14px 28px -12px rgba(15,23,42,0.55)',
|
|
};
|
|
|
|
/** Bornes appliquees a toute saisie utilisateur. */
|
|
export const LAYOUT_LIMITS = {
|
|
width: { min: 60, max: 900 },
|
|
height: { min: 32, max: 900 },
|
|
paddingX: { min: 0, max: 64 },
|
|
paddingY: { min: 0, max: 48 },
|
|
maxTextWidth: { min: 80, max: 900 },
|
|
fontSize: { min: 10, max: 48 },
|
|
lineHeight: { min: 1, max: 2.4 },
|
|
iconSize: { min: 12, max: 48 },
|
|
iconGap: { min: 0, max: 32 },
|
|
branchWidth: { min: 1, max: 10 },
|
|
} as const;
|
|
|
|
export function clampTo(value: number, bounds: { min: number; max: number }): number {
|
|
return Math.min(bounds.max, Math.max(bounds.min, Math.round(value)));
|
|
}
|
|
|
|
/** Apparence entierement resolue d'un noeud, prete a etre appliquee en CSS. */
|
|
export interface NodeAppearance {
|
|
background: string;
|
|
color: string;
|
|
borderColor: string;
|
|
borderWidth: number;
|
|
borderRadius: string;
|
|
shape: NodeShape;
|
|
circle: boolean;
|
|
boxShadow: string;
|
|
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
fontWeight: number;
|
|
fontStyle: 'normal' | 'italic';
|
|
lineHeight: number;
|
|
textAlign: TextAlign;
|
|
verticalAlign: VerticalAlign;
|
|
|
|
paddingX: number;
|
|
paddingY: number;
|
|
wrap: boolean;
|
|
widthMode: 'auto' | 'fixed';
|
|
heightMode: 'auto' | 'fixed';
|
|
width?: number;
|
|
height?: number;
|
|
maxTextWidth: number;
|
|
minWidth: number;
|
|
minHeight: number;
|
|
|
|
iconSize: number;
|
|
iconGap: number;
|
|
iconPosition: IconPosition;
|
|
}
|
|
|
|
function radiusForShape(shape: NodeShape, themeRadius: number): string {
|
|
switch (shape) {
|
|
case 'rectangle':
|
|
return '3px';
|
|
case 'capsule':
|
|
return '999px';
|
|
case 'circle':
|
|
return '50%';
|
|
case 'rounded':
|
|
default:
|
|
return `${themeRadius}px`;
|
|
}
|
|
}
|
|
|
|
export function resolveNodeAppearance(
|
|
node: MindMapNode,
|
|
depth: number,
|
|
theme: MapTheme,
|
|
): NodeAppearance {
|
|
const palette = paletteForDepth(theme, depth);
|
|
const metrics = themeMetrics(theme);
|
|
const style = node.style ?? {};
|
|
const layout = node.layout ?? {};
|
|
const iconStyle = node.iconStyle ?? {};
|
|
|
|
const shape = style.shape ?? theme.nodeShape;
|
|
const fontFamily =
|
|
style.fontFamily && style.fontFamily !== 'theme'
|
|
? FONT_FAMILIES[style.fontFamily]
|
|
: theme.fontFamily;
|
|
const shadow = style.shadow && style.shadow !== 'theme' ? SHADOWS[style.shadow] : theme.shadow;
|
|
|
|
// `style.width` est l'ancien champ v1 : il vaut largeur fixe.
|
|
const legacyWidth = style.width;
|
|
const widthMode = layout.widthMode ?? (layout.width ?? legacyWidth ? 'fixed' : 'auto');
|
|
const width = layout.width ?? legacyWidth;
|
|
const heightMode = layout.heightMode ?? (layout.height ? 'fixed' : 'auto');
|
|
|
|
return {
|
|
background: style.backgroundColor ?? palette.background,
|
|
color: style.textColor ?? palette.text,
|
|
borderColor: style.borderColor ?? palette.border,
|
|
borderWidth: theme.borderWidth,
|
|
borderRadius: radiusForShape(shape, theme.nodeRadius),
|
|
shape,
|
|
circle: shape === 'circle',
|
|
boxShadow: shadow,
|
|
|
|
fontFamily,
|
|
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
|
|
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
|
|
fontStyle: style.italic ? 'italic' : 'normal',
|
|
lineHeight: style.lineHeight ?? metrics.lineHeight,
|
|
textAlign: style.textAlign ?? 'left',
|
|
verticalAlign: style.verticalAlign ?? 'top',
|
|
|
|
paddingX: layout.paddingX ?? metrics.paddingX,
|
|
paddingY: layout.paddingY ?? metrics.paddingY,
|
|
wrap: layout.wrap ?? true,
|
|
widthMode: widthMode === 'fixed' && width ? 'fixed' : 'auto',
|
|
heightMode: heightMode === 'fixed' && layout.height ? 'fixed' : 'auto',
|
|
...(width ? { width } : {}),
|
|
...(layout.height ? { height: layout.height } : {}),
|
|
maxTextWidth: layout.maxTextWidth ?? metrics.maxTextWidth,
|
|
minWidth: metrics.minNodeWidth,
|
|
minHeight: metrics.minNodeHeight,
|
|
|
|
iconSize: iconStyle.size ?? metrics.iconSize,
|
|
iconGap: iconStyle.gap ?? metrics.iconGap,
|
|
iconPosition: iconStyle.position ?? 'left',
|
|
};
|
|
}
|
|
|
|
/** Style effectif de la branche entrante d'un noeud. */
|
|
export function resolveBranchAppearance(
|
|
node: MindMapNode,
|
|
theme: MapTheme,
|
|
fallbackStyle: MindMapNode['branch'],
|
|
): { style: NonNullable<MindMapNode['branch']>['style']; width: number; color: string } {
|
|
const branch = node.branch ?? {};
|
|
return {
|
|
style: branch.style ?? fallbackStyle?.style,
|
|
width: branch.width ?? theme.branchWidth,
|
|
color: branch.color ?? theme.branchColor,
|
|
};
|
|
}
|