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:
2026-09-09 17:29:48 +02:00
parent 0b2e5e326d
commit af6fd519c5
37 changed files with 3896 additions and 439 deletions

View File

@@ -1,46 +1,92 @@
import { type MindMapNode } from '@/types/map';
export const NODE_MIN_WIDTH = 96;
export const NODE_MAX_WIDTH = 260;
export const NODE_LINE_HEIGHT = 20;
export const NODE_VERTICAL_PADDING = 20;
import { getTheme, type MapTheme } from '@/features/editor/themes';
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
import { countRenderedLines, measureLongestLine, type FontSpec } from '@/utils/textMeasure';
export interface NodeSize {
width: number;
height: number;
}
/** Marge interne reservee aux indicateurs (note, lien) affiches dans le noeud. */
const INDICATOR_WIDTH = 18;
/**
* Estimation de la taille d'un noeud, utilisee tant que le DOM n'a pas encore
* mesure le noeud (premier layout, export sur une carte importee).
* Estimation de la taille d'un noeud.
*
* Utilisee tant que le DOM n'a pas mesure le noeud : premier auto-layout,
* export d'une carte importee, calcul des bornes. Elle reproduit les regles
* appliquees par le rendu (padding, icone, retour a la ligne, largeur max).
*/
export function estimateNodeSize(node: MindMapNode): NodeSize {
const fontSize = node.style?.fontSize ?? 14;
const charWidth = fontSize * 0.58;
export function estimateNodeSize(node: MindMapNode, theme: MapTheme = getTheme(undefined)): NodeSize {
const appearance = resolveNodeAppearance(node, node.type === 'root' ? 0 : 1, theme);
const font: FontSpec = {
fontSize: appearance.fontSize,
fontFamily: appearance.fontFamily,
fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle,
};
const text = node.text || ' ';
const iconWidth = node.icon ? fontSize + 8 : 0;
const iconInline = Boolean(node.icon) && appearance.iconPosition !== 'top';
const iconAbove = Boolean(node.icon) && appearance.iconPosition === 'top';
const longestLine = text
.split('\n')
.reduce((longest, line) => Math.max(longest, line.length), 0);
const decorations =
(iconInline ? appearance.iconSize + appearance.iconGap : 0) +
(node.note ? INDICATOR_WIDTH : 0) +
(node.url ? INDICATOR_WIDTH : 0);
const rawWidth = longestLine * charWidth + 32 + iconWidth;
const width = node.style?.width
? node.style.width
: Math.min(NODE_MAX_WIDTH, Math.max(NODE_MIN_WIDTH, Math.round(rawWidth)));
const horizontalChrome = appearance.paddingX * 2 + decorations + appearance.borderWidth * 2;
const verticalChrome = appearance.paddingY * 2 + appearance.borderWidth * 2;
const charsPerLine = Math.max(1, Math.floor((width - 32 - iconWidth) / charWidth));
const lines = text
.split('\n')
.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
// Largeur
let width: number;
let textWidth: number;
const height = Math.max(38, lines * (fontSize + 6) + NODE_VERTICAL_PADDING);
return { width, height: Math.round(height) };
if (appearance.widthMode === 'fixed' && appearance.width) {
width = appearance.width;
textWidth = Math.max(16, width - horizontalChrome);
} else {
const naturalTextWidth = measureLongestLine(text, font);
const cap = appearance.wrap ? appearance.maxTextWidth : Number.POSITIVE_INFINITY;
textWidth = Math.min(naturalTextWidth, cap);
width = Math.max(appearance.minWidth, Math.ceil(textWidth + horizontalChrome));
}
// Hauteur
if (appearance.heightMode === 'fixed' && appearance.height) {
return { width: Math.round(width), height: Math.round(appearance.height) };
}
const lines = countRenderedLines(
text,
font,
appearance.wrap ? textWidth : Number.POSITIVE_INFINITY,
);
const textHeight = lines * appearance.fontSize * appearance.lineHeight;
const iconHeight = iconAbove ? appearance.iconSize + appearance.iconGap : 0;
const height = Math.max(
appearance.minHeight,
Math.ceil(textHeight + iconHeight + verticalChrome),
);
// Un cercle doit rester circulaire : on prend la plus grande des deux mesures.
if (appearance.circle && appearance.widthMode === 'auto') {
const side = Math.max(width, height);
return { width: side, height: side };
}
return { width: Math.round(width), height: Math.round(height) };
}
/** Taille reelle si elle a ete mesuree, sinon estimation. */
export function resolveNodeSize(node: MindMapNode, measured?: Map<string, NodeSize>): NodeSize {
/** Taille reelle si elle a ete mesuree dans le DOM, sinon estimation. */
export function resolveNodeSize(
node: MindMapNode,
measured?: Map<string, NodeSize>,
theme?: MapTheme,
): NodeSize {
const known = measured?.get(node.id);
if (known && known.width > 0 && known.height > 0) return known;
return estimateNodeSize(node);
return estimateNodeSize(node, theme);
}