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>
This commit is contained in:
2026-09-08 22:19:18 +02:00
commit 0b2e5e326d
113 changed files with 14576 additions and 0 deletions

34
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,34 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'coverage', 'node_modules', 'public'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node },
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-console': ['error', { allow: ['warn', 'error'] }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
},
},
);

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="fr" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="description" content="Cartes mentales libres : local-first, sans compte, sans abonnement." />
<meta name="theme-color" content="#4f46e5" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>OpenMind</title>
</head>
<body class="h-full">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5041
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "openmind-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@xyflow/react": "^12.3.6",
"dagre": "^0.8.5",
"dexie": "^4.0.10",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/dagre": "^0.7.52",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"fake-indexeddb": "^6.0.0",
"globals": "^15.12.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="OpenMind">
<rect width="512" height="512" rx="112" fill="#4f46e5"/>
<g stroke="#ffffff" stroke-width="18" stroke-linecap="round" fill="none" opacity="0.92">
<path d="M196 256 H286"/>
<path d="M196 256 C 240 256, 250 160, 300 160"/>
<path d="M196 256 C 240 256, 250 352, 300 352"/>
</g>
<circle cx="168" cy="256" r="46" fill="#ffffff"/>
<circle cx="336" cy="160" r="30" fill="#c7d2fe"/>
<circle cx="336" cy="256" r="30" fill="#a5b4fc"/>
<circle cx="336" cy="352" r="30" fill="#c7d2fe"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,39 @@
{
"name": "OpenMind",
"short_name": "OpenMind",
"description": "Cartes mentales libres : local-first, sans compte, sans abonnement.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f8fafc",
"theme_color": "#4f46e5",
"lang": "fr",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

71
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,71 @@
/*
* Service worker d'OpenMind.
*
* Objectif : permettre d'ouvrir et d'editer les cartes locales meme sans
* reseau, une fois l'application chargee au moins une fois. Les appels API
* (partage) ne sont jamais mis en cache : ils echouent proprement hors ligne.
*/
const CACHE_NAME = 'openmind-shell-v1';
const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/icons/icon.svg'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.catch(() => undefined)
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Les routes API restent toujours en reseau direct.
if (url.pathname.startsWith('/api/')) return;
// Navigation : reseau d'abord, repli sur la coquille applicative en cache.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy));
return response;
})
.catch(() => caches.match('/index.html').then((cached) => cached || Response.error())),
);
return;
}
// Assets : cache d'abord, rafraichi en arriere-plan.
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => cached || Response.error());
return cached || network;
}),
);
});

44
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { Button } from '@/components/Button';
import { Toasts } from '@/components/Toasts';
import { DashboardPage } from '@/features/maps/DashboardPage';
import { EditorPage } from '@/features/editor/EditorPage';
import { SharedMapPage } from '@/features/sharing/SharedMapPage';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate, useRoute } from '@/hooks/useRoute';
function NotFoundPage() {
const appName = useAppConfigStore((state) => state.name);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">Page introuvable</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Cette adresse ne correspond a aucune page de {appName}.
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Retour au tableau de bord
</Button>
</div>
);
}
export function App() {
const route = useRoute();
const loadConfig = useAppConfigStore((state) => state.load);
useEffect(() => {
void loadConfig();
}, [loadConfig]);
return (
<>
<main className="h-full">
{route.name === 'dashboard' ? <DashboardPage /> : null}
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
{route.name === 'notFound' ? <NotFoundPage /> : null}
</main>
<Toasts />
</>
);
}

View File

@@ -0,0 +1,46 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const VARIANTS: Record<Variant, string> = {
primary:
'bg-indigo-600 text-white hover:bg-indigo-500 disabled:hover:bg-indigo-600 shadow-sm border border-transparent',
secondary:
'bg-white text-zinc-700 border border-zinc-200 hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-200 dark:border-zinc-700 dark:hover:bg-zinc-800',
ghost:
'bg-transparent text-zinc-600 border border-transparent hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
danger:
'bg-red-600 text-white hover:bg-red-500 border border-transparent disabled:hover:bg-red-600',
};
const SIZES: Record<Size, string> = {
sm: 'h-8 px-2.5 text-xs gap-1.5',
md: 'h-9 px-3.5 text-sm gap-2',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'secondary', size = 'md', className, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
type={type}
className={cn(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
'disabled:cursor-not-allowed disabled:opacity-50',
VARIANTS[variant],
SIZES[size],
className,
)}
{...props}
/>
);
});

View File

@@ -0,0 +1,44 @@
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
export interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'default' | 'danger';
onConfirm: () => void;
onCancel: () => void;
}
/** Confirmation explicite : utilisee pour toute action destructrice. */
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirmer',
cancelLabel = 'Annuler',
tone = 'default',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
size="sm"
footer={
<>
<Button onClick={onCancel}>{cancelLabel}</Button>
<Button variant={tone === 'danger' ? 'danger' : 'primary'} onClick={onConfirm} autoFocus>
{confirmLabel}
</Button>
</>
}
>
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">{message}</p>
</Modal>
);
}

View File

@@ -0,0 +1,41 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/utils/cn';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Libelle accessible, egalement utilise comme infobulle. */
label: string;
icon: ReactNode;
active?: boolean;
tone?: 'default' | 'danger';
}
/**
* Bouton represente uniquement par une icone : il porte toujours un aria-label
* et une infobulle native (title).
*/
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ label, icon, active = false, tone = 'default', className, type = 'button', ...props },
ref,
) {
return (
<button
ref={ref}
type={type}
aria-label={label}
title={label}
aria-pressed={active || undefined}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-transparent transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
tone === 'danger'
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
active && 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-300',
className,
)}
{...props}
>
{icon}
</button>
);
});

View File

@@ -0,0 +1,248 @@
/**
* Jeu d'icones minimal, dessine a la main (aucune dependance externe).
* Chaque icone est purement decorative : le libelle accessible est porte par
* le bouton qui la contient.
*/
interface IconProps {
className?: string;
}
const DEFAULT_CLASS = 'h-4 w-4';
function Svg({ className, children }: IconProps & { children: React.ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className ?? DEFAULT_CLASS}
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export const PlusIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 5v14M5 12h14" />
</Svg>
);
export const ImportIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</Svg>
);
export const DownloadIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3v12m0 0 4-4m-4 4-4-4M4 19h16" />
</Svg>
);
export const SearchIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</Svg>
);
export const TrashIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" />
</Svg>
);
export const CopyIcon = (p: IconProps) => (
<Svg {...p}>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h8" />
</Svg>
);
export const PencilIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 20h4L20 8l-4-4L4 16v4Z" />
</Svg>
);
export const UndoIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M9 7 4 12l5 5" />
<path d="M4 12h10a6 6 0 0 1 0 12h-1" />
</Svg>
);
export const RedoIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m15 7 5 5-5 5" />
<path d="M20 12H10a6 6 0 0 0 0 12h1" />
</Svg>
);
export const ZoomInIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="M11 8.5v5M8.5 11h5M16 16l4.5 4.5" />
</Svg>
);
export const ZoomOutIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="M8.5 11h5M16 16l4.5 4.5" />
</Svg>
);
export const FitIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</Svg>
);
export const LayoutIcon = (p: IconProps) => (
<Svg {...p}>
<rect x="3" y="10" width="5" height="4" rx="1" />
<rect x="16" y="4" width="5" height="4" rx="1" />
<rect x="16" y="16" width="5" height="4" rx="1" />
<path d="M8 12h4v-6h4M12 12v6h4" />
</Svg>
);
export const ShareIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="6" r="2.5" />
<circle cx="18" cy="18" r="2.5" />
<path d="m8.3 10.8 7.4-3.6M8.3 13.2l7.4 3.6" />
</Svg>
);
export const PrintIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M7 9V4h10v5" />
<rect x="4" y="9" width="16" height="7" rx="2" />
<path d="M7 14h10v6H7z" />
</Svg>
);
export const SunIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</Svg>
);
export const MoonIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" />
</Svg>
);
export const CloseIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M6 6l12 12M18 6L6 18" />
</Svg>
);
export const ChevronLeftIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m14 6-6 6 6 6" />
</Svg>
);
export const ChevronRightIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m10 6 6 6-6 6" />
</Svg>
);
export const ChevronDownIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m6 10 6 6 6-6" />
</Svg>
);
export const ArrowLeftIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
</Svg>
);
export const CheckIcon = (p: IconProps) => (
<Svg {...p}>
<path d="m5 13 4 4L19 7" />
</Svg>
);
export const GridIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M4 9h16M4 15h16M9 4v16M15 4v16" />
</Svg>
);
export const PaletteIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6Z" />
<circle cx="8" cy="10" r="1" />
<circle cx="12" cy="7.5" r="1" />
<circle cx="16" cy="10" r="1" />
</Svg>
);
export const LinkIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M10 13a4 4 0 0 0 5.7 0l2.6-2.6a4 4 0 0 0-5.7-5.7L11.5 6" />
<path d="M14 11a4 4 0 0 0-5.7 0l-2.6 2.6a4 4 0 0 0 5.7 5.7L12.5 18" />
</Svg>
);
export const NoteIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M6 3h9l4 4v14H6z" />
<path d="M14 3v5h5M9 12h6M9 16h4" />
</Svg>
);
export const SlidersIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M5 6h14M5 12h14M5 18h14" />
<circle cx="9" cy="6" r="2" />
<circle cx="15" cy="12" r="2" />
<circle cx="8" cy="18" r="2" />
</Svg>
);
export const SaveIcon = (p: IconProps) => (
<Svg {...p}>
<path d="M5 4h11l3 3v13H5z" />
<path d="M8 4v5h7V4M8 20v-6h8v6" />
</Svg>
);
export const MapIcon = (p: IconProps) => (
<Svg {...p}>
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="7" r="2.5" />
<circle cx="18" cy="17" r="2.5" />
<path d="M8.5 11 15.5 7.8M8.5 13l7 3.2" />
</Svg>
);
export const SpinnerIcon = ({ className }: IconProps) => (
<svg
viewBox="0 0 24 24"
className={`${className ?? DEFAULT_CLASS} animate-spin`}
aria-hidden="true"
focusable="false"
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" fill="none" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" />
</svg>
);

View File

@@ -0,0 +1,133 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/utils/cn';
export interface MenuProps {
label: string;
trigger: ReactNode;
children: (close: () => void) => ReactNode;
align?: 'left' | 'right';
className?: string;
disabled?: boolean;
}
/** Menu deroulant accessible : Echap ferme, clic exterieur ferme. */
export function Menu({
label,
trigger,
children,
align = 'left',
className,
disabled = false,
}: MenuProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return undefined;
const onPointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
aria-disabled={disabled}
onClick={() => !disabled && setOpen((value) => !value)}
onKeyDown={(event) => {
if (disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setOpen((value) => !value);
}
}}
>
{trigger}
</div>
{open ? (
<div
role="menu"
aria-label={label}
className={cn(
'panel absolute top-full z-40 mt-1.5 min-w-[13rem] animate-fade-in p-1',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{children(() => setOpen(false))}
</div>
) : null}
</div>
);
}
export interface MenuItemProps {
icon?: ReactNode;
label: string;
hint?: string;
onSelect: () => void;
disabled?: boolean;
tone?: 'default' | 'danger';
active?: boolean;
}
export function MenuItem({
icon,
label,
hint,
onSelect,
disabled = false,
tone = 'default',
active = false,
}: MenuItemProps) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onSelect}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
tone === 'danger'
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
active && 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300',
)}
>
{icon ? <span className="shrink-0 opacity-80">{icon}</span> : null}
<span className="flex-1">{label}</span>
{hint ? (
<span className="shrink-0 text-[11px] uppercase tracking-wide text-zinc-400">{hint}</span>
) : null}
</button>
);
}
export function MenuSeparator() {
return <div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800" role="separator" />;
}
export function MenuLabel({ children }: { children: ReactNode }) {
return (
<div className="px-2.5 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">
{children}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { CloseIcon } from '@/components/Icons';
import { IconButton } from '@/components/IconButton';
import { cn } from '@/utils/cn';
export interface ModalProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg';
}
const SIZES = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
};
/** Fenetre modale accessible : fermeture par Echap, focus initial, clic exterieur. */
export function Modal({
open,
title,
description,
onClose,
children,
footer,
size = 'md',
}: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', onKeyDown, true);
panelRef.current?.focus();
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-4 backdrop-blur-[2px]"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className={cn(
'panel w-full animate-fade-in overflow-hidden outline-none',
SIZES[size],
)}
>
<header className="flex items-start justify-between gap-4 border-b border-zinc-100 px-5 py-4 dark:border-zinc-800">
<div>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{description}</p>
) : null}
</div>
<IconButton label="Fermer" icon={<CloseIcon />} onClick={onClose} />
</header>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<footer className="flex justify-end gap-2 border-t border-zinc-100 bg-zinc-50/60 px-5 py-3 dark:border-zinc-800 dark:bg-zinc-900/60">
{footer}
</footer>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useUiStore } from '@/stores/uiStore';
import { CloseIcon } from '@/components/Icons';
import { cn } from '@/utils/cn';
const TONE_CLASSES = {
info: 'border-zinc-200 bg-white text-zinc-800 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100',
success:
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100',
error:
'border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100',
};
/** Zone de notifications : messages courts, jamais d'erreur technique brute. */
export function Toasts() {
const toasts = useUiStore((state) => state.toasts);
const dismissToast = useUiStore((state) => state.dismissToast);
if (toasts.length === 0) return null;
return (
<div
className="no-print pointer-events-none fixed bottom-4 left-1/2 z-[60] flex w-full max-w-md -translate-x-1/2 flex-col gap-2 px-4"
role="status"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
'pointer-events-auto flex animate-fade-in items-start gap-3 rounded-xl border px-4 py-3 text-sm shadow-panel',
TONE_CLASSES[toast.tone],
)}
>
<span className="flex-1 leading-snug">{toast.message}</span>
<button
type="button"
onClick={() => dismissToast(toast.id)}
aria-label="Fermer la notification"
className="mt-0.5 opacity-60 transition-opacity hover:opacity-100"
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Configuration centrale de l'application.
*
* Le nom du produit n'est defini QU'ICI (et surchargeable a l'execution via
* APP_NAME cote backend, expose par /api/config). Aucun autre fichier ne doit
* ecrire le nom en dur.
*/
export const APP_CONFIG = {
/** Nom par defaut, utilise tant que /api/config n'a pas repondu. */
name: 'OpenMind',
tagline: 'Vos cartes mentales, dans votre navigateur. Sans compte, sans limite.',
/** Extension du format natif d'echange. */
fileExtension: '.mmap',
fileMimeType: 'application/json',
/** Limites par defaut, alignees sur celles du backend. */
maxNodes: 5000,
maxMapSizeMb: 10,
/** Delai d'inactivite avant sauvegarde automatique dans IndexedDB. */
autosaveDelayMs: 700,
/** Nombre maximum d'etats conserves pour undo/redo. */
historyLimit: 100,
/** Prefixe des liens publics de partage. */
sharePathPrefix: '/s/',
repositoryUrl: 'https://github.com/',
} as const;
export type AppConfig = typeof APP_CONFIG;

View File

