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, 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, 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['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, }; }