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:
119
frontend/src/features/editor/alignmentGuides.ts
Normal file
119
frontend/src/features/editor/alignmentGuides.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Guides d'alignement affiches pendant un deplacement.
|
||||
*
|
||||
* Volontairement simple : on compare les bords et les centres du noeud deplace
|
||||
* a ceux des autres noeuds visibles, et on ne garde que la meilleure
|
||||
* correspondance sur chaque axe. Aucun etat global, aucune entree d'historique.
|
||||
*/
|
||||
|
||||
export interface GuideBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AlignmentGuide {
|
||||
orientation: 'vertical' | 'horizontal';
|
||||
/** Coordonnee de la ligne (x pour verticale, y pour horizontale). */
|
||||
position: number;
|
||||
/** Etendue de la ligne, pour ne pas traverser tout le canvas. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export const GUIDE_THRESHOLD = 6;
|
||||
|
||||
type Candidate = { value: number; span: [number, number] };
|
||||
|
||||
function verticalCandidates(box: GuideBox): Candidate[] {
|
||||
const span: [number, number] = [box.y, box.y + box.height];
|
||||
return [
|
||||
{ value: box.x, span },
|
||||
{ value: box.x + box.width / 2, span },
|
||||
{ value: box.x + box.width, span },
|
||||
];
|
||||
}
|
||||
|
||||
function horizontalCandidates(box: GuideBox): Candidate[] {
|
||||
const span: [number, number] = [box.x, box.x + box.width];
|
||||
return [
|
||||
{ value: box.y, span },
|
||||
{ value: box.y + box.height / 2, span },
|
||||
{ value: box.y + box.height, span },
|
||||
];
|
||||
}
|
||||
|
||||
function bestMatch(
|
||||
moving: Candidate[],
|
||||
others: GuideBox[],
|
||||
extract: (box: GuideBox) => Candidate[],
|
||||
threshold: number,
|
||||
): { position: number; span: [number, number] } | null {
|
||||
let best: { position: number; span: [number, number]; distance: number } | null = null;
|
||||
|
||||
for (const other of others) {
|
||||
for (const candidate of extract(other)) {
|
||||
for (const source of moving) {
|
||||
const distance = Math.abs(source.value - candidate.value);
|
||||
if (distance > threshold) continue;
|
||||
if (best && distance >= best.distance) continue;
|
||||
best = {
|
||||
position: candidate.value,
|
||||
span: [
|
||||
Math.min(source.span[0], candidate.span[0]),
|
||||
Math.max(source.span[1], candidate.span[1]),
|
||||
],
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { position: best.position, span: best.span } : null;
|
||||
}
|
||||
|
||||
/** Au plus un guide vertical et un guide horizontal. */
|
||||
export function computeAlignmentGuides(
|
||||
moving: GuideBox,
|
||||
others: GuideBox[],
|
||||
threshold: number = GUIDE_THRESHOLD,
|
||||
): AlignmentGuide[] {
|
||||
const candidates = others.filter((box) => box.id !== moving.id);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const guides: AlignmentGuide[] = [];
|
||||
|
||||
const vertical = bestMatch(
|
||||
verticalCandidates(moving),
|
||||
candidates,
|
||||
verticalCandidates,
|
||||
threshold,
|
||||
);
|
||||
if (vertical) {
|
||||
guides.push({
|
||||
orientation: 'vertical',
|
||||
position: vertical.position,
|
||||
start: vertical.span[0],
|
||||
end: vertical.span[1],
|
||||
});
|
||||
}
|
||||
|
||||
const horizontal = bestMatch(
|
||||
horizontalCandidates(moving),
|
||||
candidates,
|
||||
horizontalCandidates,
|
||||
threshold,
|
||||
);
|
||||
if (horizontal) {
|
||||
guides.push({
|
||||
orientation: 'horizontal',
|
||||
position: horizontal.position,
|
||||
start: horizontal.span[0],
|
||||
end: horizontal.span[1],
|
||||
});
|
||||
}
|
||||
|
||||
return guides;
|
||||
}
|
||||
Reference in New Issue
Block a user