@@ -0,0 +1,37 @@
import Dexie, { type Table } from 'dexie';
import { type MindMap, type ShareRecord } from '@/types/map';
/** Cle/valeur pour les drapeaux locaux (premier lancement, preferences...). */
export interface MetaEntry {
key: string;
value: unknown;
}
/**
* Base locale du navigateur.
*
* Trois tables seulement :
* - `maps` : les cartes privees, qui ne quittent jamais le navigateur ;
* - `shares` : les jetons de partage (dont le management token secret) ;
* - `meta` : quelques drapeaux applicatifs.
*/
export class OpenMindDatabase extends Dexie {
maps!: Table<MindMap, string>;
shares!: Table<ShareRecord, string>;
meta!: Table<MetaEntry, string>;
constructor(name = 'openmind') {
super(name);
this.version(1).stores({
maps: 'id, updatedAt, name',
shares: 'mapId, shareToken',
meta: 'key',
});
}
}
export const db = new OpenMindDatabase();
export const META_KEYS = {
sampleSeeded: 'sample-map-seeded',
} as const;

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import { Button } from '@/components/Button';
import { SpinnerIcon } from '@/components/Icons';
import { EditorToolbar } from '@/features/editor/EditorToolbar';
import { MindMapCanvas } from '@/features/editor/MindMapCanvas';
import { PropertiesPanel } from '@/features/editor/PropertiesPanel';
import { SearchPanel } from '@/features/editor/SearchPanel';
import { useAutosave } from '@/features/editor/useAutosave';
import { useEditorShortcuts } from '@/features/editor/useEditorShortcuts';
import { ShareDialog } from '@/features/sharing/ShareDialog';
import { getMap } from '@/services/mapRepository';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
export interface EditorPageProps {
mapId: string;
}
function EditorPageContent({ mapId }: EditorPageProps) {
const loadMap = useEditorStore((state) => state.loadMap);
const closeMap = useEditorStore((state) => state.closeMap);
const requestFit = useEditorStore((state) => state.requestFit);
const mapName = useEditorStore((state) => state.map?.name);
const propertiesPanelOpen = useUiStore((state) => state.propertiesPanelOpen);
const searchPanelOpen = useUiStore((state) => state.searchPanelOpen);
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const [status, setStatus] = useState<'loading' | 'ready' | 'missing'>('loading');
const [shareOpen, setShareOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const { saveNow } = useAutosave(status === 'ready');
useEffect(() => {
let cancelled = false;
setStatus('loading');
void getMap(mapId).then((map) => {
if (cancelled) return;
if (!map) {
setStatus('missing');
return;
}
loadMap(map);
setStatus('ready');
});
return () => {
cancelled = true;
closeMap();
};
}, [mapId, loadMap, closeMap]);
const openSearch = useCallback(() => {
setSearchPanelOpen(true);
window.setTimeout(() => searchInputRef.current?.focus(), 30);
}, [setSearchPanelOpen]);
useEditorShortcuts(status === 'ready', {
onSave: () => {
void saveNow().then(() => pushToast('Carte sauvegardee localement.', 'success'));
},
onFit: requestFit,
onFocusSearch: openSearch,
});
if (status === 'loading') {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
<SpinnerIcon className="h-5 w-5" />
<span className="ml-2 text-sm">Ouverture de la carte...</span>
</div>
);
}
if (status === 'missing') {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Impossible de charger cette carte.
</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Elle a peut-etre ete supprimee, ou elle a ete creee dans un autre navigateur : les cartes
restent stockees localement.
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Retour au tableau de bord
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<EditorToolbar
onOpenShare={() => setShareOpen(true)}
onToggleSearch={() => (searchPanelOpen ? setSearchPanelOpen(false) : openSearch())}
/>
<div className="relative flex min-h-0 flex-1">
<div className="relative min-w-0 flex-1">
<h1 className="print-title px-2">{mapName}</h1>
<MindMapCanvas />
{searchPanelOpen ? <SearchPanel ref={searchInputRef} /> : null}
</div>
{propertiesPanelOpen ? <PropertiesPanel /> : null}
</div>
<ShareDialog open={shareOpen} onClose={() => setShareOpen(false)} />
</div>
);
}
export function EditorPage(props: EditorPageProps) {
return (
<ReactFlowProvider>
<EditorPageContent {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,249 @@
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>
);
}

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters';
import { MindNodeView } from '@/features/editor/MindNodeView';
import { getTheme } from '@/features/editor/themes';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { resolveNodeSize } from '@/utils/nodeSize';
const NODE_TYPES = { mind: MindNodeView };
export interface MindMapCanvasProps {
readOnly?: boolean;
showMiniMap?: boolean;
showControls?: boolean;
}
export function MindMapCanvas({
readOnly = false,
showMiniMap = true,
showControls = true,
}: MindMapCanvasProps) {
const map = useEditorStore((state) => state.map);
const selectedNodeIds = useEditorStore((state) => state.selectedNodeIds);
const searchResults = useEditorStore((state) => state.searchResults);
const editingNodeId = useEditorStore((state) => state.editingNodeId);
const focusRequest = useEditorStore((state) => state.focusRequest);
const fitRequest = useEditorStore((state) => state.fitRequest);
const sizeVersion = useEditorStore((state) => state.sizeVersion);
const pushToast = useUiStore((state) => state.pushToast);
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
const initialisedFor = useRef<string | null>(null);
const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]);
const adapterOptions = useMemo(
() => ({
theme,
selectedIds: selectedNodeIds,
searchResults,
editingNodeId,
readOnly,
measuredSizes: useEditorStore.getState().measuredSizes,
}),
// sizeVersion force le recalcul quand une taille de noeud a change.
// eslint-disable-next-line react-hooks/exhaustive-deps
[theme, selectedNodeIds, searchResults, editingNodeId, readOnly, sizeVersion],
);
const nodes = useMemo(() => (map ? toFlowNodes(map, adapterOptions) : []), [map, adapterOptions]);
const edges = useMemo(() => (map ? toFlowEdges(map, adapterOptions) : []), [map, adapterOptions]);
// Cadrage initial a l'ouverture d'une carte.
useEffect(() => {
if (!map || initialisedFor.current === map.id) return undefined;
initialisedFor.current = map.id;
const timer = window.setTimeout(() => fitView({ padding: 0.25, duration: 0 }), 60);
return () => window.clearTimeout(timer);
}, [map, fitView]);
useEffect(() => {
if (fitRequest === 0) return undefined;
const timer = window.setTimeout(() => fitView({ padding: 0.25, duration: 300 }), 30);
return () => window.clearTimeout(timer);
}, [fitRequest, fitView]);
// Centrage sur un resultat de recherche.
useEffect(() => {
if (!focusRequest || !map) return;
const target = map.nodes.find((node) => node.id === focusRequest.nodeId);
if (!target) return;
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes);
setCenter(target.position.x + size.width / 2, target.position.y + size.height / 2, {
zoom: 1.15,
duration: 400,
});
}, [focusRequest, map, setCenter]);
const onNodesChange = useCallback(
(changes: NodeChange<MindFlowNode>[]) => {
const store = useEditorStore.getState();
const positions: Array<{ id: string; position: { x: number; y: number } }> = [];
let selection: Set<string> | null = null;
for (const change of changes) {
if (change.type === 'position' && change.position) {
positions.push({ id: change.id, position: change.position });
} else if (change.type === 'select') {
selection ??= new Set(store.selectedNodeIds);
if (change.selected) selection.add(change.id);
else selection.delete(change.id);
} else if (change.type === 'dimensions' && change.dimensions) {
store.setMeasuredSize(change.id, {
width: Math.round(change.dimensions.width),
height: Math.round(change.dimensions.height),
});
} else if (change.type === 'remove' && !readOnly) {
store.deleteNodes([change.id]);
}
}
if (positions.length > 0 && !readOnly) store.moveNodes(positions);
if (selection) store.setSelection([...selection]);
},
[readOnly],
);
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (readOnly) return;
const store = useEditorStore.getState();
for (const change of changes) {
if (change.type !== 'remove') continue;
const edge = store.map?.edges.find((candidate) => candidate.id === change.id);
if (edge) store.detachNode(edge.target);
}
},
[readOnly],
);
const onConnect = useCallback(
(connection: Connection) => {
if (readOnly || !connection.source || !connection.target) return;
const linked = useEditorStore.getState().reparentNode(connection.target, connection.source);
if (!linked) {
pushToast('Impossible de relier ces noeuds : cela creerait une boucle.', 'error');
}
},
[readOnly, pushToast],
);
const handlePaneDoubleClick = useCallback(
(event: React.MouseEvent) => {
if (readOnly) return;
const target = event.target as HTMLElement;
if (!target.classList.contains('react-flow__pane')) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const store = useEditorStore.getState();
const created = store.addChild(null);
if (created) store.moveNodes([{ id: created, position }]);
},
[readOnly, screenToFlowPosition],
);
if (!map) return null;
return (
<div
className="openmind-canvas print-surface h-full w-full"
style={{ background: theme.canvasBackground }}
onDoubleClick={handlePaneDoubleClick}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStart={() => useEditorStore.getState().pushHistory()}
onMoveEnd={(_event, viewport) => useEditorStore.getState().setViewport(viewport)}
onPaneClick={() => {
const store = useEditorStore.getState();
store.setSelection([]);
store.setEditingNode(null);
}}
defaultViewport={map.viewport}
minZoom={0.1}
maxZoom={3}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode="Shift"
deleteKeyCode={null}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
elementsSelectable
edgesFocusable={false}
// React Flow donne sinon le focus au noeud selectionne, ce qui coupe
// l'edition en ligne ; Tab est deja utilise comme raccourci.
nodesFocusable={false}
panOnScroll
selectionOnDrag={!readOnly}
zoomOnDoubleClick={false}
aria-label="Canvas de la carte mentale"
>
{map.settings.showGrid ? (
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color={theme.gridColor} />
) : null}
{showControls ? (
<Controls className="no-print" showInteractive={!readOnly} position="bottom-left" />
) : null}
{showMiniMap ? (
<MiniMap
className="no-print"
pannable
zoomable
position="bottom-right"
nodeColor={(node) => (node.data as MindFlowNode['data']).appearance.background}
maskColor={theme.isDark ? 'rgba(15,23,42,0.6)' : 'rgba(148,163,184,0.25)'}
style={{ background: theme.isDark ? '#0f172a' : '#ffffff' }}
/>
) : null}
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Handle, type NodeProps } from '@xyflow/react';
import { type MindFlowNode } from '@/features/editor/flowAdapters';
import { useEditorStore } from '@/stores/editorStore';
import { LinkIcon, NoteIcon } from '@/components/Icons';
import { cn } from '@/utils/cn';
/**
* Rendu d'un noeud.
*
* Le texte est toujours rendu comme du texte : aucun HTML provenant d'une carte
* n'est jamais interprete.
*/
function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
const { node, appearance, childCount, hiddenCount, isSearchHit, readOnly, isEditing } = data;
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const setEditingNode = useEditorStore((state) => state.setEditingNode);
const toggleCollapse = useEditorStore((state) => state.toggleCollapse);
const setMeasuredSize = useEditorStore((state) => state.setMeasuredSize);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [draft, setDraft] = useState(node.text);
useEffect(() => {
if (isEditing) setDraft(node.text);
}, [isEditing, node.text]);
useEffect(() => {
if (!isEditing) return undefined;
const focusTextarea = () => {
const textarea = textareaRef.current;
if (!textarea || document.activeElement === textarea) return;
textarea.focus();
textarea.select();
};
focusTextarea();
// Seconde tentative apres le rendu du canvas, au cas ou React Flow
// reprendrait le focus sur le noeud selectionne.
const timer = window.setTimeout(focusTextarea, 0);
return () => window.clearTimeout(timer);
}, [isEditing]);
// Les tailles reelles alimentent l'auto-layout et les exports.
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
setMeasuredSize(id, {
width: Math.round(element.offsetWidth),
height: Math.round(element.offsetHeight),
});
}, [id, node.text, node.style, node.icon, setMeasuredSize, isEditing]);
const commitText = () => {
const value = draft.trim();
if (value !== node.text) updateNodeText(id, value);
setEditingNode(null);
};
const cancelEdit = () => {
setDraft(node.text);
setEditingNode(null);
};
const isRoot = node.type === 'root';
const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre');
return (
<div
ref={containerRef}
className={cn(
'mind-node relative select-none px-3.5 py-2',
isSearchHit && 'mind-node--search-hit',
)}
style={{
background: appearance.background,
color: appearance.color,
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
borderRadius: appearance.borderRadius,
boxShadow: appearance.boxShadow,
fontFamily: appearance.fontFamily,
fontSize: appearance.fontSize,
fontWeight: appearance.fontWeight,
fontStyle: appearance.fontStyle,
textAlign: appearance.textAlign,
minWidth: appearance.circle ? 96 : 88,
maxWidth: appearance.width ?? 260,
width: appearance.width,
...(appearance.circle
? { aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }
: {}),
}}
aria-label={label}
onDoubleClick={(event) => {
if (readOnly) return;
event.stopPropagation();
setEditingNode(id);
}}
>
<Handle
type="target"
position={data.targetPosition}
isConnectable={!readOnly}
aria-label="Point de connexion entrant"
/>
<div className="flex items-start gap-2">
{node.icon ? (
<span aria-hidden="true" className="leading-5">
{node.icon}
</span>
) : null}
{isEditing ? (
<textarea
ref={textareaRef}
className="nodrag nowheel w-full resize-none bg-transparent leading-snug outline-none"
style={{ color: appearance.color, minWidth: 90 }}
rows={Math.min(6, Math.max(1, draft.split('\n').length))}
value={draft}
aria-label="Texte du noeud"
onChange={(event) => setDraft(event.target.value)}
onBlur={commitText}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitText();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
/>
) : (
<span className="whitespace-pre-wrap break-words leading-snug">
{node.text || <span className="opacity-50">Sans titre</span>}
</span>
)}
{node.note ? (
<span title="Ce noeud contient une note" className="mt-0.5 shrink-0 opacity-70">
<NoteIcon className="h-3.5 w-3.5" />
</span>
) : null}
{node.url ? (
<a
href={node.url}
target="_blank"
rel="noopener noreferrer nofollow"
title={`Ouvrir ${node.url}`}
aria-label={`Ouvrir le lien du noeud ${label}`}
className="nodrag mt-0.5 shrink-0 opacity-70 transition-opacity hover:opacity-100"
onClick={(event) => event.stopPropagation()}
>
<LinkIcon className="h-3.5 w-3.5" />
</a>
) : null}
</div>
{childCount > 0 ? (
<button
type="button"
className={cn(
'nodrag absolute -bottom-2.5 left-1/2 z-10 flex h-5 min-w-5 -translate-x-1/2 items-center justify-center',
'rounded-full border border-zinc-300 bg-white px-1 text-[10px] font-semibold text-zinc-600',
'shadow-sm transition-colors hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200',
)}
aria-label={
node.collapsed
? `Deplier la branche ${label} (${hiddenCount} elements masques)`
: `Replier la branche ${label}`
}
aria-expanded={!node.collapsed}
title={node.collapsed ? `${hiddenCount} elements masques` : 'Replier la branche'}
onClick={(event) => {
event.stopPropagation();
toggleCollapse(id);
}}
>
{node.collapsed ? hiddenCount : '-'}
</button>
) : null}
<Handle
type="source"
position={data.sourcePosition}
isConnectable={!readOnly}
aria-label="Point de connexion sortant"
/>
</div>
);
}
export const MindNodeView = memo(MindNodeViewComponent);

View File

@@ -0,0 +1,345 @@
import { useEffect, useState } from 'react';
import { type NodeShape, type TextAlign } from '@/types/map';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { CloseIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth } from '@/features/editor/themes';
import { selectSelectedNode, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
const SHAPES: Array<{ id: NodeShape; label: string }> = [
{ id: 'rectangle', label: 'Rectangle' },
{ id: 'rounded', label: 'Arrondi' },
{ id: 'capsule', label: 'Capsule' },
{ id: 'circle', label: 'Cercle' },
];
const ALIGNMENTS: Array<{ id: TextAlign; label: string }> = [
{ id: 'left', label: 'Gauche' },
{ id: 'center', label: 'Centre' },
{ id: 'right', label: 'Droite' },
];
const EMOJIS = ['', '💡', '✅', '📌', '⚠️', '🔒', '🌐', '🖥️', '📦', '📄', '🔗', '⭐', '🐳', '🗄️'];
/** Petit selecteur de couleur : palette native + valeur libre. */
function ColorField({
id,
label,
value,
fallback,
onChange,
}: {
id: string;
label: string;
value: string | undefined;
fallback: string;
onChange: (color: string | undefined) => void;
}) {
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={value ?? normaliseHex(fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{value ?? `theme (${fallback})`}
</span>
{value ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
function normaliseHex(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
}
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const node = useEditorStore(selectSelectedNode);
const selectedCount = useEditorStore((state) => state.selectedNodeIds.length);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const updateNodeStyle = useEditorStore((state) => state.updateNodeStyle);
const resetNodeStyle = useEditorStore((state) => state.resetNodeStyle);
const detachNode = useEditorStore((state) => state.detachNode);
const deleteNodes = useEditorStore((state) => state.deleteNodes);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
const [url, setUrl] = useState('');
const [note, setNote] = useState('');
useEffect(() => {
setText(node?.text ?? '');
setUrl(node?.url ?? '');
setNote(node?.note ?? '');
}, [node?.id, node?.text, node?.url, node?.note]);
const theme = getTheme(map?.settings.theme);
const depth = node && map ? (computeDepths(map.nodes).get(node.id) ?? 0) : 0;
const palette = paletteForDepth(theme, depth);
const style = node?.style ?? {};
const header = (
<header className="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">Proprietes</h2>
<IconButton
label="Masquer le panneau de proprietes"
icon={<CloseIcon />}
onClick={() => setPropertiesPanelOpen(false)}
/>
</header>
);
if (!node) {
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{selectedCount > 1
? `${selectedCount} noeuds selectionnes. Selectionnez un seul noeud pour modifier son apparence.`
: 'Selectionnez un noeud pour modifier son texte, ses couleurs, sa forme ou y ajouter une note.'}
</p>
</aside>
);
}
const commitUrl = () => {
const trimmed = url.trim();
if (!trimmed) {
updateNode(node.id, { url: undefined });
return;
}
const safe = sanitizeUrl(trimmed);
if (!safe) {
pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error');
setUrl(node.url ?? '');
return;
}
setUrl(safe);
updateNode(node.id, { url: safe });
};
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<div className="space-y-5 px-4 py-4">
<div>
<label className="field-label" htmlFor="node-text">
Texte
</label>
<textarea
id="node-text"
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== node.text && updateNodeText(node.id, text)}
/>
</div>
<div>
<span className="field-label">Icone</span>
<div className="flex flex-wrap gap-1">
{EMOJIS.map((emoji) => (
<button
key={emoji || 'none'}
type="button"
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
aria-pressed={(node.icon ?? '') === emoji}
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
(node.icon ?? '') === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji || '-'}
</button>
))}
</div>
</div>
<ColorField
id="node-bg"
label="Fond"
value={style.backgroundColor}
fallback={palette.background}
onChange={(color) => updateNodeStyle(node.id, { backgroundColor: color })}
/>
<ColorField
id="node-text-color"
label="Texte"
value={style.textColor}
fallback={palette.text}
onChange={(color) => updateNodeStyle(node.id, { textColor: color })}
/>
<ColorField
id="node-border"
label="Bordure"
value={style.borderColor}
fallback={palette.border}
onChange={(color) => updateNodeStyle(node.id, { borderColor: color })}
/>
<div>
<label className="field-label" htmlFor="node-shape">
Forme
</label>
<select
id="node-shape"
className="field-input"
value={style.shape ?? theme.nodeShape}
onChange={(event) => updateNodeStyle(node.id, { shape: event.target.value as NodeShape })}
>
{SHAPES.map((shape) => (
<option key={shape.id} value={shape.id}>
{shape.label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="node-size">
Taille du texte : {style.fontSize ?? (depth === 0 ? 16 : 14)} px
</label>
<input
id="node-size"
type="range"
min={10}
max={32}
step={1}
value={style.fontSize ?? (depth === 0 ? 16 : 14)}
onChange={(event) => updateNodeStyle(node.id, { fontSize: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={style.bold ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.bold)}
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
className="font-bold"
>
G
</Button>
<Button
size="sm"
variant={style.italic ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.italic)}
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
className="italic"
>
I
</Button>
<div className="ml-auto flex gap-1">
{ALIGNMENTS.map((alignment) => (
<Button
key={alignment.id}
size="sm"
variant={(style.textAlign ?? 'left') === alignment.id ? 'primary' : 'secondary'}
aria-pressed={(style.textAlign ?? 'left') === alignment.id}
aria-label={`Alignement ${alignment.label}`}
onClick={() => updateNodeStyle(node.id, { textAlign: alignment.id })}
>
{alignment.label.charAt(0)}
</Button>
))}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-url">
Lien
</label>
<div className="flex items-center gap-2">
<input
id="node-url"
type="url"
inputMode="url"
placeholder="https://exemple.fr"
className="field-input"
value={url}
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{node.url ? (
<a
href={node.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
aria-label="Ouvrir le lien dans un nouvel onglet"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LinkIcon />
</a>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-note">
Note
</label>
<textarea
id="node-note"
className="field-input min-h-[80px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note}
onChange={(event) => setNote(event.target.value)}
onBlur={() => note !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })}
/>
</div>
<div className="space-y-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<Button size="sm" className="w-full" onClick={() => resetNodeStyle(node.id)}>
Revenir au style du theme
</Button>
{node.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}>
Detacher de son parent
</Button>
) : null}
<Button
size="sm"
variant="danger"
className="w-full"
onClick={() => deleteNodes([node.id])}
>
<TrashIcon className="h-4 w-4" />
Supprimer ce noeud
</Button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,99 @@
import { forwardRef } from 'react';
import { IconButton } from '@/components/IconButton';
import { ChevronLeftIcon, ChevronRightIcon, CloseIcon, SearchIcon } from '@/components/Icons';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
/** Recherche dans la carte : navigation entre resultats et centrage du canvas. */
export const SearchPanel = forwardRef<HTMLInputElement>(function SearchPanel(_props, ref) {
const query = useEditorStore((state) => state.searchQuery);
const results = useEditorStore((state) => state.searchResults);
const index = useEditorStore((state) => state.searchIndex);
const setSearchQuery = useEditorStore((state) => state.setSearchQuery);
const nextResult = useEditorStore((state) => state.nextSearchResult);
const previousResult = useEditorStore((state) => state.previousSearchResult);
const map = useEditorStore((state) => state.map);
const gotoSearchResult = useEditorStore((state) => state.gotoSearchResult);
const setSearchPanelOpen = useUiStore((state) => state.setSearchPanelOpen);
const close = () => {
setSearchQuery('');
setSearchPanelOpen(false);
};
const labelFor = (nodeId: string) =>
map?.nodes.find((node) => node.id === nodeId)?.text || 'Sans titre';
return (
<div
className="no-print panel absolute right-4 top-4 z-20 w-80 animate-fade-in p-3"
role="search"
aria-label="Rechercher dans la carte"
>
<div className="flex items-center gap-2">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
ref={ref}
type="search"
value={query}
placeholder="Rechercher un noeud..."
aria-label="Texte a rechercher"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) previousResult();
else nextResult();
}
if (event.key === 'Escape') {
event.preventDefault();
close();
}
}}
/>
<span className="shrink-0 text-xs tabular-nums text-zinc-400">
{results.length > 0 ? `${index + 1}/${results.length}` : query ? '0' : ''}
</span>
<IconButton
label="Resultat precedent"
icon={<ChevronLeftIcon className="h-4 w-4" />}
disabled={results.length === 0}
onClick={previousResult}
/>
<IconButton
label="Resultat suivant"
icon={<ChevronRightIcon className="h-4 w-4" />}
disabled={results.length === 0}
onClick={nextResult}
/>
<IconButton label="Fermer la recherche" icon={<CloseIcon />} onClick={close} />
</div>
{query && results.length === 0 ? (
<p className="mt-2 px-1 text-xs text-zinc-500">Aucun noeud ne correspond.</p>
) : null}
{results.length > 0 ? (
<ul className="mt-2 max-h-52 overflow-y-auto" aria-label="Resultats de recherche">
{results.map((nodeId, position) => (
<li key={nodeId}>
<button
type="button"
onClick={() => gotoSearchResult(position)}
aria-current={position === index}
className={`w-full truncate rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
position === index
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800'
}`}
>
{labelFor(nodeId)}
</button>
</li>
))}
</ul>
) : null}
</div>
);
});

View File

@@ -0,0 +1,182 @@
import { type Edge, type Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
import { type BranchStyle, type MindMap, type MindMapNode, type NodeShape } from '@/types/map';
import { type MapTheme, paletteForDepth } from '@/features/editor/themes';
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { buildChildrenMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
import { type NodeSize } from '@/utils/nodeSize';
/** Style CSS resolu d'un noeud : theme + personnalisations explicites. */
export interface NodeAppearance {
background: string;
color: string;
borderColor: string;
borderWidth: number;
borderRadius: string;
fontSize: number;
fontFamily: string;
fontWeight: number;
fontStyle: 'normal' | 'italic';
textAlign: 'left' | 'center' | 'right';
boxShadow: string;
width?: number;
circle: boolean;
}
export interface MindNodeData extends Record<string, unknown> {
node: MindMapNode;
depth: number;
appearance: NodeAppearance;
childCount: number;
hiddenCount: number;
isSearchHit: boolean;
readOnly: boolean;
isEditing: boolean;
sourcePosition: Position;
targetPosition: Position;
}
export type MindFlowNode = Node<MindNodeData, 'mind'>;
function radiusForShape(shape: NodeShape, themeRadius: number): string {
switch (shape) {
case 'rectangle':
return '3px';
case 'capsule':
return '999px';
case 'circle':
return '50%';
case 'rounded':
default:
return `${themeRadius}px`;
}
}
/**
* Fusionne le theme et les personnalisations du noeud.
* Une valeur definie explicitement sur le noeud gagne toujours : changer de
* theme ne detruit donc jamais une personnalisation.
*/
export function resolveNodeAppearance(
node: MindMapNode,
depth: number,
theme: MapTheme,
): NodeAppearance {
const palette = paletteForDepth(theme, depth);
const style = node.style ?? {};
const shape = style.shape ?? theme.nodeShape;
return {
background: style.backgroundColor ?? palette.background,
color: style.textColor ?? palette.text,
borderColor: style.borderColor ?? palette.border,
borderWidth: theme.borderWidth,
borderRadius: radiusForShape(shape, theme.nodeRadius),
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
fontFamily: theme.fontFamily,
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
fontStyle: style.italic ? 'italic' : 'normal',
textAlign: style.textAlign ?? 'left',
boxShadow: theme.shadow,
...(style.width ? { width: style.width } : {}),
circle: shape === 'circle',
};
}
function handlePositions(layout: MindMap['settings']['layout']): {
source: Position;
target: Position;
} {
if (!isHorizontalLayout(layout)) {
return { source: Position.Bottom, target: Position.Top };
}
return layoutDirection(layout) === -1
? { source: Position.Left, target: Position.Right }
: { source: Position.Right, target: Position.Left };
}
const EDGE_TYPE_BY_BRANCH: Record<BranchStyle, string> = {
bezier: 'default',
curved: 'smoothstep',
straight: 'straight',
};
export interface AdapterOptions {
theme: MapTheme;
selectedIds: string[];
searchResults: string[];
editingNodeId: string | null;
readOnly: boolean;
/** Tailles reelles mesurees dans le DOM, requises par la minimap. */
measuredSizes?: Map<string, NodeSize>;
}
export function toFlowNodes(map: MindMap, options: AdapterOptions): MindFlowNode[] {
const depths = computeDepths(map.nodes);
const hidden = getHiddenNodeIds(map.nodes);
const children = buildChildrenMap(map.nodes);
const selected = new Set(options.selectedIds);
const searchHits = new Set(options.searchResults);
const positions = handlePositions(map.settings.layout);
return map.nodes.map((node) => {
const depth = depths.get(node.id) ?? 0;
const childIds = children.get(node.id) ?? [];
const measured = options.measuredSizes?.get(node.id);
return {
id: node.id,
type: 'mind' as const,
position: node.position,
...(measured ? { measured } : {}),
selected: selected.has(node.id),
hidden: hidden.has(node.id),
draggable: !options.readOnly,
selectable: true,
connectable: !options.readOnly,
deletable: !options.readOnly,
data: {
node,
depth,
appearance: resolveNodeAppearance(node, depth, options.theme),
childCount: childIds.length,
hiddenCount: node.collapsed ? countBranch(children, node.id) : 0,
isSearchHit: searchHits.has(node.id),
readOnly: options.readOnly,
isEditing: options.editingNodeId === node.id,
sourcePosition: positions.source,
targetPosition: positions.target,
},
};
});
}
function countBranch(children: Map<string, string[]>, rootId: string): number {
let total = 0;
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
total += 1;
const next = children.get(current);
if (next) queue.push(...next);
}
return total;
}
export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
const hidden = getHiddenNodeIds(map.nodes);
const edgeType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
return map.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edgeType,
hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly,
style: {
stroke: edge.color ?? options.theme.branchColor,
strokeWidth: options.theme.branchWidth,
},
}));
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { createSampleMap } from '@/services/mapFactory';
describe('auto-layout', () => {
it('positionne tous les noeuds', () => {
const map = createSampleMap();
const positions = computeLayout(map.nodes, 'mindmap-right');
expect(positions.size).toBe(map.nodes.length);
for (const node of map.nodes) {
const position = positions.get(node.id);
expect(Number.isFinite(position?.x)).toBe(true);
expect(Number.isFinite(position?.y)).toBe(true);
}
});
it('deploie vers la droite ou vers la gauche selon la disposition', () => {
const map = createSampleMap();
const root = map.nodes[0] as { id: string };
const child = map.nodes[1] as { id: string };
const right = computeLayout(map.nodes, 'mindmap-right');
const left = computeLayout(map.nodes, 'mindmap-left');
expect((right.get(child.id)?.x ?? 0) > (right.get(root.id)?.x ?? 0)).toBe(true);
expect((left.get(child.id)?.x ?? 0) < (left.get(root.id)?.x ?? 0)).toBe(true);
});
it('empile verticalement en mode arbre vertical', () => {
const map = createSampleMap();
const root = map.nodes[0] as { id: string };
const child = map.nodes[1] as { id: string };
const vertical = computeLayout(map.nodes, 'tree-vertical');
expect((vertical.get(child.id)?.y ?? 0) > (vertical.get(root.id)?.y ?? 0)).toBe(true);
});
it('expose l orientation de chaque disposition', () => {
expect(isHorizontalLayout('mindmap-right')).toBe(true);
expect(isHorizontalLayout('tree-vertical')).toBe(false);
expect(layoutDirection('mindmap-left')).toBe(-1);
expect(layoutDirection('mindmap-right')).toBe(1);
});
it('ne plante pas sur une carte vide', () => {
expect(computeLayout([], 'mindmap-right').size).toBe(0);
});
});

