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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user