Files
openmind/frontend/src/features/editor/nodeStyleResolver.ts
snoopy af6fd519c5 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>
2026-09-09 17:29:48 +02:00

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