Files
openmind/frontend/src/features/editor/MoveToDialog.tsx
snoopy 2afbf637ce 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>
2026-09-10 06:35:57 +02:00

125 lines
4.6 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { SearchIcon } from '@/components/Icons';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths, getDescendantIds } from '@/utils/tree';
import { cn } from '@/utils/cn';
export interface MoveToDialogProps {
open: boolean;
nodeId: string | null;
onClose: () => void;
}
function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Choix d'un nouveau parent par recherche.
*
* Les cibles impossibles (le noeud lui-meme et ses descendants) sont exclues de
* la liste : la detection de cycle de `reparentNode` reste la regle unique, ce
* filtre ne fait qu'eviter de proposer un choix qui serait refuse.
*/
export function MoveToDialog({ open, nodeId, onClose }: MoveToDialogProps) {
const map = useEditorStore((state) => state.map);
const moveNodeToParent = useEditorStore((state) => state.moveNodeToParent);
const pushToast = useUiStore((state) => state.pushToast);
const [query, setQuery] = useState('');
const node = map?.nodes.find((candidate) => candidate.id === nodeId);
const candidates = useMemo(() => {
if (!map || !node) return [];
const excluded = new Set([node.id, ...getDescendantIds(map.nodes, node.id)]);
const depths = computeDepths(map.nodes);
const needle = normalize(query.trim());
return map.nodes
.filter((candidate) => !excluded.has(candidate.id) && candidate.id !== node.parentId)
.filter((candidate) => !needle || normalize(candidate.text).includes(needle))
.map((candidate) => ({ node: candidate, depth: depths.get(candidate.id) ?? 0 }))
.slice(0, 60);
}, [map, node, query]);
const move = (parentId: string | null) => {
if (!node) return;
if (moveNodeToParent(node.id, parentId)) {
pushToast(
parentId ? 'Branche deplacee.' : 'Noeud detache : il devient une racine.',
'success',
);
onClose();
return;
}
pushToast('Deplacement impossible : cela creerait une boucle.', 'error');
};
return (
<Modal
open={open}
title="Deplacer vers..."
description={node ? `"${node.text || 'Sans titre'}" et sa branche changeront de parent.` : ''}
onClose={onClose}
footer={<Button onClick={onClose}>Annuler</Button>}
>
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<SearchIcon className="h-4 w-4 shrink-0 text-zinc-400" />
<input
autoFocus
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher un noeud parent"
aria-label="Rechercher un noeud parent"
className="w-full bg-transparent text-sm outline-none placeholder:text-zinc-400"
/>
</div>
<button
type="button"
onClick={() => move(null)}
className="w-full rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-left text-sm text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
Detacher : en faire une racine
</button>
{candidates.length === 0 ? (
<p className="px-1 py-3 text-sm text-zinc-500">Aucun noeud ne correspond.</p>
) : (
<ul className="max-h-64 overflow-y-auto" aria-label="Nouveaux parents possibles">
{candidates.map(({ node: candidate, depth }) => (
<li key={candidate.id}>
<button
type="button"
onClick={() => move(candidate.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors',
'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
)}
>
<span
aria-hidden="true"
className="shrink-0 font-mono text-[11px] text-zinc-400"
style={{ paddingLeft: depth * 10 }}
>
{depth === 0 ? '\u25cf' : '\u2514'}
</span>
<span className="truncate">{candidate.text || 'Sans titre'}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}