import { useEffect, useMemo } from 'react'; import { useEditorStore } from '@/stores/editorStore'; import { useUiStore } from '@/stores/uiStore'; import { getOrderedChildren } from '@/utils/tree'; import { cn } from '@/utils/cn'; export interface NodeContextMenuState { nodeId: string; x: number; y: number; } export interface NodeContextMenuProps { state: NodeContextMenuState; onClose: () => void; onAddChildren: (parentId: string) => void; onMoveTo: (nodeId: string) => void; onAddRelation: (nodeId: string) => void; onImportMarkdown: (nodeId: string) => void; } interface MenuEntry { kind: 'item'; label: string; hint?: string; disabled?: boolean; danger?: boolean; run: () => void; } interface MenuHeading { kind: 'heading'; label: string; } type MenuLine = MenuEntry | MenuHeading; /** Menu contextuel d'un noeud, organise par famille d'action. */ export function NodeContextMenu({ state, onClose, onAddChildren, onMoveTo, onAddRelation, onImportMarkdown, }: NodeContextMenuProps) { const map = useEditorStore((store) => store.map); const selectedIds = useEditorStore((store) => store.selectedNodeIds); const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null); const hasNodeClipboard = useEditorStore((store) => (store.clipboard?.nodes.length ?? 0) > 0); 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]); // Le clic droit agit sur la selection si le noeud vise en fait partie, // sinon sur ce seul noeud. const targets = useMemo( () => (selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId]), [selectedIds, state.nodeId], ); const lines = useMemo(() => { const store = useEditorStore.getState(); const node = map?.nodes.find((candidate) => candidate.id === state.nodeId); if (!node || !map) return []; const siblings = getOrderedChildren(map.nodes, node.parentId); const position = siblings.findIndex((candidate) => candidate.id === node.id); const hasSiblings = siblings.length > 1; const hasChildren = map.nodes.some((candidate) => candidate.parentId === node.id); const notify = (message: string) => pushToast(message, 'success'); // Le cote ne se regle que sur une branche de niveau 1 : ailleurs, il est // herite de la branche, il n'y a donc rien a choisir. const parent = node.parentId ? map.nodes.find((candidate) => candidate.id === node.parentId) : undefined; const isBranchRoot = Boolean(parent && !parent.parentId); const bilateral = map.settings.layout === 'mindmap-both'; return [ { kind: 'heading', label: 'Ajouter' }, { kind: 'item', label: 'Ajouter un enfant', hint: 'Tab', run: () => store.addChild(node.id) }, { kind: 'item', label: 'Ajouter plusieurs enfants...', hint: 'Maj+Entree', run: () => onAddChildren(node.id), }, { kind: 'item', label: 'Ajouter avant', disabled: !node.parentId, run: () => store.addSiblingAt(node.id, 'before'), }, { kind: 'item', label: 'Ajouter apres', hint: 'Entree', disabled: !node.parentId, run: () => store.addSiblingAt(node.id, 'after'), }, { kind: 'item', label: 'Importer Markdown ici...', run: () => onImportMarkdown(node.id), }, { kind: 'heading', label: 'Relation' }, { kind: 'item', label: 'Ajouter une relation...', run: () => onAddRelation(node.id), }, { kind: 'heading', label: 'Branche' }, { kind: 'item', label: 'Selectionner la branche', run: () => notify(`${store.selectBranch(node.id)} noeuds selectionnes.`), }, { kind: 'item', label: 'Dupliquer la branche', run: () => notify(`Branche dupliquee (${store.duplicateBranch(node.id)} noeuds).`), }, { kind: 'item', label: 'Copier la branche', run: () => notify(`${store.copyBranch(node.id)} noeuds copies.`), }, { kind: 'item', label: 'Coller comme enfant', disabled: !hasNodeClipboard, run: () => notify(`${store.pasteAsChild(node.id)} noeuds colles.`), }, { kind: 'item', label: 'Deplacer vers...', run: () => onMoveTo(node.id) }, { kind: 'item', label: node.collapsed ? 'Developper la branche' : 'Replier la branche', disabled: !hasChildren, run: () => store.setBranchCollapsed(node.id, !node.collapsed), }, { kind: 'heading', label: 'Cote' }, { kind: 'item', label: 'Placer la branche a gauche', hint: bilateral ? undefined : 'bilaterale', disabled: !isBranchRoot || node.side === 'left', run: () => { store.setNodeSide(node.id, 'left'); notify('Branche placee a gauche. Relancez l organisation pour la deplacer.'); }, }, { kind: 'item', label: 'Placer la branche a droite', hint: bilateral ? undefined : 'bilaterale', disabled: !isBranchRoot || node.side === 'right', run: () => { store.setNodeSide(node.id, 'right'); notify('Branche placee a droite. Relancez l organisation pour la deplacer.'); }, }, { kind: 'heading', label: 'Ordre' }, { kind: 'item', label: 'Monter', hint: 'Alt+Haut', disabled: !hasSiblings || position <= 0, run: () => store.moveSibling(node.id, 'up'), }, { kind: 'item', label: 'Descendre', hint: 'Alt+Bas', disabled: !hasSiblings || position >= siblings.length - 1, run: () => store.moveSibling(node.id, 'down'), }, { kind: 'item', label: 'Placer en premier', disabled: !hasSiblings || position <= 0, run: () => store.moveSibling(node.id, 'first'), }, { kind: 'item', label: 'Placer en dernier', disabled: !hasSiblings || position >= siblings.length - 1, run: () => store.moveSibling(node.id, 'last'), }, { kind: 'item', label: 'Remonter d un niveau', hint: 'Maj+Tab', disabled: !node.parentId, run: () => store.outdentNode(node.id), }, { kind: 'heading', label: 'Style' }, { kind: 'item', label: 'Copier le style', hint: 'Ctrl+Maj+C', disabled: targets.length !== 1, run: () => { if (store.copyStyle()) pushToast('Style copie.', 'info'); }, }, { kind: 'item', label: 'Coller le style', hint: 'Ctrl+Maj+V', disabled: !hasStyleClipboard, run: () => notify(`Style applique a ${store.pasteStyle()} noeud(s).`), }, { kind: 'item', label: 'Reinitialiser le style', run: () => store.resetNodesStyle(targets) }, { kind: 'item', label: 'Adapter au contenu', run: () => store.fitNodesToContent(targets) }, { kind: 'heading', label: 'Suppression' }, { kind: 'item', label: 'Supprimer la branche', hint: 'Suppr', danger: true, run: () => store.deleteNodes(targets), }, { kind: 'item', label: 'Supprimer en conservant les enfants', danger: true, disabled: !hasChildren, run: () => store.deleteNodesKeepChildren(targets), }, ]; }, [ map, state.nodeId, targets, hasStyleClipboard, hasNodeClipboard, onAddChildren, onMoveTo, onAddRelation, onImportMarkdown, pushToast, ]); return (
event.stopPropagation()} > {lines.map((line, index) => line.kind === 'heading' ? (
{line.label}
) : ( ), )}
); }