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:
187
frontend/src/features/editor/nodeStyleResolver.ts
Normal file
187
frontend/src/features/editor/nodeStyleResolver.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user