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>
312 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|