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>
662 lines
25 KiB
TypeScript
662 lines
25 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 { RelationProperties } from '@/features/editor/RelationProperties';
|
|
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 selectedRelationId = useEditorStore((state) => state.selectedRelationId);
|
|
|
|
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 relation = map?.relations.find((candidate) => candidate.id === selectedRelationId);
|
|
|
|
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>
|
|
);
|
|
|
|
// Une relation selectionnee prend la place du panneau de noeud : les deux
|
|
// selections s'excluent, le panneau ne montre donc jamais les deux a la fois.
|
|
if (nodes.length === 0 && relation) {
|
|
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}
|
|
<RelationProperties relationId={relation.id} />
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
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. Cliquez une
|
|
relation pour en modifier le libelle et le trace.
|
|
</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'icone et de style
|
|
ci-dessous s'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>
|
|
);
|
|
}
|