Files
openmind/frontend/src/features/editor/PropertiesPanel.tsx
snoopy af6fd519c5 Phase 1.1 : mise en page et edition visuelle des noeuds
Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite
retiree, compatibilite .mmap v1 et IndexedDB preservee.

Modele (format v2)
- MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels.
- NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow.
- MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides.
- Un champ absent signifie "herite du theme" : rien n'est ecrit dans le
  document pour materialiser un defaut, donc les cartes deja stockees restent
  lisibles telles quelles. Migration v1 -> v2 : style.width devient
  layout.width, toujours lu en repli par le resolveur.

Mise en page
- Nouveau resolveur de style qui fusionne theme et personnalisations.
- Taille automatique fidele au contenu, taille personnalisee bornee,
  redimensionnement a la souris via NodeResizer.
- Padding horizontal et vertical, retour a la ligne, largeur maximale du texte,
  interligne, police, alignement horizontal et vertical, ombre.
- Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et
  espacement configurables.

Multi-selection
- Barre contextuelle d'alignement (6 axes) et de distribution (2 axes).
- Application d'un reglage a toute la selection, avec etat "Mixte".

Style
- Copier / coller / reinitialiser le style, au clavier et par clic droit.

Auto-layout
- Espacement parent/enfant et entre branches configurable, presets Compact,
  Normal et Aere. Le layout reste declenche uniquement a la demande.

Historique
- Une seule entree par redimensionnement complet, par alignement, par
  distribution, par application multiple et par collage de style.

Panneau Proprietes
- Reorganise en cinq sections repliables dont l'etat est conserve pendant la
  session. Vraies icones d'alignement, tooltips et aria-labels partout.

Corrections
- Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la
  largeur min-content a un caractere, ce qui faisait s'effondrer tous les
  noeuds automatiques.
- Largeur auto en max-content bornee, pour qu'une phrase longue occupe la
  largeur maximale au lieu de former une colonne etroite.

Tests
- 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:29:48 +02:00

