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

@@ -24,7 +24,12 @@ import {
ZoomOutIcon,
} from '@/components/Icons';
import { ExportMenu } from '@/features/export/ExportMenu';
import { LAYOUT_OPTIONS } from '@/features/editor/layout';
import {
LAYOUT_OPTIONS,
SPACING_LIMITS,
SPACING_PRESETS,
resolveSpacing,
} from '@/features/editor/layout';
import { THEMES } from '@/features/editor/themes';
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
@@ -71,6 +76,7 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const { zoomIn, zoomOut } = useReactFlow();
const [name, setName] = useState(map?.name ?? '');
const spacing = resolveSpacing(map?.settings.layoutSpacing);
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
@@ -160,6 +166,71 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
}}
/>
))}
<MenuSeparator />
<MenuLabel>Espacement</MenuLabel>
<div className="flex gap-1 px-2.5 pb-2">
{SPACING_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => updateSettings({ layoutSpacing: preset.spacing })}
aria-pressed={
spacing.parentGap === preset.spacing.parentGap &&
spacing.siblingGap === preset.spacing.siblingGap
}
className={`flex-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
spacing.parentGap === preset.spacing.parentGap &&
spacing.siblingGap === preset.spacing.siblingGap
? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800'
}`}
>
{preset.label}
</button>
))}
</div>
<div className="space-y-2 px-2.5 pb-2">
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-parent">
Distance parent / enfant : {spacing.parentGap} px
</label>
<input
id="spacing-parent"
type="range"
min={SPACING_LIMITS.parentGap.min}
max={SPACING_LIMITS.parentGap.max}
step={5}
value={spacing.parentGap}
onChange={(event) =>
updateSettings({
layoutSpacing: { ...spacing, parentGap: Number(event.target.value) },
})
}
className="h-1.5 w-full accent-indigo-600"
/>
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-sibling">
Espacement entre branches : {spacing.siblingGap} px
</label>
<input
id="spacing-sibling"
type="range"
min={SPACING_LIMITS.siblingGap.min}
max={SPACING_LIMITS.siblingGap.max}
step={5}
value={spacing.siblingGap}
onChange={(event) =>
updateSettings({
layoutSpacing: { ...spacing, siblingGap: Number(event.target.value) },
})
}
className="h-1.5 w-full accent-indigo-600"
/>
<p className="text-[11px] leading-snug text-zinc-400">
Changer l&apos;espacement ne deplace rien : relancez l&apos;organisation.
</p>
</div>
<MenuSeparator />
<MenuItem
label="Reorganiser avec la disposition actuelle"
@@ -211,6 +282,19 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
label={map.settings.showGrid ? 'Masquer la grille' : 'Afficher la grille'}
onSelect={() => updateSettings({ showGrid: !map.settings.showGrid })}
/>
<MenuItem
label="Aimanter sur la grille"
active={map.settings.snapToGrid === true}
hint={`${map.settings.snapGrid ?? 20} px`}
onSelect={() => updateSettings({ snapToGrid: !map.settings.snapToGrid })}
/>
<MenuItem
label="Guides d'alignement"
active={map.settings.alignmentGuides !== false}
onSelect={() =>
updateSettings({ alignmentGuides: map.settings.alignmentGuides === false })
}
/>
</>
)}
</Menu>