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>
93 lines
3.3 KiB
TypeScript
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);
|
|
}
|