Files
openmind/frontend/src/utils/nodeSize.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

93 lines
3.3 KiB
TypeScript

import { type MindMapNode } from '@/types/map';
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 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, 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 iconInline = Boolean(node.icon) && appearance.iconPosition !== 'top';
const iconAbove = Boolean(node.icon) && appearance.iconPosition === 'top';
const decorations =
(iconInline ? appearance.iconSize + appearance.iconGap : 0) +
(node.note ? INDICATOR_WIDTH : 0) +
(node.url ? INDICATOR_WIDTH : 0);
const horizontalChrome = appearance.paddingX * 2 + decorations + appearance.borderWidth * 2;
const verticalChrome = appearance.paddingY * 2 + appearance.borderWidth * 2;
// Largeur
let width: number;
let textWidth: number;
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 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, theme);
}