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:
124
frontend/src/features/editor/MoveToDialog.tsx
Normal file
124
frontend/src/features/editor/MoveToDialog.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user