View File

@@ -0,0 +1,121 @@
import dagre from 'dagre';
import { type LayoutKind, type MindMapNode, type Position } from '@/types/map';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
export interface LayoutOption {
id: LayoutKind;
label: string;
description: string;
}
/**
* Dispositions proposees dans l'interface de la V1.
* `mindmap-both`, `organization` et `freeform` existent dans le modele mais ne
* sont pas encore implementees : elles restent disponibles pour une V2.
*/
export const LAYOUT_OPTIONS: LayoutOption[] = [
{
id: 'mindmap-right',
label: 'Mind map droite',
description: 'Les branches se deploient vers la droite.',
},
{
id: 'mindmap-left',
label: 'Mind map gauche',
description: 'Les branches se deploient vers la gauche.',
},
{
id: 'tree-horizontal',
label: 'Arbre horizontal',
description: 'Hierarchie stricte, de gauche a droite.',
},
{
id: 'tree-vertical',
label: 'Arbre vertical',
description: 'Hierarchie stricte, de haut en bas.',
},
];
interface DagreParams {
rankdir: 'LR' | 'RL' | 'TB';
ranksep: number;
nodesep: number;
}
const LAYOUT_PARAMS: Record<string, DagreParams> = {
'mindmap-right': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
'mindmap-left': { rankdir: 'RL', ranksep: 90, nodesep: 22 },
'tree-horizontal': { rankdir: 'LR', ranksep: 130, nodesep: 44 },
'tree-vertical': { rankdir: 'TB', ranksep: 90, nodesep: 48 },
// Replis pour les dispositions reservees.
'mindmap-both': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
organization: { rankdir: 'TB', ranksep: 90, nodesep: 48 },
freeform: { rankdir: 'LR', ranksep: 90, nodesep: 22 },
};
/** Orientation d'un layout : sert a placer les poignees de connexion. */
export function isHorizontalLayout(layout: LayoutKind): boolean {
return LAYOUT_PARAMS[layout]?.rankdir !== 'TB';
}
/** Sens de deploiement : -1 vers la gauche/le haut, 1 vers la droite/le bas. */
export function layoutDirection(layout: LayoutKind): 1 | -1 {
return LAYOUT_PARAMS[layout]?.rankdir === 'RL' ? -1 : 1;
}
/**
* Calcule les positions via dagre.
*
* Le layout n'est jamais applique automatiquement : il est declenche seulement
* par le bouton "Organisation automatique", ce qui preserve les deplacements
* manuels de l'utilisateur.
*/
export function computeLayout(
nodes: MindMapNode[],
layout: LayoutKind,
measuredSizes?: Map<string, NodeSize>,
): Map<string, Position> {
const positions = new Map<string, Position>();
if (nodes.length === 0) return positions;
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
const graph = new dagre.graphlib.Graph();
graph.setGraph({
rankdir: params?.rankdir ?? 'LR',
ranksep: params?.ranksep ?? 90,
nodesep: params?.nodesep ?? 22,
marginx: 40,
marginy: 40,
});
graph.setDefaultEdgeLabel(() => ({}));
const known = new Set(nodes.map((node) => node.id));
const sizes = new Map<string, NodeSize>();
for (const node of nodes) {
const size = resolveNodeSize(node, measuredSizes);
sizes.set(node.id, size);
graph.setNode(node.id, { width: size.width, height: size.height });
}
for (const node of nodes) {
if (node.parentId && known.has(node.parentId)) {
graph.setEdge(node.parentId, node.id);
}
}
dagre.layout(graph);
for (const node of nodes) {
const laidOut = graph.node(node.id) as { x?: number; y?: number } | undefined;
if (!laidOut || laidOut.x === undefined || laidOut.y === undefined) continue;
const size = sizes.get(node.id) as NodeSize;
// dagre renvoie le centre du noeud ; React Flow attend le coin haut-gauche.
positions.set(node.id, {
x: Math.round(laidOut.x - size.width / 2),
y: Math.round(laidOut.y - size.height / 2),
});
}
return positions;
}

View File

@@ -0,0 +1,235 @@
import { type NodeShape } from '@/types/map';
export interface NodePalette {
background: string;
text: string;
border: string;
}
/**
* Un theme decrit l'apparence par defaut d'une carte.
* Il ne remplace jamais une personnalisation explicite d'un noeud : la fusion
* est faite dans `flowAdapters.resolveNodeAppearance`.
*/
export interface MapTheme {
id: string;
name: string;
isDark: boolean;
canvasBackground: string;
gridColor: string;
fontFamily: string;
nodeRadius: number;
nodeShape: NodeShape;
borderWidth: number;
shadow: string;
branchColor: string;
branchWidth: number;
root: NodePalette;
levels: NodePalette[];
}
const SANS = "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif";
const MONO = "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace";
const SOFT_SHADOW = '0 1px 2px rgba(15,23,42,0.06), 0 6px 16px -10px rgba(15,23,42,0.35)';
const FLAT_SHADOW = 'none';
const GLOW_SHADOW = '0 0 0 1px rgba(148,163,184,0.10), 0 8px 24px -12px rgba(56,189,248,0.55)';
export const THEMES: MapTheme[] = [
{
id: 'default',
name: 'Default',
isDark: false,
canvasBackground: '#f8fafc',
gridColor: '#cbd5e1',
fontFamily: SANS,
nodeRadius: 10,
nodeShape: 'rounded',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#94a3b8',
branchWidth: 2,
root: { background: '#4f46e5', text: '#ffffff', border: '#4338ca' },
levels: [
{ background: '#eef2ff', text: '#3730a3', border: '#c7d2fe' },
{ background: '#ecfeff', text: '#155e75', border: '#a5f3fc' },
{ background: '#f0fdf4', text: '#166534', border: '#bbf7d0' },
{ background: '#fff7ed', text: '#9a3412', border: '#fed7aa' },
{ background: '#fdf4ff', text: '#86198f', border: '#f5d0fe' },
],
},
{
id: 'minimal',
name: 'Minimal',
isDark: false,
canvasBackground: '#ffffff',
gridColor: '#e5e7eb',
fontFamily: SANS,
nodeRadius: 6,
nodeShape: 'rounded',
borderWidth: 1,
shadow: FLAT_SHADOW,
branchColor: '#9ca3af',
branchWidth: 1.5,
root: { background: '#ffffff', text: '#111827', border: '#111827' },
levels: [
{ background: '#ffffff', text: '#374151', border: '#9ca3af' },
{ background: '#ffffff', text: '#4b5563', border: '#d1d5db' },
{ background: '#fafafa', text: '#4b5563', border: '#e5e7eb' },
],
},
{
id: 'dark',
name: 'Dark',
isDark: true,
canvasBackground: '#111827',
gridColor: '#374151',
fontFamily: SANS,
nodeRadius: 10,
nodeShape: 'rounded',
borderWidth: 1,
shadow: '0 8px 24px -14px rgba(0,0,0,0.9)',
branchColor: '#64748b',
branchWidth: 2,
root: { background: '#6366f1', text: '#ffffff', border: '#818cf8' },
levels: [
{ background: '#1f2937', text: '#e5e7eb', border: '#4b5563' },
{ background: '#1e293b', text: '#cbd5e1', border: '#475569' },
{ background: '#172033', text: '#cbd5e1', border: '#334155' },
],
},
{
id: 'midnight',
name: 'Midnight',
isDark: true,
canvasBackground: '#0b1020',
gridColor: '#1e293b',
fontFamily: SANS,
nodeRadius: 12,
nodeShape: 'rounded',
borderWidth: 1,
shadow: GLOW_SHADOW,
branchColor: '#38bdf8',
branchWidth: 2,
root: { background: '#7c3aed', text: '#ffffff', border: '#a78bfa' },
levels: [
{ background: '#111a33', text: '#c4b5fd', border: '#4c1d95' },
{ background: '#0f2233', text: '#7dd3fc', border: '#0c4a6e' },
{ background: '#0f2a2a', text: '#5eead4', border: '#115e59' },
],
},
{
id: 'ocean',
name: 'Ocean',
isDark: false,
canvasBackground: '#f0f9ff',
gridColor: '#bae6fd',
fontFamily: SANS,
nodeRadius: 14,
nodeShape: 'rounded',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#38bdf8',
branchWidth: 2,
root: { background: '#0369a1', text: '#ffffff', border: '#0284c7' },
levels: [
{ background: '#e0f2fe', text: '#075985', border: '#7dd3fc' },
{ background: '#ccfbf1', text: '#115e59', border: '#5eead4' },
{ background: '#dbeafe', text: '#1e40af', border: '#93c5fd' },
],
},
{
id: 'forest',
name: 'Forest',
isDark: false,
canvasBackground: '#f6faf6',
gridColor: '#c7e0c7',
fontFamily: SANS,
nodeRadius: 10,
nodeShape: 'rounded',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#84a98c',
branchWidth: 2,
root: { background: '#166534', text: '#ffffff', border: '#14532d' },
levels: [
{ background: '#dcfce7', text: '#14532d', border: '#86efac' },
{ background: '#ecfccb', text: '#3f6212', border: '#bef264' },
{ background: '#fef3c7', text: '#78350f', border: '#fcd34d' },
],
},
{
id: 'pastel',
name: 'Pastel',
isDark: false,
canvasBackground: '#fffdf9',
gridColor: '#f1e7dc',
fontFamily: SANS,
nodeRadius: 18,
nodeShape: 'capsule',
borderWidth: 1,
shadow: SOFT_SHADOW,
branchColor: '#d6bcfa',
branchWidth: 2,
root: { background: '#c4b5fd', text: '#312e81', border: '#a78bfa' },
levels: [
{ background: '#fde2e4', text: '#9d174d', border: '#fbcfe8' },
{ background: '#dff5ea', text: '#065f46', border: '#a7f3d0' },
{ background: '#e0e7ff', text: '#3730a3', border: '#c7d2fe' },
{ background: '#fff1cc', text: '#92400e', border: '#fde68a' },
],
},
{
id: 'monochrome',
name: 'Monochrome',
isDark: false,
canvasBackground: '#fafafa',
gridColor: '#e4e4e7',
fontFamily: SANS,
nodeRadius: 4,
nodeShape: 'rectangle',
borderWidth: 1,
shadow: FLAT_SHADOW,
branchColor: '#71717a',
branchWidth: 1.5,
root: { background: '#18181b', text: '#ffffff', border: '#18181b' },
levels: [
{ background: '#ffffff', text: '#27272a', border: '#52525b' },
{ background: '#f4f4f5', text: '#3f3f46', border: '#a1a1aa' },
{ background: '#e4e4e7', text: '#3f3f46', border: '#d4d4d8' },
],
},
{
id: 'blueprint',
name: 'Blueprint',
isDark: true,
canvasBackground: '#0d2b45',
gridColor: '#1d4666',
fontFamily: MONO,
nodeRadius: 2,
nodeShape: 'rectangle',
borderWidth: 1.5,
shadow: FLAT_SHADOW,
branchColor: '#7fb3d5',
branchWidth: 1.5,
root: { background: '#0d2b45', text: '#e8f4fd', border: '#e8f4fd' },
levels: [
{ background: 'rgba(232,244,253,0.06)', text: '#dbeafe', border: '#93c5fd' },
{ background: 'rgba(232,244,253,0.04)', text: '#bfdbfe', border: '#60a5fa' },
{ background: 'rgba(232,244,253,0.02)', text: '#bfdbfe', border: '#3b82f6' },
],
},
];
export const DEFAULT_THEME_ID = 'default';
export function getTheme(themeId: string | undefined): MapTheme {
return THEMES.find((theme) => theme.id === themeId) ?? (THEMES[0] as MapTheme);
}
/** Palette appliquee a un noeud selon sa profondeur (cycle si besoin). */
export function paletteForDepth(theme: MapTheme, depth: number): NodePalette {
if (depth <= 0) return theme.root;
const index = (depth - 1) % theme.levels.length;
return theme.levels[index] as NodePalette;
}

View File

