Phase 1.2 : productivite de creation
Construire une grande carte demande beaucoup moins de gestes. Ordre des freres (format v3) `MindMapNode.order` devient la position parmi les freres. `parentId` reste la seule source de verite de la relation hierarchique : une source par preoccupation, jamais deux versions contradictoires. L'ordre est renumerote a chaque modification structurelle dans `withEdges`, unique point de passage. Le format passe en v3 : un lecteur v2 ignorerait `order` et reordonnerait silencieusement la carte. Migration v2 -> v3 depuis la position dans le tableau, v1 -> v2 -> v3 s'enchainent, et les noeuds sont ecrits en parcours de profondeur pour rester corrects meme pour un lecteur anterieur. Creation rapide - Ajouter plusieurs enfants : une ligne = un noeud, avec apercu avant creation. - Collage d'une liste multi-ligne sur un noeud : le presse-papiers interne reste prioritaire, l'edition de texte n'est jamais interceptee. - Import d'une arborescence indentee : espaces ou tabulations, puces retirees, indentation incoherente signalee avec le numero de ligne plutot que devinee. - Les nouveaux noeuds sont repartis autour du parent au lieu de se superposer. Hierarchie Monter, descendre, placer en premier ou en dernier, remonter d'un niveau, "Deplacer vers..." avec recherche, suppression d'un noeud en conservant ses enfants a la place qu'il occupait. Le re-parentage passe toujours par `reparentNode` : la detection de cycle n'est ecrite qu'une fois. Branches Selectionner, dupliquer, copier, coller comme enfant, deplacer avec ses descendants (reglage, ou Alt pour l'inverser le temps d'un glisser), replier et developper une branche, tout replier, tout developper, replier au-dela d'un niveau. Clavier Maj+Tab remonte d'un niveau, Maj+Entree ouvre la creation multiple, Alt+Haut et Alt+Bas reordonnent. Tab, Entree et F2 gardent leur comportement. Historique Chaque operation logique reste une seule entree : creation multiple, import d'outline, reordonnancement, deplacement de branche, duplication, collage, suppression, tout replier. Tests : 172 vitest (103 -> 172) et 36 pytest (31 -> 36). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
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 {
|
||||
@@ -12,18 +13,32 @@ export interface NodeContextMenuState {
|
||||
export interface NodeContextMenuProps {
|
||||
state: NodeContextMenuState;
|
||||
onClose: () => void;
|
||||
onAddChildren: (parentId: string) => void;
|
||||
onMoveTo: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
/** Menu contextuel d'un noeud : actions de style et de mise en page. */
|
||||
export function NodeContextMenu({ state, onClose }: NodeContextMenuProps) {
|
||||
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 }: NodeContextMenuProps) {
|
||||
const map = useEditorStore((store) => store.map);
|
||||
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 hasNodeClipboard = useEditorStore((store) => (store.clipboard?.nodes.length ?? 0) > 0);
|
||||
const pushToast = useUiStore((store) => store.pushToast);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,70 +54,205 @@ export function NodeContextMenu({ state, onClose }: NodeContextMenuProps) {
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const targets = selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId];
|
||||
// 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 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');
|
||||
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');
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
{
|
||||
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');
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Ajouter avant',
|
||||
disabled: !node.parentId,
|
||||
run: () => store.addSiblingAt(node.id, 'before'),
|
||||
},
|
||||
},
|
||||
{ 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) },
|
||||
];
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Ajouter apres',
|
||||
hint: 'Entree',
|
||||
disabled: !node.parentId,
|
||||
run: () => store.addSiblingAt(node.id, 'after'),
|
||||
},
|
||||
|
||||
{ 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: '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,
|
||||
pushToast,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Actions du noeud"
|
||||
className="panel fixed z-50 min-w-[13rem] animate-fade-in p-1"
|
||||
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()}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user