643 lines
24 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
AlignCenter,
AlignLeft,
AlignRight,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignVerticalJustifyStart,
} from 'lucide-react';
import {
type BranchStyle,
type FontFamilyId,
type IconPosition,
type NodeShape,
type ShadowStyle,
type TextAlign,
type VerticalAlign,
} from '@/types/map';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { CloseIcon, CopyIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
import {
FONT_FAMILY_OPTIONS,
LAYOUT_LIMITS,
SHADOW_OPTIONS,
resolveNodeAppearance,
} from '@/features/editor/nodeStyleResolver';
import { BUILTIN_ICONS, BUILTIN_ICON_IDS, EMOJI_ICONS } from '@/features/editor/nodeIcons';
import { PanelSection } from '@/features/editor/panel/PanelSection';
import {
ColorField,
NumberField,
SegmentedControl,
SelectField,
ToggleRow,
} from '@/features/editor/panel/controls';
import { sharedValue } from '@/features/editor/panel/sharedValue';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
const SHAPES: Array<{ id: NodeShape; label: string }> = [
{ id: 'rectangle', label: 'Rectangle' },
{ id: 'rounded', label: 'Arrondi' },
{ id: 'capsule', label: 'Capsule' },
{ id: 'circle', label: 'Cercle' },
];
const BRANCH_STYLES: Array<{ id: BranchStyle | 'theme'; label: string }> = [
{ id: 'theme', label: 'Theme' },
{ id: 'bezier', label: 'Bezier' },
{ id: 'curved', label: 'Courbe' },
{ id: 'straight', label: 'Droite' },
];
const HORIZONTAL_ALIGN = [
{ value: 'left' as TextAlign, label: 'Aligner a gauche', icon: <AlignLeft size={15} /> },
{ value: 'center' as TextAlign, label: 'Centrer', icon: <AlignCenter size={15} /> },
{ value: 'right' as TextAlign, label: 'Aligner a droite', icon: <AlignRight size={15} /> },
];
const VERTICAL_ALIGN = [
{
value: 'top' as VerticalAlign,
label: 'Aligner en haut',
icon: <AlignVerticalJustifyStart size={15} />,
},
{
value: 'middle' as VerticalAlign,
label: 'Centrer verticalement',
icon: <AlignVerticalJustifyCenter size={15} />,
},
{
value: 'bottom' as VerticalAlign,
label: 'Aligner en bas',
icon: <AlignVerticalJustifyEnd size={15} />,
},
];
const ICON_POSITIONS: Array<{ id: IconPosition; label: string }> = [
{ id: 'left', label: 'Gauche' },
{ id: 'right', label: 'Droite' },
{ id: 'top', label: 'Au-dessus' },
];
const ICON_SIZES = [16, 20, 24, 32];
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const updateNodesStyle = useEditorStore((state) => state.updateNodesStyle);
const updateNodesLayout = useEditorStore((state) => state.updateNodesLayout);
const updateNodesIconStyle = useEditorStore((state) => state.updateNodesIconStyle);
const updateNodesBranch = useEditorStore((state) => state.updateNodesBranch);
const setNodesIcon = useEditorStore((state) => state.setNodesIcon);
const resetNodesStyle = useEditorStore((state) => state.resetNodesStyle);
const fitNodesToContent = useEditorStore((state) => state.fitNodesToContent);
const copyStyle = useEditorStore((state) => state.copyStyle);
const pasteStyle = useEditorStore((state) => state.pasteStyle);
const hasStyleClipboard = useEditorStore((state) => state.styleClipboard !== null);
const detachNode = useEditorStore((state) => state.detachNode);
const deleteNodes = useEditorStore((state) => state.deleteNodes);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const nodes = useMemo(() => {
if (!map) return [];
const wanted = new Set(selectedIds);
return map.nodes.filter((node) => wanted.has(node.id));
}, [map, selectedIds]);
const single = nodes.length === 1 ? nodes[0] : undefined;
const ids = useMemo(() => nodes.map((node) => node.id), [nodes]);
const [text, setText] = useState('');
const [url, setUrl] = useState('');
const [note, setNote] = useState('');
useEffect(() => {
setText(single?.text ?? '');
setUrl(single?.url ?? '');
setNote(single?.note ?? '');
}, [single?.id, single?.text, single?.url, single?.note]);
const theme = getTheme(map?.settings.theme);
const metrics = themeMetrics(theme);
const depths = useMemo(() => (map ? computeDepths(map.nodes) : new Map<string, number>()), [map]);
const referenceDepth = single ? (depths.get(single.id) ?? 0) : 0;
const palette = paletteForDepth(theme, referenceDepth);
const reference = single ? resolveNodeAppearance(single, referenceDepth, theme) : undefined;
const header = (
<header className="flex items-center justify-between gap-2 border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Proprietes
{nodes.length > 1 ? (
<span className="ml-2 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300">
{nodes.length} noeuds
</span>
) : null}
</h2>
<IconButton
label="Masquer le panneau de proprietes"
icon={<CloseIcon />}
onClick={() => setPropertiesPanelOpen(false)}
/>
</header>
);
if (nodes.length === 0) {
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
Selectionnez un noeud pour modifier son contenu, sa typographie, son apparence et sa mise
en page. Selectionnez-en plusieurs pour appliquer un reglage a tout le lot.
</p>
</aside>
);
}
const styleOf = sharedValue;
const commitUrl = () => {
if (!single) return;
const trimmed = url.trim();
if (!trimmed) {
updateNode(single.id, { url: undefined });
return;
}
const safe = sanitizeUrl(trimmed);
if (!safe) {
pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error');
setUrl(single.url ?? '');
return;
}
setUrl(safe);
updateNode(single.id, { url: safe });
};
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<div className="flex flex-wrap gap-1.5 border-b border-zinc-100 px-4 py-2.5 dark:border-zinc-800">
<Button
size="sm"
disabled={!single}
title="Copier le style (Ctrl+Shift+C)"
onClick={() => {
if (copyStyle()) pushToast('Style copie.', 'info');
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copier le style
</Button>
<Button
size="sm"
disabled={!hasStyleClipboard}
title="Coller le style (Ctrl+Shift+V)"
onClick={() => {
const count = pasteStyle();
if (count > 0) pushToast(`Style applique a ${count} noeud(s).`, 'success');
}}
>
Coller le style
</Button>
<Button size="sm" onClick={() => fitNodesToContent(ids)}>
Adapter au contenu
</Button>
<Button size="sm" onClick={() => resetNodesStyle(ids)}>
Reinitialiser le style
</Button>
</div>
<PanelSection id="content" title="Contenu">
{single ? (
<>
<div>
<label className="field-label" htmlFor="node-text">
Texte
</label>
<textarea
id="node-text"
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== single.text && updateNodeText(single.id, text)}
/>
</div>
<div>
<label className="field-label" htmlFor="node-url">
Lien
</label>
<div className="flex items-center gap-2">
<input
id="node-url"
type="url"
inputMode="url"
placeholder="https://exemple.fr"
className="field-input"
value={url}
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{single.url ? (
<a
href={single.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
aria-label="Ouvrir le lien dans un nouvel onglet"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LinkIcon />
</a>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-note">
Notes
</label>
<textarea
id="node-note"
className="field-input min-h-[70px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note}
onChange={(event) => setNote(event.target.value)}
onBlur={() =>
note !== (single.note ?? '') &&
updateNode(single.id, { note: note || undefined })
}
/>
</div>
</>
) : (
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Texte, lien et notes se modifient noeud par noeud. Les reglages d&apos;icone et de style
ci-dessous s&apos;appliquent a toute la selection.
</p>
)}
<div>
<span className="field-label">Icone</span>
<div className="mb-2 flex flex-wrap gap-1">
<button
type="button"
aria-label="Aucune icone"
title="Aucune icone"
onClick={() => setNodesIcon(ids, undefined)}
className="h-8 w-8 rounded-md border border-zinc-200 text-xs text-zinc-500 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
-
</button>
{EMOJI_ICONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={`Icone ${emoji}`}
aria-pressed={single?.icon === emoji}
onClick={() => setNodesIcon(ids, emoji, 'emoji')}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
single?.icon === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{BUILTIN_ICON_IDS.map((iconId) => {
const { label, Icon } = BUILTIN_ICONS[iconId] as (typeof BUILTIN_ICONS)[string];
return (
<button
key={iconId}
type="button"
aria-label={`Icone ${label}`}
title={label}
aria-pressed={single?.icon === iconId}
onClick={() => setNodesIcon(ids, iconId, 'builtin')}
className={`flex h-8 w-8 items-center justify-center rounded-md border text-zinc-600 transition-colors dark:text-zinc-300 ${
single?.icon === iconId
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</button>
);
})}
</div>
</div>
<SelectField
id="icon-position"
label="Position de l'icone"
value={styleOf(nodes, (node) => node.iconStyle?.position).value ?? 'left'}
mixed={styleOf(nodes, (node) => node.iconStyle?.position).mixed}
options={ICON_POSITIONS}
onChange={(position) => updateNodesIconStyle(ids, { position })}
/>
<SegmentedControl
legend="Taille de l'icone"
mixed={styleOf(nodes, (node) => node.iconStyle?.size).mixed}
selected={String(
styleOf(nodes, (node) => node.iconStyle?.size).value ?? metrics.iconSize,
)}
options={ICON_SIZES.map((size) => ({ value: String(size), label: `${size} px` }))}
onSelect={(value) => updateNodesIconStyle(ids, { size: Number(value) })}
/>
<NumberField
id="icon-gap"
label="Espacement icone / texte"
value={styleOf(nodes, (node) => node.iconStyle?.gap).value ?? metrics.iconGap}
mixed={styleOf(nodes, (node) => node.iconStyle?.gap).mixed}
min={LAYOUT_LIMITS.iconGap.min}
max={LAYOUT_LIMITS.iconGap.max}
onChange={(gap) => updateNodesIconStyle(ids, { gap })}
/>
</PanelSection>
<PanelSection id="typography" title="Typographie">
<SelectField
id="font-family"
label="Police"
value={styleOf(nodes, (node) => node.style?.fontFamily).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.fontFamily).mixed}
options={FONT_FAMILY_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(fontFamily) =>
updateNodesStyle(ids, {
fontFamily: fontFamily === 'theme' ? undefined : (fontFamily as FontFamilyId),
})
}
/>
<NumberField
id="font-size"
label="Taille du texte"
value={
styleOf(nodes, (node) => node.style?.fontSize).value ?? (reference?.fontSize ?? 14)
}
mixed={styleOf(nodes, (node) => node.style?.fontSize).mixed}
min={LAYOUT_LIMITS.fontSize.min}
max={LAYOUT_LIMITS.fontSize.max}
onChange={(fontSize) => updateNodesStyle(ids, { fontSize })}
/>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant={styleOf(nodes, (node) => node.style?.bold).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.bold).value)}
aria-label="Texte gras"
title="Gras"
className="font-bold"
onClick={() =>
updateNodesStyle(ids, {
bold: !styleOf(nodes, (node) => node.style?.bold).value,
})
}
>
G
</Button>
<Button
size="sm"
variant={styleOf(nodes, (node) => node.style?.italic).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.italic).value)}
aria-label="Texte italique"
title="Italique"
className="italic"
onClick={() =>
updateNodesStyle(ids, {
italic: !styleOf(nodes, (node) => node.style?.italic).value,
})
}
>
I
</Button>
</div>
<SegmentedControl
legend="Alignement horizontal"
options={HORIZONTAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.textAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.textAlign).value ?? 'left'}
onSelect={(textAlign) => updateNodesStyle(ids, { textAlign })}
/>
<SegmentedControl
legend="Alignement vertical"
options={VERTICAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.verticalAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.verticalAlign).value ?? 'top'}
onSelect={(verticalAlign) => updateNodesStyle(ids, { verticalAlign })}
/>
<NumberField
id="line-height"
label="Interligne"
unit=""
step={0.1}
value={styleOf(nodes, (node) => node.style?.lineHeight).value ?? metrics.lineHeight}
mixed={styleOf(nodes, (node) => node.style?.lineHeight).mixed}
min={LAYOUT_LIMITS.lineHeight.min}
max={LAYOUT_LIMITS.lineHeight.max}
onChange={(lineHeight) => updateNodesStyle(ids, { lineHeight })}
/>
</PanelSection>
<PanelSection id="appearance" title="Apparence">
<ColorField
id="node-bg"
label="Fond"
shared={styleOf(nodes, (node) => node.style?.backgroundColor)}
fallback={palette.background}
onChange={(backgroundColor) => updateNodesStyle(ids, { backgroundColor })}
/>
<ColorField
id="node-text-color"
label="Couleur du texte"
shared={styleOf(nodes, (node) => node.style?.textColor)}
fallback={palette.text}
onChange={(textColor) => updateNodesStyle(ids, { textColor })}
/>
<ColorField
id="node-border"
label="Bordure"
shared={styleOf(nodes, (node) => node.style?.borderColor)}
fallback={palette.border}
onChange={(borderColor) => updateNodesStyle(ids, { borderColor })}
/>
<SelectField
id="node-shape"
label="Forme"
value={styleOf(nodes, (node) => node.style?.shape).value ?? theme.nodeShape}
mixed={styleOf(nodes, (node) => node.style?.shape).mixed}
options={SHAPES}
onChange={(shape) => updateNodesStyle(ids, { shape })}
/>
<SelectField
id="node-shadow"
label="Ombre"
value={styleOf(nodes, (node) => node.style?.shadow).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.shadow).mixed}
options={SHADOW_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(shadow) =>
updateNodesStyle(ids, { shadow: shadow === 'theme' ? undefined : (shadow as ShadowStyle) })
}
/>
</PanelSection>
<PanelSection id="layout" title="Mise en page">
<SegmentedControl
legend="Taille du noeud"
mixed={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').mixed}
selected={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').value ?? 'auto'}
options={[
{ value: 'auto', label: 'Automatique' },
{ value: 'fixed', label: 'Personnalisee' },
]}
onSelect={(widthMode) => {
if (widthMode === 'auto') {
fitNodesToContent(ids);
return;
}
updateNodesLayout(ids, {
widthMode: 'fixed',
width: reference?.width ?? Math.round(reference?.minWidth ?? 160),
});
}}
/>
<NumberField
id="node-width"
label="Largeur"
value={
styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).value ??
(reference?.width ?? 160)
}
mixed={styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).mixed}
min={LAYOUT_LIMITS.width.min}
max={LAYOUT_LIMITS.width.max}
onChange={(width) => updateNodesLayout(ids, { widthMode: 'fixed', width })}
/>
<NumberField
id="node-height"
label="Hauteur"
value={styleOf(nodes, (node) => node.layout?.height).value ?? (reference?.height ?? 48)}
mixed={styleOf(nodes, (node) => node.layout?.height).mixed}
min={LAYOUT_LIMITS.height.min}
max={LAYOUT_LIMITS.height.max}
onChange={(height) => updateNodesLayout(ids, { heightMode: 'fixed', height })}
/>
<NumberField
id="padding-x"
label="Padding horizontal"
value={styleOf(nodes, (node) => node.layout?.paddingX).value ?? metrics.paddingX}
mixed={styleOf(nodes, (node) => node.layout?.paddingX).mixed}
min={LAYOUT_LIMITS.paddingX.min}
max={LAYOUT_LIMITS.paddingX.max}
onChange={(paddingX) => updateNodesLayout(ids, { paddingX })}
/>
<NumberField
id="padding-y"
label="Padding vertical"
value={styleOf(nodes, (node) => node.layout?.paddingY).value ?? metrics.paddingY}
mixed={styleOf(nodes, (node) => node.layout?.paddingY).mixed}
min={LAYOUT_LIMITS.paddingY.min}
max={LAYOUT_LIMITS.paddingY.max}
onChange={(paddingY) => updateNodesLayout(ids, { paddingY })}
/>
<ToggleRow
id="node-wrap"
label="Retour a la ligne automatique"
checked={styleOf(nodes, (node) => node.layout?.wrap ?? true).value ?? true}
mixed={styleOf(nodes, (node) => node.layout?.wrap ?? true).mixed}
onChange={(wrap) => updateNodesLayout(ids, { wrap })}
/>
<NumberField
id="max-text-width"
label="Largeur maximale du texte"
value={styleOf(nodes, (node) => node.layout?.maxTextWidth).value ?? metrics.maxTextWidth}
mixed={styleOf(nodes, (node) => node.layout?.maxTextWidth).mixed}
min={LAYOUT_LIMITS.maxTextWidth.min}
max={LAYOUT_LIMITS.maxTextWidth.max}
disabled={styleOf(nodes, (node) => node.layout?.wrap ?? true).value === false}
onChange={(maxTextWidth) => updateNodesLayout(ids, { maxTextWidth })}
/>
</PanelSection>
<PanelSection id="branch" title="Branche">
<SelectField
id="branch-style"
label="Style"
value={styleOf(nodes, (node) => node.branch?.style).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.branch?.style).mixed}
options={BRANCH_STYLES}
onChange={(style) =>
updateNodesBranch(ids, {
style: style === 'theme' ? undefined : (style as BranchStyle),
})
}
/>
<NumberField
id="branch-width"
label="Epaisseur"
value={styleOf(nodes, (node) => node.branch?.width).value ?? theme.branchWidth}
mixed={styleOf(nodes, (node) => node.branch?.width).mixed}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
onChange={(width) => updateNodesBranch(ids, { width })}
/>
<ColorField
id="branch-color"
label="Couleur"
shared={styleOf(nodes, (node) => node.branch?.color)}
fallback={theme.branchColor}
onChange={(color) => updateNodesBranch(ids, { color })}
/>
</PanelSection>
<div className="space-y-2 px-4 py-4">
{single?.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(single.id)}>
Detacher de son parent
</Button>
) : null}
<Button size="sm" variant="danger" className="w-full" onClick={() => deleteNodes(ids)}>
<TrashIcon className="h-4 w-4" />
{nodes.length > 1 ? `Supprimer ${nodes.length} noeuds` : 'Supprimer ce noeud'}
</Button>
</div>
</aside>
);
}