@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef } from 'react';
import { APP_CONFIG } from '@/config/app';
import { saveMap } from '@/services/mapRepository';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { useDebouncedEffect } from '@/hooks/useDebouncedEffect';
/**
* Sauvegarde automatique dans IndexedDB.
*
* Volontairement independante de l'historique undo/redo : on ecrit apres une
* courte periode d'inactivite, sans jamais recharger l'application.
*/
export function useAutosave(enabled: boolean): { saveNow: () => Promise<void> } {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const skipFirstRun = useRef(true);
const latestMap = useRef(map);
latestMap.current = map;
const persist = useCallback(async () => {
const current = latestMap.current;
if (!current || !enabled) return;
const store = useEditorStore.getState();
store.setSaveStatus('saving');
try {
await saveMap(current);
store.markSaved();
} catch {
store.setSaveStatus('error');
pushToast(
"La sauvegarde locale a echoue. Exportez votre carte pour ne rien perdre.",
'error',
);
}
}, [enabled, pushToast]);
useDebouncedEffect(
() => {
if (!enabled) return;
if (skipFirstRun.current) {
skipFirstRun.current = false;
return;
}
void persist();
},
[map, enabled],
APP_CONFIG.autosaveDelayMs,
);
// Sauvegarde de securite en quittant l'editeur ou l'onglet.
useEffect(() => {
if (!enabled) return undefined;
const onHide = () => {
if (document.visibilityState === 'hidden') void persist();
};
document.addEventListener('visibilitychange', onHide);
return () => {
document.removeEventListener('visibilitychange', onHide);
void persist();
};
}, [enabled, persist]);
return { saveNow: persist };
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useRef } from 'react';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
/** Vrai si l'utilisateur est en train de saisir du texte. */
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true
);
}
export interface ShortcutHandlers {
onSave: () => void;
onFit: () => void;
onFocusSearch: () => void;
}
/**
* Raccourcis clavier de l'editeur.
* Ctrl et Cmd (macOS) sont traites de la meme maniere.
*/
export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers): void {
// Les handlers changent a chaque rendu : on les lit via une ref pour ne
// reinscrire l'ecouteur qu'une seule fois.
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
if (!enabled) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
const store = useEditorStore.getState();
const typing = isTypingTarget(event.target) || store.editingNodeId !== null;
const modifier = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (modifier && key === 's') {
event.preventDefault();
handlersRef.current.onSave();
return;
}
if (modifier && key === 'f') {
event.preventDefault();
handlersRef.current.onFocusSearch();
return;
}
if (typing) return;
if (modifier) {
switch (key) {
case 'z':
event.preventDefault();
if (event.shiftKey) store.redo();
else store.undo();
return;
case 'y':
event.preventDefault();
store.redo();
return;
case 'c':
if (store.copySelection() > 0) {
useUiStore.getState().pushToast('Selection copiee.', 'info');
}
return;
case 'v':
event.preventDefault();
store.paste();
return;
case 'd':
event.preventDefault();
store.duplicateSelection();
return;
case 'a':
event.preventDefault();
store.setSelection(store.map?.nodes.map((node) => node.id) ?? []);
return;
default:
return;
}
}
switch (event.key) {
case 'Tab':
event.preventDefault();
store.addChild();
break;
case 'Enter':
event.preventDefault();
store.addSibling();
break;
case 'Delete':
case 'Backspace':
if (store.selectedNodeIds.length > 0) {
event.preventDefault();
store.deleteNodes(store.selectedNodeIds);
}
break;
case 'f':
case 'F':
event.preventDefault();
handlersRef.current.onFit();
break;
case 'F2':
if (store.selectedNodeIds[0]) {
event.preventDefault();
store.setEditingNode(store.selectedNodeIds[0]);
}
break;
case 'Escape':
store.setEditingNode(null);
store.setSelection([]);
break;
default:
break;
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}

View File

@@ -0,0 +1,149 @@
import { useState } from 'react';
import { Menu, MenuItem, MenuLabel, MenuSeparator } from '@/components/Menu';
import { Button } from '@/components/Button';
import { ChevronDownIcon, DownloadIcon, PrintIcon } from '@/components/Icons';
import { APP_CONFIG } from '@/config/app';
import {
ExportError,
dataUrlToBlob,
renderMapToPng,
renderMapToSvg,
} from '@/features/export/exportImage';
import { PdfExportDialog } from '@/features/export/PdfExportDialog';
import { PrintDialog } from '@/features/export/PrintDialog';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { serializeMap } from '@/services/mapSerialization';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ExportMenuProps {
/** La vue publique n'expose pas l'export .mmap natif. */
allowNativeExport?: boolean;
}
export function ExportMenu({ allowNativeExport = true }: ExportMenuProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const [pdfOpen, setPdfOpen] = useState(false);
const [printOpen, setPrintOpen] = useState(false);
const [busy, setBusy] = useState(false);
const exportNative = () => {
if (!map) return;
const blob = new Blob([serializeMap(map)], {
type: `${APP_CONFIG.fileMimeType};charset=utf-8`,
});
downloadBlob(blob, toSafeFilename(map.name, APP_CONFIG.fileExtension));
pushToast('Carte exportee.', 'success');
};
const exportImage = async (format: 'png' | 'svg', pixelRatio: number, transparent: boolean) => {
if (!map || busy) return;
setBusy(true);
try {
const sizes = useEditorStore.getState().measuredSizes;
const dataUrl =
format === 'png'
? await renderMapToPng({ map, sizes, pixelRatio, transparent })
: await renderMapToSvg({ map, sizes, transparent });
const blob = dataUrlToBlob(dataUrl);
downloadBlob(blob, toSafeFilename(map.name, format === 'png' ? '.png' : '.svg'));
pushToast(`Export ${format.toUpperCase()} termine.`, 'success');
} catch (error) {
pushToast(
error instanceof ExportError ? error.message : "L'export n'a pas pu aboutir.",
'error',
);
} finally {
setBusy(false);
}
};
return (
<>
<Menu
label="Menu d'export"
align="right"
disabled={!map}
trigger={
<Button size="sm" disabled={!map}>
<DownloadIcon className="h-4 w-4" />
Exporter
<ChevronDownIcon className="h-3.5 w-3.5 opacity-60" />
</Button>
}
>
{(close) => (
<>
{allowNativeExport ? (
<>
<MenuLabel>Format natif</MenuLabel>
<MenuItem
label={`Exporter ${APP_CONFIG.fileExtension}`}
hint="JSON"
onSelect={() => {
exportNative();
close();
}}
/>
<MenuSeparator />
</>
) : null}
<MenuLabel>Image</MenuLabel>
<MenuItem
label="PNG - resolution normale"
onSelect={() => {
void exportImage('png', 1, false);
close();
}}
/>
<MenuItem
label="PNG - resolution x2"
onSelect={() => {
void exportImage('png', 2, false);
close();
}}
/>
<MenuItem
label="PNG - fond transparent"
onSelect={() => {
void exportImage('png', 2, true);
close();
}}
/>
<MenuItem
label="SVG (vectoriel)"
onSelect={() => {
void exportImage('svg', 1, false);
close();
}}
/>
<MenuSeparator />
<MenuLabel>Document</MenuLabel>
<MenuItem
label="PDF..."
onSelect={() => {
setPdfOpen(true);
close();
}}
/>
<MenuItem
icon={<PrintIcon className="h-4 w-4" />}
label="Imprimer..."
onSelect={() => {
setPrintOpen(true);
close();
}}
/>
</>
)}
</Menu>
<PdfExportDialog open={pdfOpen} onClose={() => setPdfOpen(false)} />
<PrintDialog open={printOpen} onClose={() => setPrintOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SpinnerIcon } from '@/components/Icons';
import {
DEFAULT_PDF_OPTIONS,
renderMapToPdf,
type PdfExportOptions,
} from '@/features/export/exportPdf';
import { ExportError } from '@/features/export/exportImage';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface PdfExportDialogProps {
open: boolean;
onClose: () => void;
}
const SEGMENT_CLASS =
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors border border-transparent';
export function PdfExportDialog({ open, onClose }: PdfExportDialogProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const [options, setOptions] = useState<PdfExportOptions>(DEFAULT_PDF_OPTIONS);
const [busy, setBusy] = useState(false);
const update = (patch: Partial<PdfExportOptions>) =>
setOptions((current) => ({ ...current, ...patch }));
const handleExport = async () => {
if (!map) return;
setBusy(true);
try {
const result = await renderMapToPdf(map, options, useEditorStore.getState().measuredSizes);
downloadBlob(result.blob, toSafeFilename(map.name, '.pdf'));
if (result.downscaled) {
pushToast(
'La carte etait plus grande que la page : elle a ete reduite pour tenir entierement.',
'info',
);
} else {
pushToast('PDF genere.', 'success');
}
onClose();
} catch (error) {
pushToast(
error instanceof ExportError ? error.message : "Le PDF n'a pas pu etre genere.",
'error',
);
} finally {
setBusy(false);
}
};
const segment = (active: boolean) =>
`${SEGMENT_CLASS} ${
active
? 'bg-white text-indigo-700 shadow-sm border-zinc-200 dark:bg-zinc-800 dark:text-indigo-300 dark:border-zinc-700'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
}`;
return (
<Modal
open={open}
title="Exporter en PDF"
description={`La carte "${map?.name ?? ''}" sera exportee entierement, centree dans la page.`}
onClose={onClose}
footer={
<>
<Button onClick={onClose} disabled={busy}>
Annuler
</Button>
<Button variant="primary" onClick={handleExport} disabled={busy || !map}>
{busy ? <SpinnerIcon className="h-4 w-4" /> : null}
{busy ? 'Generation...' : 'Exporter le PDF'}
</Button>
</>
}
>
<div className="space-y-5">
<fieldset>
<legend className="field-label">Papier</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(['a4', 'a3'] as const).map((paper) => (
<button
key={paper}
type="button"
className={segment(options.paper === paper)}
onClick={() => update({ paper })}
aria-pressed={options.paper === paper}
>
{paper.toUpperCase()}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="field-label">Orientation</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(
[
['portrait', 'Portrait'],
['landscape', 'Paysage'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={segment(options.orientation === value)}
onClick={() => update({ orientation: value })}
aria-pressed={options.orientation === value}
>
{label}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="field-label">Echelle</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(
[
['fit', 'Adapter a une page'],
['actual', 'Taille reelle'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={segment(options.scaleMode === value)}
onClick={() => update({ scaleMode: value })}
aria-pressed={options.scaleMode === value}
>
{label}
</button>
))}
</div>
</fieldset>
<div>
<label className="field-label" htmlFor="pdf-margin">
Marges : {options.marginMm} mm
</label>
<input
id="pdf-margin"
type="range"
min={0}
max={30}
step={1}
value={options.marginMm}
onChange={(event) => update({ marginMm: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<fieldset className="space-y-2">
<legend className="field-label">Afficher</legend>
{(
[
['showBackground', 'Le fond de la carte'],
['showTitle', 'Le titre'],
['showDate', 'La date de derniere modification'],
] as const
).map(([key, label]) => (
<label key={key} className="flex items-center gap-2.5 text-sm">
<input
type="checkbox"
checked={options[key]}
onChange={(event) => update({ [key]: event.target.checked })}
className="h-4 w-4 rounded border-zinc-300 accent-indigo-600"
/>
<span className="text-zinc-700 dark:text-zinc-200">{label}</span>
</label>
))}
</fieldset>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le PDF est genere entierement dans votre navigateur : aucune donnee n&apos;est envoyee au
serveur.
</p>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import {
DEFAULT_PRINT_OPTIONS,
printMap,
type PrintOptions,
} from '@/features/export/printMap';
import { useEditorStore } from '@/stores/editorStore';
export interface PrintDialogProps {
open: boolean;
onClose: () => void;
}
const SEGMENT_CLASS = 'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors';
export function PrintDialog({ open, onClose }: PrintDialogProps) {
const requestFit = useEditorStore((state) => state.requestFit);
const [options, setOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
const segment = (active: boolean) =>
`${SEGMENT_CLASS} ${
active
? 'bg-white text-indigo-700 shadow-sm dark:bg-zinc-800 dark:text-indigo-300'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
}`;
const handlePrint = () => {
// On recadre la carte avant d'ouvrir l'apercu pour qu'elle tienne dans la page.
requestFit();
onClose();
window.setTimeout(() => printMap(options), 420);
};
return (
<Modal
open={open}
title="Imprimer la carte"
description="L'interface est masquee a l'impression : seule la carte est envoyee a l'imprimante."
onClose={onClose}
size="sm"
footer={
<>
<Button onClick={onClose}>Annuler</Button>
<Button variant="primary" onClick={handlePrint}>
Ouvrir l&apos;apercu
</Button>
</>
}
>
<div className="space-y-5">
<fieldset>
<legend className="field-label">Papier</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(['a4', 'a3'] as const).map((paper) => (
<button
key={paper}
type="button"
className={segment(options.paper === paper)}
onClick={() => setOptions((current) => ({ ...current, paper }))}
aria-pressed={options.paper === paper}
>
{paper.toUpperCase()}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="field-label">Orientation</legend>
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60">
{(
[
['portrait', 'Portrait'],
['landscape', 'Paysage'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={segment(options.orientation === value)}
onClick={() => setOptions((current) => ({ ...current, orientation: value }))}
aria-pressed={options.orientation === value}
>
{label}
</button>
))}
</div>
</fieldset>
<div>
<label className="field-label" htmlFor="print-margin">
Marges : {options.marginMm} mm
</label>
<input
id="print-margin"
type="range"
min={0}
max={25}
step={1}
value={options.marginMm}
onChange={(event) =>
setOptions((current) => ({ ...current, marginMm: Number(event.target.value) }))
}
className="w-full accent-indigo-600"
/>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,218 @@
import { toPng, toSvg } from 'html-to-image';
import { type MindMap } from '@/types/map';
import { getTheme } from '@/features/editor/themes';
import { getHiddenNodeIds } from '@/utils/tree';
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
/** Erreur d'export presentee telle quelle a l'utilisateur. */
export class ExportError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExportError';
}
}
export const EXPORT_PADDING = 48;
/** Garde-fou : au-dela, les navigateurs echouent a produire le canvas. */
const MAX_IMAGE_DIMENSION = 12000;
export interface MapBounds {
x: number;
y: number;
width: number;
height: number;
}
/** Rectangle englobant tous les noeuds visibles, en coordonnees du canvas. */
export function computeMapBounds(map: MindMap, sizes?: Map<string, NodeSize>): MapBounds {
const hidden = getHiddenNodeIds(map.nodes);
const visible = map.nodes.filter((node) => !hidden.has(node.id));
if (visible.length === 0) return { x: 0, y: 0, width: 400, height: 300 };
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of visible) {
const size = resolveNodeSize(node, sizes);
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + size.width);
maxY = Math.max(maxY, node.position.y + size.height);
}
return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
}
function findViewportElement(): HTMLElement {
const element = document.querySelector<HTMLElement>('.openmind-canvas .react-flow__viewport');
if (!element) {
throw new ExportError("La carte n'est pas encore affichee : impossible de l'exporter.");
}
return element;
}
function findCanvasElement(): HTMLElement | null {
return document.querySelector<HTMLElement>('.openmind-canvas');
}
/** Ne capture ni la minimap, ni les controles, ni l'attribution. */
function exportFilter(node: HTMLElement): boolean {
const className = typeof node.className === 'string' ? node.className : '';
return !(
className.includes('react-flow__minimap') ||
className.includes('react-flow__controls') ||
className.includes('react-flow__attribution') ||
className.includes('no-print')
);
}
export interface RenderOptions {
map: MindMap;
sizes?: Map<string, NodeSize>;
pixelRatio?: number;
transparent?: boolean;
backgroundColor?: string;
}
interface RenderContext {
element: HTMLElement;
width: number;
height: number;
options: Parameters<typeof toPng>[1];
}
function prepare(options: RenderOptions): RenderContext {
const element = findViewportElement();
const bounds = computeMapBounds(options.map, options.sizes);
const width = Math.min(MAX_IMAGE_DIMENSION, Math.ceil(bounds.width + EXPORT_PADDING * 2));
const height = Math.min(MAX_IMAGE_DIMENSION, Math.ceil(bounds.height + EXPORT_PADDING * 2));
const theme = getTheme(options.map.settings.theme);
const background = options.transparent
? undefined
: (options.backgroundColor ?? theme.canvasBackground);
return {
element,
width,
height,
options: {
width,
height,
pixelRatio: options.pixelRatio ?? 1,
backgroundColor: background,
cacheBust: true,
skipFonts: true,
filter: exportFilter as (node: HTMLElement) => boolean,
style: {
width: `${width}px`,
height: `${height}px`,
// Toute la carte est cadree, pas seulement la zone visible a l'ecran.
transform: `translate(${EXPORT_PADDING - bounds.x}px, ${EXPORT_PADDING - bounds.y}px) scale(1)`,
transformOrigin: '0 0',
},
},
};
}
/**
* Garde-fou : la bibliotheque de rendu attend une frame d'animation, qui n'est
* jamais produite si l'onglet passe en arriere-plan pendant la generation.
* Sans ce delai, l'export resterait bloque sans message.
*/
const EXPORT_TIMEOUT_MS = 45000;
function withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
window.setTimeout(
() =>
reject(
new ExportError(
"L'export a ete interrompu : il a pris trop de temps. " +
'Gardez cet onglet au premier plan pendant la generation.',
),
),
EXPORT_TIMEOUT_MS,
);
}),
]);
}
async function withExportMode<T>(run: () => Promise<T>): Promise<T> {
const canvas = findCanvasElement();
canvas?.classList.add('openmind-exporting');
try {
return await run();
} finally {
canvas?.classList.remove('openmind-exporting');
}
}
/** Rendu PNG de la carte complete. */
export async function renderMapToPng(options: RenderOptions): Promise<string> {
const context = prepare(options);
return withExportMode(async () => {
try {
return await withTimeout(toPng(context.element, context.options));
} catch (error) {
if (error instanceof ExportError) throw error;
throw new ExportError("L'image n'a pas pu etre generee. Reessayez.");
}
});
}
/**
* Rendu SVG de la carte complete.
*
* Limitation connue : les noeuds sont encapsules dans un `foreignObject`
* (HTML embarque). Le fichier reste vectoriel et redimensionnable sans perte,
* les liens sont de vrais chemins SVG, mais certains editeurs vectoriels
* n'exploitent pas le contenu HTML des noeuds.
*/
export async function renderMapToSvg(options: RenderOptions): Promise<string> {
const context = prepare(options);
return withExportMode(async () => {
try {
return await withTimeout(toSvg(context.element, context.options));
} catch (error) {
if (error instanceof ExportError) throw error;
throw new ExportError("Le SVG n'a pas pu etre genere. Reessayez.");
}
});
}
/**
* Convertit une data URL en Blob.
*
* La conversion est faite a la main plutot qu'avec `fetch(dataUrl)` : la CSP de
* l'application n'autorise que `connect-src 'self'`, ce qui bloquerait une
* requete vers une URL `data:`.
*/
export function dataUrlToBlob(dataUrl: string): Blob {
const separator = dataUrl.indexOf(',');
if (!dataUrl.startsWith('data:') || separator === -1) {
throw new ExportError("Le fichier genere n'a pas pu etre prepare.");
}
const header = dataUrl.slice(5, separator);
const payload = dataUrl.slice(separator + 1);
const isBase64 = header.endsWith(';base64');
const mimeType = (isBase64 ? header.slice(0, -';base64'.length) : header).split(';')[0] || 'application/octet-stream';
if (!isBase64) {
// Cas des SVG : le contenu est encode en pourcentage, pas en base64.
return new Blob([decodeURIComponent(payload)], { type: `${mimeType};charset=utf-8` });
}
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}

View File

@@ -0,0 +1,145 @@
import { type MindMap } from '@/types/map';
import { getTheme } from '@/features/editor/themes';
import { ExportError, renderMapToPng } from '@/features/export/exportImage';
import { type NodeSize } from '@/utils/nodeSize';
import { formatDate } from '@/utils/date';
export type PaperFormat = 'a4' | 'a3';
export type PageOrientation = 'portrait' | 'landscape';
export type PdfScaleMode = 'fit' | 'actual';
export interface PdfExportOptions {
paper: PaperFormat;
orientation: PageOrientation;
/** Marge en millimetres appliquee sur les quatre cotes. */
marginMm: number;
scaleMode: PdfScaleMode;
showBackground: boolean;
showTitle: boolean;
showDate: boolean;
}
export const DEFAULT_PDF_OPTIONS: PdfExportOptions = {
paper: 'a4',
orientation: 'landscape',
marginMm: 10,
scaleMode: 'fit',
showBackground: true,
showTitle: true,
showDate: true,
};
/** 96 dpi : conversion pixels CSS -> millimetres. */
const MM_PER_PX = 25.4 / 96;
export interface PdfExportResult {
blob: Blob;
/** Vrai si la carte a du etre reduite alors que "taille reelle" etait demande. */
downscaled: boolean;
}
export async function renderMapToPdf(
map: MindMap,
options: PdfExportOptions,
sizes?: Map<string, NodeSize>,
): Promise<PdfExportResult> {
const theme = getTheme(map.settings.theme);
const dataUrl = await renderMapToPng({
map,
sizes,
pixelRatio: 2,
transparent: false,
backgroundColor: options.showBackground ? theme.canvasBackground : '#ffffff',
});
const image = await loadImage(dataUrl);
// jsPDF est charge a la demande : il ne pese pas sur le premier affichage.
const { jsPDF } = await import('jspdf');
const document_ = new jsPDF({
orientation: options.orientation,
unit: 'mm',
format: options.paper,
compress: true,
});
const pageWidth = document_.internal.pageSize.getWidth();
const pageHeight = document_.internal.pageSize.getHeight();
const margin = Math.max(0, Math.min(40, options.marginMm));
let contentTop = margin;
let contentBottom = pageHeight - margin;
if (options.showTitle || options.showDate) {
document_.setFont('helvetica', 'normal');
if (options.showTitle) {
document_.setFontSize(13);
document_.setTextColor(30, 30, 40);
document_.text(map.name, margin, contentTop + 4, { maxWidth: pageWidth - margin * 2 - 40 });
}
if (options.showDate) {
document_.setFontSize(9);
document_.setTextColor(120, 120, 130);
document_.text(formatDate(map.updatedAt), pageWidth - margin, contentTop + 4, {
align: 'right',
});
}
contentTop += 10;
}
contentBottom -= 2;
const usableWidth = pageWidth - margin * 2;
const usableHeight = contentBottom - contentTop;
if (usableWidth <= 5 || usableHeight <= 5) {
throw new ExportError('Les marges choisies ne laissent pas assez de place pour la carte.');
}
// L'image est rendue en x2 : on revient d'abord a la taille CSS.
const cssWidth = image.width / 2;
const cssHeight = image.height / 2;
let widthMm: number;
let heightMm: number;
let downscaled = false;
if (options.scaleMode === 'actual') {
widthMm = cssWidth * MM_PER_PX;
heightMm = cssHeight * MM_PER_PX;
if (widthMm > usableWidth || heightMm > usableHeight) {
// On preserve la lisibilite plutot que de couper la carte.
const ratio = Math.min(usableWidth / widthMm, usableHeight / heightMm);
widthMm *= ratio;
heightMm *= ratio;
downscaled = true;
}
} else {
const ratio = Math.min(usableWidth / cssWidth, usableHeight / cssHeight);
widthMm = cssWidth * ratio;
heightMm = cssHeight * ratio;
}
// Centrage dans la zone utile.
const x = margin + (usableWidth - widthMm) / 2;
const y = contentTop + (usableHeight - heightMm) / 2;
if (options.showBackground) {
document_.setFillColor(theme.canvasBackground);
document_.rect(margin, contentTop, usableWidth, usableHeight, 'F');
}
document_.addImage(dataUrl, 'PNG', x, y, widthMm, heightMm, undefined, 'FAST');
return { blob: document_.output('blob'), downscaled };
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new ExportError("L'image de la carte n'a pas pu etre preparee."));
image.src = dataUrl;
});
}

View File

@@ -0,0 +1,39 @@
import { type PageOrientation, type PaperFormat } from '@/features/export/exportPdf';
export interface PrintOptions {
paper: PaperFormat;
orientation: PageOrientation;
marginMm: number;
}
export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
paper: 'a4',
orientation: 'landscape',
marginMm: 10,
};
const STYLE_ELEMENT_ID = 'openmind-print-page-rule';
/**
* Impression native du navigateur.
* La regle @page est injectee dynamiquement pour honorer le format et
* l'orientation choisis ; le CSS `@media print` masque toute l'interface.
*/
export function printMap(options: PrintOptions = DEFAULT_PRINT_OPTIONS): void {
const existing = document.getElementById(STYLE_ELEMENT_ID);
existing?.remove();
const style = document.createElement('style');
style.id = STYLE_ELEMENT_ID;
style.textContent = `@page { size: ${options.paper.toUpperCase()} ${options.orientation}; margin: ${options.marginMm}mm; }`;
document.head.appendChild(style);
const cleanup = () => {
document.getElementById(STYLE_ELEMENT_ID)?.remove();
window.removeEventListener('afterprint', cleanup);
};
window.addEventListener('afterprint', cleanup);
// Laisse le temps au navigateur d'appliquer la regle avant d'ouvrir l'apercu.
window.setTimeout(() => window.print(), 60);
}

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type MapSummary } from '@/types/map';
import { APP_CONFIG } from '@/config/app';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Modal } from '@/components/Modal';
import { ImportIcon, MoonIcon, PlusIcon, SearchIcon, SunIcon } from '@/components/Icons';
import { MapCard } from '@/features/maps/MapCard';
import {
createMap,
deleteMap,
duplicateMap,
getMap,
importMap,
listMaps,
renameMap,
seedSampleMapOnce,
} from '@/services/mapRepository';
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useUiStore } from '@/stores/uiStore';
import { buildMapPath, navigate } from '@/hooks/useRoute';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() {
const appName = useAppConfigStore((state) => state.name);
const maxMapSizeMb = useAppConfigStore((state) => state.maxMapSizeMb);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
const [maps, setMaps] = useState<MapSummary[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [renaming, setRenaming] = useState<MapSummary | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleting, setDeleting] = useState<MapSummary | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => {
setLoading(true);
// La carte d'exemple du premier lancement doit exister avant de lister.
await seedSampleMapOnce();
setMaps(await listMaps());
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return maps;
return maps.filter((map) => map.name.toLowerCase().includes(needle));
}, [maps, query]);
const handleCreate = async () => {
const map = await createMap('Nouvelle carte');
navigate(buildMapPath(map.id));
};
const handleImportFile = async (file: File) => {
try {
const parsed = await readMapFile(file, maxMapSizeMb);
const stored = await importMap(parsed);
pushToast(`Carte "${stored.name}" importee.`, 'success');
navigate(buildMapPath(stored.id));
} catch (error) {
pushToast(
error instanceof MapFormatError
? error.message
: "Le fichier selectionne n'est pas une carte valide.",
'error',
);
}
};
const handleExport = async (summary: MapSummary) => {
const map = await getMap(summary.id);
if (!map) {
pushToast('Impossible de charger cette carte.', 'error');
return;
}
const blob = new Blob([serializeMap(map)], {
type: `${APP_CONFIG.fileMimeType};charset=utf-8`,
});
downloadBlob(blob, toSafeFilename(map.name, APP_CONFIG.fileExtension));
};
const handleDuplicate = async (summary: MapSummary) => {
const copy = await duplicateMap(summary.id);
if (copy) {
pushToast(`Copie creee : ${copy.name}`, 'success');
await refresh();
}
};
const confirmRename = async () => {
if (!renaming) return;
const trimmed = renameValue.trim();
if (trimmed) {
await renameMap(renaming.id, trimmed);
await refresh();
}
setRenaming(null);
};
const confirmDelete = async () => {
if (!deleting) return;
await deleteMap(deleting.id);
setDeleting(null);
pushToast('Carte supprimee.', 'success');
await refresh();
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col px-5 py-8 sm:px-8">
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{appName}
</h1>
<p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
{APP_CONFIG.tagline}
</p>
</div>
<IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
icon={colorMode === 'dark' ? <SunIcon /> : <MoonIcon />}
onClick={toggleColorMode}
/>
</header>
<div className="mb-6 flex flex-wrap items-center gap-2">
<Button variant="primary" onClick={() => void handleCreate()}>
<PlusIcon className="h-4 w-4" />
Nouvelle carte
</Button>
<Button onClick={() => fileInputRef.current?.click()}>
<ImportIcon className="h-4 w-4" />
Importer
</Button>
<input
ref={fileInputRef}
type="file"
accept={`${APP_CONFIG.fileExtension},application/json`}
className="sr-only"
aria-label="Importer un fichier de carte"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportFile(file);
}}
/>
<div className="ml-auto flex w-full items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:w-64 dark:border-zinc-700 dark:bg-zinc-900">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher une carte"
aria-label="Rechercher une carte"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
</div>
{loading ? (
<p className="text-sm text-zinc-500">Chargement des cartes...</p>
) : filtered.length === 0 ? (
<div className="panel flex flex-col items-center gap-3 px-6 py-14 text-center">
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
{query ? 'Aucune carte ne correspond' : 'Aucune carte pour le moment'}
</h2>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
{query
? 'Essayez un autre terme de recherche.'
: `Creez votre premiere carte ou importez un fichier ${APP_CONFIG.fileExtension}.`}
</p>
{!query ? (
<Button variant="primary" onClick={() => void handleCreate()}>
<PlusIcon className="h-4 w-4" />
Nouvelle carte
</Button>
) : null}
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((map) => (
<li key={map.id}>
<MapCard
map={map}
onOpen={() => navigate(buildMapPath(map.id))}
onRename={() => {
setRenaming(map);
setRenameValue(map.name);
}}
onDuplicate={() => void handleDuplicate(map)}
onExport={() => void handleExport(map)}
onDelete={() => setDeleting(map)}
/>
</li>
))}
</ul>
)}
<footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont
envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '}
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
</footer>
<Modal
open={renaming !== null}
title="Renommer la carte"
onClose={() => setRenaming(null)}
size="sm"
footer={
<>
<Button onClick={() => setRenaming(null)}>Annuler</Button>
<Button variant="primary" onClick={() => void confirmRename()}>
Renommer
</Button>
</>
}
>
<label className="field-label" htmlFor="rename-input">
Nouveau nom
</label>
<input
id="rename-input"
className="field-input"
value={renameValue}
autoFocus
onChange={(event) => setRenameValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void confirmRename();
}}
/>
</Modal>
<ConfirmDialog
open={deleting !== null}
title="Supprimer cette carte ?"
message={`"${deleting?.name ?? ''}" sera definitivement supprimee de ce navigateur. Cette action est irreversible : exportez-la d'abord si vous souhaitez la conserver.`}
confirmLabel="Supprimer"
tone="danger"
onConfirm={() => void confirmDelete()}
onCancel={() => setDeleting(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { type MapSummary } from '@/types/map';
import { Menu, MenuItem, MenuSeparator } from '@/components/Menu';
import { IconButton } from '@/components/IconButton';
import {
CopyIcon,
DownloadIcon,
MapIcon,
PencilIcon,
ShareIcon,
SlidersIcon,
TrashIcon,
} from '@/components/Icons';
import { formatRelative } from '@/utils/date';
export interface MapCardProps {
map: MapSummary;
onOpen: () => void;
onRename: () => void;
onDuplicate: () => void;
onExport: () => void;
onDelete: () => void;
}
export function MapCard({ map, onOpen, onRename, onDuplicate, onExport, onDelete }: MapCardProps) {
return (
<article className="panel group flex flex-col gap-3 p-4 transition-shadow hover:shadow-lg">
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={onOpen}
className="min-w-0 flex-1 text-left"
aria-label={`Ouvrir la carte ${map.name}`}
>
<span className="flex items-center gap-2">
<MapIcon className="h-4 w-4 shrink-0 text-indigo-500" />
<span className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{map.name}
</span>
</span>
</button>
<Menu
label={`Actions pour ${map.name}`}
align="right"
trigger={
<IconButton
label="Actions"
icon={<SlidersIcon />}
className="opacity-60 group-hover:opacity-100"
/>
}
>
{(close) => (
<>
<MenuItem
label="Ouvrir"
onSelect={() => {
onOpen();
close();
}}
/>
<MenuItem
icon={<PencilIcon className="h-4 w-4" />}
label="Renommer"
onSelect={() => {
onRename();
close();
}}
/>
<MenuItem
icon={<CopyIcon className="h-4 w-4" />}
label="Dupliquer"
onSelect={() => {
onDuplicate();
close();
}}
/>
<MenuItem
icon={<DownloadIcon className="h-4 w-4" />}
label="Exporter (.mmap)"
onSelect={() => {
onExport();
close();
}}
/>
<MenuSeparator />
<MenuItem
icon={<TrashIcon className="h-4 w-4" />}
label="Supprimer"
tone="danger"
onSelect={() => {
onDelete();
close();
}}
/>
</>
)}
</Menu>
</div>
<button
type="button"
onClick={onOpen}
className="flex items-center gap-3 text-left text-xs text-zinc-500 dark:text-zinc-400"
tabIndex={-1}
aria-hidden="true"
>
<span>{map.nodeCount} noeuds</span>
<span aria-hidden="true">.</span>
<span>Modifiee {formatRelative(map.updatedAt)}</span>
</button>
{map.isShared ? (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
<ShareIcon className="h-3 w-3" />
Partagee
</span>
) : null}
</article>
);
}

View File

@@ -0,0 +1,239 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CheckIcon, CopyIcon, SpinnerIcon } from '@/components/Icons';
import { type ShareRecord } from '@/types/map';
import { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
import {
deleteShareRecord,
getShareRecord,
saveShareRecord,
} from '@/services/shareRepository';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { buildShareUrl } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date';
export interface ShareDialogProps {
open: boolean;
onClose: () => void;
onShareStateChange?: (shared: boolean) => void;
}
export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const [record, setRecord] = useState<ShareRecord | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const refresh = useCallback(async (mapId: string) => {
setLoading(true);
const existing = await getShareRecord(mapId);
setRecord(existing ?? null);
setLoading(false);
}, []);
useEffect(() => {
if (!open || !map) return;
void refresh(map.id);
}, [open, map, refresh]);
const handleError = (error: unknown, fallback: string) => {
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
};
const handleCreate = async () => {
if (!map || busy) return;
setBusy(true);
try {
const response = await createShare(map, map.name);
const created: ShareRecord = {
mapId: map.id,
shareToken: response.shareToken,
managementToken: response.managementToken,
sharePath: response.sharePath,
title: response.title,
createdAt: response.createdAt,
updatedAt: response.updatedAt,
};
await saveShareRecord(created);
setRecord(created);
onShareStateChange?.(true);
pushToast('Lien de partage cree.', 'success');
} catch (error) {
handleError(error, "Le partage n'a pas pu etre cree.");
} finally {
setBusy(false);
}
};
const handleUpdate = async () => {
if (!map || !record || busy) return;
setBusy(true);
try {
const response = await updateShare(
record.shareToken,
record.managementToken,
map,
map.name,
);
const updated = { ...record, title: map.name, updatedAt: response.updatedAt };
await saveShareRecord(updated);
setRecord(updated);
pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
} catch (error) {
handleError(error, "La mise a jour du partage n'a pas abouti.");
} finally {
setBusy(false);
}
};
const handleDelete = async () => {
if (!map || !record || busy) return;
setConfirmDelete(false);
setBusy(true);
try {
await deleteShare(record.shareToken, record.managementToken);
} catch (error) {
// Deja supprime cote serveur : on nettoie quand meme la reference locale.
if (!(error instanceof ShareApiError) || error.status !== 404) {
handleError(error, "Le partage n'a pas pu etre supprime.");
setBusy(false);
return;
}
}
await deleteShareRecord(map.id);
setRecord(null);
onShareStateChange?.(false);
setBusy(false);
pushToast('Partage supprime : le lien public ne fonctionne plus.', 'success');
};
const handleCopy = async () => {
if (!record) return;
const url = buildShareUrl(record.shareToken);
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
pushToast('Copie impossible : selectionnez le lien puis copiez-le manuellement.', 'error');
}
};
const shareUrl = record ? buildShareUrl(record.shareToken) : '';
return (
<>
<Modal
open={open}
title={record ? 'Carte partagee' : 'Partager cette carte'}
onClose={onClose}
footer={
record ? (
<>
<Button
variant="danger"
onClick={() => setConfirmDelete(true)}
disabled={busy}
>
Supprimer le partage
</Button>
<Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}>
{busy ? <SpinnerIcon className="h-4 w-4" /> : null}
Mettre a jour le partage
</Button>
</>
) : (
<>
<Button onClick={onClose} disabled={busy}>
Annuler
</Button>
<Button variant="primary" onClick={() => void handleCreate()} disabled={busy || !map}>
{busy ? <SpinnerIcon className="h-4 w-4" /> : null}
Creer le lien de partage
</Button>
</>
)
}
>
{loading ? (
<p className="text-sm text-zinc-500">Chargement...</p>
) : record ? (
<div className="space-y-4">
<div>
<span className="field-label">Lien public (lecture seule)</span>
<div className="flex items-center gap-2">
<input
readOnly
value={shareUrl}
aria-label="Lien public de la carte"
className="field-input font-mono text-xs"
onFocus={(event) => event.target.select()}
/>
<Button onClick={() => void handleCopy()} aria-label="Copier le lien">
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
{copied ? 'Copie' : 'Copier'}
</Button>
</div>
</div>
<dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Publie le</dt>
<dd className="text-zinc-700 dark:text-zinc-200">
{formatDateTime(record.createdAt)}
</dd>
</div>
<div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Derniere publication</dt>
<dd className="text-zinc-700 dark:text-zinc-200">
{formatDateTime(record.updatedAt)}
</dd>
</div>
</dl>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le lien est <strong>non repertorie</strong> : il n&apos;apparait dans aucune liste
publique, mais toute personne qui le connait peut consulter la carte. Les
modifications faites ici ne sont visibles publiquement qu&apos;apres un
<em> Mettre a jour le partage</em>.
</p>
</div>
) : (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p>
Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour
generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base
de donnees.
</p>
<ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400">
<li>Le lien obtenu est non repertorie et en lecture seule.</li>
<li>Aucun compte n&apos;est necessaire pour le consulter.</li>
<li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li>
<li>
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
appareil, vous ne pourrez pas gerer ce partage.
</li>
</ul>
</div>
)}
</Modal>
<ConfirmDialog
open={confirmDelete}
title="Supprimer le partage ?"
message="Le lien public cessera immediatement de fonctionner et la copie serveur sera effacee. Votre carte locale n'est pas affectee."
confirmLabel="Supprimer le partage"
tone="danger"
onConfirm={() => void handleDelete()}
onCancel={() => setConfirmDelete(false)}
/>
</>
);
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import {
FitIcon,
MapIcon,
MoonIcon,
SpinnerIcon,
SunIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@/components/Icons';
import { ExportMenu } from '@/features/export/ExportMenu';
import { MindMapCanvas } from '@/features/editor/MindMapCanvas';
import { ShareApiError, fetchSharedMap } from '@/services/shareApi';
import { MapFormatError } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date';
export interface SharedMapPageProps {
token: string;
}
type Status =
| { kind: 'loading' }
| { kind: 'ready'; title: string; updatedAt: string }
| { kind: 'error'; message: string };
function SharedMapContent({ token }: SharedMapPageProps) {
const appName = useAppConfigStore((state) => state.name);
const loadMap = useEditorStore((state) => state.loadMap);
const closeMap = useEditorStore((state) => state.closeMap);
const requestFit = useEditorStore((state) => state.requestFit);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const { zoomIn, zoomOut } = useReactFlow();
const [status, setStatus] = useState<Status>({ kind: 'loading' });
useEffect(() => {
let cancelled = false;
setStatus({ kind: 'loading' });
fetchSharedMap(token)
.then((response) => {
if (cancelled) return;
loadMap(response.map, { readOnly: true });
setStatus({ kind: 'ready', title: response.title, updatedAt: response.updatedAt });
document.title = `${response.title} - ${appName}`;
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ShareApiError || error instanceof MapFormatError
? error.message
: 'Cette carte partagee est introuvable.';
setStatus({ kind: 'error', message });
});
return () => {
cancelled = true;
closeMap();
};
}, [token, loadMap, closeMap, appName]);
if (status.kind === 'loading') {
return (
<div className="flex h-full items-center justify-center text-zinc-500">
<SpinnerIcon className="h-5 w-5" />
<span className="ml-2 text-sm">Chargement de la carte partagee...</span>
</div>
);
}
if (status.kind === 'error') {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Le lien a peut-etre ete supprime par son auteur, ou l&apos;adresse est incorrecte.
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Ouvrir {appName}
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<header className="no-print flex h-14 shrink-0 items-center gap-3 border-b border-zinc-200 bg-white px-4 dark:border-zinc-800 dark:bg-zinc-900">
<MapIcon className="h-5 w-5 shrink-0 text-indigo-500" />
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{status.title}
</h1>
<p className="truncate text-[11px] text-zinc-400">
Carte partagee - lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
</p>
</div>
<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" icon={<FitIcon />} onClick={requestFit} />
<IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
icon={colorMode === 'dark' ? <SunIcon /> : <MoonIcon />}
onClick={toggleColorMode}
/>
<ExportMenu allowNativeExport={false} />
</div>
</header>
<div className="relative min-h-0 flex-1">
<h2 className="print-title px-2">{status.title}</h2>
<MindMapCanvas readOnly showMiniMap={false} />
</div>
<footer className="no-print flex h-8 shrink-0 items-center justify-center border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
Carte partagee via {appName} - aucune inscription necessaire
</footer>
</div>
);
}
export function SharedMapPage(props: SharedMapPageProps) {
return (
<ReactFlowProvider>
<SharedMapContent {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react';
/**
* Execute `effect` apres `delay` ms d'inactivite sur `deps`.
* Utilise pour l'autosave : on n'ecrit pas en base a chaque micro-mouvement.
*/
export function useDebouncedEffect(effect: () => void, deps: unknown[], delay: number): void {
const callbackRef = useRef(effect);
callbackRef.current = effect;
useEffect(() => {
const timer = window.setTimeout(() => callbackRef.current(), delay);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, delay]);
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { APP_CONFIG } from '@/config/app';
export type Route =
| { name: 'dashboard' }
| { name: 'editor'; mapId: string }
| { name: 'shared'; token: string }
| { name: 'notFound' };
const NAVIGATION_EVENT = 'openmind:navigate';
export function parseRoute(pathname: string): Route {
const clean = pathname.replace(/\/+$/, '') || '/';
if (clean === '/' || clean === '') return { name: 'dashboard' };
const editorMatch = clean.match(/^\/map\/([^/]+)$/);
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };
const sharePattern = new RegExp(`^${APP_CONFIG.sharePathPrefix}([^/]+)$`);
const shareMatch = clean.match(sharePattern);
if (shareMatch?.[1]) return { name: 'shared', token: decodeURIComponent(shareMatch[1]) };
return { name: 'notFound' };
}
/** Navigation cote client, sans dependance a un routeur externe. */
export function navigate(path: string, options: { replace?: boolean } = {}): void {
if (options.replace) {
window.history.replaceState({}, '', path);
} else {
window.history.pushState({}, '', path);
}
window.dispatchEvent(new Event(NAVIGATION_EVENT));
}
export function useRoute(): Route {
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname));
useEffect(() => {
const sync = () => setRoute(parseRoute(window.location.pathname));
window.addEventListener('popstate', sync);
window.addEventListener(NAVIGATION_EVENT, sync);
return () => {
window.removeEventListener('popstate', sync);
window.removeEventListener(NAVIGATION_EVENT, sync);
};
}, []);
return route;
}
export function buildMapPath(mapId: string): string {
return `/map/${encodeURIComponent(mapId)}`;
}
export function buildSharePath(token: string): string {
return `${APP_CONFIG.sharePathPrefix}${encodeURIComponent(token)}`;
}
export function buildShareUrl(token: string): string {
return `${window.location.origin}${buildSharePath(token)}`;
}

29
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@/App';
import { initializeColorMode } from '@/stores/uiStore';
import '@/styles/index.css';
import '@/styles/print.css';
initializeColorMode();
const container = document.getElementById('root');
if (!container) {
throw new Error('Element racine introuvable.');
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
// Service worker : permet d'ouvrir et d'editer les cartes locales hors ligne
// une fois l'application chargee au moins une fois.
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// L'echec d'enregistrement ne doit jamais empecher l'application de fonctionner.
});
});
}

