OpenMind v1 : cartes mentales libres, local-first et dockerisees

Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:19:18 +02:00
commit 0b2e5e326d
113 changed files with 14576 additions and 0 deletions

View File

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