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:
34
frontend/eslint.config.js
Normal file
34
frontend/eslint.config.js
Normal 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
17
frontend/index.html
Normal 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
5041
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
12
frontend/public/icons/icon.svg
Normal file
12
frontend/public/icons/icon.svg
Normal 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 |
39
frontend/public/manifest.webmanifest
Normal file
39
frontend/public/manifest.webmanifest
Normal 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
71
frontend/public/sw.js
Normal 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
44
frontend/src/App.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/Button.tsx
Normal file
46
frontend/src/components/Button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
44
frontend/src/components/ConfirmDialog.tsx
Normal file
44
frontend/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/IconButton.tsx
Normal file
41
frontend/src/components/IconButton.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
248
frontend/src/components/Icons.tsx
Normal file
248
frontend/src/components/Icons.tsx
Normal 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>
|
||||
);
|
||||
133
frontend/src/components/Menu.tsx
Normal file
133
frontend/src/components/Menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/Modal.tsx
Normal file
89
frontend/src/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/Toasts.tsx
Normal file
47
frontend/src/components/Toasts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/config/app.ts
Normal file
27
frontend/src/config/app.ts
Normal 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;
|
||||
37
frontend/src/db/database.ts
Normal file
37
frontend/src/db/database.ts
Normal 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;
|
||||
124
frontend/src/features/editor/EditorPage.tsx
Normal file
124
frontend/src/features/editor/EditorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
frontend/src/features/editor/EditorToolbar.tsx
Normal file
249
frontend/src/features/editor/EditorToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
frontend/src/features/editor/MindMapCanvas.tsx
Normal file
219
frontend/src/features/editor/MindMapCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
frontend/src/features/editor/MindNodeView.tsx
Normal file
200
frontend/src/features/editor/MindNodeView.tsx
Normal 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);
|
||||
345
frontend/src/features/editor/PropertiesPanel.tsx
Normal file
345
frontend/src/features/editor/PropertiesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/features/editor/SearchPanel.tsx
Normal file
99
frontend/src/features/editor/SearchPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
182
frontend/src/features/editor/flowAdapters.ts
Normal file
182
frontend/src/features/editor/flowAdapters.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
49
frontend/src/features/editor/layout.test.ts
Normal file
49
frontend/src/features/editor/layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
frontend/src/features/editor/layout.ts
Normal file
121
frontend/src/features/editor/layout.ts
Normal 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;
|
||||
}
|
||||
235
frontend/src/features/editor/themes.ts
Normal file
235
frontend/src/features/editor/themes.ts
Normal 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;
|
||||
}
|
||||
66
frontend/src/features/editor/useAutosave.ts
Normal file
66
frontend/src/features/editor/useAutosave.ts
Normal 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 };
|
||||
}
|
||||
124
frontend/src/features/editor/useEditorShortcuts.ts
Normal file
124
frontend/src/features/editor/useEditorShortcuts.ts
Normal 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]);
|
||||
}
|
||||
149
frontend/src/features/export/ExportMenu.tsx
Normal file
149
frontend/src/features/export/ExportMenu.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
188
frontend/src/features/export/PdfExportDialog.tsx
Normal file
188
frontend/src/features/export/PdfExportDialog.tsx
Normal 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'est envoyee au
|
||||
serveur.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
112
frontend/src/features/export/PrintDialog.tsx
Normal file
112
frontend/src/features/export/PrintDialog.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
218
frontend/src/features/export/exportImage.ts
Normal file
218
frontend/src/features/export/exportImage.ts
Normal 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 });
|
||||
}
|
||||
145
frontend/src/features/export/exportPdf.ts
Normal file
145
frontend/src/features/export/exportPdf.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
39
frontend/src/features/export/printMap.ts
Normal file
39
frontend/src/features/export/printMap.ts
Normal 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);
|
||||
}
|
||||
256
frontend/src/features/maps/DashboardPage.tsx
Normal file
256
frontend/src/features/maps/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/features/maps/MapCard.tsx
Normal file
121
frontend/src/features/maps/MapCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
frontend/src/features/sharing/ShareDialog.tsx
Normal file
239
frontend/src/features/sharing/ShareDialog.tsx
Normal 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'apparait dans aucune liste
|
||||
publique, mais toute personne qui le connait peut consulter la carte. Les
|
||||
modifications faites ici ne sont visibles publiquement qu'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'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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
frontend/src/features/sharing/SharedMapPage.tsx
Normal file
138
frontend/src/features/sharing/SharedMapPage.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/hooks/useDebouncedEffect.ts
Normal file
16
frontend/src/hooks/useDebouncedEffect.ts
Normal 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]);
|
||||
}
|
||||
63
frontend/src/hooks/useRoute.ts
Normal file
63
frontend/src/hooks/useRoute.ts
Normal 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
29
frontend/src/main.tsx
Normal 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.
|
||||
});
|
||||
});
|
||||
}
|
||||
131
frontend/src/services/mapFactory.ts
Normal file
131
frontend/src/services/mapFactory.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
105
frontend/src/services/mapRepository.test.ts
Normal file
105
frontend/src/services/mapRepository.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
98
frontend/src/services/mapRepository.ts
Normal file
98
frontend/src/services/mapRepository.ts
Normal 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;
|
||||
}
|
||||
127
frontend/src/services/mapSerialization.test.ts
Normal file
127
frontend/src/services/mapSerialization.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
293
frontend/src/services/mapSerialization.ts
Normal file
293
frontend/src/services/mapSerialization.ts
Normal 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);
|
||||
}
|
||||
164
frontend/src/services/shareApi.ts
Normal file
164
frontend/src/services/shareApi.ts
Normal 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');
|
||||
}
|
||||
22
frontend/src/services/shareRepository.ts
Normal file
22
frontend/src/services/shareRepository.ts
Normal 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);
|
||||
}
|
||||
41
frontend/src/stores/appConfigStore.ts
Normal file
41
frontend/src/stores/appConfigStore.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
185
frontend/src/stores/editorStore.test.ts
Normal file
185
frontend/src/stores/editorStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
652
frontend/src/stores/editorStore.ts
Normal file
652
frontend/src/stores/editorStore.ts
Normal 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;
|
||||
};
|
||||
81
frontend/src/stores/uiStore.ts
Normal file
81
frontend/src/stores/uiStore.ts
Normal 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);
|
||||
}
|
||||
97
frontend/src/styles/index.css
Normal file
97
frontend/src/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
frontend/src/styles/print.css
Normal file
46
frontend/src/styles/print.css
Normal 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;
|
||||
}
|
||||
2
frontend/src/test/setup.ts
Normal file
2
frontend/src/test/setup.ts
Normal 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
135
frontend/src/types/map.ts
Normal 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
4
frontend/src/utils/cn.ts
Normal 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(' ');
|
||||
}
|
||||
41
frontend/src/utils/date.ts
Normal file
41
frontend/src/utils/date.ts
Normal 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);
|
||||
}
|
||||
34
frontend/src/utils/download.ts
Normal file
34
frontend/src/utils/download.ts
Normal 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
12
frontend/src/utils/id.ts
Normal 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)}`;
|
||||
}
|
||||
46
frontend/src/utils/nodeSize.ts
Normal file
46
frontend/src/utils/nodeSize.ts
Normal 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
157
frontend/src/utils/tree.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
28
frontend/src/utils/url.test.ts
Normal file
28
frontend/src/utils/url.test.ts
Normal 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
26
frontend/src/utils/url.ts
Normal 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;
|
||||
}
|
||||
35
frontend/tailwind.config.js
Normal file
35
frontend/tailwind.config.js
Normal 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
28
frontend/tsconfig.json
Normal 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
32
frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user