View File

@@ -0,0 +1,131 @@
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
import { createId } from '@/utils/id';
import { rebuildEdges } from '@/utils/tree';
export const DEFAULT_SETTINGS = {
theme: 'default',
layout: 'mindmap-right',
branchStyle: 'bezier',
showGrid: true,
} as const;
function nowIso(): string {
return new Date().toISOString();
}
export function createNode(partial: Partial<MindMapNode> & { text: string }): MindMapNode {
return {
id: partial.id ?? createId(),
type: partial.type ?? 'standard',
text: partial.text,
position: partial.position ?? { x: 0, y: 0 },
parentId: partial.parentId ?? null,
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
...(partial.icon ? { icon: partial.icon } : {}),
...(partial.url ? { url: partial.url } : {}),
...(partial.note ? { note: partial.note } : {}),
...(partial.style ? { style: partial.style } : {}),
};
}
export function createEmptyMap(name: string): MindMap {
const timestamp = nowIso();
const root = createNode({
type: 'root',
text: name || 'Nouvelle carte',
position: { x: 0, y: 0 },
});
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: name || 'Nouvelle carte',
createdAt: timestamp,
updatedAt: timestamp,
nodes: [root],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
}
/** Copie profonde avec nouveaux identifiants : aucune collision avec l'original. */
export function cloneMap(map: MindMap, name?: string): MindMap {
const timestamp = nowIso();
const idMapping = new Map<string, string>();
for (const node of map.nodes) idMapping.set(node.id, createId());
const nodes = map.nodes.map((node) => ({
...node,
id: idMapping.get(node.id) as string,
parentId: node.parentId ? (idMapping.get(node.parentId) ?? null) : null,
position: { ...node.position },
...(node.style ? { style: { ...node.style } } : {}),
}));
return {
...map,
id: createId(),
name: name ?? map.name,
createdAt: timestamp,
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
viewport: { ...map.viewport },
settings: { ...map.settings },
};
}
/**
* Carte d'exemple creee au premier lancement.
* Volontairement minimale : elle sert a comprendre l'outil en dix secondes.
*/
export function createSampleMap(): MindMap {
const timestamp = nowIso();
const root = createNode({
id: createId(),
type: 'root',
text: 'Mon projet',
position: { x: 0, y: 0 },
});
const branches: Array<{ text: string; icon: string; children?: string[] }> = [
{ text: 'Idees', icon: '💡', children: ['Brainstorming', 'Inspirations'] },
{ text: 'Taches', icon: '✅', children: ['A faire', 'En cours'] },
{ text: 'Documentation', icon: '📄' },
{ text: 'Ressources', icon: '🔗' },
];
const nodes: MindMapNode[] = [root];
branches.forEach((branch, index) => {
const node = createNode({
text: branch.text,
icon: branch.icon,
parentId: root.id,
position: { x: 260, y: index * 90 - 135 },
});
nodes.push(node);
branch.children?.forEach((childText, childIndex) => {
nodes.push(
createNode({
text: childText,
parentId: node.id,
position: { x: 500, y: index * 90 - 135 + childIndex * 46 - 23 },
}),
);
});
});
return {
version: MAP_FORMAT_VERSION,
id: createId(),
name: 'Exemple : Mon projet',
createdAt: timestamp,
updatedAt: timestamp,
nodes,
edges: rebuildEdges(nodes),
viewport: { x: 0, y: 0, zoom: 1 },
settings: { ...DEFAULT_SETTINGS },
};
}

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/db/database';
import {
createMap,
deleteMap,
duplicateMap,
getMap,
importMap,
listMaps,
renameMap,
seedSampleMapOnce,
} from '@/services/mapRepository';
import { saveShareRecord } from '@/services/shareRepository';
import { createSampleMap } from '@/services/mapFactory';
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('stockage IndexedDB des cartes', () => {
it('cree, relit et liste une carte', async () => {
const map = await createMap('Homelab');
const stored = await getMap(map.id);
expect(stored?.name).toBe('Homelab');
expect(stored?.nodes).toHaveLength(1);
const list = await listMaps();
expect(list).toHaveLength(1);
expect(list[0]?.nodeCount).toBe(1);
expect(list[0]?.isShared).toBe(false);
});
it('renomme une carte', async () => {
const map = await createMap('Avant');
await renameMap(map.id, 'Apres');
expect((await getMap(map.id))?.name).toBe('Apres');
});
it('duplique une carte avec de nouveaux identifiants', async () => {
const map = await createMap('Original');
const copy = await duplicateMap(map.id);
expect(copy?.id).not.toBe(map.id);
expect(copy?.name).toBe('Original (copie)');
expect(copy?.nodes[0]?.id).not.toBe(map.nodes[0]?.id);
expect(await listMaps()).toHaveLength(2);
});
it('supprime une carte et sa reference de partage locale', async () => {
const map = await createMap('A supprimer');
await saveShareRecord({
mapId: map.id,
shareToken: 'public-token',
managementToken: 'secret-token',
sharePath: '/s/public-token',
title: map.name,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect((await listMaps())[0]?.isShared).toBe(true);
await deleteMap(map.id);
expect(await getMap(map.id)).toBeUndefined();
expect(await db.shares.get(map.id)).toBeUndefined();
});
it('importe une carte sous un nouvel identifiant', async () => {
const source = createSampleMap();
const first = await importMap(source);
const second = await importMap(source);
expect(first.id).not.toBe(source.id);
expect(second.id).not.toBe(first.id);
expect(await listMaps()).toHaveLength(2);
});
it('ajoute la carte d exemple une seule fois', async () => {
await seedSampleMapOnce();
expect(await listMaps()).toHaveLength(1);
await seedSampleMapOnce();
expect(await listMaps()).toHaveLength(1);
});
it('n ajoute pas d exemple si des cartes existent deja', async () => {
await createMap('Deja la');
await seedSampleMapOnce();
const list = await listMaps();
expect(list).toHaveLength(1);
expect(list[0]?.name).toBe('Deja la');
});
it('conserve les cartes apres reouverture de la base', async () => {
const map = await createMap('Persistante');
db.close();
await db.open();
expect((await getMap(map.id))?.name).toBe('Persistante');
});
});

View File

@@ -0,0 +1,98 @@
import { db, META_KEYS } from '@/db/database';
import { type MindMap, type MapSummary } from '@/types/map';
import { cloneMap, createEmptyMap, createSampleMap } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { deleteShareRecord, getShareRecord, listShareRecords } from '@/services/shareRepository';
/** Toutes les cartes locales, de la plus recemment modifiee a la plus ancienne. */
export async function listMaps(): Promise<MapSummary[]> {
const [maps, shares] = await Promise.all([db.maps.toArray(), listShareRecords()]);
const sharedIds = new Set(shares.map((share) => share.mapId));
return maps
.map((map) => ({
id: map.id,
name: map.name,
createdAt: map.createdAt,
updatedAt: map.updatedAt,
nodeCount: map.nodes.length,
isShared: sharedIds.has(map.id),
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function getMap(id: string): Promise<MindMap | undefined> {
return db.maps.get(id);
}
export async function saveMap(map: MindMap): Promise<void> {
await db.maps.put(map);
}
export async function createMap(name: string): Promise<MindMap> {
const map = createEmptyMap(name);
await db.maps.put(map);
return map;
}
export async function renameMap(id: string, name: string): Promise<void> {
const map = await db.maps.get(id);
if (!map) return;
await db.maps.put({ ...map, name, updatedAt: new Date().toISOString() });
}
export async function duplicateMap(id: string): Promise<MindMap | undefined> {
const map = await db.maps.get(id);
if (!map) return undefined;
const copy = cloneMap(map, `${map.name} (copie)`);
await db.maps.put(copy);
return copy;
}
/** Supprime la carte locale. Le partage serveur eventuel n'est pas touche ici. */
export async function deleteMap(id: string): Promise<void> {
await db.maps.delete(id);
await deleteShareRecord(id);
}
export async function importMap(map: MindMap): Promise<MindMap> {
// Un import cree toujours une nouvelle carte : jamais d'ecrasement silencieux.
const stored: MindMap = {
...map,
id: createId(),
updatedAt: new Date().toISOString(),
};
await db.maps.put(stored);
return stored;
}
export async function isShared(mapId: string): Promise<boolean> {
return (await getShareRecord(mapId)) !== undefined;
}
/**
* Ajoute une carte d'exemple au tout premier lancement, pour que l'outil soit
* comprehensible immediatement. Ne s'execute qu'une seule fois.
*
* La promesse est memorisee : plusieurs appels concurrents (StrictMode, montage
* simultane de composants) partagent la meme execution et attendent son
* resultat, afin que la liste des cartes ne soit jamais lue trop tot.
*/
let seedingPromise: Promise<void> | null = null;
async function runSeed(): Promise<void> {
const flag = await db.meta.get(META_KEYS.sampleSeeded);
if (flag) return;
const count = await db.maps.count();
if (count === 0) {
await db.maps.put(createSampleMap());
}
await db.meta.put({ key: META_KEYS.sampleSeeded, value: true });
}
export function seedSampleMapOnce(): Promise<void> {
seedingPromise ??= runSeed();
return seedingPromise;
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION } from '@/types/map';
import { createSampleMap } from '@/services/mapFactory';
import {
MapFormatError,
parseMapDocument,
serializeMap,
} from '@/services/mapSerialization';
describe('serialisation des cartes', () => {
it('effectue un aller-retour sans perte', () => {
const original = createSampleMap();
const restored = parseMapDocument(JSON.parse(serializeMap(original)));
expect(restored.name).toBe(original.name);
expect(restored.nodes).toHaveLength(original.nodes.length);
expect(restored.edges).toHaveLength(original.edges.length);
expect(restored.settings.theme).toBe(original.settings.theme);
expect(restored.nodes[0]?.type).toBe('root');
});
it('rejette un contenu qui n est pas un objet', () => {
expect(() => parseMapDocument('pas une carte')).toThrow(MapFormatError);
expect(() => parseMapDocument(null)).toThrow(MapFormatError);
expect(() => parseMapDocument([1, 2, 3])).toThrow(MapFormatError);
});
it('rejette une carte sans version', () => {
const map = createSampleMap() as unknown as Record<string, unknown>;
delete map.version;
expect(() => parseMapDocument(map)).toThrow(/version/i);
});
it('rejette une version de format plus recente', () => {
const map = { ...createSampleMap(), version: MAP_FORMAT_VERSION + 5 };
expect(() => parseMapDocument(map)).toThrow(/plus recente/i);
});
it('rejette une carte sans noeud exploitable', () => {
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
expect(() => parseMapDocument({ version: 1, id: 'x', nodes: [{ text: 'sans id' }] })).toThrow(
MapFormatError,
);
});
it('applique la limite de noeuds', () => {
const nodes = Array.from({ length: 12 }, (_, index) => ({
id: `n${index}`,
text: `Noeud ${index}`,
parentId: index === 0 ? null : 'n0',
}));
expect(() => parseMapDocument({ version: 1, id: 'x', nodes }, { maxNodes: 5 })).toThrow(
/limite/i,
);
});
it('neutralise les URL dangereuses', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
name: 'Test',
nodes: [
{ id: 'a', type: 'root', text: 'Racine', url: 'javascript:alert(1)' },
{ id: 'b', text: 'Lien', parentId: 'a', url: 'exemple.fr' },
{ id: 'c', text: 'Data', parentId: 'a', url: 'data:text/html,<script>' },
],
});
expect(parsed.nodes[0]?.url).toBeUndefined();
expect(parsed.nodes[1]?.url).toBe('https://exemple.fr/');
expect(parsed.nodes[2]?.url).toBeUndefined();
});
it('conserve le texte tel quel sans interpreter le HTML', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: '<img src=x onerror=alert(1)>' }],
});
expect(parsed.nodes[0]?.text).toBe('<img src=x onerror=alert(1)>');
});
it('reconstruit la hierarchie a partir des liens', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [
{ id: 'a', type: 'root', text: 'Racine' },
{ id: 'b', text: 'Enfant' },
],
edges: [{ id: 'e1', source: 'a', target: 'b' }],
});
expect(parsed.nodes.find((node) => node.id === 'b')?.parentId).toBe('a');
expect(parsed.edges).toHaveLength(1);
expect(parsed.edges[0]?.source).toBe('a');
});
it('ignore les liens pointant vers des noeuds inexistants', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [{ id: 'a', type: 'root', text: 'Racine' }],
edges: [{ id: 'e1', source: 'a', target: 'fantome' }],
});
expect(parsed.edges).toHaveLength(0);
});
it('ignore les styles invalides mais garde les styles valides', () => {
const parsed = parseMapDocument({
version: 1,
id: 'x',
nodes: [
{
id: 'a',
type: 'root',
text: 'Racine',
style: { backgroundColor: 'url(javascript:1)', textColor: '#ff0000', shape: 'triangle' },
},
],
});
expect(parsed.nodes[0]?.style?.backgroundColor).toBeUndefined();
expect(parsed.nodes[0]?.style?.textColor).toBe('#ff0000');
expect(parsed.nodes[0]?.style?.shape).toBeUndefined();
});
});

