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>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useReactFlow } from '@xyflow/react';
|
|
import { type BranchStyle } from '@/types/map';
|
|
import { Button } from '@/components/Button';
|
|
import { IconButton } from '@/components/IconButton';
|
|
import { Menu, MenuItem, MenuLabel, MenuSeparator } from '@/components/Menu';
|
|
import {
|
|
ArrowLeftIcon,
|
|
ChevronDownIcon,
|
|
FitIcon,
|
|
GridIcon,
|
|
LayoutIcon,
|
|
MoonIcon,
|
|
PaletteIcon,
|
|
PlusIcon,
|
|
RedoIcon,
|
|
SearchIcon,
|
|
ShareIcon,
|
|
SlidersIcon,
|
|
SunIcon,
|
|
TrashIcon,
|
|
UndoIcon,
|
|
ZoomInIcon,
|
|
ZoomOutIcon,
|
|
} from '@/components/Icons';
|
|
import { ExportMenu } from '@/features/export/ExportMenu';
|
|
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';
|
|
import { navigate } from '@/hooks/useRoute';
|
|
|
|
const BRANCH_STYLES: Array<{ id: BranchStyle; label: string }> = [
|
|
{ id: 'bezier', label: 'Bezier' },
|
|
{ id: 'curved', label: 'Courbe' },
|
|
{ id: 'straight', label: 'Droite' },
|
|
];
|
|
|
|
const SAVE_LABELS = {
|
|
idle: '',
|
|
dirty: 'Modifications non enregistrees',
|
|
saving: 'Sauvegarde...',
|
|
saved: 'Sauvegarde',
|
|
error: 'Sauvegarde impossible',
|
|
} as const;
|
|
|
|
export interface EditorToolbarProps {
|
|
onOpenShare: () => void;
|
|
onToggleSearch: () => void;
|
|
}
|
|
|
|
export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProps) {
|
|
const map = useEditorStore((state) => state.map);
|
|
const saveStatus = useEditorStore((state) => state.saveStatus);
|
|
const canUndo = useEditorStore(selectCanUndo);
|
|
const canRedo = useEditorStore(selectCanRedo);
|
|
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
|
|
const undo = useEditorStore((state) => state.undo);
|
|
const redo = useEditorStore((state) => state.redo);
|
|
const addChild = useEditorStore((state) => state.addChild);
|
|
const deleteNodes = useEditorStore((state) => state.deleteNodes);
|
|
const applyAutoLayout = useEditorStore((state) => state.applyAutoLayout);
|
|
const updateSettings = useEditorStore((state) => state.updateSettings);
|
|
const renameMap = useEditorStore((state) => state.renameMap);
|
|
const requestFit = useEditorStore((state) => state.requestFit);
|
|
|
|
const colorMode = useUiStore((state) => state.colorMode);
|
|
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
|
|
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
|
|
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
|
|
|
|
const { zoomIn, zoomOut } = useReactFlow();
|
|
const [name, setName] = useState(map?.name ?? '');
|
|
const spacing = resolveSpacing(map?.settings.layoutSpacing);
|
|
|
|
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
|
|
|
|
if (!map) return null;
|
|
|
|
const commitName = () => {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
setName(map.name);
|
|
return;
|
|
}
|
|
if (trimmed !== map.name) renameMap(trimmed);
|
|
};
|
|
|
|
return (
|
|
<header
|
|
className="no-print flex h-14 shrink-0 items-center gap-2 border-b border-zinc-200 bg-white px-3 dark:border-zinc-800 dark:bg-zinc-900"
|
|
aria-label="Barre d'outils de l'editeur"
|
|
>
|
|
<IconButton
|
|
label="Revenir au tableau de bord"
|
|
icon={<ArrowLeftIcon />}
|
|
onClick={() => navigate('/')}
|
|
/>
|
|
|
|
<input
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
onBlur={commitName}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') event.currentTarget.blur();
|
|
}}
|
|
aria-label="Nom de la carte"
|
|
className="min-w-0 max-w-[16rem] flex-1 truncate rounded-md border border-transparent bg-transparent px-2 py-1 text-sm font-semibold text-zinc-900 hover:border-zinc-200 focus:border-indigo-400 dark:text-zinc-50 dark:hover:border-zinc-700"
|
|
/>
|
|
|
|
<span
|
|
className={`hidden shrink-0 text-xs sm:inline ${
|
|
saveStatus === 'error' ? 'text-red-500' : 'text-zinc-400'
|
|
}`}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
{SAVE_LABELS[saveStatus]}
|
|
</span>
|
|
|
|
<div className="mx-1 hidden h-6 w-px bg-zinc-200 dark:bg-zinc-700 sm:block" />
|
|
|
|
<IconButton label="Annuler (Ctrl+Z)" icon={<UndoIcon />} disabled={!canUndo} onClick={undo} />
|
|
<IconButton label="Retablir (Ctrl+Y)" icon={<RedoIcon />} disabled={!canRedo} onClick={redo} />
|
|
<IconButton
|
|
label="Ajouter un noeud enfant (Tab)"
|
|
icon={<PlusIcon />}
|
|
onClick={() => addChild()}
|
|
/>
|
|
<IconButton
|
|
label="Supprimer la selection (Suppr)"
|
|
icon={<TrashIcon />}
|
|
tone="danger"
|
|
disabled={selectedIds.length === 0}
|
|
onClick={() => deleteNodes(selectedIds)}
|
|
/>
|
|
|
|
<div className="mx-1 hidden h-6 w-px bg-zinc-200 dark:bg-zinc-700 sm:block" />
|
|
|
|
<Menu
|
|
label="Organisation automatique"
|
|
trigger={
|
|
<Button size="sm">
|
|
<LayoutIcon className="h-4 w-4" />
|
|
<span className="hidden md:inline">Organisation automatique</span>
|
|
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
|
|
</Button>
|
|
}
|
|
>
|
|
{(close) => (
|
|
<>
|
|
<MenuLabel>Disposition</MenuLabel>
|
|
{LAYOUT_OPTIONS.map((option) => (
|
|
<MenuItem
|
|
key={option.id}
|
|
label={option.label}
|
|
active={map.settings.layout === option.id}
|
|
onSelect={() => {
|
|
applyAutoLayout(option.id);
|
|
close();
|
|
}}
|
|
/>
|
|
))}
|
|
<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'espacement ne deplace rien : relancez l'organisation.
|
|
</p>
|
|
</div>
|
|
|
|
<MenuSeparator />
|
|
<MenuItem
|
|
label="Reorganiser avec la disposition actuelle"
|
|
onSelect={() => {
|
|
applyAutoLayout();
|
|
close();
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</Menu>
|
|
|
|
<Menu
|
|
label="Theme et style"
|
|
trigger={
|
|
<Button size="sm">
|
|
<PaletteIcon className="h-4 w-4" />
|
|
<span className="hidden lg:inline">Theme</span>
|
|
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
|
|
</Button>
|
|
}
|
|
>
|
|
{() => (
|
|
<>
|
|
<MenuLabel>Theme de la carte</MenuLabel>
|
|
<div className="max-h-56 overflow-y-auto">
|
|
{THEMES.map((theme) => (
|
|
<MenuItem
|
|
key={theme.id}
|
|
label={theme.name}
|
|
active={map.settings.theme === theme.id}
|
|
onSelect={() => updateSettings({ theme: theme.id })}
|
|
/>
|
|
))}
|
|
</div>
|
|
<MenuSeparator />
|
|
<MenuLabel>Style des branches</MenuLabel>
|
|
{BRANCH_STYLES.map((branch) => (
|
|
<MenuItem
|
|
key={branch.id}
|
|
label={branch.label}
|
|
active={map.settings.branchStyle === branch.id}
|
|
onSelect={() => updateSettings({ branchStyle: branch.id })}
|
|
/>
|
|
))}
|
|
<MenuSeparator />
|
|
<MenuItem
|
|
icon={<GridIcon className="h-4 w-4" />}
|
|
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>
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<IconButton label="Zoom avant" icon={<ZoomInIcon />} onClick={() => zoomIn()} />
|
|
<IconButton label="Zoom arriere" icon={<ZoomOutIcon />} onClick={() => zoomOut()} />
|
|
<IconButton label="Adapter a l'ecran (F)" icon={<FitIcon />} onClick={requestFit} />
|
|
<IconButton
|
|
label="Rechercher dans la carte (Ctrl+F)"
|
|
icon={<SearchIcon />}
|
|
onClick={onToggleSearch}
|
|
/>
|
|
<IconButton
|
|
label={propertiesPanelOpen ? 'Masquer les proprietes' : 'Afficher les proprietes'}
|
|
icon={<SlidersIcon />}
|
|
active={propertiesPanelOpen}
|
|
onClick={() => setPropertiesPanelOpen(!propertiesPanelOpen)}
|
|
/>
|
|
<IconButton
|
|
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
|
|
icon={colorMode === 'dark' ? <SunIcon /> : <MoonIcon />}
|
|
onClick={toggleColorMode}
|
|
/>
|
|
|
|
<div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" />
|
|
|
|
<Button size="sm" onClick={onOpenShare}>
|
|
<ShareIcon className="h-4 w-4" />
|
|
<span className="hidden md:inline">Partager</span>
|
|
</Button>
|
|
<ExportMenu />
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|