Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite retiree, compatibilite .mmap v1 et IndexedDB preservee. Modele (format v2) - MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels. - NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow. - MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides. - Un champ absent signifie "herite du theme" : rien n'est ecrit dans le document pour materialiser un defaut, donc les cartes deja stockees restent lisibles telles quelles. Migration v1 -> v2 : style.width devient layout.width, toujours lu en repli par le resolveur. Mise en page - Nouveau resolveur de style qui fusionne theme et personnalisations. - Taille automatique fidele au contenu, taille personnalisee bornee, redimensionnement a la souris via NodeResizer. - Padding horizontal et vertical, retour a la ligne, largeur maximale du texte, interligne, police, alignement horizontal et vertical, ombre. - Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et espacement configurables. Multi-selection - Barre contextuelle d'alignement (6 axes) et de distribution (2 axes). - Application d'un reglage a toute la selection, avec etat "Mixte". Style - Copier / coller / reinitialiser le style, au clavier et par clic droit. Auto-layout - Espacement parent/enfant et entre branches configurable, presets Compact, Normal et Aere. Le layout reste declenche uniquement a la demande. Historique - Une seule entree par redimensionnement complet, par alignement, par distribution, par application multiple et par collage de style. Panneau Proprietes - Reorganise en cinq sections repliables dont l'etat est conserve pendant la session. Vraies icones d'alignement, tooltips et aria-labels partout. Corrections - Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la largeur min-content a un caractere, ce qui faisait s'effondrer tous les noeuds automatiques. - Largeur auto en max-content bornee, pour qu'une phrase longue occupe la largeur maximale au lieu de former une colonne etroite. Tests - 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
3.9 KiB
TypeScript
138 lines
3.9 KiB
TypeScript
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 && event.shiftKey && (key === 'c' || key === 'v')) {
|
|
event.preventDefault();
|
|
if (key === 'c') {
|
|
if (store.copyStyle()) useUiStore.getState().pushToast('Style copie.', 'info');
|
|
return;
|
|
}
|
|
const applied = store.pasteStyle();
|
|
if (applied > 0) {
|
|
useUiStore.getState().pushToast(`Style applique a ${applied} noeud(s).`, 'success');
|
|
}
|
|
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]);
|
|
}
|