View File

@@ -0,0 +1,293 @@
import { APP_CONFIG } from '@/config/app';
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapNode,
type NodeKind,
type NodeShape,
type NodeStyle,
type TextAlign,
} from '@/types/map';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
export class MapFormatError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapFormatError';
}
}
const MAX_TEXT_LENGTH = 5000;
const MAX_NOTE_LENGTH = 20000;
const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
'tree-horizontal',
'tree-vertical',
'mindmap-both',
'organization',
'freeform',
];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.slice(0, maxLength);
}
function asFiniteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/** Une couleur CSS acceptee : hex, rgb(a) ou nom simple. Rien d'autre. */
function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim().slice(0, 40);
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
if (/^rgba?\(\s*[\d.\s,%/]+\)$/.test(trimmed)) return trimmed;
if (/^[a-zA-Z]{3,20}$/.test(trimmed)) return trimmed;
return undefined;
}
function parseStyle(value: unknown): NodeStyle | undefined {
if (!isRecord(value)) return undefined;
const style: NodeStyle = {};
const background = asColor(value.backgroundColor);
if (background) style.backgroundColor = background;
const text = asColor(value.textColor);
if (text) style.textColor = text;
const border = asColor(value.borderColor);
if (border) style.borderColor = border;
if (typeof value.shape === 'string' && NODE_SHAPES.includes(value.shape as NodeShape)) {
style.shape = value.shape as NodeShape;
}
if (typeof value.fontSize === 'number' && Number.isFinite(value.fontSize)) {
style.fontSize = Math.min(48, Math.max(10, Math.round(value.fontSize)));
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
style.width = Math.min(600, Math.max(80, Math.round(value.width)));
}
if (typeof value.bold === 'boolean') style.bold = value.bold;
if (typeof value.italic === 'boolean') style.italic = value.italic;
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign;
}
return Object.keys(style).length > 0 ? style : undefined;
}
function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null;
const id = asString(value.id, 128);
if (!id) return null;
const type =
typeof value.type === 'string' && NODE_KINDS.includes(value.type as NodeKind)
? (value.type as NodeKind)
: 'standard';
const rawPosition = isRecord(value.position) ? value.position : {};
const node: MindMapNode = {
id,
type,
text: asString(value.text, MAX_TEXT_LENGTH) ?? '',
position: {
x: asFiniteNumber(rawPosition.x, 0),
y: asFiniteNumber(rawPosition.y, 0),
},
parentId: asString(value.parentId, 128) ?? null,
};
if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8);
if (icon) node.icon = icon;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url;
const note = asString(value.note, MAX_NOTE_LENGTH);
if (note) node.note = note;
const style = parseStyle(value.style);
if (style) node.style = style;
if (isRecord(value.meta)) node.meta = value.meta;
return node;
}
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
if (typeof value.theme === 'string') settings.theme = value.theme.slice(0, 40);
if (typeof value.layout === 'string' && LAYOUTS.includes(value.layout as LayoutKind)) {
settings.layout = value.layout as LayoutKind;
}
if (
typeof value.branchStyle === 'string' &&
BRANCH_STYLES.includes(value.branchStyle as BranchStyle)
) {
settings.branchStyle = value.branchStyle as BranchStyle;
}
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
return settings;
}
/**
* Point d'extension des migrations de format.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
* recente, avant la normalisation finale.
*/
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
const document = raw;
const current = version;
// Exemple d'evolution future :
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
if (current !== MAP_FORMAT_VERSION) {
return { ...document, version: MAP_FORMAT_VERSION };
}
return document;
}
/**
* Valide et normalise un document carte provenant d'un fichier ou du serveur.
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue,
* ni injecte en HTML.
*/
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap {
if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide.");
}
const version = raw.version;
if (typeof version !== 'number' || !Number.isFinite(version) || version < 1) {
throw new MapFormatError(
"Ce fichier ne precise aucune version de format : il n'est pas reconnu comme une carte.",
);
}
if (version > MAP_FORMAT_VERSION) {
throw new MapFormatError(
'Cette carte a ete creee avec une version plus recente du format (v' +
version +
"). Mettez a jour l'application pour l'ouvrir.",
);
}
const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents).");
}
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
if (document.nodes.length > maxNodes) {
throw new MapFormatError(
'Cette carte contient ' +
document.nodes.length +
' noeuds : la limite est de ' +
maxNodes +
'.',
);
}
const seen = new Set<string>();
const nodes: MindMapNode[] = [];
for (const rawNode of document.nodes) {
const node = parseNode(rawNode);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
}
if (nodes.length === 0) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
}
const rawEdges = Array.isArray(document.edges) ? document.edges : [];
const parsedEdges = rawEdges
.filter(isRecord)
.map((edge) => ({
id: asString(edge.id, 256) ?? createId(),
source: asString(edge.source, 128) ?? '',
target: asString(edge.target, 128) ?? '',
}))
.filter((edge) => seen.has(edge.source) && seen.has(edge.target));
// La hierarchie fait autorite ; les liens en sont deduits.
const linked = rebuildHierarchyFromEdges(nodes, parsedEdges).map((node) => ({
...node,
parentId: node.parentId && seen.has(node.parentId) ? node.parentId : null,
}));
// Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null };
}
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString();
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked,
edges: rebuildEdges(linked),
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))),
},
settings: parseSettings(document.settings),
};
}
/** Lit un fichier .mmap choisi par l'utilisateur et retourne la carte validee. */
export async function readMapFile(
file: File,
maxSizeMb: number = APP_CONFIG.maxMapSizeMb,
): Promise<MindMap> {
if (file.size > maxSizeMb * 1024 * 1024) {
throw new MapFormatError('Ce fichier depasse la taille maximale de ' + maxSizeMb + ' Mo.');
}
const text = await file.text();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
}
return parseMapDocument(raw);
}

View File

