Files
openmind/frontend/src/features/editor/NodeContextMenu.tsx
snoopy e3c530348e Phase 2 : mind map bilaterale, relations, templates et Markdown
Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et
`MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les
cartes v1/v2/v3 restent ouvrables telles quelles.

Une relation est une connexion purement visuelle : elle ne touche jamais
parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue
de deriver les liens de parentId et se contente de retirer les relations dont
une extremite a disparu.

Mind map bilaterale : repartition gloutonne et deterministe des branches selon
la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre
recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul
Reorganiser applique.

Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou
texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant,
et l'export Markdown de la hierarchie.

Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles
changent de cote, ce qui faisait disparaitre les liens concernes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:12:00 +02:00

312 lines
9.5 KiB
TypeScript

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<MenuLine[]>(() => {
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 (
<div
role="menu"
aria-label="Actions du noeud"
className="panel fixed z-50 max-h-[80vh] min-w-[15rem] animate-fade-in overflow-y-auto p-1"
style={{ left: state.x, top: state.y }}
onClick={(event) => event.stopPropagation()}
>
{lines.map((line, index) =>
line.kind === 'heading' ? (
<div
key={`heading-${line.label}`}
className={cn(
'px-2.5 pb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-400',
index === 0 ? 'pt-1.5' : 'mt-1 border-t border-zinc-100 pt-2 dark:border-zinc-800',
)}
>
{line.label}
</div>
) : (
<button
key={line.label}
type="button"
role="menuitem"
disabled={line.disabled}
onClick={() => {
line.run();
onClose();
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40',
line.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">{line.label}</span>
{line.hint ? (
<span className="shrink-0 text-[11px] uppercase tracking-wide text-zinc-400">
{line.hint}
</span>
) : null}
</button>
),
)}
</div>
);
}