Files
openmind/frontend/src/features/editor/EditorToolbar.tsx
snoopy 0b2e5e326d OpenMind v1 : cartes mentales libres, local-first et dockerisees
Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

Architecture
- Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react,
  etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre.
- Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les
  fichiers statiques du frontend compile.
- Image Docker multi-stage, un seul service, bind mount ./data pour SQLite.

Local-first
Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur
demande explicite de partage.

Partage
Deux secrets distincts : share_token public en lecture seule dans l'URL, et
management_token conserve uniquement cote client, transmis en en-tete pour les
modifications et stocke hashe en base.

Fonctionnalites
Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis,
branches repliables, recherche), 9 themes, 4 dispositions automatiques,
panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression,
page publique en lecture seule, PWA.

Qualite
25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur,
TypeScript strict sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:19:18 +02:00

250 lines
8.4 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 } 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 ?? '');
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 />
<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 })}
/>
</>
)}
</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>
);
}