@@ -0,0 +1,164 @@
import { type MindMap } from '@/types/map';
import { parseMapDocument } from '@/services/mapSerialization';
/** Erreur reseau/serveur deja traduite en message utilisateur. */
export class ShareApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = 'ShareApiError';
this.status = status;
}
}
export interface ShareCreatedResponse {
shareToken: string;
managementToken: string;
sharePath: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface SharedMapResponse {
shareToken: string;
title: string;
map: MindMap;
createdAt: string;
updatedAt: string;
}
const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
function messageForStatus(status: number, serverDetail?: string): string {
if (status === 404) return "Ce partage n'existe plus.";
if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur.";
if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.';
if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur.";
if (status >= 500) return 'Le serveur de partage est momentanement indisponible.';
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
});
} catch {
// Hors ligne, ou backend injoignable : l'application locale reste utilisable.
throw new ShareApiError('Le serveur de partage est momentanement indisponible.', 0);
}
if (response.status === 204) {
return undefined as T;
}
let payload: unknown = null;
const text = await response.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const detail =
payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: undefined;
throw new ShareApiError(messageForStatus(response.status, detail), response.status);
}
return payload as T;
}
interface RawShareCreated {
share_token: string;
management_token: string;
share_path: string;
title: string;
created_at: string;
updated_at: string;
}
interface RawSharePublic {
share_token: string;
title: string;
map_version: number;
map: unknown;
created_at: string;
updated_at: string;
}
/** Publie un snapshot de la carte. Appele uniquement sur action explicite. */
export async function createShare(map: MindMap, title: string): Promise<ShareCreatedResponse> {
const raw = await request<RawShareCreated>('/api/shares', {
method: 'POST',
body: JSON.stringify({ title, map }),
});
return {
shareToken: raw.share_token,
managementToken: raw.management_token,
sharePath: raw.share_path,
title: raw.title,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export async function updateShare(
shareToken: string,
managementToken: string,
map: MindMap,
title: string,
): Promise<{ updatedAt: string }> {
const raw = await request<{ updated_at: string }>(
`/api/shares/${encodeURIComponent(shareToken)}`,
{
method: 'PUT',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
body: JSON.stringify({ title, map }),
},
);
return { updatedAt: raw.updated_at };
}
export async function deleteShare(shareToken: string, managementToken: string): Promise<void> {
await request<void>(`/api/shares/${encodeURIComponent(shareToken)}`, {
method: 'DELETE',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
});
}
/** Lecture publique : le document recu est valide comme un fichier importe. */
export async function fetchSharedMap(shareToken: string): Promise<SharedMapResponse> {
const raw = await request<RawSharePublic>(`/api/shares/${encodeURIComponent(shareToken)}`);
return {
shareToken: raw.share_token,
title: raw.title,
map: parseMapDocument(raw.map),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
export interface PublicAppConfig {
appName: string;
maxNodes: number;
maxMapSizeMb: number;
sharingEnabled: boolean;
}
export async function fetchAppConfig(): Promise<PublicAppConfig> {
return request<PublicAppConfig>('/api/config');
}

View File

@@ -0,0 +1,22 @@
import { db } from '@/db/database';
import { type ShareRecord } from '@/types/map';
/**
* Les jetons de partage vivent uniquement dans l'IndexedDB du proprietaire.
* Le `managementToken` n'est jamais place dans une URL ni exporte en .mmap.
*/
export async function getShareRecord(mapId: string): Promise<ShareRecord | undefined> {
return db.shares.get(mapId);
}
export async function listShareRecords(): Promise<ShareRecord[]> {
return db.shares.toArray();
}
export async function saveShareRecord(record: ShareRecord): Promise<void> {
await db.shares.put(record);
}
export async function deleteShareRecord(mapId: string): Promise<void> {
await db.shares.delete(mapId);
}

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { APP_CONFIG } from '@/config/app';
import { fetchAppConfig } from '@/services/shareApi';
interface AppConfigState {
name: string;
maxNodes: number;
maxMapSizeMb: number;
/** Faux tant que le backend n'a pas repondu : l'app reste utilisable hors ligne. */
serverReachable: boolean;
load: () => Promise<void>;
}
/**
* Le nom du produit vient de la configuration serveur (APP_NAME), avec repli
* sur `APP_CONFIG.name`. Aucun composant ne doit ecrire le nom en dur.
*/
export const useAppConfigStore = create<AppConfigState>((set) => ({
name: APP_CONFIG.name,
maxNodes: APP_CONFIG.maxNodes,
maxMapSizeMb: APP_CONFIG.maxMapSizeMb,
serverReachable: false,
load: async () => {
try {
const config = await fetchAppConfig();
set({
name: config.appName || APP_CONFIG.name,
maxNodes: config.maxNodes || APP_CONFIG.maxNodes,
maxMapSizeMb: config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
serverReachable: true,
});
if (typeof document !== 'undefined') {
document.title = config.appName || APP_CONFIG.name;
}
} catch {
// Hors ligne : on garde les valeurs par defaut, sans bloquer l'interface.
set({ serverReachable: false });
}
},
}));

View File

@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyMap } from '@/services/mapFactory';
import { useEditorStore } from '@/stores/editorStore';
function store() {
return useEditorStore.getState();
}
function rootId(): string {
return store().map?.nodes[0]?.id as string;
}
beforeEach(() => {
useEditorStore.getState().loadMap(createEmptyMap('Carte de test'));
});
describe('store de l editeur', () => {
it('cree un enfant et le lien correspondant', () => {
const parent = rootId();
const childId = store().addChild(parent);
expect(childId).toBeTruthy();
expect(store().map?.nodes).toHaveLength(2);
expect(store().map?.edges).toHaveLength(1);
expect(store().map?.edges[0]?.source).toBe(parent);
expect(store().selectedNodeIds).toEqual([childId]);
expect(store().editingNodeId).toBe(childId);
});
it('cree un frere au meme niveau', () => {
const child = store().addChild(rootId()) as string;
const sibling = store().addSibling(child);
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(3);
expect(nodes.find((node) => node.id === sibling)?.parentId).toBe(rootId());
});
it('supprime la branche entiere', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().addChild(branch);
expect(store().map?.nodes).toHaveLength(4);
store().deleteNodes([branch]);
expect(store().map?.nodes).toHaveLength(1);
expect(store().map?.edges).toHaveLength(0);
});
it('annule et retablit une suppression', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
const before = store().map?.nodes.length;
store().deleteNodes([branch]);
expect(store().map?.nodes).toHaveLength(1);
store().undo();
expect(store().map?.nodes).toHaveLength(before as number);
store().redo();
expect(store().map?.nodes).toHaveLength(1);
});
it('ne cree pas d entree d historique pendant un deplacement', () => {
const child = store().addChild(rootId()) as string;
const historyLength = store().past.length;
store().moveNodes([{ id: child, position: { x: 10, y: 10 } }]);
store().moveNodes([{ id: child, position: { x: 20, y: 20 } }]);
store().moveNodes([{ id: child, position: { x: 30, y: 30 } }]);
expect(store().past.length).toBe(historyLength);
expect(store().map?.nodes.find((node) => node.id === child)?.position).toEqual({
x: 30,
y: 30,
});
});
it('regroupe un deplacement en une seule annulation', () => {
const child = store().addChild(rootId()) as string;
const originalPosition = store().map?.nodes.find((node) => node.id === child)?.position;
// Un glisser-deposer : une seule entree d'historique au debut du drag.
store().pushHistory();
store().moveNodes([{ id: child, position: { x: 10, y: 10 } }]);
store().moveNodes([{ id: child, position: { x: 50, y: 50 } }]);
store().undo();
expect(store().map?.nodes.find((node) => node.id === child)?.position).toEqual(
originalPosition,
);
});
it('copie et colle une branche avec de nouveaux identifiants', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().setSelection([branch]);
expect(store().copySelection()).toBe(2);
store().setSelection([rootId()]);
expect(store().paste()).toBe(2);
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(5);
expect(new Set(nodes.map((node) => node.id)).size).toBe(5);
});
it('duplique la selection au meme niveau', () => {
const branch = store().addChild(rootId()) as string;
store().setSelection([branch]);
expect(store().duplicateSelection()).toBe(1);
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(3);
expect(nodes.filter((node) => node.parentId === rootId())).toHaveLength(2);
});
it('replie une branche et compte les descendants masques', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().toggleCollapse(branch);
expect(store().map?.nodes.find((node) => node.id === branch)?.collapsed).toBe(true);
});
it('refuse un rattachement qui creerait une boucle', () => {
const parent = store().addChild(rootId()) as string;
const child = store().addChild(parent) as string;
expect(store().reparentNode(parent, child)).toBe(false);
expect(store().map?.nodes.find((node) => node.id === parent)?.parentId).toBe(rootId());
});
it('accepte un rattachement valide', () => {
const first = store().addChild(rootId()) as string;
const second = store().addChild(rootId()) as string;
expect(store().reparentNode(second, first)).toBe(true);
expect(store().map?.nodes.find((node) => node.id === second)?.parentId).toBe(first);
});
it('trouve les noeuds par recherche insensible aux accents', () => {
const child = store().addChild(rootId()) as string;
store().updateNodeText(child, 'Reseau prive');
store().setSearchQuery('reseau');
expect(store().searchResults).toContain(child);
store().setSearchQuery('inexistant');
expect(store().searchResults).toHaveLength(0);
});
it('applique une organisation automatique sans perdre de noeud', () => {
const branch = store().addChild(rootId()) as string;
store().addChild(branch);
store().addChild(branch);
store().applyAutoLayout('tree-vertical');
const nodes = store().map?.nodes ?? [];
expect(nodes).toHaveLength(4);
expect(store().map?.settings.layout).toBe('tree-vertical');
expect(new Set(nodes.map((node) => `${node.position.x}:${node.position.y}`)).size).toBe(4);
});
it('conserve les personnalisations de noeud quand le theme change', () => {
const child = store().addChild(rootId()) as string;
store().updateNodeStyle(child, { backgroundColor: '#123456' });
store().updateSettings({ theme: 'midnight' });
expect(store().map?.nodes.find((node) => node.id === child)?.style?.backgroundColor).toBe(
'#123456',
);
});
it('bloque toute modification en lecture seule', () => {
store().loadMap(createEmptyMap('Publique'), { readOnly: true });
const before = store().map?.nodes.length;
store().addChild(rootId());
expect(store().map?.nodes.length).toBe(before);
});
});

View File

@@ -0,0 +1,652 @@
import { create } from 'zustand';
import { APP_CONFIG } from '@/config/app';
import {
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapEdge,
type MindMapNode,
type NodeStyle,
type Position,
type Viewport,
} from '@/types/map';
import { createNode } from '@/services/mapFactory';
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
import { createId } from '@/utils/id';
import { type NodeSize } from '@/utils/nodeSize';
import {
buildNodeMap,
rebuildEdges,
suggestChildPosition,
withDescendants,
wouldCreateCycle,
} from '@/utils/tree';
export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
interface HistorySnapshot {
nodes: MindMapNode[];
edges: MindMapEdge[];
}
interface ClipboardPayload {
nodes: MindMapNode[];
}
export interface FocusRequest {
nodeId: string;
nonce: number;
}
interface EditorState {
map: MindMap | null;
readOnly: boolean;
selectedNodeIds: string[];
editingNodeId: string | null;
past: HistorySnapshot[];
future: HistorySnapshot[];
clipboard: ClipboardPayload | null;
saveStatus: SaveStatus;
lastSavedAt: string | null;
searchQuery: string;
searchResults: string[];
searchIndex: number;
focusRequest: FocusRequest | null;
fitRequest: number;
/** Tailles mesurees dans le DOM, utilisees par l'auto-layout et les exports. */
measuredSizes: Map<string, NodeSize>;
/** Incremente uniquement quand une taille change reellement. */
sizeVersion: number;
loadMap: (map: MindMap, options?: { readOnly?: boolean }) => void;
closeMap: () => void;
setSelection: (ids: string[]) => void;
setEditingNode: (id: string | null) => void;
pushHistory: () => void;
undo: () => void;
redo: () => void;
addChild: (parentId?: string | null) => string | null;
addSibling: (nodeId?: string) => string | null;
deleteNodes: (ids: string[]) => void;
updateNodeText: (id: string, text: string) => void;
updateNode: (id: string, patch: Partial<Omit<MindMapNode, 'id'>>) => void;
updateNodeStyle: (id: string, patch: NodeStyle) => void;
resetNodeStyle: (id: string) => void;
moveNodes: (updates: Array<{ id: string; position: Position }>) => void;
reparentNode: (childId: string, parentId: string) => boolean;
detachNode: (childId: string) => void;
toggleCollapse: (id: string) => void;
expandAncestors: (id: string) => void;
copySelection: () => number;
paste: () => number;
duplicateSelection: () => number;
applyAutoLayout: (layout?: LayoutKind) => void;
updateSettings: (patch: Partial<MapSettings>) => void;
renameMap: (name: string) => void;
setViewport: (viewport: Viewport) => void;
setSaveStatus: (status: SaveStatus) => void;
markSaved: () => void;
setSearchQuery: (query: string) => void;
gotoSearchResult: (index: number) => void;
nextSearchResult: () => void;
previousSearchResult: () => void;
requestFit: () => void;
setMeasuredSize: (id: string, size: NodeSize) => void;
}
function snapshot(map: MindMap): HistorySnapshot {
return { nodes: map.nodes, edges: map.edges };
}
/** Normalisation pour la recherche : insensible a la casse et aux accents. */
function normalizeText(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
return { ...map, nodes, edges: rebuildEdges(nodes) };
}
/**
* Copie un ensemble de sous-arbres en regenerant tous les identifiants.
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
*/
function cloneSubtrees(
source: MindMapNode[],
offset: number,
parentOverride?: string | null,
): MindMapNode[] {
const idMapping = new Map<string, string>();
for (const node of source) idMapping.set(node.id, createId());
return source.map((node) => {
const mappedParent = node.parentId ? idMapping.get(node.parentId) : undefined;
const parentId =
mappedParent ?? (parentOverride !== undefined ? parentOverride : (node.parentId ?? null));
return {
...node,
id: idMapping.get(node.id) as string,
type: node.type === 'root' && parentId ? 'standard' : node.type,
parentId,
position: { x: node.position.x + offset, y: node.position.y + offset },
...(node.style ? { style: { ...node.style } } : {}),
};
});
}
export const useEditorStore = create<EditorState>((set, get) => {
/**
* Applique une transformation sur la carte.
* `history: false` regroupe les micro-modifications (glisser-deposer) pour
* eviter de saturer la pile d'annulation.
*/
const commit = (
updater: (map: MindMap) => MindMap,
options: { history?: boolean } = {},
): void => {
const state = get();
const current = state.map;
if (!current || state.readOnly) return;
const next = updater(current);
if (next === current) return;
const withHistory = options.history !== false;
set({
map: { ...next, updatedAt: new Date().toISOString() },
saveStatus: 'dirty',
...(withHistory
? {
past: [...state.past, snapshot(current)].slice(-APP_CONFIG.historyLimit),
future: [],
}
: {}),
});
};
const restore = (target: HistorySnapshot): void => {
const state = get();
const current = state.map;
if (!current) return;
const existing = new Set(target.nodes.map((node) => node.id));
set({
map: {
...current,
nodes: target.nodes,
edges: target.edges,
updatedAt: new Date().toISOString(),
},
selectedNodeIds: state.selectedNodeIds.filter((id) => existing.has(id)),
editingNodeId: null,
saveStatus: 'dirty',
});
};
return {
map: null,
readOnly: false,
selectedNodeIds: [],
editingNodeId: null,
past: [],
future: [],
clipboard: null,
saveStatus: 'idle',
lastSavedAt: null,
searchQuery: '',
searchResults: [],
searchIndex: 0,
focusRequest: null,
fitRequest: 0,
measuredSizes: new Map(),
sizeVersion: 0,
loadMap: (map, options) => {
set({
map,
readOnly: options?.readOnly ?? false,
selectedNodeIds: [],
editingNodeId: null,
past: [],
future: [],
clipboard: null,
saveStatus: 'saved',
lastSavedAt: map.updatedAt,
searchQuery: '',
searchResults: [],
searchIndex: 0,
focusRequest: null,
measuredSizes: new Map(),
sizeVersion: get().sizeVersion + 1,
});
},
closeMap: () => {
set({
map: null,
readOnly: false,
selectedNodeIds: [],
editingNodeId: null,
past: [],
future: [],
saveStatus: 'idle',
searchQuery: '',
searchResults: [],
measuredSizes: new Map(),
});
},
setSelection: (ids) => set({ selectedNodeIds: ids }),
setEditingNode: (id) => set({ editingNodeId: id }),
pushHistory: () => {
const state = get();
if (!state.map || state.readOnly) return;
set({
past: [...state.past, snapshot(state.map)].slice(-APP_CONFIG.historyLimit),
future: [],
});
},
undo: () => {
const state = get();
const previous = state.past[state.past.length - 1];
if (!previous || !state.map || state.readOnly) return;
set({
past: state.past.slice(0, -1),
future: [...state.future, snapshot(state.map)].slice(-APP_CONFIG.historyLimit),
});
restore(previous);
},
redo: () => {
const state = get();
const next = state.future[state.future.length - 1];
if (!next || !state.map || state.readOnly) return;
set({
future: state.future.slice(0, -1),
past: [...state.past, snapshot(state.map)].slice(-APP_CONFIG.historyLimit),
});
restore(next);
},
addChild: (parentId) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return null;
const targetId = parentId ?? state.selectedNodeIds[0] ?? null;
const parent = targetId ? map.nodes.find((node) => node.id === targetId) : undefined;
const horizontal = isHorizontalLayout(map.settings.layout);
const direction = layoutDirection(map.settings.layout);
const child = parent
? createNode({
text: '',
parentId: parent.id,
position: suggestChildPosition(map.nodes, parent, horizontal, direction),
})
: createNode({
text: '',
type: map.nodes.length === 0 ? 'root' : 'standard',
position: { x: 0, y: map.nodes.length * 70 },
});
commit((current) => {
const nodes = current.nodes.map((node) =>
parent && node.id === parent.id && node.collapsed ? { ...node, collapsed: false } : node,
);
return withEdges(current, [...nodes, child]);
});
set({ selectedNodeIds: [child.id], editingNodeId: child.id });
return child.id;
},
addSibling: (nodeId) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return null;
const referenceId = nodeId ?? state.selectedNodeIds[0];
const reference = referenceId
? map.nodes.find((node) => node.id === referenceId)
: undefined;
if (!reference) return get().addChild(null);
// Un frere de la racine devient une nouvelle racine detachee.
if (!reference.parentId) {
const sibling = createNode({
text: '',
position: { x: reference.position.x, y: reference.position.y + 120 },
});
commit((current) => withEdges(current, [...current.nodes, sibling]));
set({ selectedNodeIds: [sibling.id], editingNodeId: sibling.id });
return sibling.id;
}
const parent = map.nodes.find((node) => node.id === reference.parentId);
if (!parent) return null;
const horizontal = isHorizontalLayout(map.settings.layout);
const sibling = createNode({
text: '',
parentId: parent.id,
position: horizontal
? { x: reference.position.x, y: reference.position.y + 70 }
: { x: reference.position.x + 200, y: reference.position.y },
});
commit((current) => withEdges(current, [...current.nodes, sibling]));
set({ selectedNodeIds: [sibling.id], editingNodeId: sibling.id });
return sibling.id;
},
deleteNodes: (ids) => {
const state = get();
const map = state.map;
if (!map || state.readOnly || ids.length === 0) return;
// Supprimer un noeud supprime toute sa branche : c'est reversible via Ctrl+Z.
const doomed = new Set(withDescendants(map.nodes, ids));
const remaining = map.nodes.filter((node) => !doomed.has(node.id));
const firstParent = map.nodes.find((node) => node.id === ids[0])?.parentId ?? null;
commit((current) => withEdges(current, remaining));
set({
selectedNodeIds: firstParent && !doomed.has(firstParent) ? [firstParent] : [],
editingNodeId: null,
});
},
updateNodeText: (id, text) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => (node.id === id ? { ...node, text } : node)),
),
);
},
updateNode: (id, patch) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => (node.id === id ? { ...node, ...patch } : node)),
),
);
},
updateNodeStyle: (id, patch) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) =>
node.id === id ? { ...node, style: { ...node.style, ...patch } } : node,
),
),
);
},
resetNodeStyle: (id) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => {
if (node.id !== id) return node;
const { style: _style, ...rest } = node;
return rest as MindMapNode;
}),
),
);
},
moveNodes: (updates) => {
if (updates.length === 0) return;
const byId = new Map(updates.map((update) => [update.id, update.position]));
// Pas d'entree d'historique ici : `pushHistory` est appele au debut du drag.
commit(
(current) => ({
...current,
nodes: current.nodes.map((node) => {
const position = byId.get(node.id);
return position ? { ...node, position } : node;
}),
}),
{ history: false },
);
},
reparentNode: (childId, parentId) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return false;
if (wouldCreateCycle(map.nodes, childId, parentId)) return false;
commit((current) =>
withEdges(
current,
current.nodes.map((node) => {
if (node.id === childId) return { ...node, parentId, type: 'standard' };
if (node.id === parentId && node.collapsed) return { ...node, collapsed: false };
return node;
}),
),
);
return true;
},
detachNode: (childId) => {
commit((current) =>
withEdges(
current,
current.nodes.map((node) => (node.id === childId ? { ...node, parentId: null } : node)),
),
);
},
toggleCollapse: (id) => {
const state = get();
const map = state.map;
if (!map) return;
const nodes = map.nodes.map((node) =>
node.id === id ? { ...node, collapsed: !node.collapsed } : node,
);
// Replier reste possible en lecture seule : c'est un confort de lecture
// sur une carte partagee, et rien n'est sauvegarde dans ce mode.
if (state.readOnly) {
set({ map: { ...map, nodes } });
return;
}
commit((current) => withEdges(current, nodes));
},
expandAncestors: (id) => {
const state = get();
const map = state.map;
if (!map) return;
const nodeMap = buildNodeMap(map.nodes);
const toExpand = new Set<string>();
let cursor = nodeMap.get(id)?.parentId ?? null;
let guard = 0;
while (cursor && guard < 1000) {
const parent = nodeMap.get(cursor);
if (!parent) break;
if (parent.collapsed) toExpand.add(parent.id);
cursor = parent.parentId;
guard += 1;
}
if (toExpand.size === 0) return;
commit(
(current) =>
withEdges(
current,
current.nodes.map((node) =>
toExpand.has(node.id) ? { ...node, collapsed: false } : node,
),
),
{ history: false },
);
},
copySelection: () => {
const state = get();
const map = state.map;
if (!map || state.selectedNodeIds.length === 0) return 0;
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const nodes = map.nodes.filter((node) => ids.has(node.id));
set({ clipboard: { nodes: nodes.map((node) => ({ ...node })) } });
return nodes.length;
},
paste: () => {
const state = get();
const map = state.map;
const clipboard = state.clipboard;
if (!map || state.readOnly || !clipboard || clipboard.nodes.length === 0) return 0;
const target = state.selectedNodeIds[0] ?? null;
const clones = cloneSubtrees(clipboard.nodes, 40, target);
commit((current) => withEdges(current, [...current.nodes, ...clones]));
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
duplicateSelection: () => {
const state = get();
const map = state.map;
if (!map || state.readOnly || state.selectedNodeIds.length === 0) return 0;
const ids = new Set(withDescendants(map.nodes, state.selectedNodeIds));
const source = map.nodes.filter((node) => ids.has(node.id));
// parentOverride non fourni : chaque copie reste au meme niveau que l'original.
const clones = cloneSubtrees(source, 40);
commit((current) => withEdges(current, [...current.nodes, ...clones]));
set({ selectedNodeIds: clones.length > 0 ? [clones[0]?.id as string] : [] });
return clones.length;
},
applyAutoLayout: (layout) => {
const state = get();
const map = state.map;
if (!map || state.readOnly) return;
const target = layout ?? map.settings.layout;
const positions = computeLayout(map.nodes, target, state.measuredSizes);
if (positions.size === 0) return;
commit((current) => ({
...current,
settings: { ...current.settings, layout: target },
nodes: current.nodes.map((node) => {
const position = positions.get(node.id);
return position ? { ...node, position } : node;
}),
}));
set({ fitRequest: get().fitRequest + 1 });
},
updateSettings: (patch) => {
commit((current) => ({ ...current, settings: { ...current.settings, ...patch } }));
},
renameMap: (name) => {
commit((current) => ({ ...current, name }));
},
setViewport: (viewport) => {
const state = get();
if (!state.map || state.readOnly) return;
// Le deplacement du canvas ne cree pas d'entree d'historique.
set({ map: { ...state.map, viewport } });
},
setSaveStatus: (status) => set({ saveStatus: status }),
markSaved: () => set({ saveStatus: 'saved', lastSavedAt: new Date().toISOString() }),
setSearchQuery: (query) => {
const state = get();
const map = state.map;
const trimmed = query.trim();
if (!map || trimmed.length === 0) {
set({ searchQuery: query, searchResults: [], searchIndex: 0 });
return;
}
const needle = normalizeText(trimmed);
const results = map.nodes
.filter(
(node) =>
normalizeText(node.text).includes(needle) ||
(node.note ? normalizeText(node.note).includes(needle) : false),
)
.map((node) => node.id);
set({ searchQuery: query, searchResults: results, searchIndex: 0 });
if (results.length > 0) get().gotoSearchResult(0);
},
gotoSearchResult: (index) => {
const state = get();
const results = state.searchResults;
if (results.length === 0) return;
const bounded = ((index % results.length) + results.length) % results.length;
const nodeId = results[bounded] as string;
get().expandAncestors(nodeId);
set({
searchIndex: bounded,
selectedNodeIds: [nodeId],
focusRequest: { nodeId, nonce: (state.focusRequest?.nonce ?? 0) + 1 },
});
},
nextSearchResult: () => get().gotoSearchResult(get().searchIndex + 1),
previousSearchResult: () => get().gotoSearchResult(get().searchIndex - 1),
requestFit: () => set({ fitRequest: get().fitRequest + 1 }),
setMeasuredSize: (id, size) => {
const state = get();
const known = state.measuredSizes.get(id);
if (known && known.width === size.width && known.height === size.height) return;
state.measuredSizes.set(id, size);
// React Flow a besoin des dimensions sur les noeuds controles (minimap,
// cadrage). On ne redessine que lorsqu'une taille a reellement change.
set({ sizeVersion: state.sizeVersion + 1 });
},
};
});
/** Selecteurs utilitaires, pour eviter de recalculer dans les composants. */
export const selectCanUndo = (state: EditorState): boolean => state.past.length > 0;
export const selectCanRedo = (state: EditorState): boolean => state.future.length > 0;
export const selectSelectedNode = (state: EditorState): MindMapNode | null => {
if (!state.map || state.selectedNodeIds.length !== 1) return null;
return state.map.nodes.find((node) => node.id === state.selectedNodeIds[0]) ?? null;
};

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand';
import { createId } from '@/utils/id';
export type ColorMode = 'light' | 'dark';
export type ToastTone = 'info' | 'success' | 'error';
export interface Toast {
id: string;
message: string;
tone: ToastTone;
}
interface UiState {
colorMode: ColorMode;
propertiesPanelOpen: boolean;
searchPanelOpen: boolean;
toasts: Toast[];
setColorMode: (mode: ColorMode) => void;
toggleColorMode: () => void;
setPropertiesPanelOpen: (open: boolean) => void;
setSearchPanelOpen: (open: boolean) => void;
pushToast: (message: string, tone?: ToastTone) => void;
dismissToast: (id: string) => void;
}
const COLOR_MODE_KEY = 'openmind:color-mode';
function readInitialColorMode(): ColorMode {
if (typeof window === 'undefined') return 'light';
try {
const stored = window.localStorage.getItem(COLOR_MODE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
} catch {
// Stockage indisponible (navigation privee stricte) : on ignore.
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyColorMode(mode: ColorMode): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', mode === 'dark');
try {
window.localStorage.setItem(COLOR_MODE_KEY, mode);
} catch {
// Ignore : la preference restera celle du systeme.
}
}
export const useUiStore = create<UiState>((set, get) => ({
colorMode: readInitialColorMode(),
propertiesPanelOpen: true,
searchPanelOpen: false,
toasts: [],
setColorMode: (mode) => {
applyColorMode(mode);
set({ colorMode: mode });
},
toggleColorMode: () => {
const next = get().colorMode === 'dark' ? 'light' : 'dark';
get().setColorMode(next);
},
setPropertiesPanelOpen: (open) => set({ propertiesPanelOpen: open }),
setSearchPanelOpen: (open) => set({ searchPanelOpen: open }),
pushToast: (message, tone = 'info') => {
const toast: Toast = { id: createId(), message, tone };
set({ toasts: [...get().toasts, toast] });
window.setTimeout(() => get().dismissToast(toast.id), tone === 'error' ? 7000 : 3800);
},
dismissToast: (id) => set({ toasts: get().toasts.filter((toast) => toast.id !== id) }),
}));
/** Applique le mode couleur au chargement de l'application. */
export function initializeColorMode(): void {
applyColorMode(useUiStore.getState().colorMode);
}

View File

@@ -0,0 +1,97 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100;
font-family: theme('fontFamily.sans');
}
/* Focus visible homogene et lisible sur les deux themes. */
:focus-visible {
@apply outline-none ring-2 ring-indigo-500 ring-offset-2 ring-offset-white dark:ring-offset-zinc-950;
}
}
@layer components {
.panel {
@apply rounded-xl border border-zinc-200 bg-white shadow-panel dark:border-zinc-800 dark:bg-zinc-900;
}
.field-label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400;
}
.field-input {
@apply w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500;
}
}
/* ---------------------------------------------------------------- Canvas */
/* L'attribution React Flow reste visible a l'ecran ; elle est retiree
uniquement des exports et de l'impression. */
.openmind-exporting .react-flow__attribution {
display: none;
}
.openmind-canvas .react-flow__handle {
width: 9px;
height: 9px;
border: 2px solid rgba(255, 255, 255, 0.9);
background: rgba(99, 102, 241, 0.9);
opacity: 0;
transition: opacity 120ms ease-out;
}
.openmind-canvas .react-flow__node:hover .react-flow__handle,
.openmind-canvas .react-flow__node.selected .react-flow__handle {
opacity: 1;
}
.openmind-canvas .react-flow__node.selected .mind-node {
box-shadow: 0 0 0 2px #6366f1, 0 0 0 5px rgba(99, 102, 241, 0.22);
}
.mind-node {
transition:
box-shadow 120ms ease-out,
transform 120ms ease-out;
}
.mind-node--search-hit {
animation: mind-node-pulse 1.6s ease-out 2;
}
@keyframes mind-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
35% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.mind-node,
.mind-node--search-hit {
transition: none;
animation: none;
}
}

