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:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"dexie": "^4.0.10",
|
||||
"html-to-image": "^1.11.11",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^5.0.2"
|
||||
@@ -3645,6 +3646,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.462.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
|
||||
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dexie": "^4.0.10",
|
||||
"html-to-image": "^1.11.11",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^5.0.2"
|
||||
|
||||
50
frontend/src/features/editor/AlignmentGuidesLayer.tsx
Normal file
50
frontend/src/features/editor/AlignmentGuidesLayer.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ViewportPortal, useStore } from '@xyflow/react';
|
||||
import { type AlignmentGuide } from '@/features/editor/alignmentGuides';
|
||||
|
||||
const GUIDE_COLOR = '#f59e0b';
|
||||
|
||||
/**
|
||||
* Rendu des guides dans le repere du canvas.
|
||||
* L'epaisseur est compensee par le zoom pour rester fine a l'ecran.
|
||||
*/
|
||||
export function AlignmentGuidesLayer({ guides }: { guides: AlignmentGuide[] }) {
|
||||
const zoom = useStore((state) => state.transform[2]);
|
||||
|
||||
if (guides.length === 0) return null;
|
||||
const thickness = Math.max(0.5, 1 / zoom);
|
||||
|
||||
return (
|
||||
<ViewportPortal>
|
||||
{guides.map((guide) => {
|
||||
const vertical = guide.orientation === 'vertical';
|
||||
const margin = 24;
|
||||
return (
|
||||
<div
|
||||
key={`${guide.orientation}-${guide.position}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
background: GUIDE_COLOR,
|
||||
opacity: 0.85,
|
||||
zIndex: 5,
|
||||
...(vertical
|
||||
? {
|
||||
left: guide.position,
|
||||
top: guide.start - margin,
|
||||
width: thickness,
|
||||
height: guide.end - guide.start + margin * 2,
|
||||
}
|
||||
: {
|
||||
left: guide.start - margin,
|
||||
top: guide.position,
|
||||
height: thickness,
|
||||
width: guide.end - guide.start + margin * 2,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
ZoomOutIcon,
|
||||
} from '@/components/Icons';
|
||||
import { ExportMenu } from '@/features/export/ExportMenu';
|
||||
import { LAYOUT_OPTIONS } from '@/features/editor/layout';
|
||||
import {
|
||||
LAYOUT_OPTIONS,
|
||||
SPACING_LIMITS,
|
||||
SPACING_PRESETS,
|
||||
resolveSpacing,
|
||||
} from '@/features/editor/layout';
|
||||
import { THEMES } from '@/features/editor/themes';
|
||||
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
@@ -71,6 +76,7 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
|
||||
const { zoomIn, zoomOut } = useReactFlow();
|
||||
const [name, setName] = useState(map?.name ?? '');
|
||||
const spacing = resolveSpacing(map?.settings.layoutSpacing);
|
||||
|
||||
useEffect(() => setName(map?.name ?? ''), [map?.id, map?.name]);
|
||||
|
||||
@@ -160,6 +166,71 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuLabel>Espacement</MenuLabel>
|
||||
<div className="flex gap-1 px-2.5 pb-2">
|
||||
{SPACING_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => updateSettings({ layoutSpacing: preset.spacing })}
|
||||
aria-pressed={
|
||||
spacing.parentGap === preset.spacing.parentGap &&
|
||||
spacing.siblingGap === preset.spacing.siblingGap
|
||||
}
|
||||
className={`flex-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
|
||||
spacing.parentGap === preset.spacing.parentGap &&
|
||||
spacing.siblingGap === preset.spacing.siblingGap
|
||||
? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300'
|
||||
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-2.5 pb-2">
|
||||
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-parent">
|
||||
Distance parent / enfant : {spacing.parentGap} px
|
||||
</label>
|
||||
<input
|
||||
id="spacing-parent"
|
||||
type="range"
|
||||
min={SPACING_LIMITS.parentGap.min}
|
||||
max={SPACING_LIMITS.parentGap.max}
|
||||
step={5}
|
||||
value={spacing.parentGap}
|
||||
onChange={(event) =>
|
||||
updateSettings({
|
||||
layoutSpacing: { ...spacing, parentGap: Number(event.target.value) },
|
||||
})
|
||||
}
|
||||
className="h-1.5 w-full accent-indigo-600"
|
||||
/>
|
||||
|
||||
<label className="block text-[11px] text-zinc-500" htmlFor="spacing-sibling">
|
||||
Espacement entre branches : {spacing.siblingGap} px
|
||||
</label>
|
||||
<input
|
||||
id="spacing-sibling"
|
||||
type="range"
|
||||
min={SPACING_LIMITS.siblingGap.min}
|
||||
max={SPACING_LIMITS.siblingGap.max}
|
||||
step={5}
|
||||
value={spacing.siblingGap}
|
||||
onChange={(event) =>
|
||||
updateSettings({
|
||||
layoutSpacing: { ...spacing, siblingGap: Number(event.target.value) },
|
||||
})
|
||||
}
|
||||
className="h-1.5 w-full accent-indigo-600"
|
||||
/>
|
||||
<p className="text-[11px] leading-snug text-zinc-400">
|
||||
Changer l'espacement ne deplace rien : relancez l'organisation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
label="Reorganiser avec la disposition actuelle"
|
||||
@@ -211,6 +282,19 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
|
||||
label={map.settings.showGrid ? 'Masquer la grille' : 'Afficher la grille'}
|
||||
onSelect={() => updateSettings({ showGrid: !map.settings.showGrid })}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Aimanter sur la grille"
|
||||
active={map.settings.snapToGrid === true}
|
||||
hint={`${map.settings.snapGrid ?? 20} px`}
|
||||
onSelect={() => updateSettings({ snapToGrid: !map.settings.snapToGrid })}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Guides d'alignement"
|
||||
active={map.settings.alignmentGuides !== false}
|
||||
onSelect={() =>
|
||||
updateSettings({ alignmentGuides: map.settings.alignmentGuides === false })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
@@ -15,6 +15,17 @@ import '@xyflow/react/dist/style.css';
|
||||
import { type MindFlowNode, toFlowEdges, toFlowNodes } from '@/features/editor/flowAdapters';
|
||||
import { MindNodeView } from '@/features/editor/MindNodeView';
|
||||
import { getTheme } from '@/features/editor/themes';
|
||||
import { AlignmentGuidesLayer } from '@/features/editor/AlignmentGuidesLayer';
|
||||
import {
|
||||
computeAlignmentGuides,
|
||||
type AlignmentGuide,
|
||||
type GuideBox,
|
||||
} from '@/features/editor/alignmentGuides';
|
||||
import {
|
||||
NodeContextMenu,
|
||||
type NodeContextMenuState,
|
||||
} from '@/features/editor/NodeContextMenu';
|
||||
import { SelectionToolbar } from '@/features/editor/SelectionToolbar';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { resolveNodeSize } from '@/utils/nodeSize';
|
||||
@@ -43,6 +54,8 @@ export function MindMapCanvas({
|
||||
const pushToast = useUiStore((state) => state.pushToast);
|
||||
const { fitView, setCenter, screenToFlowPosition } = useReactFlow();
|
||||
const initialisedFor = useRef<string | null>(null);
|
||||
const [guides, setGuides] = useState<AlignmentGuide[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<NodeContextMenuState | null>(null);
|
||||
|
||||
const theme = useMemo(() => getTheme(map?.settings.theme), [map?.settings.theme]);
|
||||
|
||||
@@ -82,12 +95,12 @@ export function MindMapCanvas({
|
||||
if (!focusRequest || !map) return;
|
||||
const target = map.nodes.find((node) => node.id === focusRequest.nodeId);
|
||||
if (!target) return;
|
||||
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes);
|
||||
const size = resolveNodeSize(target, useEditorStore.getState().measuredSizes, theme);
|
||||
setCenter(target.position.x + size.width / 2, target.position.y + size.height / 2, {
|
||||
zoom: 1.15,
|
||||
duration: 400,
|
||||
});
|
||||
}, [focusRequest, map, setCenter]);
|
||||
}, [focusRequest, map, setCenter, theme]);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange<MindFlowNode>[]) => {
|
||||
@@ -142,6 +155,37 @@ export function MindMapCanvas({
|
||||
[readOnly, pushToast],
|
||||
);
|
||||
|
||||
/** Guides d'alignement : etat purement local, jamais persiste ni annulable. */
|
||||
const handleNodeDrag = useCallback(
|
||||
(_event: MouseEvent | TouchEvent, dragged: MindFlowNode) => {
|
||||
const store = useEditorStore.getState();
|
||||
const current = store.map;
|
||||
if (!current || current.settings.alignmentGuides === false) return;
|
||||
|
||||
const sizes = store.measuredSizes;
|
||||
const toBox = (node: (typeof current.nodes)[number]): GuideBox => {
|
||||
const size = resolveNodeSize(node, sizes, theme);
|
||||
return {
|
||||
id: node.id,
|
||||
x: node.id === dragged.id ? dragged.position.x : node.position.x,
|
||||
y: node.id === dragged.id ? dragged.position.y : node.position.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
};
|
||||
|
||||
const hiddenIds = new Set(nodes.filter((node) => node.hidden).map((node) => node.id));
|
||||
const boxes = current.nodes.filter((node) => !hiddenIds.has(node.id)).map(toBox);
|
||||
const moving = boxes.find((box) => box.id === dragged.id);
|
||||
if (!moving) return;
|
||||
|
||||
setGuides(computeAlignmentGuides(moving, boxes));
|
||||
},
|
||||
[nodes, theme],
|
||||
);
|
||||
|
||||
const handleNodeDragStop = useCallback(() => setGuides([]), []);
|
||||
|
||||
const handlePaneDoubleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
@@ -172,11 +216,23 @@ export function MindMapCanvas({
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStart={() => useEditorStore.getState().pushHistory()}
|
||||
onNodeDrag={handleNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onNodeContextMenu={(event, node) => {
|
||||
if (readOnly) return;
|
||||
event.preventDefault();
|
||||
const store = useEditorStore.getState();
|
||||
if (!store.selectedNodeIds.includes(node.id)) store.setSelection([node.id]);
|
||||
setContextMenu({ nodeId: node.id, x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
snapToGrid={map.settings.snapToGrid === true}
|
||||
snapGrid={[map.settings.snapGrid ?? 20, map.settings.snapGrid ?? 20]}
|
||||
onMoveEnd={(_event, viewport) => useEditorStore.getState().setViewport(viewport)}
|
||||
onPaneClick={() => {
|
||||
const store = useEditorStore.getState();
|
||||
store.setSelection([]);
|
||||
store.setEditingNode(null);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
defaultViewport={map.viewport}
|
||||
minZoom={0.1}
|
||||
@@ -196,6 +252,8 @@ export function MindMapCanvas({
|
||||
zoomOnDoubleClick={false}
|
||||
aria-label="Canvas de la carte mentale"
|
||||
>
|
||||
<AlignmentGuidesLayer guides={guides} />
|
||||
{!readOnly ? <SelectionToolbar /> : null}
|
||||
{map.settings.showGrid ? (
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color={theme.gridColor} />
|
||||
) : null}
|
||||
@@ -214,6 +272,10 @@ export function MindMapCanvas({
|
||||
/>
|
||||
) : null}
|
||||
</ReactFlow>
|
||||
|
||||
{contextMenu ? (
|
||||
<NodeContextMenu state={contextMenu} onClose={() => setContextMenu(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Handle, type NodeProps } from '@xyflow/react';
|
||||
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Handle, NodeResizer, type NodeProps } from '@xyflow/react';
|
||||
import { type MindFlowNode } from '@/features/editor/flowAdapters';
|
||||
import { LAYOUT_LIMITS } from '@/features/editor/nodeStyleResolver';
|
||||
import { NodeIconGlyph } from '@/features/editor/NodeIconGlyph';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { LinkIcon, NoteIcon } from '@/components/Icons';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
/** Largeur reservee a un indicateur (note / lien), alignee sur `nodeSize.ts`. */
|
||||
const INDICATOR_WIDTH = 18;
|
||||
|
||||
const VERTICAL_ALIGNMENT = {
|
||||
top: 'flex-start',
|
||||
middle: 'center',
|
||||
bottom: 'flex-end',
|
||||
} as const;
|
||||
|
||||
const CROSS_ALIGNMENT = {
|
||||
left: 'flex-start',
|
||||
center: 'center',
|
||||
right: 'flex-end',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Rendu d'un noeud.
|
||||
*
|
||||
* Le texte est toujours rendu comme du texte : aucun HTML provenant d'une carte
|
||||
* n'est jamais interprete.
|
||||
*/
|
||||
function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
|
||||
function MindNodeViewComponent({ data, id, selected }: NodeProps<MindFlowNode>) {
|
||||
const { node, appearance, childCount, hiddenCount, isSearchHit, readOnly, isEditing } = data;
|
||||
|
||||
const updateNodeText = useEditorStore((state) => state.updateNodeText);
|
||||
const setEditingNode = useEditorStore((state) => state.setEditingNode);
|
||||
const toggleCollapse = useEditorStore((state) => state.toggleCollapse);
|
||||
const setMeasuredSize = useEditorStore((state) => state.setMeasuredSize);
|
||||
const beginResize = useEditorStore((state) => state.beginResize);
|
||||
const resizeNode = useEditorStore((state) => state.resizeNode);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -44,7 +63,7 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isEditing]);
|
||||
|
||||
// Les tailles reelles alimentent l'auto-layout et les exports.
|
||||
// Les tailles reelles alimentent l'auto-layout, la minimap et les exports.
|
||||
useLayoutEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
@@ -52,7 +71,7 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
|
||||
width: Math.round(element.offsetWidth),
|
||||
height: Math.round(element.offsetHeight),
|
||||
});
|
||||
}, [id, node.text, node.style, node.icon, setMeasuredSize, isEditing]);
|
||||
}, [id, node.text, node.style, node.layout, node.icon, node.iconStyle, setMeasuredSize, isEditing]);
|
||||
|
||||
const commitText = () => {
|
||||
const value = draft.trim();
|
||||
@@ -68,31 +87,91 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
|
||||
const isRoot = node.type === 'root';
|
||||
const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre');
|
||||
|
||||
const hasIcon = Boolean(node.icon);
|
||||
const iconKind = node.iconStyle?.kind ?? 'emoji';
|
||||
const iconOnTop = hasIcon && appearance.iconPosition === 'top';
|
||||
|
||||
const decorations =
|
||||
(hasIcon && !iconOnTop ? appearance.iconSize + appearance.iconGap : 0) +
|
||||
(node.note ? INDICATOR_WIDTH : 0) +
|
||||
(node.url ? INDICATOR_WIDTH : 0);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
const fixedWidth = appearance.widthMode === 'fixed' ? appearance.width : undefined;
|
||||
const fixedHeight = appearance.heightMode === 'fixed' ? appearance.height : undefined;
|
||||
|
||||
return {
|
||||
background: appearance.background,
|
||||
color: appearance.color,
|
||||
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
|
||||
borderRadius: appearance.borderRadius,
|
||||
boxShadow: appearance.boxShadow,
|
||||
fontFamily: appearance.fontFamily,
|
||||
fontSize: appearance.fontSize,
|
||||
fontWeight: appearance.fontWeight,
|
||||
fontStyle: appearance.fontStyle,
|
||||
lineHeight: appearance.lineHeight,
|
||||
paddingLeft: appearance.paddingX,
|
||||
paddingRight: appearance.paddingX,
|
||||
paddingTop: appearance.paddingY,
|
||||
paddingBottom: appearance.paddingY,
|
||||
boxSizing: 'border-box' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
justifyContent: VERTICAL_ALIGNMENT[appearance.verticalAlign],
|
||||
minWidth: appearance.minWidth,
|
||||
/*
|
||||
* En mode automatique, `max-content` plutot que la largeur par defaut :
|
||||
* le noeud est absolument positionne, sa largeur "auto" se resoudrait
|
||||
* sinon a la largeur min-content (le mot le plus long), ce qui produirait
|
||||
* une colonne etroite et haute pour une phrase. Avec `max-content` borne
|
||||
* par `maxWidth`, une phrase longue occupe la largeur maximale autorisee
|
||||
* puis passe a la ligne, et un texte court reste compact.
|
||||
*/
|
||||
...(fixedWidth ? { width: fixedWidth } : { width: 'max-content' }),
|
||||
...(fixedHeight ? { height: fixedHeight } : { minHeight: appearance.minHeight }),
|
||||
...(fixedWidth || !appearance.wrap
|
||||
? {}
|
||||
: { maxWidth: appearance.maxTextWidth + appearance.paddingX * 2 + decorations }),
|
||||
...(appearance.circle && !fixedWidth ? { aspectRatio: '1 / 1', alignItems: 'center' } : {}),
|
||||
};
|
||||
}, [appearance, decorations]);
|
||||
|
||||
const iconElement = hasIcon ? (
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={{
|
||||
// Aligne l'icone sur la premiere ligne de texte.
|
||||
marginTop: iconOnTop
|
||||
? 0
|
||||
: Math.max(0, (appearance.fontSize * appearance.lineHeight - appearance.iconSize) / 2),
|
||||
}}
|
||||
>
|
||||
<NodeIconGlyph
|
||||
value={node.icon as string}
|
||||
kind={iconKind}
|
||||
size={appearance.iconSize}
|
||||
/>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/*
|
||||
* Uniquement `overflow-wrap: break-word`.
|
||||
* `word-break: break-word` (et `overflow-wrap: anywhere`) ramenent la largeur
|
||||
* min-content a un seul caractere : dans une boite dimensionnee par son
|
||||
* contenu, tous les noeuds s'effondreraient a leur largeur minimale.
|
||||
* Avec `break-word`, la largeur naturelle reste celle du mot le plus long, et
|
||||
* une URL trop longue n'est coupee que si elle depasse la largeur maximale.
|
||||
*/
|
||||
const textStyle = appearance.wrap
|
||||
? ({ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' } as const)
|
||||
: ({ whiteSpace: 'pre', overflow: 'hidden', textOverflow: 'ellipsis' } as const);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'mind-node relative select-none px-3.5 py-2',
|
||||
isSearchHit && 'mind-node--search-hit',
|
||||
)}
|
||||
style={{
|
||||
background: appearance.background,
|
||||
color: appearance.color,
|
||||
border: `${appearance.borderWidth}px solid ${appearance.borderColor}`,
|
||||
borderRadius: appearance.borderRadius,
|
||||
boxShadow: appearance.boxShadow,
|
||||
fontFamily: appearance.fontFamily,
|
||||
fontSize: appearance.fontSize,
|
||||
fontWeight: appearance.fontWeight,
|
||||
fontStyle: appearance.fontStyle,
|
||||
textAlign: appearance.textAlign,
|
||||
minWidth: appearance.circle ? 96 : 88,
|
||||
maxWidth: appearance.width ?? 260,
|
||||
width: appearance.width,
|
||||
...(appearance.circle
|
||||
? { aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }
|
||||
: {}),
|
||||
}}
|
||||
className={cn('mind-node relative select-none', isSearchHit && 'mind-node--search-hit')}
|
||||
style={containerStyle}
|
||||
aria-label={label}
|
||||
onDoubleClick={(event) => {
|
||||
if (readOnly) return;
|
||||
@@ -100,6 +179,27 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
|
||||
setEditingNode(id);
|
||||
}}
|
||||
>
|
||||
{!readOnly && selected && !isEditing ? (
|
||||
<NodeResizer
|
||||
isVisible
|
||||
color="#6366f1"
|
||||
minWidth={appearance.minWidth}
|
||||
minHeight={appearance.minHeight}
|
||||
maxWidth={LAYOUT_LIMITS.width.max}
|
||||
maxHeight={LAYOUT_LIMITS.height.max}
|
||||
handleClassName="openmind-resize-handle"
|
||||
lineClassName="openmind-resize-line"
|
||||
onResizeStart={() => beginResize()}
|
||||
onResize={(_event, params) =>
|
||||
resizeNode(id, {
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
position: { x: params.x, y: params.y },
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={data.targetPosition}
|
||||
@@ -107,40 +207,55 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
|
||||
aria-label="Point de connexion entrant"
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
{node.icon ? (
|
||||
<span aria-hidden="true" className="leading-5">
|
||||
{node.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: iconOnTop ? 'column' : 'row',
|
||||
gap: hasIcon ? appearance.iconGap : 0,
|
||||
alignItems: iconOnTop ? CROSS_ALIGNMENT[appearance.textAlign] : 'flex-start',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{hasIcon && (appearance.iconPosition === 'left' || iconOnTop) ? iconElement : null}
|
||||
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="nodrag nowheel w-full resize-none bg-transparent leading-snug outline-none"
|
||||
style={{ color: appearance.color, minWidth: 90 }}
|
||||
rows={Math.min(6, Math.max(1, draft.split('\n').length))}
|
||||
value={draft}
|
||||
aria-label="Texte du noeud"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commitText}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
commitText();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap break-words leading-snug">
|
||||
{node.text || <span className="opacity-50">Sans titre</span>}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: '1 1 auto', minWidth: 0, textAlign: appearance.textAlign }}>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="nodrag nowheel w-full resize-none bg-transparent outline-none"
|
||||
style={{
|
||||
color: appearance.color,
|
||||
font: 'inherit',
|
||||
lineHeight: appearance.lineHeight,
|
||||
textAlign: appearance.textAlign,
|
||||
minWidth: 80,
|
||||
}}
|
||||
rows={Math.min(8, Math.max(1, draft.split('\n').length))}
|
||||
value={draft}
|
||||
aria-label="Texte du noeud"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commitText}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
commitText();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={textStyle}>
|
||||
{node.text || <span className="opacity-50">Sans titre</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasIcon && appearance.iconPosition === 'right' ? iconElement : null}
|
||||
|
||||
{node.note ? (
|
||||
<span title="Ce noeud contient une note" className="mt-0.5 shrink-0 opacity-70">
|
||||
|
||||
108
frontend/src/features/editor/NodeContextMenu.tsx
Normal file
108
frontend/src/features/editor/NodeContextMenu.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface NodeContextMenuState {
|
||||
nodeId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface NodeContextMenuProps {
|
||||
state: NodeContextMenuState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Menu contextuel d'un noeud : actions de style et de mise en page. */
|
||||
export function NodeContextMenu({ state, onClose }: NodeContextMenuProps) {
|
||||
const selectedIds = useEditorStore((store) => store.selectedNodeIds);
|
||||
const copyStyle = useEditorStore((store) => store.copyStyle);
|
||||
const pasteStyle = useEditorStore((store) => store.pasteStyle);
|
||||
const resetNodesStyle = useEditorStore((store) => store.resetNodesStyle);
|
||||
const fitNodesToContent = useEditorStore((store) => store.fitNodesToContent);
|
||||
const duplicateSelection = useEditorStore((store) => store.duplicateSelection);
|
||||
const deleteNodes = useEditorStore((store) => store.deleteNodes);
|
||||
const hasStyleClipboard = useEditorStore((store) => store.styleClipboard !== null);
|
||||
const pushToast = useUiStore((store) => store.pushToast);
|
||||
|
||||
useEffect(() => {
|
||||
const close = () => onClose();
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const targets = selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId];
|
||||
|
||||
const items: Array<{
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
run: () => void;
|
||||
}> = [
|
||||
{
|
||||
label: 'Copier le style',
|
||||
hint: 'Ctrl+Maj+C',
|
||||
disabled: targets.length !== 1,
|
||||
run: () => {
|
||||
if (copyStyle()) pushToast('Style copie.', 'info');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Coller le style',
|
||||
hint: 'Ctrl+Maj+V',
|
||||
disabled: !hasStyleClipboard,
|
||||
run: () => {
|
||||
const count = pasteStyle();
|
||||
if (count > 0) pushToast(`Style applique a ${count} noeud(s).`, 'success');
|
||||
},
|
||||
},
|
||||
{ label: 'Reinitialiser le style', run: () => resetNodesStyle(targets) },
|
||||
{ label: 'Adapter au contenu', run: () => fitNodesToContent(targets) },
|
||||
{ label: 'Dupliquer', hint: 'Ctrl+D', run: () => duplicateSelection() },
|
||||
{ label: 'Supprimer', danger: true, run: () => deleteNodes(targets) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Actions du noeud"
|
||||
className="panel fixed z-50 min-w-[13rem] animate-fade-in p-1"
|
||||
style={{ left: state.x, top: state.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.run();
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
item.danger
|
||||
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
|
||||
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.hint ? (
|
||||
<span className="text-[11px] uppercase tracking-wide text-zinc-400">{item.hint}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/features/editor/NodeIconGlyph.tsx
Normal file
28
frontend/src/features/editor/NodeIconGlyph.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BUILTIN_ICONS } from '@/features/editor/nodeIcons';
|
||||
|
||||
export interface NodeIconGlyphProps {
|
||||
value: string;
|
||||
kind: 'emoji' | 'builtin';
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Rendu d'une icone de noeud : emoji brut ou icone integree. */
|
||||
export function NodeIconGlyph({ value, kind, size, className }: NodeIconGlyphProps) {
|
||||
if (kind === 'builtin') {
|
||||
const entry = BUILTIN_ICONS[value];
|
||||
if (!entry) return null;
|
||||
const { Icon } = entry;
|
||||
return <Icon size={size} strokeWidth={1.8} className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{ fontSize: size, lineHeight: 1, display: 'inline-block' }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
112
frontend/src/features/editor/SelectionToolbar.tsx
Normal file
112
frontend/src/features/editor/SelectionToolbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AlignCenterHorizontal,
|
||||
AlignCenterVertical,
|
||||
AlignEndHorizontal,
|
||||
AlignEndVertical,
|
||||
AlignHorizontalDistributeCenter,
|
||||
AlignStartHorizontal,
|
||||
AlignStartVertical,
|
||||
AlignVerticalDistributeCenter,
|
||||
} from 'lucide-react';
|
||||
import { type AlignAxis, type DistributeAxis, useEditorStore } from '@/stores/editorStore';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ToolButton {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Barre contextuelle de mise en page, visible des qu'au moins deux noeuds sont
|
||||
* selectionnes. Chaque action produit une seule entree d'historique.
|
||||
*/
|
||||
export function SelectionToolbar() {
|
||||
const count = useEditorStore((state) => state.selectedNodeIds.length);
|
||||
const readOnly = useEditorStore((state) => state.readOnly);
|
||||
const alignNodes = useEditorStore((state) => state.alignNodes);
|
||||
const distributeNodes = useEditorStore((state) => state.distributeNodes);
|
||||
|
||||
if (readOnly || count < 2) return null;
|
||||
|
||||
const align = (axis: AlignAxis) => () => alignNodes(axis);
|
||||
const distribute = (axis: DistributeAxis) => () => distributeNodes(axis);
|
||||
|
||||
const alignButtons: ToolButton[] = [
|
||||
{ label: 'Aligner a gauche', icon: <AlignStartVertical size={16} />, run: align('left') },
|
||||
{
|
||||
label: 'Aligner horizontalement au centre',
|
||||
icon: <AlignCenterVertical size={16} />,
|
||||
run: align('hcenter'),
|
||||
},
|
||||
{ label: 'Aligner a droite', icon: <AlignEndVertical size={16} />, run: align('right') },
|
||||
{ label: 'Aligner en haut', icon: <AlignStartHorizontal size={16} />, run: align('top') },
|
||||
{
|
||||
label: 'Aligner verticalement au centre',
|
||||
icon: <AlignCenterHorizontal size={16} />,
|
||||
run: align('vcenter'),
|
||||
},
|
||||
{ label: 'Aligner en bas', icon: <AlignEndHorizontal size={16} />, run: align('bottom') },
|
||||
];
|
||||
|
||||
const distributeButtons: ToolButton[] = [
|
||||
{
|
||||
label: 'Distribuer horizontalement',
|
||||
icon: <AlignHorizontalDistributeCenter size={16} />,
|
||||
run: distribute('horizontal'),
|
||||
},
|
||||
{
|
||||
label: 'Distribuer verticalement',
|
||||
icon: <AlignVerticalDistributeCenter size={16} />,
|
||||
run: distribute('vertical'),
|
||||
},
|
||||
];
|
||||
|
||||
const buttonClass = cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md text-zinc-600 transition-colors',
|
||||
'hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-40',
|
||||
'dark:text-zinc-300 dark:hover:bg-zinc-800',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="no-print panel absolute left-1/2 top-4 z-20 flex -translate-x-1/2 animate-fade-in items-center gap-1 px-2 py-1.5"
|
||||
role="toolbar"
|
||||
aria-label="Mise en page de la selection"
|
||||
>
|
||||
<span className="px-1.5 text-xs font-medium text-zinc-400">{count} noeuds</span>
|
||||
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
{alignButtons.map((button) => (
|
||||
<button
|
||||
key={button.label}
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
title={button.label}
|
||||
aria-label={button.label}
|
||||
onClick={button.run}
|
||||
>
|
||||
{button.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
{distributeButtons.map((button) => (
|
||||
<button
|
||||
key={button.label}
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
title={
|
||||
count < 3 ? `${button.label} (3 noeuds minimum)` : button.label
|
||||
}
|
||||
aria-label={button.label}
|
||||
disabled={count < 3}
|
||||
onClick={button.run}
|
||||
>
|
||||
{button.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/features/editor/alignmentGuides.test.ts
Normal file
51
frontend/src/features/editor/alignmentGuides.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeAlignmentGuides, type GuideBox } from '@/features/editor/alignmentGuides';
|
||||
|
||||
const box = (id: string, x: number, y: number, width = 100, height = 40): GuideBox => ({
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
describe('guides d alignement', () => {
|
||||
it('detecte un alignement des bords gauches', () => {
|
||||
const moving = box('a', 100, 300);
|
||||
const guides = computeAlignmentGuides(moving, [moving, box('b', 102, 0)]);
|
||||
|
||||
const vertical = guides.find((guide) => guide.orientation === 'vertical');
|
||||
expect(vertical?.position).toBe(102);
|
||||
});
|
||||
|
||||
it('detecte un alignement des centres horizontaux', () => {
|
||||
const moving = box('a', 0, 200);
|
||||
const guides = computeAlignmentGuides(moving, [moving, box('b', 500, 202)]);
|
||||
|
||||
const horizontal = guides.find((guide) => guide.orientation === 'horizontal');
|
||||
expect(horizontal?.position).toBe(202);
|
||||
});
|
||||
|
||||
it('ne propose rien au-dela du seuil', () => {
|
||||
const moving = box('a', 0, 0);
|
||||
expect(computeAlignmentGuides(moving, [moving, box('b', 400, 400)])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignore le noeud deplace lui-meme', () => {
|
||||
const moving = box('a', 0, 0);
|
||||
expect(computeAlignmentGuides(moving, [moving])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('produit au plus un guide par axe', () => {
|
||||
const moving = box('a', 100, 100);
|
||||
const guides = computeAlignmentGuides(moving, [
|
||||
moving,
|
||||
box('b', 100, 300),
|
||||
box('c', 101, 400),
|
||||
box('d', 400, 100),
|
||||
]);
|
||||
|
||||
expect(guides.filter((guide) => guide.orientation === 'vertical')).toHaveLength(1);
|
||||
expect(guides.filter((guide) => guide.orientation === 'horizontal')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
119
frontend/src/features/editor/alignmentGuides.ts
Normal file
119
frontend/src/features/editor/alignmentGuides.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Guides d'alignement affiches pendant un deplacement.
|
||||
*
|
||||
* Volontairement simple : on compare les bords et les centres du noeud deplace
|
||||
* a ceux des autres noeuds visibles, et on ne garde que la meilleure
|
||||
* correspondance sur chaque axe. Aucun etat global, aucune entree d'historique.
|
||||
*/
|
||||
|
||||
export interface GuideBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AlignmentGuide {
|
||||
orientation: 'vertical' | 'horizontal';
|
||||
/** Coordonnee de la ligne (x pour verticale, y pour horizontale). */
|
||||
position: number;
|
||||
/** Etendue de la ligne, pour ne pas traverser tout le canvas. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export const GUIDE_THRESHOLD = 6;
|
||||
|
||||
type Candidate = { value: number; span: [number, number] };
|
||||
|
||||
function verticalCandidates(box: GuideBox): Candidate[] {
|
||||
const span: [number, number] = [box.y, box.y + box.height];
|
||||
return [
|
||||
{ value: box.x, span },
|
||||
{ value: box.x + box.width / 2, span },
|
||||
{ value: box.x + box.width, span },
|
||||
];
|
||||
}
|
||||
|
||||
function horizontalCandidates(box: GuideBox): Candidate[] {
|
||||
const span: [number, number] = [box.x, box.x + box.width];
|
||||
return [
|
||||
{ value: box.y, span },
|
||||
{ value: box.y + box.height / 2, span },
|
||||
{ value: box.y + box.height, span },
|
||||
];
|
||||
}
|
||||
|
||||
function bestMatch(
|
||||
moving: Candidate[],
|
||||
others: GuideBox[],
|
||||
extract: (box: GuideBox) => Candidate[],
|
||||
threshold: number,
|
||||
): { position: number; span: [number, number] } | null {
|
||||
let best: { position: number; span: [number, number]; distance: number } | null = null;
|
||||
|
||||
for (const other of others) {
|
||||
for (const candidate of extract(other)) {
|
||||
for (const source of moving) {
|
||||
const distance = Math.abs(source.value - candidate.value);
|
||||
if (distance > threshold) continue;
|
||||
if (best && distance >= best.distance) continue;
|
||||
best = {
|
||||
position: candidate.value,
|
||||
span: [
|
||||
Math.min(source.span[0], candidate.span[0]),
|
||||
Math.max(source.span[1], candidate.span[1]),
|
||||
],
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { position: best.position, span: best.span } : null;
|
||||
}
|
||||
|
||||
/** Au plus un guide vertical et un guide horizontal. */
|
||||
export function computeAlignmentGuides(
|
||||
moving: GuideBox,
|
||||
others: GuideBox[],
|
||||
threshold: number = GUIDE_THRESHOLD,
|
||||
): AlignmentGuide[] {
|
||||
const candidates = others.filter((box) => box.id !== moving.id);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const guides: AlignmentGuide[] = [];
|
||||
|
||||
const vertical = bestMatch(
|
||||
verticalCandidates(moving),
|
||||
candidates,
|
||||
verticalCandidates,
|
||||
threshold,
|
||||
);
|
||||
if (vertical) {
|
||||
guides.push({
|
||||
orientation: 'vertical',
|
||||
position: vertical.position,
|
||||
start: vertical.span[0],
|
||||
end: vertical.span[1],
|
||||
});
|
||||
}
|
||||
|
||||
const horizontal = bestMatch(
|
||||
horizontalCandidates(moving),
|
||||
candidates,
|
||||
horizontalCandidates,
|
||||
threshold,
|
||||
);
|
||||
if (horizontal) {
|
||||
guides.push({
|
||||
orientation: 'horizontal',
|
||||
position: horizontal.position,
|
||||
start: horizontal.span[0],
|
||||
end: horizontal.span[1],
|
||||
});
|
||||
}
|
||||
|
||||
return guides;
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import { type Edge, type Node } from '@xyflow/react';
|
||||
import { Position } from '@xyflow/react';
|
||||
import { type BranchStyle, type MindMap, type MindMapNode, type NodeShape } from '@/types/map';
|
||||
import { type MapTheme, paletteForDepth } from '@/features/editor/themes';
|
||||
import { type BranchStyle, type MindMap, type MindMapNode } from '@/types/map';
|
||||
import { type MapTheme } from '@/features/editor/themes';
|
||||
import {
|
||||
resolveNodeAppearance,
|
||||
type NodeAppearance,
|
||||
} from '@/features/editor/nodeStyleResolver';
|
||||
import { isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
|
||||
import { buildChildrenMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
|
||||
import { buildChildrenMap, buildNodeMap, computeDepths, getHiddenNodeIds } from '@/utils/tree';
|
||||
import { type NodeSize } from '@/utils/nodeSize';
|
||||
|
||||
/** Style CSS resolu d'un noeud : theme + personnalisations explicites. */
|
||||
export interface NodeAppearance {
|
||||
background: string;
|
||||
color: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
borderRadius: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: number;
|
||||
fontStyle: 'normal' | 'italic';
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
boxShadow: string;
|
||||
width?: number;
|
||||
circle: boolean;
|
||||
}
|
||||
export { resolveNodeAppearance };
|
||||
export type { NodeAppearance };
|
||||
|
||||
export interface MindNodeData extends Record<string, unknown> {
|
||||
node: MindMapNode;
|
||||
@@ -38,51 +28,6 @@ export interface MindNodeData extends Record<string, unknown> {
|
||||
|
||||
export type MindFlowNode = Node<MindNodeData, 'mind'>;
|
||||
|
||||
function radiusForShape(shape: NodeShape, themeRadius: number): string {
|
||||
switch (shape) {
|
||||
case 'rectangle':
|
||||
return '3px';
|
||||
case 'capsule':
|
||||
return '999px';
|
||||
case 'circle':
|
||||
return '50%';
|
||||
case 'rounded':
|
||||
default:
|
||||
return `${themeRadius}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne le theme et les personnalisations du noeud.
|
||||
* Une valeur definie explicitement sur le noeud gagne toujours : changer de
|
||||
* theme ne detruit donc jamais une personnalisation.
|
||||
*/
|
||||
export function resolveNodeAppearance(
|
||||
node: MindMapNode,
|
||||
depth: number,
|
||||
theme: MapTheme,
|
||||
): NodeAppearance {
|
||||
const palette = paletteForDepth(theme, depth);
|
||||
const style = node.style ?? {};
|
||||
const shape = style.shape ?? theme.nodeShape;
|
||||
|
||||
return {
|
||||
background: style.backgroundColor ?? palette.background,
|
||||
color: style.textColor ?? palette.text,
|
||||
borderColor: style.borderColor ?? palette.border,
|
||||
borderWidth: theme.borderWidth,
|
||||
borderRadius: radiusForShape(shape, theme.nodeRadius),
|
||||
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
|
||||
fontFamily: theme.fontFamily,
|
||||
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
|
||||
fontStyle: style.italic ? 'italic' : 'normal',
|
||||
textAlign: style.textAlign ?? 'left',
|
||||
boxShadow: theme.shadow,
|
||||
...(style.width ? { width: style.width } : {}),
|
||||
circle: shape === 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
function handlePositions(layout: MindMap['settings']['layout']): {
|
||||
source: Position;
|
||||
target: Position;
|
||||
@@ -163,20 +108,31 @@ function countBranch(children: Map<string, string[]>, rootId: string): number {
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les liens heritent du style de branche de la carte, sauf si le noeud enfant
|
||||
* definit sa propre personnalisation (`node.branch`).
|
||||
*/
|
||||
export function toFlowEdges(map: MindMap, options: AdapterOptions): Edge[] {
|
||||
const hidden = getHiddenNodeIds(map.nodes);
|
||||
const edgeType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
|
||||
const nodeMap = buildNodeMap(map.nodes);
|
||||
const defaultType = EDGE_TYPE_BY_BRANCH[map.settings.branchStyle] ?? 'default';
|
||||
|
||||
return map.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edgeType,
|
||||
hidden: hidden.has(edge.source) || hidden.has(edge.target),
|
||||
deletable: !options.readOnly,
|
||||
style: {
|
||||
stroke: edge.color ?? options.theme.branchColor,
|
||||
strokeWidth: options.theme.branchWidth,
|
||||
},
|
||||
}));
|
||||
return map.edges.map((edge) => {
|
||||
const target = nodeMap.get(edge.target);
|
||||
const branch = target?.branch ?? {};
|
||||
const type = branch.style ? (EDGE_TYPE_BY_BRANCH[branch.style] ?? defaultType) : defaultType;
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type,
|
||||
hidden: hidden.has(edge.source) || hidden.has(edge.target),
|
||||
deletable: !options.readOnly,
|
||||
style: {
|
||||
stroke: branch.color ?? edge.color ?? options.theme.branchColor,
|
||||
strokeWidth: branch.width ?? options.theme.branchWidth,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
|
||||
import {
|
||||
DEFAULT_LAYOUT_SPACING,
|
||||
computeLayout,
|
||||
isHorizontalLayout,
|
||||
layoutDirection,
|
||||
} from '@/features/editor/layout';
|
||||
import { createSampleMap } from '@/services/mapFactory';
|
||||
|
||||
describe('auto-layout', () => {
|
||||
@@ -47,3 +52,68 @@ describe('auto-layout', () => {
|
||||
expect(computeLayout([], 'mindmap-right').size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('espacement de l auto-layout', () => {
|
||||
it('ecarte davantage les rangs quand parentGap augmente', () => {
|
||||
const map = createSampleMap();
|
||||
const root = map.nodes[0] as { id: string };
|
||||
const child = map.nodes[1] as { id: string };
|
||||
|
||||
const compact = computeLayout(map.nodes, 'mindmap-right', {
|
||||
spacing: { parentGap: 70, siblingGap: 30 },
|
||||
});
|
||||
const airy = computeLayout(map.nodes, 'mindmap-right', {
|
||||
spacing: { parentGap: 200, siblingGap: 30 },
|
||||
});
|
||||
|
||||
const compactDistance = (compact.get(child.id)?.x ?? 0) - (compact.get(root.id)?.x ?? 0);
|
||||
const airyDistance = (airy.get(child.id)?.x ?? 0) - (airy.get(root.id)?.x ?? 0);
|
||||
expect(airyDistance).toBeGreaterThan(compactDistance);
|
||||
});
|
||||
|
||||
it('ecarte davantage les freres quand siblingGap augmente', () => {
|
||||
const map = createSampleMap();
|
||||
const siblings = map.nodes.filter((node) => node.parentId === map.nodes[0]?.id);
|
||||
const first = siblings[0] as { id: string };
|
||||
const second = siblings[1] as { id: string };
|
||||
|
||||
const tight = computeLayout(map.nodes, 'mindmap-right', {
|
||||
spacing: { parentGap: 100, siblingGap: 10 },
|
||||
});
|
||||
const loose = computeLayout(map.nodes, 'mindmap-right', {
|
||||
spacing: { parentGap: 100, siblingGap: 200 },
|
||||
});
|
||||
|
||||
const tightGap = Math.abs((tight.get(second.id)?.y ?? 0) - (tight.get(first.id)?.y ?? 0));
|
||||
const looseGap = Math.abs((loose.get(second.id)?.y ?? 0) - (loose.get(first.id)?.y ?? 0));
|
||||
expect(looseGap).toBeGreaterThan(tightGap);
|
||||
});
|
||||
|
||||
it('utilise le preset Normal quand aucun espacement n est fourni', () => {
|
||||
const map = createSampleMap();
|
||||
const implicit = computeLayout(map.nodes, 'mindmap-right');
|
||||
const explicit = computeLayout(map.nodes, 'mindmap-right', {
|
||||
spacing: DEFAULT_LAYOUT_SPACING,
|
||||
});
|
||||
|
||||
for (const node of map.nodes) {
|
||||
expect(implicit.get(node.id)).toEqual(explicit.get(node.id));
|
||||
}
|
||||
});
|
||||
|
||||
it('tient compte des tailles fixes des noeuds', () => {
|
||||
const map = createSampleMap();
|
||||
const wide = { ...(map.nodes[1] as (typeof map.nodes)[number]) };
|
||||
wide.layout = { widthMode: 'fixed', width: 600, heightMode: 'fixed', height: 200 };
|
||||
const nodes = map.nodes.map((node) => (node.id === wide.id ? wide : node));
|
||||
|
||||
const positions = computeLayout(nodes, 'mindmap-right');
|
||||
const grandChild = nodes.find((node) => node.parentId === wide.id);
|
||||
if (!grandChild) return;
|
||||
|
||||
// Le petit-enfant doit etre place au-dela du noeud large.
|
||||
expect(positions.get(grandChild.id)?.x ?? 0).toBeGreaterThan(
|
||||
(positions.get(wide.id)?.x ?? 0) + 400,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dagre from 'dagre';
|
||||
import { type LayoutKind, type MindMapNode, type Position } from '@/types/map';
|
||||
import { type LayoutKind, type LayoutSpacing, type MindMapNode, type Position } from '@/types/map';
|
||||
import { type MapTheme } from '@/features/editor/themes';
|
||||
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
|
||||
|
||||
export interface LayoutOption {
|
||||
@@ -36,21 +37,43 @@ export const LAYOUT_OPTIONS: LayoutOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Espacement par defaut quand la carte n'en definit pas (preset "Normal"). */
|
||||
export const DEFAULT_LAYOUT_SPACING: LayoutSpacing = { parentGap: 100, siblingGap: 50 };
|
||||
|
||||
export const SPACING_PRESETS: Array<{ id: string; label: string; spacing: LayoutSpacing }> = [
|
||||
{ id: 'compact', label: 'Compact', spacing: { parentGap: 70, siblingGap: 30 } },
|
||||
{ id: 'normal', label: 'Normal', spacing: DEFAULT_LAYOUT_SPACING },
|
||||
{ id: 'airy', label: 'Aere', spacing: { parentGap: 140, siblingGap: 80 } },
|
||||
];
|
||||
|
||||
export const SPACING_LIMITS = {
|
||||
parentGap: { min: 30, max: 320 },
|
||||
siblingGap: { min: 10, max: 240 },
|
||||
} as const;
|
||||
|
||||
export function resolveSpacing(spacing?: LayoutSpacing): LayoutSpacing {
|
||||
return {
|
||||
parentGap: spacing?.parentGap ?? DEFAULT_LAYOUT_SPACING.parentGap,
|
||||
siblingGap: spacing?.siblingGap ?? DEFAULT_LAYOUT_SPACING.siblingGap,
|
||||
};
|
||||
}
|
||||
|
||||
interface DagreParams {
|
||||
rankdir: 'LR' | 'RL' | 'TB';
|
||||
ranksep: number;
|
||||
nodesep: number;
|
||||
/** Multiplicateurs appliques a l'espacement demande par l'utilisateur. */
|
||||
rankFactor: number;
|
||||
nodeFactor: number;
|
||||
}
|
||||
|
||||
const LAYOUT_PARAMS: Record<string, DagreParams> = {
|
||||
'mindmap-right': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
|
||||
'mindmap-left': { rankdir: 'RL', ranksep: 90, nodesep: 22 },
|
||||
'tree-horizontal': { rankdir: 'LR', ranksep: 130, nodesep: 44 },
|
||||
'tree-vertical': { rankdir: 'TB', ranksep: 90, nodesep: 48 },
|
||||
'mindmap-right': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
'mindmap-left': { rankdir: 'RL', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
'tree-horizontal': { rankdir: 'LR', rankFactor: 1.3, nodeFactor: 0.88 },
|
||||
'tree-vertical': { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
|
||||
// Replis pour les dispositions reservees.
|
||||
'mindmap-both': { rankdir: 'LR', ranksep: 90, nodesep: 22 },
|
||||
organization: { rankdir: 'TB', ranksep: 90, nodesep: 48 },
|
||||
freeform: { rankdir: 'LR', ranksep: 90, nodesep: 22 },
|
||||
'mindmap-both': { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
organization: { rankdir: 'TB', rankFactor: 0.9, nodeFactor: 0.96 },
|
||||
freeform: { rankdir: 'LR', rankFactor: 0.9, nodeFactor: 0.44 },
|
||||
};
|
||||
|
||||
/** Orientation d'un layout : sert a placer les poignees de connexion. */
|
||||
@@ -70,20 +93,36 @@ export function layoutDirection(layout: LayoutKind): 1 | -1 {
|
||||
* par le bouton "Organisation automatique", ce qui preserve les deplacements
|
||||
* manuels de l'utilisateur.
|
||||
*/
|
||||
export interface LayoutOptions {
|
||||
measuredSizes?: Map<string, NodeSize>;
|
||||
spacing?: LayoutSpacing;
|
||||
theme?: MapTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les positions via dagre.
|
||||
*
|
||||
* Le layout n'est jamais applique automatiquement : il est declenche seulement
|
||||
* par le bouton "Organisation automatique", ce qui preserve les deplacements
|
||||
* manuels de l'utilisateur. Modifier l'espacement ne deplace donc rien tant que
|
||||
* l'utilisateur n'a pas relance l'organisation.
|
||||
*/
|
||||
export function computeLayout(
|
||||
nodes: MindMapNode[],
|
||||
layout: LayoutKind,
|
||||
measuredSizes?: Map<string, NodeSize>,
|
||||
options: LayoutOptions = {},
|
||||
): Map<string, Position> {
|
||||
const positions = new Map<string, Position>();
|
||||
if (nodes.length === 0) return positions;
|
||||
|
||||
const params = LAYOUT_PARAMS[layout] ?? LAYOUT_PARAMS['mindmap-right'];
|
||||
const spacing = resolveSpacing(options.spacing);
|
||||
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
graph.setGraph({
|
||||
rankdir: params?.rankdir ?? 'LR',
|
||||
ranksep: params?.ranksep ?? 90,
|
||||
nodesep: params?.nodesep ?? 22,
|
||||
ranksep: Math.max(20, Math.round(spacing.parentGap * (params?.rankFactor ?? 0.9))),
|
||||
nodesep: Math.max(8, Math.round(spacing.siblingGap * (params?.nodeFactor ?? 0.44))),
|
||||
marginx: 40,
|
||||
marginy: 40,
|
||||
});
|
||||
@@ -93,7 +132,7 @@ export function computeLayout(
|
||||
const sizes = new Map<string, NodeSize>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const size = resolveNodeSize(node, measuredSizes);
|
||||
const size = resolveNodeSize(node, options.measuredSizes, options.theme);
|
||||
sizes.set(node.id, size);
|
||||
graph.setNode(node.id, { width: size.width, height: size.height });
|
||||
}
|
||||
|
||||
90
frontend/src/features/editor/nodeIcons.ts
Normal file
90
frontend/src/features/editor/nodeIcons.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
Container,
|
||||
Cpu,
|
||||
Database,
|
||||
FileText,
|
||||
Flag,
|
||||
Folder,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Key,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
Lock,
|
||||
type LucideIcon,
|
||||
Mail,
|
||||
Monitor,
|
||||
Network,
|
||||
Router,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Star,
|
||||
Terminal,
|
||||
Timer,
|
||||
Users,
|
||||
Wifi,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Bibliotheque d'icones integrees (Lucide, licence ISC).
|
||||
*
|
||||
* Volontairement restreinte et orientee schema technique : aucune icone n'est
|
||||
* chargee dynamiquement, et aucun SVG fourni par l'utilisateur n'est stocke.
|
||||
*/
|
||||
export const BUILTIN_ICONS: Record<string, { label: string; Icon: LucideIcon }> = {
|
||||
server: { label: 'Serveur', Icon: Server },
|
||||
network: { label: 'Reseau', Icon: Network },
|
||||
router: { label: 'Routeur', Icon: Router },
|
||||
wifi: { label: 'Wi-Fi', Icon: Wifi },
|
||||
shield: { label: 'Securite', Icon: Shield },
|
||||
lock: { label: 'Verrou', Icon: Lock },
|
||||
key: { label: 'Cle', Icon: Key },
|
||||
database: { label: 'Base de donnees', Icon: Database },
|
||||
harddrive: { label: 'Stockage', Icon: HardDrive },
|
||||
container: { label: 'Conteneur', Icon: Container },
|
||||
cpu: { label: 'Processeur', Icon: Cpu },
|
||||
monitor: { label: 'Poste', Icon: Monitor },
|
||||
cloud: { label: 'Cloud', Icon: Cloud },
|
||||
globe: { label: 'Internet', Icon: Globe },
|
||||
terminal: { label: 'Terminal', Icon: Terminal },
|
||||
settings: { label: 'Configuration', Icon: Settings },
|
||||
layers: { label: 'Couches', Icon: Layers },
|
||||
boxes: { label: 'Services', Icon: Boxes },
|
||||
folder: { label: 'Dossier', Icon: Folder },
|
||||
file: { label: 'Document', Icon: FileText },
|
||||
link: { label: 'Lien', Icon: Link2 },
|
||||
mail: { label: 'Messagerie', Icon: Mail },
|
||||
users: { label: 'Utilisateurs', Icon: Users },
|
||||
check: { label: 'Termine', Icon: CheckCircle2 },
|
||||
warning: { label: 'Attention', Icon: AlertTriangle },
|
||||
flag: { label: 'Jalon', Icon: Flag },
|
||||
star: { label: 'Important', Icon: Star },
|
||||
timer: { label: 'Echeance', Icon: Timer },
|
||||
idea: { label: 'Idee', Icon: Lightbulb },
|
||||
};
|
||||
|
||||
export const BUILTIN_ICON_IDS = Object.keys(BUILTIN_ICONS);
|
||||
|
||||
/** Emojis proposes, conserves depuis la V1 pour ne rien casser. */
|
||||
export const EMOJI_ICONS = [
|
||||
'💡',
|
||||
'✅',
|
||||
'📌',
|
||||
'⚠️',
|
||||
'🔒',
|
||||
'🌐',
|
||||
'🖥️',
|
||||
'📦',
|
||||
'📄',
|
||||
'🔗',
|
||||
'⭐',
|
||||
'🐳',
|
||||
'🗄️',
|
||||
'🚀',
|
||||
];
|
||||
110
frontend/src/features/editor/nodeStyleResolver.test.ts
Normal file
110
frontend/src/features/editor/nodeStyleResolver.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type MindMapNode } from '@/types/map';
|
||||
import { getTheme, themeMetrics } from '@/features/editor/themes';
|
||||
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
|
||||
|
||||
function node(partial: Partial<MindMapNode> = {}): MindMapNode {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: 'standard',
|
||||
text: 'Reseau',
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const theme = getTheme('default');
|
||||
const metrics = themeMetrics(theme);
|
||||
|
||||
describe('resolution du style d un noeud', () => {
|
||||
it('applique les valeurs du theme quand le noeud ne precise rien', () => {
|
||||
const appearance = resolveNodeAppearance(node(), 1, theme);
|
||||
|
||||
expect(appearance.paddingX).toBe(metrics.paddingX);
|
||||
expect(appearance.paddingY).toBe(metrics.paddingY);
|
||||
expect(appearance.lineHeight).toBe(metrics.lineHeight);
|
||||
expect(appearance.maxTextWidth).toBe(metrics.maxTextWidth);
|
||||
expect(appearance.textAlign).toBe('left');
|
||||
expect(appearance.verticalAlign).toBe('top');
|
||||
expect(appearance.widthMode).toBe('auto');
|
||||
expect(appearance.heightMode).toBe('auto');
|
||||
expect(appearance.wrap).toBe(true);
|
||||
expect(appearance.iconPosition).toBe('left');
|
||||
});
|
||||
|
||||
it('donne la priorite aux personnalisations du noeud', () => {
|
||||
const appearance = resolveNodeAppearance(
|
||||
node({
|
||||
style: {
|
||||
backgroundColor: '#123456',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 1.8,
|
||||
fontFamily: 'mono',
|
||||
shadow: 'none',
|
||||
},
|
||||
layout: { paddingX: 30, paddingY: 20, wrap: false, maxTextWidth: 400 },
|
||||
}),
|
||||
1,
|
||||
theme,
|
||||
);
|
||||
|
||||
expect(appearance.background).toBe('#123456');
|
||||
expect(appearance.textAlign).toBe('center');
|
||||
expect(appearance.verticalAlign).toBe('middle');
|
||||
expect(appearance.lineHeight).toBe(1.8);
|
||||
expect(appearance.fontFamily).toContain('monospace');
|
||||
expect(appearance.boxShadow).toBe('none');
|
||||
expect(appearance.paddingX).toBe(30);
|
||||
expect(appearance.paddingY).toBe(20);
|
||||
expect(appearance.wrap).toBe(false);
|
||||
expect(appearance.maxTextWidth).toBe(400);
|
||||
});
|
||||
|
||||
it('change de theme sans perdre les personnalisations', () => {
|
||||
const customised = node({ style: { backgroundColor: '#ff8800', textAlign: 'right' } });
|
||||
|
||||
const light = resolveNodeAppearance(customised, 1, getTheme('default'));
|
||||
const dark = resolveNodeAppearance(customised, 1, getTheme('midnight'));
|
||||
|
||||
expect(light.background).toBe('#ff8800');
|
||||
expect(dark.background).toBe('#ff8800');
|
||||
expect(dark.textAlign).toBe('right');
|
||||
// Les valeurs non personnalisees suivent bien le nouveau theme.
|
||||
expect(dark.color).not.toBe(light.color);
|
||||
});
|
||||
|
||||
it('lit la largeur fixe du format v1 (style.width)', () => {
|
||||
const legacy = resolveNodeAppearance(node({ style: { width: 240 } }), 1, theme);
|
||||
|
||||
expect(legacy.widthMode).toBe('fixed');
|
||||
expect(legacy.width).toBe(240);
|
||||
});
|
||||
|
||||
it('donne la priorite a layout.width sur la largeur v1', () => {
|
||||
const appearance = resolveNodeAppearance(
|
||||
node({ style: { width: 240 }, layout: { widthMode: 'fixed', width: 320 } }),
|
||||
1,
|
||||
theme,
|
||||
);
|
||||
expect(appearance.width).toBe(320);
|
||||
});
|
||||
|
||||
it('herite des metriques propres a chaque theme', () => {
|
||||
const pastel = resolveNodeAppearance(node(), 1, getTheme('pastel'));
|
||||
const minimal = resolveNodeAppearance(node(), 1, getTheme('minimal'));
|
||||
|
||||
expect(pastel.paddingX).toBe(18);
|
||||
expect(minimal.paddingX).toBe(12);
|
||||
});
|
||||
|
||||
it('ignore une hauteur fixe declaree sans valeur', () => {
|
||||
const appearance = resolveNodeAppearance(
|
||||
node({ layout: { heightMode: 'fixed' } }),
|
||||
1,
|
||||
theme,
|
||||
);
|
||||
expect(appearance.heightMode).toBe('auto');
|
||||
});
|
||||
});
|
||||
187
frontend/src/features/editor/nodeStyleResolver.ts
Normal file
187
frontend/src/features/editor/nodeStyleResolver.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
type FontFamilyId,
|
||||
type IconPosition,
|
||||
type MindMapNode,
|
||||
type NodeShape,
|
||||
type ShadowStyle,
|
||||
type TextAlign,
|
||||
type VerticalAlign,
|
||||
} from '@/types/map';
|
||||
import { type MapTheme, paletteForDepth, themeMetrics } from '@/features/editor/themes';
|
||||
|
||||
/**
|
||||
* Fusion theme + personnalisations explicites du noeud.
|
||||
*
|
||||
* Regle unique : un champ absent du noeud herite du theme. Rien n'est jamais
|
||||
* ecrit dans le document pour "materialiser" un defaut, ce qui garantit que les
|
||||
* cartes v1 deja stockees restent lisibles et que changer de theme n'ecrase
|
||||
* aucune personnalisation.
|
||||
*/
|
||||
|
||||
export const FONT_FAMILIES: Record<Exclude<FontFamilyId, 'theme'>, string> = {
|
||||
sans: "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', Times, serif",
|
||||
mono: "ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace",
|
||||
rounded: "'Segoe UI', 'Trebuchet MS', ui-rounded, system-ui, sans-serif",
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_OPTIONS: Array<{ id: FontFamilyId; label: string }> = [
|
||||
{ id: 'theme', label: 'Theme' },
|
||||
{ id: 'sans', label: 'Sans-serif' },
|
||||
{ id: 'serif', label: 'Serif' },
|
||||
{ id: 'mono', label: 'Monospace' },
|
||||
{ id: 'rounded', label: 'Arrondie' },
|
||||
];
|
||||
|
||||
export const SHADOW_OPTIONS: Array<{ id: ShadowStyle; label: string }> = [
|
||||
{ id: 'theme', label: 'Theme' },
|
||||
{ id: 'none', label: 'Aucune' },
|
||||
{ id: 'soft', label: 'Douce' },
|
||||
{ id: 'strong', label: 'Marquee' },
|
||||
];
|
||||
|
||||
const SHADOWS: Record<Exclude<ShadowStyle, 'theme'>, string> = {
|
||||
none: 'none',
|
||||
soft: '0 1px 2px rgba(15,23,42,0.06), 0 6px 16px -10px rgba(15,23,42,0.35)',
|
||||
strong: '0 2px 4px rgba(15,23,42,0.10), 0 14px 28px -12px rgba(15,23,42,0.55)',
|
||||
};
|
||||
|
||||
/** Bornes appliquees a toute saisie utilisateur. */
|
||||
export const LAYOUT_LIMITS = {
|
||||
width: { min: 60, max: 900 },
|
||||
height: { min: 32, max: 900 },
|
||||
paddingX: { min: 0, max: 64 },
|
||||
paddingY: { min: 0, max: 48 },
|
||||
maxTextWidth: { min: 80, max: 900 },
|
||||
fontSize: { min: 10, max: 48 },
|
||||
lineHeight: { min: 1, max: 2.4 },
|
||||
iconSize: { min: 12, max: 48 },
|
||||
iconGap: { min: 0, max: 32 },
|
||||
branchWidth: { min: 1, max: 10 },
|
||||
} as const;
|
||||
|
||||
export function clampTo(value: number, bounds: { min: number; max: number }): number {
|
||||
return Math.min(bounds.max, Math.max(bounds.min, Math.round(value)));
|
||||
}
|
||||
|
||||
/** Apparence entierement resolue d'un noeud, prete a etre appliquee en CSS. */
|
||||
export interface NodeAppearance {
|
||||
background: string;
|
||||
color: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
borderRadius: string;
|
||||
shape: NodeShape;
|
||||
circle: boolean;
|
||||
boxShadow: string;
|
||||
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: number;
|
||||
fontStyle: 'normal' | 'italic';
|
||||
lineHeight: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
wrap: boolean;
|
||||
widthMode: 'auto' | 'fixed';
|
||||
heightMode: 'auto' | 'fixed';
|
||||
width?: number;
|
||||
height?: number;
|
||||
maxTextWidth: number;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
|
||||
iconSize: number;
|
||||
iconGap: number;
|
||||
iconPosition: IconPosition;
|
||||
}
|
||||
|
||||
function radiusForShape(shape: NodeShape, themeRadius: number): string {
|
||||
switch (shape) {
|
||||
case 'rectangle':
|
||||
return '3px';
|
||||
case 'capsule':
|
||||
return '999px';
|
||||
case 'circle':
|
||||
return '50%';
|
||||
case 'rounded':
|
||||
default:
|
||||
return `${themeRadius}px`;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNodeAppearance(
|
||||
node: MindMapNode,
|
||||
depth: number,
|
||||
theme: MapTheme,
|
||||
): NodeAppearance {
|
||||
const palette = paletteForDepth(theme, depth);
|
||||
const metrics = themeMetrics(theme);
|
||||
const style = node.style ?? {};
|
||||
const layout = node.layout ?? {};
|
||||
const iconStyle = node.iconStyle ?? {};
|
||||
|
||||
const shape = style.shape ?? theme.nodeShape;
|
||||
const fontFamily =
|
||||
style.fontFamily && style.fontFamily !== 'theme'
|
||||
? FONT_FAMILIES[style.fontFamily]
|
||||
: theme.fontFamily;
|
||||
const shadow = style.shadow && style.shadow !== 'theme' ? SHADOWS[style.shadow] : theme.shadow;
|
||||
|
||||
// `style.width` est l'ancien champ v1 : il vaut largeur fixe.
|
||||
const legacyWidth = style.width;
|
||||
const widthMode = layout.widthMode ?? (layout.width ?? legacyWidth ? 'fixed' : 'auto');
|
||||
const width = layout.width ?? legacyWidth;
|
||||
const heightMode = layout.heightMode ?? (layout.height ? 'fixed' : 'auto');
|
||||
|
||||
return {
|
||||
background: style.backgroundColor ?? palette.background,
|
||||
color: style.textColor ?? palette.text,
|
||||
borderColor: style.borderColor ?? palette.border,
|
||||
borderWidth: theme.borderWidth,
|
||||
borderRadius: radiusForShape(shape, theme.nodeRadius),
|
||||
shape,
|
||||
circle: shape === 'circle',
|
||||
boxShadow: shadow,
|
||||
|
||||
fontFamily,
|
||||
fontSize: style.fontSize ?? (depth === 0 ? 16 : 14),
|
||||
fontWeight: style.bold ? 700 : depth === 0 ? 600 : 500,
|
||||
fontStyle: style.italic ? 'italic' : 'normal',
|
||||
lineHeight: style.lineHeight ?? metrics.lineHeight,
|
||||
textAlign: style.textAlign ?? 'left',
|
||||
verticalAlign: style.verticalAlign ?? 'top',
|
||||
|
||||
paddingX: layout.paddingX ?? metrics.paddingX,
|
||||
paddingY: layout.paddingY ?? metrics.paddingY,
|
||||
wrap: layout.wrap ?? true,
|
||||
widthMode: widthMode === 'fixed' && width ? 'fixed' : 'auto',
|
||||
heightMode: heightMode === 'fixed' && layout.height ? 'fixed' : 'auto',
|
||||
...(width ? { width } : {}),
|
||||
...(layout.height ? { height: layout.height } : {}),
|
||||
maxTextWidth: layout.maxTextWidth ?? metrics.maxTextWidth,
|
||||
minWidth: metrics.minNodeWidth,
|
||||
minHeight: metrics.minNodeHeight,
|
||||
|
||||
iconSize: iconStyle.size ?? metrics.iconSize,
|
||||
iconGap: iconStyle.gap ?? metrics.iconGap,
|
||||
iconPosition: iconStyle.position ?? 'left',
|
||||
};
|
||||
}
|
||||
|
||||
/** Style effectif de la branche entrante d'un noeud. */
|
||||
export function resolveBranchAppearance(
|
||||
node: MindMapNode,
|
||||
theme: MapTheme,
|
||||
fallbackStyle: MindMapNode['branch'],
|
||||
): { style: NonNullable<MindMapNode['branch']>['style']; width: number; color: string } {
|
||||
const branch = node.branch ?? {};
|
||||
return {
|
||||
style: branch.style ?? fallbackStyle?.style,
|
||||
width: branch.width ?? theme.branchWidth,
|
||||
color: branch.color ?? theme.branchColor,
|
||||
};
|
||||
}
|
||||
56
frontend/src/features/editor/panel/PanelSection.tsx
Normal file
56
frontend/src/features/editor/panel/PanelSection.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@/components/Icons';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface PanelSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section repliable du panneau Proprietes.
|
||||
* L'etat ouvert/ferme est conserve pendant la session (voir `uiStore`).
|
||||
*/
|
||||
export function PanelSection({ id, title, icon, children }: PanelSectionProps) {
|
||||
const open = useUiStore((state) => state.panelSections[id] ?? true);
|
||||
const togglePanelSection = useUiStore((state) => state.togglePanelSection);
|
||||
const contentId = `panel-section-${id}`;
|
||||
|
||||
return (
|
||||
<section className="border-b border-zinc-100 last:border-b-0 dark:border-zinc-800">
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePanelSection(id)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide',
|
||||
'text-zinc-500 transition-colors hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-zinc-400">
|
||||
{open ? (
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
{icon ? (
|
||||
<span aria-hidden="true" className="text-zinc-400">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1">{title}</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<div id={contentId} hidden={!open} className="space-y-3 px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
288
frontend/src/features/editor/panel/controls.tsx
Normal file
288
frontend/src/features/editor/panel/controls.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { CloseIcon } from '@/components/Icons';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { MIXED_LABEL, type SharedValue } from '@/features/editor/panel/sharedValue';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
{hint ? <span className="ml-1 font-normal normal-case text-zinc-400">{hint}</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SegmentOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/** Groupe de boutons compact. Chaque bouton porte un libelle accessible. */
|
||||
export function SegmentedControl<T extends string>({
|
||||
legend,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
mixed = false,
|
||||
}: {
|
||||
legend: string;
|
||||
options: Array<SegmentOption<T>>;
|
||||
selected: T | undefined;
|
||||
onSelect: (value: T) => void;
|
||||
mixed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="field-label">
|
||||
{legend}
|
||||
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
|
||||
</legend>
|
||||
<div className="flex gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800/60" role="group">
|
||||
{options.map((option) => {
|
||||
const active = !mixed && selected === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
title={option.label}
|
||||
aria-label={option.label}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelect(option.value)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center rounded-md px-2 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-white text-indigo-700 shadow-sm dark:bg-zinc-900 dark:text-indigo-300'
|
||||
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100',
|
||||
)}
|
||||
>
|
||||
{option.icon ?? option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Champ numerique borne, avec slider optionnel. La saisie est validee au blur. */
|
||||
export function NumberField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
unit = 'px',
|
||||
slider = true,
|
||||
mixed = false,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
slider?: boolean;
|
||||
mixed?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
useEffect(() => setDraft(String(value)), [value]);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setDraft(String(value));
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(max, Math.max(min, Math.round(parsed / step) * step));
|
||||
setDraft(String(clamped));
|
||||
if (clamped !== value) onChange(clamped);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{slider ? (
|
||||
<input
|
||||
type="range"
|
||||
aria-label={`${label} (curseur)`}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-1.5 flex-1 accent-indigo-600 disabled:opacity-40"
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={(event) => commit(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
className="field-input w-20 px-2 py-1 text-right text-xs tabular-nums disabled:opacity-40"
|
||||
/>
|
||||
<span className="w-5 shrink-0 text-xs text-zinc-400">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normaliseHex(color: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* Selecteur de couleur : palette native, valeur hexadecimale lisible et retour
|
||||
* explicite a la valeur du theme.
|
||||
*/
|
||||
export function ColorField({
|
||||
id,
|
||||
label,
|
||||
shared,
|
||||
fallback,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
shared: SharedValue<string>;
|
||||
fallback: string;
|
||||
onChange: (color: string | undefined) => void;
|
||||
}) {
|
||||
const current = shared.mixed ? undefined : shared.value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
{shared.mixed ? (
|
||||
<span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span>
|
||||
) : null}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="color"
|
||||
value={normaliseHex(current ?? fallback)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-7 w-9 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
|
||||
/>
|
||||
<span className="flex-1 truncate font-mono text-[11px] text-zinc-500">
|
||||
{shared.mixed ? MIXED_LABEL : (current ?? `theme (${fallback})`)}
|
||||
</span>
|
||||
{current ? (
|
||||
<IconButton
|
||||
label={`Revenir au theme pour ${label.toLowerCase()}`}
|
||||
icon={<CloseIcon className="h-3.5 w-3.5" />}
|
||||
onClick={() => onChange(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Case a cocher avec libelle, alignee sur le reste du panneau. */
|
||||
export function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
mixed = false,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
mixed?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label htmlFor={id} className="flex items-center gap-2.5 text-sm">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
ref={(element) => {
|
||||
if (element) element.indeterminate = mixed;
|
||||
}}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-zinc-300 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-zinc-700 dark:text-zinc-200">{label}</span>
|
||||
{mixed ? <span className="text-xs text-amber-500">{MIXED_LABEL}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Liste deroulante compacte. */
|
||||
export function SelectField<T extends string>({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
mixed = false,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: Array<{ id: T; label: string }>;
|
||||
mixed?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
{mixed ? <span className="ml-1 font-normal normal-case text-amber-500">{MIXED_LABEL}</span> : null}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
className="field-input py-1.5 text-sm"
|
||||
value={mixed ? '' : value}
|
||||
onChange={(event) => onChange(event.target.value as T)}
|
||||
>
|
||||
{mixed ? <option value="">{MIXED_LABEL}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/features/editor/panel/sharedValue.ts
Normal file
35
frontend/src/features/editor/panel/sharedValue.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type MindMapNode } from '@/types/map';
|
||||
|
||||
/**
|
||||
* Valeur commune a une selection.
|
||||
*
|
||||
* `mixed` indique que les noeuds selectionnes n'ont pas la meme valeur : le
|
||||
* panneau affiche alors "Mixte" plutot que de faire passer la valeur du premier
|
||||
* noeud pour une valeur commune.
|
||||
*/
|
||||
export interface SharedValue<T> {
|
||||
value: T | undefined;
|
||||
mixed: boolean;
|
||||
}
|
||||
|
||||
export function sharedValue<T>(
|
||||
nodes: MindMapNode[],
|
||||
read: (node: MindMapNode) => T | undefined,
|
||||
): SharedValue<T> {
|
||||
if (nodes.length === 0) return { value: undefined, mixed: false };
|
||||
|
||||
const first = read(nodes[0] as MindMapNode);
|
||||
for (let index = 1; index < nodes.length; index += 1) {
|
||||
if (read(nodes[index] as MindMapNode) !== first) {
|
||||
return { value: undefined, mixed: true };
|
||||
}
|
||||
}
|
||||
return { value: first, mixed: false };
|
||||
}
|
||||
|
||||
/** Valeur a afficher dans un controle : la valeur commune, sinon le defaut. */
|
||||
export function displayValue<T>(shared: SharedValue<T>, fallback: T): T {
|
||||
return shared.mixed ? fallback : (shared.value ?? fallback);
|
||||
}
|
||||
|
||||
export const MIXED_LABEL = 'Mixte';
|
||||
@@ -26,6 +26,48 @@ export interface MapTheme {
|
||||
branchWidth: number;
|
||||
root: NodePalette;
|
||||
levels: NodePalette[];
|
||||
|
||||
/*
|
||||
* Metriques de mise en page (v2). Optionnelles : `themeMetrics()` applique
|
||||
* les valeurs par defaut, ce qui evite de dupliquer les memes chiffres dans
|
||||
* les neuf themes.
|
||||
*/
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
lineHeight?: number;
|
||||
minNodeWidth?: number;
|
||||
minNodeHeight?: number;
|
||||
maxTextWidth?: number;
|
||||
iconSize?: number;
|
||||
iconGap?: number;
|
||||
}
|
||||
|
||||
/** Valeurs de mise en page utilisees quand le theme ne precise rien. */
|
||||
export const THEME_METRIC_DEFAULTS = {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
lineHeight: 1.35,
|
||||
minNodeWidth: 72,
|
||||
minNodeHeight: 36,
|
||||
maxTextWidth: 260,
|
||||
iconSize: 16,
|
||||
iconGap: 8,
|
||||
} as const;
|
||||
|
||||
export type ThemeMetrics = { -readonly [K in keyof typeof THEME_METRIC_DEFAULTS]: number };
|
||||
|
||||
/** Metriques effectives d'un theme : ses valeurs, completees par les defauts. */
|
||||
export function themeMetrics(theme: MapTheme): ThemeMetrics {
|
||||
return {
|
||||
paddingX: theme.paddingX ?? THEME_METRIC_DEFAULTS.paddingX,
|
||||
paddingY: theme.paddingY ?? THEME_METRIC_DEFAULTS.paddingY,
|
||||
lineHeight: theme.lineHeight ?? THEME_METRIC_DEFAULTS.lineHeight,
|
||||
minNodeWidth: theme.minNodeWidth ?? THEME_METRIC_DEFAULTS.minNodeWidth,
|
||||
minNodeHeight: theme.minNodeHeight ?? THEME_METRIC_DEFAULTS.minNodeHeight,
|
||||
maxTextWidth: theme.maxTextWidth ?? THEME_METRIC_DEFAULTS.maxTextWidth,
|
||||
iconSize: theme.iconSize ?? THEME_METRIC_DEFAULTS.iconSize,
|
||||
iconGap: theme.iconGap ?? THEME_METRIC_DEFAULTS.iconGap,
|
||||
};
|
||||
}
|
||||
|
||||
const SANS = "Inter, ui-sans-serif, system-ui, 'Segoe UI', Roboto, sans-serif";
|
||||
@@ -61,6 +103,8 @@ export const THEMES: MapTheme[] = [
|
||||
{
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
paddingX: 12,
|
||||
paddingY: 6,
|
||||
isDark: false,
|
||||
canvasBackground: '#ffffff',
|
||||
gridColor: '#e5e7eb',
|
||||
@@ -161,6 +205,8 @@ export const THEMES: MapTheme[] = [
|
||||
{
|
||||
id: 'pastel',
|
||||
name: 'Pastel',
|
||||
paddingX: 18,
|
||||
paddingY: 10,
|
||||
isDark: false,
|
||||
canvasBackground: '#fffdf9',
|
||||
gridColor: '#f1e7dc',
|
||||
@@ -202,6 +248,9 @@ export const THEMES: MapTheme[] = [
|
||||
{
|
||||
id: 'blueprint',
|
||||
name: 'Blueprint',
|
||||
paddingX: 12,
|
||||
paddingY: 7,
|
||||
lineHeight: 1.25,
|
||||
isDark: true,
|
||||
canvasBackground: '#0d2b45',
|
||||
gridColor: '#1d4666',
|
||||
|
||||
@@ -49,6 +49,19 @@ export function useEditorShortcuts(enabled: boolean, handlers: ShortcutHandlers)
|
||||
|
||||
if (typing) return;
|
||||
|
||||
if (modifier && event.shiftKey && (key === 'c' || key === 'v')) {
|
||||
event.preventDefault();
|
||||
if (key === 'c') {
|
||||
if (store.copyStyle()) useUiStore.getState().pushToast('Style copie.', 'info');
|
||||
return;
|
||||
}
|
||||
const applied = store.pasteStyle();
|
||||
if (applied > 0) {
|
||||
useUiStore.getState().pushToast(`Style applique a ${applied} noeud(s).`, 'success');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modifier) {
|
||||
switch (key) {
|
||||
case 'z':
|
||||
|
||||
@@ -29,13 +29,16 @@ export function computeMapBounds(map: MindMap, sizes?: Map<string, NodeSize>): M
|
||||
const visible = map.nodes.filter((node) => !hidden.has(node.id));
|
||||
if (visible.length === 0) return { x: 0, y: 0, width: 400, height: 300 };
|
||||
|
||||
// Le theme influence padding et typographie, donc la taille estimee.
|
||||
const theme = getTheme(map.settings.theme);
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const node of visible) {
|
||||
const size = resolveNodeSize(node, sizes);
|
||||
const size = resolveNodeSize(node, sizes, theme);
|
||||
minX = Math.min(minX, node.position.x);
|
||||
minY = Math.min(minY, node.position.y);
|
||||
maxX = Math.max(maxX, node.position.x + size.width);
|
||||
|
||||
171
frontend/src/services/mapLayoutSerialization.test.ts
Normal file
171
frontend/src/services/mapLayoutSerialization.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { parseMapDocument, serializeMap } from '@/services/mapSerialization';
|
||||
|
||||
/** Carte v1 telle qu'elle etait exportee avant la phase 1.1. */
|
||||
const LEGACY_V1 = {
|
||||
version: 1,
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
name: 'Infrastructure v1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
nodes: [
|
||||
{
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
text: 'Infrastructure',
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: null,
|
||||
icon: '🖥️',
|
||||
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
|
||||
},
|
||||
{ id: 'n1', type: 'standard', text: 'Reseau', position: { x: 260, y: 0 }, parentId: 'root' },
|
||||
],
|
||||
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
|
||||
};
|
||||
|
||||
describe('compatibilite du format', () => {
|
||||
it('importe une carte v1 sans propriete de mise en page', () => {
|
||||
const map = parseMapDocument(LEGACY_V1);
|
||||
|
||||
expect(map.version).toBe(MAP_FORMAT_VERSION);
|
||||
expect(map.nodes).toHaveLength(2);
|
||||
expect(map.nodes[1]?.layout).toBeUndefined();
|
||||
expect(map.nodes[1]?.iconStyle).toBeUndefined();
|
||||
expect(map.settings.layoutSpacing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migre la largeur v1 vers layout.width', () => {
|
||||
const map = parseMapDocument(LEGACY_V1);
|
||||
const root = map.nodes[0];
|
||||
|
||||
expect(root?.layout?.widthMode).toBe('fixed');
|
||||
expect(root?.layout?.width).toBe(240);
|
||||
expect(root?.style?.width).toBeUndefined();
|
||||
// Les autres personnalisations v1 sont conservees.
|
||||
expect(root?.style?.backgroundColor).toBe('#eef2ff');
|
||||
expect(root?.style?.textAlign).toBe('center');
|
||||
expect(root?.icon).toBe('🖥️');
|
||||
});
|
||||
|
||||
it('refuse une version plus recente que celle supportee', () => {
|
||||
expect(() => parseMapDocument({ ...LEGACY_V1, version: MAP_FORMAT_VERSION + 1 })).toThrow(
|
||||
/plus recente/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aller-retour des nouvelles proprietes', () => {
|
||||
function richMap(): MindMap {
|
||||
const map = createEmptyMap('Mise en page');
|
||||
const root = map.nodes[0] as MindMap['nodes'][number];
|
||||
return {
|
||||
...map,
|
||||
nodes: [
|
||||
{
|
||||
...root,
|
||||
icon: 'server',
|
||||
iconStyle: { kind: 'builtin', position: 'top', size: 24, gap: 12 },
|
||||
style: {
|
||||
backgroundColor: '#112233',
|
||||
textColor: '#ffffff',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 1.6,
|
||||
fontFamily: 'mono',
|
||||
shadow: 'strong',
|
||||
fontSize: 18,
|
||||
bold: true,
|
||||
italic: true,
|
||||
shape: 'capsule',
|
||||
},
|
||||
layout: {
|
||||
widthMode: 'fixed',
|
||||
width: 320,
|
||||
heightMode: 'fixed',
|
||||
height: 140,
|
||||
paddingX: 28,
|
||||
paddingY: 18,
|
||||
wrap: false,
|
||||
maxTextWidth: 400,
|
||||
},
|
||||
branch: { style: 'straight', width: 4, color: '#ff8800' },
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
...map.settings,
|
||||
layoutSpacing: { parentGap: 140, siblingGap: 80 },
|
||||
snapToGrid: true,
|
||||
snapGrid: 10,
|
||||
alignmentGuides: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('conserve toute la mise en page apres export puis import', () => {
|
||||
const original = richMap();
|
||||
const restored = parseMapDocument(JSON.parse(serializeMap(original)));
|
||||
const node = restored.nodes[0];
|
||||
const source = original.nodes[0];
|
||||
|
||||
expect(node?.style).toEqual(source?.style);
|
||||
expect(node?.layout).toEqual(source?.layout);
|
||||
expect(node?.iconStyle).toEqual(source?.iconStyle);
|
||||
expect(node?.branch).toEqual(source?.branch);
|
||||
expect(node?.icon).toBe('server');
|
||||
expect(node?.position).toEqual(source?.position);
|
||||
expect(restored.settings.layoutSpacing).toEqual({ parentGap: 140, siblingGap: 80 });
|
||||
expect(restored.settings.snapToGrid).toBe(true);
|
||||
expect(restored.settings.snapGrid).toBe(10);
|
||||
expect(restored.settings.alignmentGuides).toBe(false);
|
||||
});
|
||||
|
||||
it('borne les valeurs aberrantes a l import', () => {
|
||||
const map = parseMapDocument({
|
||||
version: 2,
|
||||
id: 'x',
|
||||
nodes: [
|
||||
{
|
||||
id: 'a',
|
||||
type: 'root',
|
||||
text: 'Racine',
|
||||
layout: { width: 99999, paddingX: -20, maxTextWidth: 5 },
|
||||
branch: { width: 500 },
|
||||
iconStyle: { size: 900, gap: -5 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const node = map.nodes[0];
|
||||
expect(node?.layout?.width).toBeLessThanOrEqual(900);
|
||||
expect(node?.layout?.paddingX).toBeGreaterThanOrEqual(0);
|
||||
expect(node?.layout?.maxTextWidth).toBeGreaterThanOrEqual(80);
|
||||
expect(node?.branch?.width).toBeLessThanOrEqual(10);
|
||||
expect(node?.iconStyle?.size).toBeLessThanOrEqual(48);
|
||||
expect(node?.iconStyle?.gap).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('rejette les valeurs de mise en page invalides', () => {
|
||||
const map = parseMapDocument({
|
||||
version: 2,
|
||||
id: 'x',
|
||||
nodes: [
|
||||
{
|
||||
id: 'a',
|
||||
type: 'root',
|
||||
text: 'Racine',
|
||||
layout: { widthMode: 'elastique', wrap: 'oui' },
|
||||
iconStyle: { kind: 'svg', position: 'diagonale' },
|
||||
style: { verticalAlign: 'flottant', fontFamily: 'comic-sans' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(map.nodes[0]?.layout).toBeUndefined();
|
||||
expect(map.nodes[0]?.iconStyle).toBeUndefined();
|
||||
expect(map.nodes[0]?.style).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
importMap,
|
||||
listMaps,
|
||||
renameMap,
|
||||
saveMap,
|
||||
seedSampleMapOnce,
|
||||
} from '@/services/mapRepository';
|
||||
import { saveShareRecord } from '@/services/shareRepository';
|
||||
@@ -103,3 +104,49 @@ describe('stockage IndexedDB des cartes', () => {
|
||||
expect((await getMap(map.id))?.name).toBe('Persistante');
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistance de la mise en page', () => {
|
||||
it('conserve taille, padding et alignement apres reouverture', async () => {
|
||||
const map = await createMap('Mise en page');
|
||||
const rootNodeId = map.nodes[0]?.id as string;
|
||||
|
||||
await saveMap({
|
||||
...map,
|
||||
nodes: map.nodes.map((node) =>
|
||||
node.id === rootNodeId
|
||||
? {
|
||||
...node,
|
||||
style: { textAlign: 'center', verticalAlign: 'middle', lineHeight: 1.6 },
|
||||
layout: {
|
||||
widthMode: 'fixed',
|
||||
width: 280,
|
||||
heightMode: 'fixed',
|
||||
height: 120,
|
||||
paddingX: 26,
|
||||
paddingY: 16,
|
||||
wrap: false,
|
||||
},
|
||||
iconStyle: { kind: 'builtin', position: 'top', size: 24 },
|
||||
icon: 'server',
|
||||
}
|
||||
: node,
|
||||
),
|
||||
});
|
||||
|
||||
// Fermeture puis reouverture de la base, comme un redemarrage du navigateur.
|
||||
db.close();
|
||||
await db.open();
|
||||
|
||||
const stored = await getMap(map.id);
|
||||
const node = stored?.nodes.find((candidate) => candidate.id === rootNodeId);
|
||||
|
||||
expect(node?.layout?.width).toBe(280);
|
||||
expect(node?.layout?.height).toBe(120);
|
||||
expect(node?.layout?.paddingX).toBe(26);
|
||||
expect(node?.layout?.wrap).toBe(false);
|
||||
expect(node?.style?.textAlign).toBe('center');
|
||||
expect(node?.style?.verticalAlign).toBe('middle');
|
||||
expect(node?.icon).toBe('server');
|
||||
expect(node?.iconStyle?.position).toBe('top');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,25 @@ import { APP_CONFIG } from '@/config/app';
|
||||
import {
|
||||
MAP_FORMAT_VERSION,
|
||||
type BranchStyle,
|
||||
type FontFamilyId,
|
||||
type IconPosition,
|
||||
type LayoutKind,
|
||||
type MapSettings,
|
||||
type MindMap,
|
||||
type MindMapNode,
|
||||
type NodeBranchStyle,
|
||||
type NodeIconStyle,
|
||||
type NodeKind,
|
||||
type NodeLayout,
|
||||
type NodeShape,
|
||||
type NodeStyle,
|
||||
type ShadowStyle,
|
||||
type SizeMode,
|
||||
type TextAlign,
|
||||
type VerticalAlign,
|
||||
} from '@/types/map';
|
||||
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { SPACING_LIMITS } from '@/features/editor/layout';
|
||||
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
|
||||
import { createId } from '@/utils/id';
|
||||
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
|
||||
@@ -31,6 +41,12 @@ const MAX_NAME_LENGTH = 200;
|
||||
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
|
||||
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
|
||||
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
|
||||
const VERTICAL_ALIGNS: VerticalAlign[] = ['top', 'middle', 'bottom'];
|
||||
const SIZE_MODES: SizeMode[] = ['auto', 'fixed'];
|
||||
const SHADOW_STYLES: ShadowStyle[] = ['theme', 'none', 'soft', 'strong'];
|
||||
const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounded'];
|
||||
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
|
||||
const ICON_KINDS = ['emoji', 'builtin'] as const;
|
||||
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
|
||||
const LAYOUTS: LayoutKind[] = [
|
||||
'mindmap-right',
|
||||
@@ -95,10 +111,107 @@ function parseStyle(value: unknown): NodeStyle | undefined {
|
||||
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
|
||||
style.textAlign = value.textAlign as TextAlign;
|
||||
}
|
||||
if (
|
||||
typeof value.verticalAlign === 'string' &&
|
||||
VERTICAL_ALIGNS.includes(value.verticalAlign as VerticalAlign)
|
||||
) {
|
||||
style.verticalAlign = value.verticalAlign as VerticalAlign;
|
||||
}
|
||||
if (
|
||||
typeof value.fontFamily === 'string' &&
|
||||
FONT_FAMILIES.includes(value.fontFamily as FontFamilyId)
|
||||
) {
|
||||
style.fontFamily = value.fontFamily as FontFamilyId;
|
||||
}
|
||||
if (typeof value.shadow === 'string' && SHADOW_STYLES.includes(value.shadow as ShadowStyle)) {
|
||||
style.shadow = value.shadow as ShadowStyle;
|
||||
}
|
||||
if (typeof value.lineHeight === 'number' && Number.isFinite(value.lineHeight)) {
|
||||
style.lineHeight = Math.min(
|
||||
LAYOUT_LIMITS.lineHeight.max,
|
||||
Math.max(LAYOUT_LIMITS.lineHeight.min, Math.round(value.lineHeight * 100) / 100),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.keys(style).length > 0 ? style : undefined;
|
||||
}
|
||||
|
||||
/** Mise en page d'un noeud (v2). Chaque valeur est bornee. */
|
||||
function parseLayout(value: unknown): NodeLayout | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const layout: NodeLayout = {};
|
||||
if (typeof value.widthMode === 'string' && SIZE_MODES.includes(value.widthMode as SizeMode)) {
|
||||
layout.widthMode = value.widthMode as SizeMode;
|
||||
}
|
||||
if (typeof value.heightMode === 'string' && SIZE_MODES.includes(value.heightMode as SizeMode)) {
|
||||
layout.heightMode = value.heightMode as SizeMode;
|
||||
}
|
||||
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
|
||||
layout.width = clampTo(value.width, LAYOUT_LIMITS.width);
|
||||
}
|
||||
if (typeof value.height === 'number' && Number.isFinite(value.height)) {
|
||||
layout.height = clampTo(value.height, LAYOUT_LIMITS.height);
|
||||
}
|
||||
if (typeof value.paddingX === 'number' && Number.isFinite(value.paddingX)) {
|
||||
layout.paddingX = clampTo(value.paddingX, LAYOUT_LIMITS.paddingX);
|
||||
}
|
||||
if (typeof value.paddingY === 'number' && Number.isFinite(value.paddingY)) {
|
||||
layout.paddingY = clampTo(value.paddingY, LAYOUT_LIMITS.paddingY);
|
||||
}
|
||||
if (typeof value.maxTextWidth === 'number' && Number.isFinite(value.maxTextWidth)) {
|
||||
layout.maxTextWidth = clampTo(value.maxTextWidth, LAYOUT_LIMITS.maxTextWidth);
|
||||
}
|
||||
if (typeof value.wrap === 'boolean') layout.wrap = value.wrap;
|
||||
|
||||
return Object.keys(layout).length > 0 ? layout : undefined;
|
||||
}
|
||||
|
||||
function parseIconStyle(value: unknown): NodeIconStyle | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const iconStyle: NodeIconStyle = {};
|
||||
if (
|
||||
typeof value.kind === 'string' &&
|
||||
(ICON_KINDS as readonly string[]).includes(value.kind)
|
||||
) {
|
||||
iconStyle.kind = value.kind as NodeIconStyle['kind'];
|
||||
}
|
||||
if (
|
||||
typeof value.position === 'string' &&
|
||||
ICON_POSITIONS.includes(value.position as IconPosition)
|
||||
) {
|
||||
iconStyle.position = value.position as IconPosition;
|
||||
}
|
||||
if (typeof value.size === 'number' && Number.isFinite(value.size)) {
|
||||
iconStyle.size = clampTo(value.size, LAYOUT_LIMITS.iconSize);
|
||||
}
|
||||
if (typeof value.gap === 'number' && Number.isFinite(value.gap)) {
|
||||
iconStyle.gap = clampTo(value.gap, LAYOUT_LIMITS.iconGap);
|
||||
}
|
||||
|
||||
return Object.keys(iconStyle).length > 0 ? iconStyle : undefined;
|
||||
}
|
||||
|
||||
function parseBranch(value: unknown): NodeBranchStyle | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const branch: NodeBranchStyle = {};
|
||||
if (
|
||||
typeof value.style === 'string' &&
|
||||
BRANCH_STYLES.includes(value.style as BranchStyle)
|
||||
) {
|
||||
branch.style = value.style as BranchStyle;
|
||||
}
|
||||
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
|
||||
branch.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
|
||||
}
|
||||
const color = asColor(value.color);
|
||||
if (color) branch.color = color;
|
||||
|
||||
return Object.keys(branch).length > 0 ? branch : undefined;
|
||||
}
|
||||
|
||||
function parseNode(value: unknown): MindMapNode | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
@@ -124,9 +237,13 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
|
||||
if (value.collapsed === true) node.collapsed = true;
|
||||
|
||||
const icon = asString(value.icon, 8);
|
||||
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
|
||||
const icon = asString(value.icon, 32);
|
||||
if (icon) node.icon = icon;
|
||||
|
||||
const iconStyle = parseIconStyle(value.iconStyle);
|
||||
if (iconStyle) node.iconStyle = iconStyle;
|
||||
|
||||
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
|
||||
const url = sanitizeUrl(asString(value.url, 2048));
|
||||
if (url) node.url = url;
|
||||
@@ -137,6 +254,12 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
const style = parseStyle(value.style);
|
||||
if (style) node.style = style;
|
||||
|
||||
const layout = parseLayout(value.layout);
|
||||
if (layout) node.layout = layout;
|
||||
|
||||
const branch = parseBranch(value.branch);
|
||||
if (branch) node.branch = branch;
|
||||
|
||||
if (isRecord(value.meta)) node.meta = value.meta;
|
||||
|
||||
return node;
|
||||
@@ -157,22 +280,66 @@ function parseSettings(value: unknown): MapSettings {
|
||||
settings.branchStyle = value.branchStyle as BranchStyle;
|
||||
}
|
||||
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
|
||||
if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid;
|
||||
if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides;
|
||||
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
|
||||
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
|
||||
}
|
||||
|
||||
if (isRecord(value.layoutSpacing)) {
|
||||
const raw = value.layoutSpacing;
|
||||
const parentGap = typeof raw.parentGap === 'number' ? raw.parentGap : undefined;
|
||||
const siblingGap = typeof raw.siblingGap === 'number' ? raw.siblingGap : undefined;
|
||||
if (parentGap !== undefined && siblingGap !== undefined) {
|
||||
settings.layoutSpacing = {
|
||||
parentGap: clampTo(parentGap, SPACING_LIMITS.parentGap),
|
||||
siblingGap: clampTo(siblingGap, SPACING_LIMITS.siblingGap),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point d'extension des migrations de format.
|
||||
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
|
||||
* recente, avant la normalisation finale.
|
||||
* Migrations de format, appliquees de la plus ancienne a la plus recente.
|
||||
*
|
||||
* v1 -> v2 : la largeur fixe vivait dans `style.width`. Elle rejoint
|
||||
* `layout.width` avec un `widthMode` explicite. Les autres nouveautes de la v2
|
||||
* sont facultatives : leur absence signifie "herite du theme", il n'y a donc
|
||||
* rien a ecrire dans le document.
|
||||
*/
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
const document = raw;
|
||||
const current = version;
|
||||
function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!Array.isArray(document.nodes)) return { ...document, version: 2 };
|
||||
|
||||
// Exemple d'evolution future :
|
||||
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
|
||||
const nodes = document.nodes.map((rawNode) => {
|
||||
if (!isRecord(rawNode) || !isRecord(rawNode.style)) return rawNode;
|
||||
|
||||
const { width, ...styleRest } = rawNode.style;
|
||||
if (typeof width !== 'number' || !Number.isFinite(width)) return rawNode;
|
||||
|
||||
const existingLayout = isRecord(rawNode.layout) ? rawNode.layout : {};
|
||||
return {
|
||||
...rawNode,
|
||||
style: styleRest,
|
||||
layout: { widthMode: 'fixed', width, ...existingLayout },
|
||||
};
|
||||
});
|
||||
|
||||
return { ...document, nodes, version: 2 };
|
||||
}
|
||||
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
let document = raw;
|
||||
let current = version;
|
||||
|
||||
if (current === 1) {
|
||||
document = migrateV1ToV2(document);
|
||||
current = 2;
|
||||
}
|
||||
|
||||
if (current !== MAP_FORMAT_VERSION) {
|
||||
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
|
||||
return { ...document, version: MAP_FORMAT_VERSION };
|
||||
}
|
||||
return document;
|
||||
|
||||
187
frontend/src/stores/editorLayout.test.ts
Normal file
187
frontend/src/stores/editorLayout.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
|
||||
function store() {
|
||||
return useEditorStore.getState();
|
||||
}
|
||||
|
||||
function rootId(): string {
|
||||
return store().map?.nodes[0]?.id as string;
|
||||
}
|
||||
|
||||
function nodeById(id: string) {
|
||||
return store().map?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
/** Cree trois noeuds a des positions connues, avec des tailles mesurees. */
|
||||
function threeNodes(): string[] {
|
||||
const ids = [rootId()];
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
ids.push(store().addChild(rootId()) as string);
|
||||
}
|
||||
store().setEditingNode(null);
|
||||
|
||||
const positions = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 60, y: 200 },
|
||||
{ x: 300, y: 500 },
|
||||
];
|
||||
ids.forEach((id, index) => {
|
||||
store().moveNodes([{ id, position: positions[index] as { x: number; y: number } }]);
|
||||
store().setMeasuredSize(id, { width: 100, height: 40 });
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().loadMap(createEmptyMap('Mise en page'));
|
||||
});
|
||||
|
||||
describe('redimensionnement', () => {
|
||||
it('passe le noeud en taille fixe et conserve les dimensions', () => {
|
||||
const id = rootId();
|
||||
store().beginResize();
|
||||
store().resizeNode(id, { width: 260, height: 120 });
|
||||
|
||||
const node = nodeById(id);
|
||||
expect(node?.layout?.widthMode).toBe('fixed');
|
||||
expect(node?.layout?.heightMode).toBe('fixed');
|
||||
expect(node?.layout?.width).toBe(260);
|
||||
expect(node?.layout?.height).toBe(120);
|
||||
});
|
||||
|
||||
it('borne les dimensions saisies', () => {
|
||||
const id = rootId();
|
||||
store().beginResize();
|
||||
store().resizeNode(id, { width: -50, height: 100000 });
|
||||
|
||||
expect(nodeById(id)?.layout?.width).toBeGreaterThanOrEqual(60);
|
||||
expect(nodeById(id)?.layout?.height).toBeLessThanOrEqual(900);
|
||||
});
|
||||
|
||||
it('un resize continu ne cree qu une seule entree d historique', () => {
|
||||
const id = rootId();
|
||||
const before = store().past.length;
|
||||
|
||||
store().beginResize();
|
||||
for (let width = 200; width <= 260; width += 10) {
|
||||
store().resizeNode(id, { width, height: 80 });
|
||||
}
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
store().undo();
|
||||
expect(nodeById(id)?.layout?.width).toBeUndefined();
|
||||
});
|
||||
|
||||
it('met a jour la position quand la poignee haut-gauche est utilisee', () => {
|
||||
const id = rootId();
|
||||
store().beginResize();
|
||||
store().resizeNode(id, { width: 200, height: 90, position: { x: -40, y: -20 } });
|
||||
|
||||
expect(nodeById(id)?.position).toEqual({ x: -40, y: -20 });
|
||||
});
|
||||
|
||||
it('repasse en taille automatique avec "adapter au contenu"', () => {
|
||||
const id = rootId();
|
||||
store().beginResize();
|
||||
store().resizeNode(id, { width: 300, height: 150 });
|
||||
store().fitNodesToContent([id]);
|
||||
|
||||
const node = nodeById(id);
|
||||
expect(node?.layout?.width).toBeUndefined();
|
||||
expect(node?.layout?.height).toBeUndefined();
|
||||
expect(node?.layout?.widthMode).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignement multi-selection', () => {
|
||||
it('aligne les bords gauches en une seule entree d historique', () => {
|
||||
const ids = threeNodes();
|
||||
store().setSelection(ids);
|
||||
const before = store().past.length;
|
||||
|
||||
store().alignNodes('left');
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
const xs = ids.map((id) => nodeById(id)?.position.x);
|
||||
expect(new Set(xs).size).toBe(1);
|
||||
expect(xs[0]).toBe(0);
|
||||
});
|
||||
|
||||
it('aligne les bords droits en tenant compte de la largeur', () => {
|
||||
const ids = threeNodes();
|
||||
store().setMeasuredSize(ids[1] as string, { width: 200, height: 40 });
|
||||
store().setSelection(ids);
|
||||
|
||||
store().alignNodes('right');
|
||||
|
||||
const rights = ids.map((id) => {
|
||||
const node = nodeById(id);
|
||||
const width = id === ids[1] ? 200 : 100;
|
||||
return (node?.position.x ?? 0) + width;
|
||||
});
|
||||
expect(new Set(rights).size).toBe(1);
|
||||
});
|
||||
|
||||
it('centre verticalement la selection', () => {
|
||||
const ids = threeNodes();
|
||||
store().setSelection(ids);
|
||||
|
||||
store().alignNodes('vcenter');
|
||||
|
||||
const centers = ids.map((id) => (nodeById(id)?.position.y ?? 0) + 20);
|
||||
expect(new Set(centers).size).toBe(1);
|
||||
});
|
||||
|
||||
it('ne fait rien avec un seul noeud selectionne', () => {
|
||||
const ids = threeNodes();
|
||||
store().setSelection([ids[0] as string]);
|
||||
const before = store().past.length;
|
||||
|
||||
store().alignNodes('left');
|
||||
expect(store().past.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('distribution', () => {
|
||||
it('repartit les espaces verticaux en conservant les extremes', () => {
|
||||
const ids = threeNodes();
|
||||
store().setSelection(ids);
|
||||
const before = store().past.length;
|
||||
|
||||
store().distributeNodes('vertical');
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
const ys = ids.map((id) => nodeById(id)?.position.y ?? 0).sort((a, b) => a - b);
|
||||
expect(ys[0]).toBe(0);
|
||||
expect(ys[2]).toBe(500);
|
||||
// Espaces egaux entre les bords : (500 + 40 - 0 - 120) / 2 = 210
|
||||
expect((ys[1] as number) - ((ys[0] as number) + 40)).toBeCloseTo(210, 0);
|
||||
});
|
||||
|
||||
it('repartit les espaces horizontaux', () => {
|
||||
const ids = threeNodes();
|
||||
store().setSelection(ids);
|
||||
|
||||
store().distributeNodes('horizontal');
|
||||
|
||||
const xs = ids.map((id) => nodeById(id)?.position.x ?? 0).sort((a, b) => a - b);
|
||||
expect(xs[0]).toBe(0);
|
||||
expect(xs[2]).toBe(300);
|
||||
expect((xs[1] as number) - ((xs[0] as number) + 100)).toBeCloseTo(
|
||||
(xs[2] as number) - ((xs[1] as number) + 100),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('exige au moins trois noeuds', () => {
|
||||
const ids = threeNodes();
|
||||
store().setSelection([ids[0] as string, ids[1] as string]);
|
||||
const before = store().past.length;
|
||||
|
||||
store().distributeNodes('horizontal');
|
||||
expect(store().past.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,24 @@ import { create } from 'zustand';
|
||||
import { APP_CONFIG } from '@/config/app';
|
||||
import {
|
||||
type LayoutKind,
|
||||
type LayoutSpacing,
|
||||
type MapSettings,
|
||||
type MindMap,
|
||||
type MindMapEdge,
|
||||
type MindMapNode,
|
||||
type NodeBranchStyle,
|
||||
type NodeIconStyle,
|
||||
type NodeLayout,
|
||||
type NodeStyle,
|
||||
type Position,
|
||||
type Viewport,
|
||||
} from '@/types/map';
|
||||
import { createNode } from '@/services/mapFactory';
|
||||
import { computeLayout, isHorizontalLayout, layoutDirection } from '@/features/editor/layout';
|
||||
import { getTheme } from '@/features/editor/themes';
|
||||
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { createId } from '@/utils/id';
|
||||
import { type NodeSize } from '@/utils/nodeSize';
|
||||
import { resolveNodeSize, type NodeSize } from '@/utils/nodeSize';
|
||||
import {
|
||||
buildNodeMap,
|
||||
rebuildEdges,
|
||||
@@ -33,6 +39,27 @@ interface ClipboardPayload {
|
||||
nodes: MindMapNode[];
|
||||
}
|
||||
|
||||
/** Proprietes purement visuelles, copiees par "Copier le style". */
|
||||
export interface NodeStyleSnapshot {
|
||||
style?: NodeStyle;
|
||||
layout?: NodeLayout;
|
||||
iconStyle?: NodeIconStyle;
|
||||
branch?: NodeBranchStyle;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type AlignAxis = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
|
||||
export type DistributeAxis = 'horizontal' | 'vertical';
|
||||
|
||||
/** Rectangle occupe par un noeud, taille reelle comprise. */
|
||||
interface NodeBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FocusRequest {
|
||||
nodeId: string;
|
||||
nonce: number;
|
||||
@@ -46,6 +73,8 @@ interface EditorState {
|
||||
past: HistorySnapshot[];
|
||||
future: HistorySnapshot[];
|
||||
clipboard: ClipboardPayload | null;
|
||||
/** Style copie par Ctrl+Shift+C, applique par Ctrl+Shift+V. */
|
||||
styleClipboard: NodeStyleSnapshot | null;
|
||||
saveStatus: SaveStatus;
|
||||
lastSavedAt: string | null;
|
||||
searchQuery: string;
|
||||
@@ -74,8 +103,23 @@ interface EditorState {
|
||||
updateNodeText: (id: string, text: string) => void;
|
||||
updateNode: (id: string, patch: Partial<Omit<MindMapNode, 'id'>>) => void;
|
||||
updateNodeStyle: (id: string, patch: NodeStyle) => void;
|
||||
resetNodeStyle: (id: string) => void;
|
||||
updateNodesStyle: (ids: string[], patch: NodeStyle) => void;
|
||||
updateNodesLayout: (ids: string[], patch: NodeLayout) => void;
|
||||
updateNodesIconStyle: (ids: string[], patch: NodeIconStyle) => void;
|
||||
updateNodesBranch: (ids: string[], patch: NodeBranchStyle) => void;
|
||||
setNodesIcon: (ids: string[], icon: string | undefined, kind?: NodeIconStyle['kind']) => void;
|
||||
resetNodesStyle: (ids: string[]) => void;
|
||||
fitNodesToContent: (ids: string[]) => void;
|
||||
moveNodes: (updates: Array<{ id: string; position: Position }>) => void;
|
||||
beginResize: () => void;
|
||||
resizeNode: (
|
||||
id: string,
|
||||
size: { width: number; height: number; position?: Position },
|
||||
) => void;
|
||||
alignNodes: (axis: AlignAxis) => void;
|
||||
distributeNodes: (axis: DistributeAxis) => void;
|
||||
copyStyle: () => boolean;
|
||||
pasteStyle: () => number;
|
||||
reparentNode: (childId: string, parentId: string) => boolean;
|
||||
detachNode: (childId: string) => void;
|
||||
toggleCollapse: (id: string) => void;
|
||||
@@ -85,7 +129,7 @@ interface EditorState {
|
||||
paste: () => number;
|
||||
duplicateSelection: () => number;
|
||||
|
||||
applyAutoLayout: (layout?: LayoutKind) => void;
|
||||
applyAutoLayout: (layout?: LayoutKind, spacing?: LayoutSpacing) => void;
|
||||
updateSettings: (patch: Partial<MapSettings>) => void;
|
||||
renameMap: (name: string) => void;
|
||||
setViewport: (viewport: Viewport) => void;
|
||||
@@ -118,6 +162,17 @@ function withEdges(map: MindMap, nodes: MindMapNode[]): MindMap {
|
||||
return { ...map, nodes, edges: rebuildEdges(nodes) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les cles indefinies et renvoie `undefined` si l'objet est vide.
|
||||
* Le document reste ainsi minimal : un champ absent signifie "herite du theme",
|
||||
* ce qui est exactement la semantique attendue par le resolveur de style.
|
||||
*/
|
||||
function pruneEmpty<T extends object>(value: T | undefined): T | undefined {
|
||||
if (!value) return undefined;
|
||||
const entries = Object.entries(value).filter(([, item]) => item !== undefined);
|
||||
return entries.length > 0 ? (Object.fromEntries(entries) as T) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie un ensemble de sous-arbres en regenerant tous les identifiants.
|
||||
* Les noeuds dont le parent n'est pas copie sont rattaches a `parentOverride`.
|
||||
@@ -194,6 +249,40 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Rectangles reels des noeuds selectionnes, tailles mesurees comprises. */
|
||||
const selectionBoxes = (): NodeBox[] => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return [];
|
||||
|
||||
const theme = getTheme(map.settings.theme);
|
||||
const selected = new Set(state.selectedNodeIds);
|
||||
return map.nodes
|
||||
.filter((node) => selected.has(node.id))
|
||||
.map((node) => {
|
||||
const size = resolveNodeSize(node, state.measuredSizes, theme);
|
||||
return {
|
||||
id: node.id,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** Applique un lot de positions en une seule entree d'historique. */
|
||||
const applyPositions = (positions: Map<string, Position>): void => {
|
||||
if (positions.size === 0) return;
|
||||
commit((current) => ({
|
||||
...current,
|
||||
nodes: current.nodes.map((node) => {
|
||||
const position = positions.get(node.id);
|
||||
return position ? { ...node, position } : node;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
map: null,
|
||||
readOnly: false,
|
||||
@@ -202,6 +291,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: null,
|
||||
styleClipboard: null,
|
||||
saveStatus: 'idle',
|
||||
lastSavedAt: null,
|
||||
searchQuery: '',
|
||||
@@ -221,6 +311,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: null,
|
||||
styleClipboard: null,
|
||||
saveStatus: 'saved',
|
||||
lastSavedAt: map.updatedAt,
|
||||
searchQuery: '',
|
||||
@@ -240,6 +331,8 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
editingNodeId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: null,
|
||||
styleClipboard: null,
|
||||
saveStatus: 'idle',
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
@@ -400,19 +493,127 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
resetNodeStyle: (id) => {
|
||||
updateNodesStyle: (ids, patch) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) =>
|
||||
target.has(node.id) ? { ...node, style: pruneEmpty({ ...node.style, ...patch }) } : node,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
updateNodesLayout: (ids, patch) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) =>
|
||||
target.has(node.id)
|
||||
? { ...node, layout: pruneEmpty({ ...node.layout, ...patch }) }
|
||||
: node,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
updateNodesIconStyle: (ids, patch) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) =>
|
||||
target.has(node.id)
|
||||
? { ...node, iconStyle: pruneEmpty({ ...node.iconStyle, ...patch }) }
|
||||
: node,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
updateNodesBranch: (ids, patch) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) =>
|
||||
target.has(node.id)
|
||||
? { ...node, branch: pruneEmpty({ ...node.branch, ...patch }) }
|
||||
: node,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
setNodesIcon: (ids, icon, kind) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) => {
|
||||
if (node.id !== id) return node;
|
||||
const { style: _style, ...rest } = node;
|
||||
if (!target.has(node.id)) return node;
|
||||
const next: MindMapNode = { ...node, icon: icon || undefined };
|
||||
if (!icon) {
|
||||
delete next.icon;
|
||||
} else if (kind) {
|
||||
next.iconStyle = pruneEmpty({ ...node.iconStyle, kind });
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Remet les noeuds sur le style du theme, sans toucher au contenu. */
|
||||
resetNodesStyle: (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) => {
|
||||
if (!target.has(node.id)) return node;
|
||||
const {
|
||||
style: _style,
|
||||
layout: _layout,
|
||||
iconStyle: _iconStyle,
|
||||
branch: _branch,
|
||||
...rest
|
||||
} = node;
|
||||
return rest as MindMapNode;
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Repasse les noeuds en dimensionnement automatique. */
|
||||
fitNodesToContent: (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
const target = new Set(ids);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) => {
|
||||
if (!target.has(node.id)) return node;
|
||||
const { width: _width, height: _height, ...layoutRest } = node.layout ?? {};
|
||||
const { width: _legacyWidth, ...styleRest } = node.style ?? {};
|
||||
return {
|
||||
...node,
|
||||
style: pruneEmpty(styleRest),
|
||||
layout: pruneEmpty({ ...layoutRest, widthMode: 'auto', heightMode: 'auto' }),
|
||||
} as MindMapNode;
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
moveNodes: (updates) => {
|
||||
if (updates.length === 0) return;
|
||||
const byId = new Map(updates.map((update) => [update.id, update.position]));
|
||||
@@ -429,6 +630,165 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Debut d'un redimensionnement : une seule entree d'historique pour toute
|
||||
* l'operation, exactement comme pour le glisser-deposer.
|
||||
*/
|
||||
beginResize: () => {
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
resizeNode: (id, size) => {
|
||||
const width = clampTo(size.width, LAYOUT_LIMITS.width);
|
||||
const height = clampTo(size.height, LAYOUT_LIMITS.height);
|
||||
|
||||
commit(
|
||||
(current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) => {
|
||||
if (node.id !== id) return node;
|
||||
// La legacy `style.width` disparait des qu'un resize explicite a lieu.
|
||||
const { width: _legacyWidth, ...styleRest } = node.style ?? {};
|
||||
return {
|
||||
...node,
|
||||
style: pruneEmpty(styleRest),
|
||||
layout: {
|
||||
...node.layout,
|
||||
widthMode: 'fixed',
|
||||
heightMode: 'fixed',
|
||||
width,
|
||||
height,
|
||||
},
|
||||
...(size.position ? { position: size.position } : {}),
|
||||
} as MindMapNode;
|
||||
}),
|
||||
),
|
||||
{ history: false },
|
||||
);
|
||||
},
|
||||
|
||||
/** Aligne les bords ou les centres de la selection. Une seule annulation. */
|
||||
alignNodes: (axis) => {
|
||||
const boxes = selectionBoxes();
|
||||
if (boxes.length < 2) return;
|
||||
|
||||
const minX = Math.min(...boxes.map((box) => box.x));
|
||||
const maxX = Math.max(...boxes.map((box) => box.x + box.width));
|
||||
const minY = Math.min(...boxes.map((box) => box.y));
|
||||
const maxY = Math.max(...boxes.map((box) => box.y + box.height));
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
const positions = new Map<string, Position>();
|
||||
for (const box of boxes) {
|
||||
switch (axis) {
|
||||
case 'left':
|
||||
positions.set(box.id, { x: minX, y: box.y });
|
||||
break;
|
||||
case 'right':
|
||||
positions.set(box.id, { x: maxX - box.width, y: box.y });
|
||||
break;
|
||||
case 'hcenter':
|
||||
positions.set(box.id, { x: Math.round(centerX - box.width / 2), y: box.y });
|
||||
break;
|
||||
case 'top':
|
||||
positions.set(box.id, { x: box.x, y: minY });
|
||||
break;
|
||||
case 'bottom':
|
||||
positions.set(box.id, { x: box.x, y: maxY - box.height });
|
||||
break;
|
||||
case 'vcenter':
|
||||
positions.set(box.id, { x: box.x, y: Math.round(centerY - box.height / 2) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
applyPositions(positions);
|
||||
},
|
||||
|
||||
/**
|
||||
* Repartit les noeuds intermediaires avec des espaces egaux, en tenant
|
||||
* compte de leur taille reelle. Les deux extremes ne bougent pas.
|
||||
*/
|
||||
distributeNodes: (axis) => {
|
||||
const boxes = selectionBoxes();
|
||||
if (boxes.length < 3) return;
|
||||
|
||||
const horizontal = axis === 'horizontal';
|
||||
const sorted = [...boxes].sort((a, b) => (horizontal ? a.x - b.x : a.y - b.y));
|
||||
const first = sorted[0] as NodeBox;
|
||||
const last = sorted[sorted.length - 1] as NodeBox;
|
||||
|
||||
const start = horizontal ? first.x : first.y;
|
||||
const end = horizontal ? last.x + last.width : last.y + last.height;
|
||||
const occupied = sorted.reduce(
|
||||
(total, box) => total + (horizontal ? box.width : box.height),
|
||||
0,
|
||||
);
|
||||
const gap = (end - start - occupied) / (sorted.length - 1);
|
||||
|
||||
const positions = new Map<string, Position>();
|
||||
let cursor = start;
|
||||
for (const box of sorted) {
|
||||
positions.set(
|
||||
box.id,
|
||||
horizontal
|
||||
? { x: Math.round(cursor), y: box.y }
|
||||
: { x: box.x, y: Math.round(cursor) },
|
||||
);
|
||||
cursor += (horizontal ? box.width : box.height) + gap;
|
||||
}
|
||||
applyPositions(positions);
|
||||
},
|
||||
|
||||
copyStyle: () => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
const node = map?.nodes.find((candidate) => candidate.id === state.selectedNodeIds[0]);
|
||||
if (!node) return false;
|
||||
|
||||
// Uniquement l'apparence : ni texte, ni note, ni URL, ni hierarchie.
|
||||
set({
|
||||
styleClipboard: {
|
||||
...(node.style ? { style: { ...node.style } } : {}),
|
||||
...(node.layout ? { layout: { ...node.layout } } : {}),
|
||||
...(node.iconStyle ? { iconStyle: { ...node.iconStyle } } : {}),
|
||||
...(node.branch ? { branch: { ...node.branch } } : {}),
|
||||
...(node.icon ? { icon: node.icon } : {}),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
pasteStyle: () => {
|
||||
const state = get();
|
||||
const snapshot = state.styleClipboard;
|
||||
if (!snapshot || state.selectedNodeIds.length === 0 || state.readOnly) return 0;
|
||||
|
||||
const target = new Set(state.selectedNodeIds);
|
||||
commit((current) =>
|
||||
withEdges(
|
||||
current,
|
||||
current.nodes.map((node) => {
|
||||
if (!target.has(node.id)) return node;
|
||||
const next: MindMapNode = { ...node };
|
||||
delete next.style;
|
||||
delete next.layout;
|
||||
delete next.iconStyle;
|
||||
delete next.branch;
|
||||
delete next.icon;
|
||||
if (snapshot.style) next.style = { ...snapshot.style };
|
||||
if (snapshot.layout) next.layout = { ...snapshot.layout };
|
||||
if (snapshot.iconStyle) next.iconStyle = { ...snapshot.iconStyle };
|
||||
if (snapshot.branch) next.branch = { ...snapshot.branch };
|
||||
if (snapshot.icon) next.icon = snapshot.icon;
|
||||
return next;
|
||||
}),
|
||||
),
|
||||
);
|
||||
return target.size;
|
||||
},
|
||||
|
||||
reparentNode: (childId, parentId) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
@@ -546,18 +906,27 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
return clones.length;
|
||||
},
|
||||
|
||||
applyAutoLayout: (layout) => {
|
||||
applyAutoLayout: (layout, spacing) => {
|
||||
const state = get();
|
||||
const map = state.map;
|
||||
if (!map || state.readOnly) return;
|
||||
|
||||
const target = layout ?? map.settings.layout;
|
||||
const positions = computeLayout(map.nodes, target, state.measuredSizes);
|
||||
const effectiveSpacing = spacing ?? map.settings.layoutSpacing;
|
||||
const positions = computeLayout(map.nodes, target, {
|
||||
measuredSizes: state.measuredSizes,
|
||||
spacing: effectiveSpacing,
|
||||
theme: getTheme(map.settings.theme),
|
||||
});
|
||||
if (positions.size === 0) return;
|
||||
|
||||
commit((current) => ({
|
||||
...current,
|
||||
settings: { ...current.settings, layout: target },
|
||||
settings: {
|
||||
...current.settings,
|
||||
layout: target,
|
||||
...(spacing ? { layoutSpacing: spacing } : {}),
|
||||
},
|
||||
nodes: current.nodes.map((node) => {
|
||||
const position = positions.get(node.id);
|
||||
return position ? { ...node, position } : node;
|
||||
|
||||
139
frontend/src/stores/editorStyle.test.ts
Normal file
139
frontend/src/stores/editorStyle.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
|
||||
function store() {
|
||||
return useEditorStore.getState();
|
||||
}
|
||||
|
||||
function rootId(): string {
|
||||
return store().map?.nodes[0]?.id as string;
|
||||
}
|
||||
|
||||
function nodeById(id: string) {
|
||||
return store().map?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
function makeChildren(count: number): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
ids.push(store().addChild(rootId()) as string);
|
||||
}
|
||||
store().setEditingNode(null);
|
||||
return ids;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().loadMap(createEmptyMap('Styles'));
|
||||
});
|
||||
|
||||
describe('application de style a plusieurs noeuds', () => {
|
||||
it('applique une couleur a toute la selection en une entree d historique', () => {
|
||||
const ids = makeChildren(3);
|
||||
store().setSelection(ids);
|
||||
const before = store().past.length;
|
||||
|
||||
store().updateNodesStyle(ids, { backgroundColor: '#112233' });
|
||||
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
for (const id of ids) {
|
||||
expect(nodeById(id)?.style?.backgroundColor).toBe('#112233');
|
||||
}
|
||||
});
|
||||
|
||||
it('applique un padding a toute la selection', () => {
|
||||
const ids = makeChildren(2);
|
||||
store().updateNodesLayout(ids, { paddingX: 28, paddingY: 18 });
|
||||
|
||||
for (const id of ids) {
|
||||
expect(nodeById(id)?.layout?.paddingX).toBe(28);
|
||||
expect(nodeById(id)?.layout?.paddingY).toBe(18);
|
||||
}
|
||||
});
|
||||
|
||||
it('ne laisse pas d objet de style vide dans le document', () => {
|
||||
const [id] = makeChildren(1);
|
||||
store().updateNodesStyle([id as string], { bold: true });
|
||||
store().updateNodesStyle([id as string], { bold: undefined });
|
||||
|
||||
expect(nodeById(id as string)?.style).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copier / coller le style', () => {
|
||||
it('copie uniquement les proprietes visuelles', () => {
|
||||
const [source, target] = makeChildren(2) as [string, string];
|
||||
|
||||
store().updateNodeText(source, 'Source');
|
||||
store().updateNode(source, { note: 'Note privee', url: 'https://exemple.fr/' });
|
||||
store().updateNodesStyle([source], { backgroundColor: '#abcdef', textAlign: 'center' });
|
||||
store().updateNodesLayout([source], { paddingX: 24 });
|
||||
store().setNodesIcon([source], 'server', 'builtin');
|
||||
|
||||
store().updateNodeText(target, 'Cible');
|
||||
store().setSelection([source]);
|
||||
expect(store().copyStyle()).toBe(true);
|
||||
|
||||
store().setSelection([target]);
|
||||
expect(store().pasteStyle()).toBe(1);
|
||||
|
||||
const pasted = nodeById(target);
|
||||
expect(pasted?.style?.backgroundColor).toBe('#abcdef');
|
||||
expect(pasted?.style?.textAlign).toBe('center');
|
||||
expect(pasted?.layout?.paddingX).toBe(24);
|
||||
expect(pasted?.icon).toBe('server');
|
||||
expect(pasted?.iconStyle?.kind).toBe('builtin');
|
||||
|
||||
// Le contenu de la cible n'est pas touche.
|
||||
expect(pasted?.text).toBe('Cible');
|
||||
expect(pasted?.note).toBeUndefined();
|
||||
expect(pasted?.url).toBeUndefined();
|
||||
expect(pasted?.parentId).toBe(rootId());
|
||||
});
|
||||
|
||||
it('colle le style sur plusieurs noeuds en une entree d historique', () => {
|
||||
const ids = makeChildren(4) as string[];
|
||||
const source = ids[0] as string;
|
||||
store().updateNodesStyle([source], { borderColor: '#ff0000' });
|
||||
|
||||
store().setSelection([source]);
|
||||
store().copyStyle();
|
||||
store().setSelection(ids.slice(1));
|
||||
|
||||
const before = store().past.length;
|
||||
expect(store().pasteStyle()).toBe(3);
|
||||
expect(store().past.length).toBe(before + 1);
|
||||
|
||||
for (const id of ids.slice(1)) {
|
||||
expect(nodeById(id)?.style?.borderColor).toBe('#ff0000');
|
||||
}
|
||||
});
|
||||
|
||||
it('ne colle rien sans style copie', () => {
|
||||
const ids = makeChildren(1);
|
||||
store().setSelection(ids);
|
||||
expect(store().pasteStyle()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinitialisation du style', () => {
|
||||
it('remet le theme sans toucher au contenu ni a la hierarchie', () => {
|
||||
const [id] = makeChildren(1) as [string];
|
||||
store().updateNodeText(id, 'Firewall');
|
||||
store().updateNode(id, { note: 'A verifier', url: 'https://exemple.fr/' });
|
||||
store().updateNodesStyle([id], { backgroundColor: '#000000', shape: 'circle' });
|
||||
store().updateNodesLayout([id], { paddingX: 30 });
|
||||
store().updateNodesBranch([id], { color: '#00ff00' });
|
||||
|
||||
store().resetNodesStyle([id]);
|
||||
|
||||
const node = nodeById(id);
|
||||
expect(node?.style).toBeUndefined();
|
||||
expect(node?.layout).toBeUndefined();
|
||||
expect(node?.branch).toBeUndefined();
|
||||
expect(node?.text).toBe('Firewall');
|
||||
expect(node?.note).toBe('A verifier');
|
||||
expect(node?.url).toBe('https://exemple.fr/');
|
||||
expect(node?.parentId).toBe(rootId());
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ interface UiState {
|
||||
propertiesPanelOpen: boolean;
|
||||
searchPanelOpen: boolean;
|
||||
toasts: Toast[];
|
||||
/** Sections repliables du panneau Proprietes, conservees pendant la session. */
|
||||
panelSections: Record<string, boolean>;
|
||||
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
toggleColorMode: () => void;
|
||||
@@ -22,9 +24,25 @@ interface UiState {
|
||||
setSearchPanelOpen: (open: boolean) => void;
|
||||
pushToast: (message: string, tone?: ToastTone) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
togglePanelSection: (id: string) => void;
|
||||
}
|
||||
|
||||
const COLOR_MODE_KEY = 'openmind:color-mode';
|
||||
const PANEL_SECTIONS_KEY = 'openmind:panel-sections';
|
||||
|
||||
function readPanelSections(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(PANEL_SECTIONS_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, boolean>;
|
||||
}
|
||||
} catch {
|
||||
// Stockage indisponible : toutes les sections restent ouvertes.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function readInitialColorMode(): ColorMode {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
@@ -52,6 +70,7 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
propertiesPanelOpen: true,
|
||||
searchPanelOpen: false,
|
||||
toasts: [],
|
||||
panelSections: readPanelSections(),
|
||||
|
||||
setColorMode: (mode) => {
|
||||
applyColorMode(mode);
|
||||
@@ -73,6 +92,16 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
},
|
||||
|
||||
dismissToast: (id) => set({ toasts: get().toasts.filter((toast) => toast.id !== id) }),
|
||||
|
||||
togglePanelSection: (id) => {
|
||||
const next = { ...get().panelSections, [id]: !(get().panelSections[id] ?? true) };
|
||||
set({ panelSections: next });
|
||||
try {
|
||||
window.sessionStorage.setItem(PANEL_SECTIONS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// La preference restera valable pour l'affichage courant uniquement.
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/** Applique le mode couleur au chargement de l'application. */
|
||||
|
||||
@@ -68,6 +68,21 @@
|
||||
box-shadow: 0 0 0 2px #6366f1, 0 0 0 5px rgba(99, 102, 241, 0.22);
|
||||
}
|
||||
|
||||
/* Poignees de redimensionnement : discretes, visibles au survol du noeud. */
|
||||
.openmind-canvas .openmind-resize-handle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1.5px solid #ffffff;
|
||||
background: #6366f1;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.openmind-canvas .openmind-resize-line {
|
||||
border-color: rgba(99, 102, 241, 0.55);
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.mind-node {
|
||||
transition:
|
||||
box-shadow 120ms ease-out,
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
* evoluer le rendu sans toucher au format de fichier.
|
||||
*/
|
||||
|
||||
/** Version du format de fichier. Incrementer en cas de changement cassant. */
|
||||
export const MAP_FORMAT_VERSION = 1;
|
||||
/**
|
||||
* Version du format de fichier.
|
||||
*
|
||||
* v1 : format initial.
|
||||
* v2 : ajout de la mise en page des noeuds (layout, iconStyle, branch) et des
|
||||
* reglages d'espacement. Tous les champs ajoutes sont optionnels : un
|
||||
* document v1 reste lisible sans transformation, et les valeurs par defaut
|
||||
* sont resolues a l'affichage (voir `nodeStyleResolver`).
|
||||
*/
|
||||
export const MAP_FORMAT_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Types de noeuds.
|
||||
@@ -24,7 +32,20 @@ export type NodeShape = 'rectangle' | 'rounded' | 'capsule' | 'circle';
|
||||
|
||||
export type TextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
/** Personnalisations explicites d'un noeud : elles priment toujours sur le theme. */
|
||||
export type VerticalAlign = 'top' | 'middle' | 'bottom';
|
||||
|
||||
/** Mode de dimensionnement : ajuste au contenu, ou taille imposee. */
|
||||
export type SizeMode = 'auto' | 'fixed';
|
||||
|
||||
export type ShadowStyle = 'theme' | 'none' | 'soft' | 'strong';
|
||||
|
||||
/** Familles de polices proposees : uniquement des polices systeme, rien de distant. */
|
||||
export type FontFamilyId = 'theme' | 'sans' | 'serif' | 'mono' | 'rounded';
|
||||
|
||||
/**
|
||||
* Personnalisations explicites d'un noeud : elles priment toujours sur le theme.
|
||||
* Un champ absent signifie "herite du theme".
|
||||
*/
|
||||
export interface NodeStyle {
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
@@ -34,7 +55,40 @@ export interface NodeStyle {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
textAlign?: TextAlign;
|
||||
/**
|
||||
* Largeur fixe heritee du format v1. Conservee en lecture pour ne pas casser
|
||||
* les cartes existantes ; les nouvelles ecritures passent par `layout.width`.
|
||||
*/
|
||||
width?: number;
|
||||
fontFamily?: FontFamilyId;
|
||||
verticalAlign?: VerticalAlign;
|
||||
lineHeight?: number;
|
||||
shadow?: ShadowStyle;
|
||||
}
|
||||
|
||||
/** Mise en page interne d'un noeud. Tous les champs sont optionnels (v2). */
|
||||
export interface NodeLayout {
|
||||
widthMode?: SizeMode;
|
||||
width?: number;
|
||||
heightMode?: SizeMode;
|
||||
height?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
/** Retour a la ligne automatique du texte. */
|
||||
wrap?: boolean;
|
||||
/** Largeur maximale du texte avant retour a la ligne, en mode automatique. */
|
||||
maxTextWidth?: number;
|
||||
}
|
||||
|
||||
export type IconKind = 'emoji' | 'builtin';
|
||||
export type IconPosition = 'left' | 'right' | 'top';
|
||||
|
||||
/** Presentation de l'icone. La valeur elle-meme reste dans `MindMapNode.icon`. */
|
||||
export interface NodeIconStyle {
|
||||
kind?: IconKind;
|
||||
position?: IconPosition;
|
||||
size?: number;
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
@@ -52,14 +106,28 @@ export interface MindMapNode {
|
||||
parentId: string | null;
|
||||
/** Branche repliee : les descendants sont masques. */
|
||||
collapsed?: boolean;
|
||||
/** Emoji, ou identifiant d'icone integree selon `iconStyle.kind`. */
|
||||
icon?: string;
|
||||
url?: string;
|
||||
note?: string;
|
||||
style?: NodeStyle;
|
||||
/** Mise en page (v2). Absent = entierement herite du theme. */
|
||||
layout?: NodeLayout;
|
||||
/** Presentation de l'icone (v2). */
|
||||
iconStyle?: NodeIconStyle;
|
||||
/** Personnalisation de la branche reliant ce noeud a son parent (v2). */
|
||||
branch?: NodeBranchStyle;
|
||||
/** Espace libre reserve aux futurs types de noeuds. */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Personnalisation de la branche entrante d'un noeud. Absent = theme. */
|
||||
export interface NodeBranchStyle {
|
||||
style?: BranchStyle;
|
||||
width?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface MindMapEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
@@ -83,11 +151,27 @@ export type LayoutKind =
|
||||
| 'organization'
|
||||
| 'freeform';
|
||||
|
||||
/** Espacement applique par l'organisation automatique. */
|
||||
export interface LayoutSpacing {
|
||||
/** Distance entre un parent et ses enfants (rangs successifs). */
|
||||
parentGap: number;
|
||||
/** Espacement entre noeuds freres d'un meme rang. */
|
||||
siblingGap: number;
|
||||
}
|
||||
|
||||
export interface MapSettings {
|
||||
theme: string;
|
||||
layout: LayoutKind;
|
||||
branchStyle: BranchStyle;
|
||||
showGrid: boolean;
|
||||
/** Espacement de l'auto-layout (v2). Absent = valeurs "Normal". */
|
||||
layoutSpacing?: LayoutSpacing;
|
||||
/** Aimantation des deplacements sur la grille (v2). */
|
||||
snapToGrid?: boolean;
|
||||
/** Pas de la grille d'aimantation, en pixels (v2). */
|
||||
snapGrid?: number;
|
||||
/** Guides d'alignement affiches pendant un deplacement (v2). */
|
||||
alignmentGuides?: boolean;
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
|
||||
@@ -1,46 +1,92 @@
|
||||
import { type MindMapNode } from '@/types/map';
|
||||
|
||||
export const NODE_MIN_WIDTH = 96;
|
||||
export const NODE_MAX_WIDTH = 260;
|
||||
export const NODE_LINE_HEIGHT = 20;
|
||||
export const NODE_VERTICAL_PADDING = 20;
|
||||
import { getTheme, type MapTheme } from '@/features/editor/themes';
|
||||
import { resolveNodeAppearance } from '@/features/editor/nodeStyleResolver';
|
||||
import { countRenderedLines, measureLongestLine, type FontSpec } from '@/utils/textMeasure';
|
||||
|
||||
export interface NodeSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Marge interne reservee aux indicateurs (note, lien) affiches dans le noeud. */
|
||||
const INDICATOR_WIDTH = 18;
|
||||
|
||||
/**
|
||||
* Estimation de la taille d'un noeud, utilisee tant que le DOM n'a pas encore
|
||||
* mesure le noeud (premier layout, export sur une carte importee).
|
||||
* Estimation de la taille d'un noeud.
|
||||
*
|
||||
* Utilisee tant que le DOM n'a pas mesure le noeud : premier auto-layout,
|
||||
* export d'une carte importee, calcul des bornes. Elle reproduit les regles
|
||||
* appliquees par le rendu (padding, icone, retour a la ligne, largeur max).
|
||||
*/
|
||||
export function estimateNodeSize(node: MindMapNode): NodeSize {
|
||||
const fontSize = node.style?.fontSize ?? 14;
|
||||
const charWidth = fontSize * 0.58;
|
||||
export function estimateNodeSize(node: MindMapNode, theme: MapTheme = getTheme(undefined)): NodeSize {
|
||||
const appearance = resolveNodeAppearance(node, node.type === 'root' ? 0 : 1, theme);
|
||||
|
||||
const font: FontSpec = {
|
||||
fontSize: appearance.fontSize,
|
||||
fontFamily: appearance.fontFamily,
|
||||
fontWeight: appearance.fontWeight,
|
||||
fontStyle: appearance.fontStyle,
|
||||
};
|
||||
|
||||
const text = node.text || ' ';
|
||||
const iconWidth = node.icon ? fontSize + 8 : 0;
|
||||
const iconInline = Boolean(node.icon) && appearance.iconPosition !== 'top';
|
||||
const iconAbove = Boolean(node.icon) && appearance.iconPosition === 'top';
|
||||
|
||||
const longestLine = text
|
||||
.split('\n')
|
||||
.reduce((longest, line) => Math.max(longest, line.length), 0);
|
||||
const decorations =
|
||||
(iconInline ? appearance.iconSize + appearance.iconGap : 0) +
|
||||
(node.note ? INDICATOR_WIDTH : 0) +
|
||||
(node.url ? INDICATOR_WIDTH : 0);
|
||||
|
||||
const rawWidth = longestLine * charWidth + 32 + iconWidth;
|
||||
const width = node.style?.width
|
||||
? node.style.width
|
||||
: Math.min(NODE_MAX_WIDTH, Math.max(NODE_MIN_WIDTH, Math.round(rawWidth)));
|
||||
const horizontalChrome = appearance.paddingX * 2 + decorations + appearance.borderWidth * 2;
|
||||
const verticalChrome = appearance.paddingY * 2 + appearance.borderWidth * 2;
|
||||
|
||||
const charsPerLine = Math.max(1, Math.floor((width - 32 - iconWidth) / charWidth));
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
|
||||
// Largeur
|
||||
let width: number;
|
||||
let textWidth: number;
|
||||
|
||||
const height = Math.max(38, lines * (fontSize + 6) + NODE_VERTICAL_PADDING);
|
||||
return { width, height: Math.round(height) };
|
||||
if (appearance.widthMode === 'fixed' && appearance.width) {
|
||||
width = appearance.width;
|
||||
textWidth = Math.max(16, width - horizontalChrome);
|
||||
} else {
|
||||
const naturalTextWidth = measureLongestLine(text, font);
|
||||
const cap = appearance.wrap ? appearance.maxTextWidth : Number.POSITIVE_INFINITY;
|
||||
textWidth = Math.min(naturalTextWidth, cap);
|
||||
width = Math.max(appearance.minWidth, Math.ceil(textWidth + horizontalChrome));
|
||||
}
|
||||
|
||||
// Hauteur
|
||||
if (appearance.heightMode === 'fixed' && appearance.height) {
|
||||
return { width: Math.round(width), height: Math.round(appearance.height) };
|
||||
}
|
||||
|
||||
const lines = countRenderedLines(
|
||||
text,
|
||||
font,
|
||||
appearance.wrap ? textWidth : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const textHeight = lines * appearance.fontSize * appearance.lineHeight;
|
||||
const iconHeight = iconAbove ? appearance.iconSize + appearance.iconGap : 0;
|
||||
const height = Math.max(
|
||||
appearance.minHeight,
|
||||
Math.ceil(textHeight + iconHeight + verticalChrome),
|
||||
);
|
||||
|
||||
// Un cercle doit rester circulaire : on prend la plus grande des deux mesures.
|
||||
if (appearance.circle && appearance.widthMode === 'auto') {
|
||||
const side = Math.max(width, height);
|
||||
return { width: side, height: side };
|
||||
}
|
||||
|
||||
return { width: Math.round(width), height: Math.round(height) };
|
||||
}
|
||||
|
||||
/** Taille reelle si elle a ete mesuree, sinon estimation. */
|
||||
export function resolveNodeSize(node: MindMapNode, measured?: Map<string, NodeSize>): NodeSize {
|
||||
/** Taille reelle si elle a ete mesuree dans le DOM, sinon estimation. */
|
||||
export function resolveNodeSize(
|
||||
node: MindMapNode,
|
||||
measured?: Map<string, NodeSize>,
|
||||
theme?: MapTheme,
|
||||
): NodeSize {
|
||||
const known = measured?.get(node.id);
|
||||
if (known && known.width > 0 && known.height > 0) return known;
|
||||
return estimateNodeSize(node);
|
||||
return estimateNodeSize(node, theme);
|
||||
}
|
||||
|
||||
94
frontend/src/utils/textMeasure.ts
Normal file
94
frontend/src/utils/textMeasure.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Mesure de texte pour l'estimation de taille des noeuds.
|
||||
*
|
||||
* Le rendu reel est fait par le navigateur : cette mesure ne sert qu'aux cas ou
|
||||
* le noeud n'est pas encore dans le DOM (auto-layout d'une carte importee,
|
||||
* export, calcul de bornes). Un canvas hors ecran donne un resultat bien plus
|
||||
* fidele qu'un comptage de caracteres, tout en restant synchrone.
|
||||
*/
|
||||
|
||||
let context: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
function getContext(): CanvasRenderingContext2D | null {
|
||||
if (context !== undefined) return context;
|
||||
if (typeof document === 'undefined') {
|
||||
context = null;
|
||||
return context;
|
||||
}
|
||||
context = document.createElement('canvas').getContext('2d');
|
||||
return context;
|
||||
}
|
||||
|
||||
export interface FontSpec {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: number;
|
||||
fontStyle: 'normal' | 'italic';
|
||||
}
|
||||
|
||||
function toCssFont(font: FontSpec): string {
|
||||
return `${font.fontStyle} ${font.fontWeight} ${font.fontSize}px ${font.fontFamily}`;
|
||||
}
|
||||
|
||||
/** Largeur d'une ligne en pixels. Repli sur une approximation hors navigateur. */
|
||||
export function measureLineWidth(line: string, font: FontSpec): number {
|
||||
const canvasContext = getContext();
|
||||
if (canvasContext) {
|
||||
canvasContext.font = toCssFont(font);
|
||||
return canvasContext.measureText(line).width;
|
||||
}
|
||||
// Repli (tests Node) : largeur moyenne d'un caractere.
|
||||
const ratio = font.fontFamily.includes('monospace') ? 0.6 : 0.52;
|
||||
const bold = font.fontWeight >= 700 ? 1.05 : 1;
|
||||
return line.length * font.fontSize * ratio * bold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de lignes rendues pour un texte donne.
|
||||
* `maxWidth` a `Infinity` desactive le retour a la ligne.
|
||||
*/
|
||||
export function countRenderedLines(text: string, font: FontSpec, maxWidth: number): number {
|
||||
const paragraphs = text.split('\n');
|
||||
if (!Number.isFinite(maxWidth) || maxWidth <= 0) return Math.max(1, paragraphs.length);
|
||||
|
||||
let total = 0;
|
||||
for (const paragraph of paragraphs) {
|
||||
total += countWrappedLines(paragraph, font, maxWidth);
|
||||
}
|
||||
return Math.max(1, total);
|
||||
}
|
||||
|
||||
function countWrappedLines(paragraph: string, font: FontSpec, maxWidth: number): number {
|
||||
if (paragraph.length === 0) return 1;
|
||||
|
||||
const words = paragraph.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return 1;
|
||||
|
||||
let lines = 1;
|
||||
let current = '';
|
||||
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (measureLineWidth(candidate, font) <= maxWidth) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) lines += 1;
|
||||
// Un mot plus long que la ligne (URL, identifiant) est coupe par le CSS.
|
||||
const wordWidth = measureLineWidth(word, font);
|
||||
if (wordWidth > maxWidth) {
|
||||
lines += Math.ceil(wordWidth / maxWidth) - 1;
|
||||
current = '';
|
||||
} else {
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Largeur de la ligne la plus longue, sans retour a la ligne. */
|
||||
export function measureLongestLine(text: string, font: FontSpec): number {
|
||||
return text
|
||||
.split('\n')
|
||||
.reduce((widest, line) => Math.max(widest, measureLineWidth(line, font)), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user