Phase 1.1 : mise en page et edition visuelle des noeuds
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>
This commit is contained in:
108
frontend/src/features/editor/NodeContextMenu.tsx
Normal file
108
frontend/src/features/editor/NodeContextMenu.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface NodeContextMenuState {
|
||||
nodeId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface NodeContextMenuProps {
|
||||
state: NodeContextMenuState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Menu contextuel d'un noeud : actions de style et de mise en page. */
|
||||
export function NodeContextMenu({ state, onClose }: NodeContextMenuProps) {
|
||||
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
|
||||
const copyStyle = useEditorStore((store) => store.copyStyle);
|
||||
const pasteStyle = useEditorStore((store) => store.pasteStyle);
|
||||
const resetNodesStyle = useEditorStore((store) => store.resetNodesStyle);
|
||||
const fitNodesToContent = useEditorStore((store) => store.fitNodesToContent);
|
||||
const duplicateSelection = useEditorStore((store) => store.duplicateSelection);
|
||||
const deleteNodes = useEditorStore((store) => store.deleteNodes);
|
||||
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
|
||||
const pushToast = useUiStore((store) => store.pushToast);
|
||||
|
||||
useEffect(() => {
|
||||
const close = () => onClose();
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const targets = selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId];
|
||||
|
||||
const items: Array<{
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
run: () => void;
|
||||
}> = [
|
||||
{
|
||||
label: 'Copier le style',
|
||||
hint: 'Ctrl+Maj+C',
|
||||
disabled: targets.length !== 1,
|
||||
run: () => {
|
||||
if (copyStyle()) pushToast('Style copie.', 'info');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Coller le style',
|
||||
hint: 'Ctrl+Maj+V',
|
||||
disabled: !hasStyleClipboard,
|
||||
run: () => {
|
||||
const count = pasteStyle();
|
||||
if (count > 0) pushToast(`Style applique a ${count} noeud(s).`, 'success');
|
||||
},
|
||||
},
|
||||
{ label: 'Reinitialiser le style', run: () => resetNodesStyle(targets) },
|
||||
{ label: 'Adapter au contenu', run: () => fitNodesToContent(targets) },
|
||||
{ label: 'Dupliquer', hint: 'Ctrl+D', run: () => duplicateSelection() },
|
||||
{ label: 'Supprimer', danger: true, run: () => deleteNodes(targets) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Actions du noeud"
|
||||
className="panel fixed z-50 min-w-[13rem] animate-fade-in p-1"
|
||||
style={{ left: state.x, top: state.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.run();
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
item.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',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.hint ? (
|
||||
<span className="text-[11px] uppercase tracking-wide text-zinc-400">{item.hint}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user