View File

@@ -0,0 +1,46 @@
/* Mode impression : seule la carte reste visible. */
@media print {
.no-print,
.no-print * {
display: none !important;
}
html,
body,
#root {
height: auto !important;
background: #ffffff !important;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print-surface {
position: static !important;
width: 100% !important;
height: 100vh !important;
border: 0 !important;
box-shadow: none !important;
overflow: visible !important;
}
.react-flow__minimap,
.react-flow__controls,
.react-flow__background,
.react-flow__attribution {
display: none !important;
}
.print-title {
display: block !important;
margin-bottom: 6mm;
font-size: 14pt;
font-weight: 600;
}
}
.print-title {
display: none;
}

View File

@@ -0,0 +1,2 @@
// IndexedDB en memoire pour tester la couche Dexie hors navigateur.
import 'fake-indexeddb/auto';

135
frontend/src/types/map.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Modele metier d'une carte.
*
* Ces types sont volontairement independants de React Flow : la couche
* `features/editor/flowAdapters.ts` fait la traduction. Cela permet de faire
* evoluer le rendu sans toucher au format de fichier.
*/
/** Version du format de fichier. Incrementer en cas de changement cassant. */
export const MAP_FORMAT_VERSION = 1;
/**
* Types de noeuds.
*
* La V1 ne produit que `root` et `standard`. Les autres valeurs sont reservees
* pour les evolutions futures (note, image, groupe, lien, tache) : elles sont
* deja acceptees a la lecture pour ne pas bloquer l'architecture.
*/
export type NodeKind = 'root' | 'standard' | 'note' | 'image' | 'group' | 'link' | 'task';
export const EDITABLE_NODE_KINDS: NodeKind[] = ['root', 'standard'];
export type NodeShape = 'rectangle' | 'rounded' | 'capsule' | 'circle';
export type TextAlign = 'left' | 'center' | 'right';
/** Personnalisations explicites d'un noeud : elles priment toujours sur le theme. */
export interface NodeStyle {
backgroundColor?: string;
textColor?: string;
borderColor?: string;
shape?: NodeShape;
fontSize?: number;
bold?: boolean;
italic?: boolean;
textAlign?: TextAlign;
width?: number;
}
export interface Position {
x: number;
y: number;
}
export interface MindMapNode {
id: string;
type: NodeKind;
/** Texte brut. Jamais interprete comme du HTML. */
text: string;
position: Position;
/** Parent hierarchique. `null` = racine (une carte peut en avoir plusieurs). */
parentId: string | null;
/** Branche repliee : les descendants sont masques. */
collapsed?: boolean;
icon?: string;
url?: string;
note?: string;
style?: NodeStyle;
/** Espace libre reserve aux futurs types de noeuds. */
meta?: Record<string, unknown>;
}
export interface MindMapEdge {
id: string;
source: string;
target: string;
color?: string;
}
export type BranchStyle = 'straight' | 'curved' | 'bezier';
/**
* Dispositions d'auto-layout.
* `mindmap-both`, `organization` et `freeform` sont reserves : ils sont
* acceptes par le modele mais non proposes dans l'interface de la V1.
*/
export type LayoutKind =
| 'mindmap-right'
| 'mindmap-left'
| 'tree-horizontal'
| 'tree-vertical'
| 'mindmap-both'
| 'organization'
| 'freeform';
export interface MapSettings {
theme: string;
layout: LayoutKind;
branchStyle: BranchStyle;
showGrid: boolean;
}
export interface Viewport {
x: number;
y: number;
zoom: number;
}
/** Document complet d'une carte : c'est exactement ce qui est exporte en .mmap. */
export interface MindMap {
version: number;
id: string;
name: string;
createdAt: string;
updatedAt: string;
nodes: MindMapNode[];
edges: MindMapEdge[];
viewport: Viewport;
settings: MapSettings;
}
/** Resume affiche dans le tableau de bord. */
export interface MapSummary {
id: string;
name: string;
createdAt: string;
updatedAt: string;
nodeCount: number;
isShared: boolean;
}
/**
* Informations de partage conservees localement.
* `managementToken` ne quitte jamais l'IndexedDB du proprietaire, sauf dans
* l'en-tete X-Management-Token des requetes PUT/DELETE.
*/
export interface ShareRecord {
mapId: string;
shareToken: string;
managementToken: string;
sharePath: string;
title: string;
createdAt: string;
updatedAt: string;
}

4
frontend/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,4 @@
/** Concatene des classes CSS conditionnelles. */
export function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(' ');
}

View File

@@ -0,0 +1,41 @@
const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
const dateTimeFormatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
export function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '-';
return dateFormatter.format(date);
}
export function formatDateTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '-';
return dateTimeFormatter.format(date);
}
/** Date relative courte ("il y a 3 min"), avec repli sur la date absolue. */
export function formatRelative(iso: string, now: number = Date.now()): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '-';
const seconds = Math.round((now - date.getTime()) / 1000);
if (seconds < 60) return "a l'instant";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `il y a ${minutes} min`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `il y a ${hours} h`;
const days = Math.round(hours / 24);
if (days < 7) return `il y a ${days} j`;
return formatDate(iso);
}

View File

@@ -0,0 +1,34 @@
/** Declenche le telechargement d'un contenu genere cote client. */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
triggerDownload(url, filename);
// Laisse au navigateur le temps de demarrer le telechargement.
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
export function downloadDataUrl(dataUrl: string, filename: string): void {
triggerDownload(dataUrl, filename);
}
function triggerDownload(href: string, filename: string): void {
const link = document.createElement('a');
link.href = href;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
}
/** Transforme un nom de carte en nom de fichier sur. */
export function toSafeFilename(name: string, extension: string): string {
const base =
name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9-_ ]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'carte';
return `${base}${extension}`;
}

12
frontend/src/utils/id.ts Normal file
View File

@@ -0,0 +1,12 @@
/** Identifiants locaux. Aucune valeur predictible n'est utilisee comme secret. */
export function createId(): string {
const globalCrypto = globalThis.crypto;
if (globalCrypto?.randomUUID) {
return globalCrypto.randomUUID();
}
if (globalCrypto?.getRandomValues) {
const bytes = globalCrypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

View File

@@ -0,0 +1,46 @@
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;
export interface NodeSize {
width: number;
height: number;
}
/**
* 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).
*/
export function estimateNodeSize(node: MindMapNode): NodeSize {
const fontSize = node.style?.fontSize ?? 14;
const charWidth = fontSize * 0.58;
const text = node.text || ' ';
const iconWidth = node.icon ? fontSize + 8 : 0;
const longestLine = text
.split('\n')
.reduce((longest, line) => Math.max(longest, line.length), 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 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);
const height = Math.max(38, lines * (fontSize + 6) + NODE_VERTICAL_PADDING);
return { width, height: Math.round(height) };
}
/** Taille reelle si elle a ete mesuree, sinon estimation. */
export function resolveNodeSize(node: MindMapNode, measured?: Map<string, NodeSize>): NodeSize {
const known = measured?.get(node.id);
if (known && known.width > 0 && known.height > 0) return known;
return estimateNodeSize(node);
}

157
frontend/src/utils/tree.ts Normal file
View File

@@ -0,0 +1,157 @@
import { type MindMapEdge, type MindMapNode } from '@/types/map';
/** Index enfant -> parents directs, construit une seule fois par operation. */
export function buildChildrenMap(nodes: MindMapNode[]): Map<string, string[]> {
const children = new Map<string, string[]>();
for (const node of nodes) {
if (!node.parentId) continue;
const list = children.get(node.parentId);
if (list) list.push(node.id);
else children.set(node.parentId, [node.id]);
}
return children;
}
export function buildNodeMap(nodes: MindMapNode[]): Map<string, MindMapNode> {
return new Map(nodes.map((node) => [node.id, node]));
}
/** Racines de la carte : noeuds sans parent (ou dont le parent n'existe plus). */
export function getRootNodes(nodes: MindMapNode[]): MindMapNode[] {
const ids = new Set(nodes.map((node) => node.id));
return nodes.filter((node) => !node.parentId || !ids.has(node.parentId));
}
/** Profondeur de chaque noeud depuis sa racine (racine = 0). */
export function computeDepths(nodes: MindMapNode[]): Map<string, number> {
const nodeMap = buildNodeMap(nodes);
const depths = new Map<string, number>();
const resolve = (id: string, seen: Set<string>): number => {
const cached = depths.get(id);
if (cached !== undefined) return cached;
if (seen.has(id)) return 0; // securite : cycle impossible mais on ne boucle pas
seen.add(id);
const node = nodeMap.get(id);
const parentId = node?.parentId;
const depth = parentId && nodeMap.has(parentId) ? resolve(parentId, seen) + 1 : 0;
depths.set(id, depth);
return depth;
};
for (const node of nodes) resolve(node.id, new Set());
return depths;
}
/** Tous les descendants d'un noeud (lui-meme exclu). */
export function getDescendantIds(nodes: MindMapNode[], rootId: string): string[] {
const children = buildChildrenMap(nodes);
const result: string[] = [];
const queue = [...(children.get(rootId) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
result.push(current);
const next = children.get(current);
if (next) queue.push(...next);
}
return result;
}
/** Un noeud plus tous ses descendants, sans doublon. */
export function withDescendants(nodes: MindMapNode[], ids: string[]): string[] {
const collected = new Set<string>();
for (const id of ids) {
collected.add(id);
for (const descendant of getDescendantIds(nodes, id)) collected.add(descendant);
}
return [...collected];
}
/** Noeuds masques parce qu'un ancetre est replie. */
export function getHiddenNodeIds(nodes: MindMapNode[]): Set<string> {
const hidden = new Set<string>();
const children = buildChildrenMap(nodes);
for (const node of nodes) {
if (!node.collapsed) continue;
const queue = [...(children.get(node.id) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
if (hidden.has(current)) continue;
hidden.add(current);
const next = children.get(current);
if (next) queue.push(...next);
}
}
return hidden;
}
/** Nombre de descendants masques par un noeud replie. */
export function countHiddenDescendants(nodes: MindMapNode[], nodeId: string): number {
return getDescendantIds(nodes, nodeId).length;
}
/** Empeche de rattacher un noeud a l'un de ses propres descendants. */
export function wouldCreateCycle(
nodes: MindMapNode[],
childId: string,
nextParentId: string,
): boolean {
if (childId === nextParentId) return true;
return getDescendantIds(nodes, childId).includes(nextParentId);
}
/** Recalcule les liens a partir de la hierarchie : parentId fait autorite. */
export function rebuildEdges(nodes: MindMapNode[]): MindMapEdge[] {
const ids = new Set(nodes.map((node) => node.id));
return nodes
.filter((node) => node.parentId && ids.has(node.parentId))
.map((node) => ({
id: `e-${node.parentId}-${node.id}`,
source: node.parentId as string,
target: node.id,
}));
}
/**
* Restaure `parentId` a partir des liens.
* Utilise a l'import, si un fichier fournit des liens mais pas de hierarchie.
*/
export function rebuildHierarchyFromEdges(
nodes: MindMapNode[],
edges: MindMapEdge[],
): MindMapNode[] {
const parentByChild = new Map<string, string>();
for (const edge of edges) {
if (!parentByChild.has(edge.target)) parentByChild.set(edge.target, edge.source);
}
return nodes.map((node) =>
node.parentId === undefined || node.parentId === null
? { ...node, parentId: parentByChild.get(node.id) ?? null }
: node,
);
}
/** Prochaine position libre pour un nouvel enfant, avant tout auto-layout. */
export function suggestChildPosition(
nodes: MindMapNode[],
parent: MindMapNode,
horizontal: boolean,
direction: 1 | -1,
): { x: number; y: number } {
const siblings = nodes.filter((node) => node.parentId === parent.id);
const offset = siblings.length;
if (horizontal) {
return {
x: parent.position.x + direction * 220,
y: parent.position.y + offset * 72 - (siblings.length > 0 ? 36 : 0),
};
}
return {
x: parent.position.x + offset * 200 - (siblings.length > 0 ? 100 : 0),
y: parent.position.y + direction * 140,
};
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { isValidUrl, sanitizeUrl } from '@/utils/url';
describe('assainissement des URL', () => {
it('accepte http, https et mailto', () => {
expect(sanitizeUrl('https://exemple.fr/page')).toBe('https://exemple.fr/page');
expect(sanitizeUrl('http://10.0.0.1:8080')).toBe('http://10.0.0.1:8080/');
expect(sanitizeUrl('mailto:contact@exemple.fr')).toBe('mailto:contact@exemple.fr');
});
it('prefixe en https les adresses sans protocole', () => {
expect(sanitizeUrl('exemple.fr')).toBe('https://exemple.fr/');
});
it('rejette les protocoles dangereux', () => {
expect(sanitizeUrl('javascript:alert(1)')).toBeNull();
expect(sanitizeUrl('data:text/html,<script>')).toBeNull();
expect(sanitizeUrl('vbscript:msgbox')).toBeNull();
expect(sanitizeUrl('file:///etc/passwd')).toBeNull();
});
it('rejette les valeurs vides', () => {
expect(sanitizeUrl('')).toBeNull();
expect(sanitizeUrl(' ')).toBeNull();
expect(sanitizeUrl(undefined)).toBeNull();
expect(isValidUrl(null)).toBe(false);
});
});

26
frontend/src/utils/url.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Assainissement des URL saisies dans un noeud.
* Seuls http(s) et mailto sont autorises : `javascript:` et `data:` sont
* systematiquement rejetes.
*/
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
export function sanitizeUrl(rawUrl: string | undefined | null): string | null {
if (!rawUrl) return null;
const trimmed = rawUrl.trim();
if (!trimmed) return null;
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const parsed = new URL(candidate);
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) return null;
return parsed.toString();
} catch {
return null;
}
}
export function isValidUrl(rawUrl: string | undefined | null): boolean {
return sanitizeUrl(rawUrl) !== null;
}

View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
boxShadow: {
panel: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -12px rgba(15, 23, 42, 0.18)',
},
keyframes: {
'fade-in': {
from: { opacity: '0', transform: 'translateY(4px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 140ms ease-out',
},
},
},
plugins: [],
};

28
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": false,
"skipLibCheck": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

32
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
chunkSizeWarningLimit: 1200,
},
test: {
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.ts'],
},
});