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>
This commit is contained in:
@@ -1,10 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type NodeShape, type TextAlign } from '@/types/map';
|
||||
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, LinkIcon, TrashIcon } from '@/components/Icons';
|
||||
import { getTheme, paletteForDepth } from '@/features/editor/themes';
|
||||
import { selectSelectedNode, useEditorStore } from '@/stores/editorStore';
|
||||
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';
|
||||
@@ -16,92 +48,103 @@ const SHAPES: Array<{ id: NodeShape; label: string }> = [
|
||||
{ id: 'circle', label: 'Cercle' },
|
||||
];
|
||||
|
||||
const ALIGNMENTS: Array<{ id: TextAlign; label: string }> = [
|
||||
{ id: 'left', label: 'Gauche' },
|
||||
{ id: 'center', label: 'Centre' },
|
||||
{ id: 'right', label: 'Droite' },
|
||||
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 EMOJIS = ['', '💡', '✅', '📌', '⚠️', '🔒', '🌐', '🖥️', '📦', '📄', '🔗', '⭐', '🐳', '🗄️'];
|
||||
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} /> },
|
||||
];
|
||||
|
||||
/** Petit selecteur de couleur : palette native + valeur libre. */
|
||||
function ColorField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
fallback,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
fallback: string;
|
||||
onChange: (color: string | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="color"
|
||||
value={value ?? normaliseHex(fallback)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
|
||||
/>
|
||||
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
|
||||
{value ?? `theme (${fallback})`}
|
||||
</span>
|
||||
{value ? (
|
||||
<IconButton
|
||||
label={`Revenir au theme pour ${label.toLowerCase()}`}
|
||||
icon={<CloseIcon className="h-3.5 w-3.5" />}
|
||||
onClick={() => onChange(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
|
||||
function normaliseHex(color: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
|
||||
}
|
||||
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 node = useEditorStore(selectSelectedNode);
|
||||
const selectedCount = useEditorStore((state) => state.selectedNodeIds.length);
|
||||
const selectedIds = useEditorStore((state) => state.selectedNodeIds);
|
||||
|
||||
const updateNode = useEditorStore((state) => state.updateNode);
|
||||
const updateNodeText = useEditorStore((state) => state.updateNodeText);
|
||||
const updateNodeStyle = useEditorStore((state) => state.updateNodeStyle);
|
||||
const resetNodeStyle = useEditorStore((state) => state.resetNodeStyle);
|
||||
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(node?.text ?? '');
|
||||
setUrl(node?.url ?? '');
|
||||
setNote(node?.note ?? '');
|
||||
}, [node?.id, node?.text, node?.url, node?.note]);
|
||||
setText(single?.text ?? '');
|
||||
setUrl(single?.url ?? '');
|
||||
setNote(single?.note ?? '');
|
||||
}, [single?.id, single?.text, single?.url, single?.note]);
|
||||
|
||||
const theme = getTheme(map?.settings.theme);
|
||||
const depth = node && map ? (computeDepths(map.nodes).get(node.id) ?? 0) : 0;
|
||||
const palette = paletteForDepth(theme, depth);
|
||||
const style = node?.style ?? {};
|
||||
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 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</h2>
|
||||
<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 />}
|
||||
@@ -110,7 +153,7 @@ export function PropertiesPanel() {
|
||||
</header>
|
||||
);
|
||||
|
||||
if (!node) {
|
||||
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"
|
||||
@@ -118,28 +161,29 @@ export function PropertiesPanel() {
|
||||
>
|
||||
{header}
|
||||
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||
{selectedCount > 1
|
||||
? `${selectedCount} noeuds selectionnes. Selectionnez un seul noeud pour modifier son apparence.`
|
||||
: 'Selectionnez un noeud pour modifier son texte, ses couleurs, sa forme ou y ajouter une note.'}
|
||||
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(node.id, { url: undefined });
|
||||
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(node.url ?? '');
|
||||
setUrl(single.url ?? '');
|
||||
return;
|
||||
}
|
||||
setUrl(safe);
|
||||
updateNode(node.id, { url: safe });
|
||||
updateNode(single.id, { url: safe });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -149,196 +193,449 @@ export function PropertiesPanel() {
|
||||
>
|
||||
{header}
|
||||
|
||||
<div className="space-y-5 px-4 py-4">
|
||||
<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 !== node.text && updateNodeText(node.id, text)}
|
||||
/>
|
||||
</div>
|
||||
<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="flex flex-wrap gap-1">
|
||||
{EMOJIS.map((emoji) => (
|
||||
<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 || 'none'}
|
||||
key={emoji}
|
||||
type="button"
|
||||
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
|
||||
aria-pressed={(node.icon ?? '') === emoji}
|
||||
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
|
||||
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 ${
|
||||
(node.icon ?? '') === emoji
|
||||
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 || '-'}
|
||||
{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>
|
||||
|
||||
<ColorField
|
||||
id="node-bg"
|
||||
label="Fond"
|
||||
value={style.backgroundColor}
|
||||
fallback={palette.background}
|
||||
onChange={(color) => updateNodeStyle(node.id, { backgroundColor: color })}
|
||||
/>
|
||||
<ColorField
|
||||
id="node-text-color"
|
||||
label="Texte"
|
||||
value={style.textColor}
|
||||
fallback={palette.text}
|
||||
onChange={(color) => updateNodeStyle(node.id, { textColor: color })}
|
||||
/>
|
||||
<ColorField
|
||||
id="node-border"
|
||||
label="Bordure"
|
||||
value={style.borderColor}
|
||||
fallback={palette.border}
|
||||
onChange={(color) => updateNodeStyle(node.id, { borderColor: color })}
|
||||
<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 })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="node-shape">
|
||||
Forme
|
||||
</label>
|
||||
<select
|
||||
id="node-shape"
|
||||
className="field-input"
|
||||
value={style.shape ?? theme.nodeShape}
|
||||
onChange={(event) => updateNodeStyle(node.id, { shape: event.target.value as NodeShape })}
|
||||
>
|
||||
{SHAPES.map((shape) => (
|
||||
<option key={shape.id} value={shape.id}>
|
||||
{shape.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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) })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="node-size">
|
||||
Taille du texte : {style.fontSize ?? (depth === 0 ? 16 : 14)} px
|
||||
</label>
|
||||
<input
|
||||
id="node-size"
|
||||
type="range"
|
||||
min={10}
|
||||
max={32}
|
||||
step={1}
|
||||
value={style.fontSize ?? (depth === 0 ? 16 : 14)}
|
||||
onChange={(event) => updateNodeStyle(node.id, { fontSize: Number(event.target.value) })}
|
||||
className="w-full accent-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<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={style.bold ? 'primary' : 'secondary'}
|
||||
aria-pressed={Boolean(style.bold)}
|
||||
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
|
||||
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={style.italic ? 'primary' : 'secondary'}
|
||||
aria-pressed={Boolean(style.italic)}
|
||||
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
|
||||
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 className="ml-auto flex gap-1">
|
||||
{ALIGNMENTS.map((alignment) => (
|
||||
<Button
|
||||
key={alignment.id}
|
||||
size="sm"
|
||||
variant={(style.textAlign ?? 'left') === alignment.id ? 'primary' : 'secondary'}
|
||||
aria-pressed={(style.textAlign ?? 'left') === alignment.id}
|
||||
aria-label={`Alignement ${alignment.label}`}
|
||||
onClick={() => updateNodeStyle(node.id, { textAlign: alignment.id })}
|
||||
>
|
||||
{alignment.label.charAt(0)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
{node.url ? (
|
||||
<a
|
||||
href={node.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>
|
||||
<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 })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="node-note">
|
||||
Note
|
||||
</label>
|
||||
<textarea
|
||||
id="node-note"
|
||||
className="field-input min-h-[80px] resize-y"
|
||||
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
onBlur={() => note !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<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 })}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||
<Button size="sm" className="w-full" onClick={() => resetNodeStyle(node.id)}>
|
||||
Revenir au style du theme
|
||||
<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>
|
||||
{node.parentId ? (
|
||||
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}>
|
||||
Detacher de son parent
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
className="w-full"
|
||||
onClick={() => deleteNodes([node.id])}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Supprimer ce noeud
|
||||
</Button>
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user