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:
2026-09-09 17:29:48 +02:00
parent 0b2e5e326d
commit af6fd519c5
37 changed files with 3896 additions and 439 deletions

View File

@@ -226,11 +226,28 @@ double-clic, undo/redo, copier/coller, duplication, sauvegarde automatique.
Bezier, branches repliables avec compteur d'elements masques.
**Auto-layout**, uniquement sur demande : mind map droite, mind map gauche,
arbre horizontal, arbre vertical.
arbre horizontal, arbre vertical. L'espacement est configurable (distance
parent/enfant et espacement entre branches), avec les presets Compact, Normal
et Aere. Modifier l'espacement ne deplace rien tant que l'organisation n'est pas
relancee.
**Proprietes du noeud** : texte, couleurs de fond/texte/bordure, forme
(rectangle, arrondi, capsule, cercle), taille du texte, gras, italique,
alignement, icone, URL ouverte dans un nouvel onglet, note.
**Proprietes du noeud**, regroupees en sections repliables (Contenu,
Typographie, Apparence, Mise en page, Branche) : texte, icone, URL, notes ;
police, taille, gras, italique, alignement horizontal et vertical, interligne ;
couleurs de fond/texte/bordure, forme (rectangle, arrondi, capsule, cercle),
ombre ; taille automatique ou personnalisee, largeur, hauteur, padding
horizontal et vertical, retour a la ligne, largeur maximale du texte ; style,
epaisseur et couleur de la branche. Un reglage s'applique a toute la selection,
avec un etat *Mixte* quand les valeurs different.
**Mise en page visuelle** : redimensionnement des noeuds a la souris (une seule
annulation par redimensionnement), taille automatique qui epouse le contenu,
alignement et distribution d'une multi-selection, aimantation sur la grille,
guides d'alignement pendant le deplacement, copier / coller / reinitialiser le
style (Ctrl+Maj+C, Ctrl+Maj+V), menu contextuel par clic droit.
**Bibliotheque d'icones** : emojis et icones integrees (Lucide), avec position
(gauche, droite, au-dessus), taille et espacement configurables.
**Themes** : Default, Minimal, Dark, Midnight, Ocean, Forest, Pastel,
Monochrome, Blueprint. Plus un mode clair/sombre pour l'interface. Changer de
@@ -265,6 +282,8 @@ l'application chargee.
| `Ctrl+C` | Copier |
| `Ctrl+V` | Coller |
| `Ctrl+D` | Dupliquer |
| `Ctrl+Maj+C` | Copier le style du noeud |
| `Ctrl+Maj+V` | Coller le style sur la selection |
| `Ctrl+A` | Tout selectionner |
| `Ctrl+S` | Sauvegarde explicite |
| `Ctrl+F` | Rechercher dans la carte |
@@ -278,6 +297,12 @@ l'application chargee.
Un `.mmap` est un fichier **JSON UTF-8** contenant le document complet de la
carte. Il ne contient jamais de jeton de partage.
Le format est en **version 2**. Les fichiers v1 restent lisibles : tous les
champs ajoutes en v2 (`layout`, `iconStyle`, `branch`, reglages d'espacement)
sont optionnels et un champ absent signifie simplement *herite du theme*. La
seule transformation appliquee a l'import d'un fichier v1 est le deplacement de
`style.width` vers `layout.width`.
```json
{
"version": 1,
@@ -296,7 +321,26 @@ carte. Il ne contient jamais de jeton de partage.
"icon": "\ud83d\udda5\ufe0f",
"url": "https://exemple.fr",
"note": "Texte libre",
"style": { "backgroundColor": "#eef2ff", "shape": "rounded" }
"style": {
"backgroundColor": "#eef2ff",
"shape": "rounded",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.35,
"fontFamily": "sans",
"shadow": "soft"
},
"layout": {
"widthMode": "fixed",
"width": 240,
"heightMode": "auto",
"paddingX": 14,
"paddingY": 8,
"wrap": true,
"maxTextWidth": 260
},
"iconStyle": { "kind": "emoji", "position": "left", "size": 16, "gap": 8 },
"branch": { "style": "bezier", "width": 2, "color": "#94a3b8" }
}
],
"edges": [{ "id": "e-a-b", "source": "a", "target": "b" }],
@@ -305,7 +349,11 @@ carte. Il ne contient jamais de jeton de partage.
"theme": "default",
"layout": "mindmap-right",
"branchStyle": "bezier",
"showGrid": true
"showGrid": true,
"layoutSpacing": { "parentGap": 100, "siblingGap": 50 },
"snapToGrid": false,
"snapGrid": 20,
"alignmentGuides": true
}
}
```
@@ -498,6 +546,13 @@ de boucle, recherche, auto-layout, lecture seule), assainissement des URL.
- **Migrations de schema SQLite** : la V1 initialise le schema avec
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra
l'introduire au premier changement de schema.
- **Guides d'alignement** : ils apparaissent pendant un deplacement lorsqu'un
bord ou un centre coincide (a 6 px pres) avec celui d'un autre noeud. Ils sont
purement visuels : ils n'aimantent pas le noeud. Pour aimanter, activez
*Aimanter sur la grille*.
- **Alignement vertical** : il n'a d'effet visible que si le noeud est plus haut
que son texte, c'est-a-dire avec une hauteur fixe ou un padding vertical
important.
- **Mobile** : consultation et edition basique fonctionnent, mais l'interface
est concue pour desktop et tablette.
- **Aucun compte utilisateur** : c'est voulu. Pas de connexion, pas de
@@ -520,6 +575,7 @@ n'est utilisee.
| dagre | MIT |
| html-to-image | MIT |
| jsPDF | MIT |
| lucide-react | ISC |
| Vite, Tailwind CSS, TypeScript, ESLint, Vitest | MIT |
| FastAPI, Starlette, Pydantic, SQLAlchemy, Uvicorn | MIT / BSD |

View File

@@ -0,0 +1,120 @@
"""Le backend doit accepter la mise en page v2 sans relacher sa validation."""
from __future__ import annotations
import os
from fastapi.testclient import TestClient
from app.config import get_settings
from tests.conftest import build_map
RICH_NODE = {
"id": "styled",
"type": "standard",
"text": "Firewall",
"position": {"x": 120, "y": 40},
"parentId": "root",
"icon": "server",
"iconStyle": {"kind": "builtin", "position": "top", "size": 24, "gap": 12},
"style": {
"backgroundColor": "#112233",
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": 1.6,
"fontFamily": "mono",
"shadow": "strong",
},
"layout": {
"widthMode": "fixed",
"width": 320,
"heightMode": "fixed",
"height": 140,
"paddingX": 28,
"paddingY": 18,
"wrap": False,
"maxTextWidth": 400,
},
"branch": {"style": "straight", "width": 4, "color": "#ff8800"},
}
def build_v2_map() -> dict:
payload = build_map(node_count=2, name="Infrastructure")
payload["version"] = 2
payload["nodes"].append(RICH_NODE)
payload["edges"].append({"id": "e-root-styled", "source": "root", "target": "styled"})
payload["settings"]["layoutSpacing"] = {"parentGap": 140, "siblingGap": 80}
payload["settings"]["snapToGrid"] = True
payload["settings"]["snapGrid"] = 10
payload["settings"]["alignmentGuides"] = False
return payload
def test_share_accepts_layout_properties(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
assert response.status_code == 201, response.text
def test_shared_map_is_returned_unchanged(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
token = created.json()["share_token"]
public = client.get("/api/shares/" + token).json()
assert public["map_version"] == 2
node = next(item for item in public["map"]["nodes"] if item["id"] == "styled")
assert node["layout"] == RICH_NODE["layout"]
assert node["style"] == RICH_NODE["style"]
assert node["iconStyle"] == RICH_NODE["iconStyle"]
assert node["branch"] == RICH_NODE["branch"]
assert node["icon"] == "server"
assert node["position"] == {"x": 120, "y": 40}
settings = public["map"]["settings"]
assert settings["layoutSpacing"] == {"parentGap": 140, "siblingGap": 80}
assert settings["snapToGrid"] is True
assert settings["alignmentGuides"] is False
def test_version_1_maps_are_still_accepted(client: TestClient) -> None:
response = client.post("/api/shares", json={"title": "v1", "map": build_map()})
assert response.status_code == 201
assert response.json()["share_token"]
def test_node_limit_still_applies_to_v2_maps(client: TestClient) -> None:
os.environ["MAX_NODES"] = "5"
get_settings.cache_clear()
try:
payload = build_v2_map()
for index in range(20):
payload["nodes"].append(
{
"id": f"extra{index}",
"type": "standard",
"text": "x",
"position": {"x": 0, "y": 0},
"parentId": "root",
"layout": {"paddingX": 10},
}
)
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422
finally:
os.environ["MAX_NODES"] = "5000"
get_settings.cache_clear()
def test_unknown_top_level_field_is_still_rejected(client: TestClient) -> None:
response = client.post(
"/api/shares", json={"title": "x", "map": build_v2_map(), "premium": True}
)
assert response.status_code == 422
def test_node_without_id_is_still_rejected(client: TestClient) -> None:
payload = build_v2_map()
payload["nodes"].append({"text": "sans id", "layout": {"paddingX": 4}})
response = client.post("/api/shares", json={"title": "x", "map": payload})
assert response.status_code == 422

View File

@@ -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",

View File

@@ -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"

View 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>
);
}

View File

@@ -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&apos;espacement ne deplace rien : relancez l&apos;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>

View File

@@ -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>
);
}

View File

@@ -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,14 +87,20 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
const isRoot = node.type === 'root';
const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre');
return (
<div
ref={containerRef}
className={cn(
'mind-node relative select-none px-3.5 py-2',
isSearchHit && 'mind-node--search-hit',
)}
style={{
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}`,
@@ -85,14 +110,68 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
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' }
: {}),
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', 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,19 +207,31 @@ 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}
<div style={{ flex: '1 1 auto', minWidth: 0, textAlign: appearance.textAlign }}>
{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))}
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)}
@@ -137,10 +249,13 @@ function MindNodeViewComponent({ data, id }: NodeProps<MindFlowNode>) {
}}
/>
) : (
<span className="whitespace-pre-wrap break-words leading-snug">
<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">

View 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>
);
}

View 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>
);
}

View File

@@ -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,7 +193,40 @@ export function PropertiesPanel() {
>
{header}
<div className="space-y-5 px-4 py-4">
<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
@@ -159,123 +236,10 @@ export function PropertiesPanel() {
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== node.text && updateNodeText(node.id, text)}
onBlur={() => text !== single.text && updateNodeText(single.id, text)}
/>
</div>
<div>
<span className="field-label">Icone</span>
<div className="flex flex-wrap gap-1">
{EMOJIS.map((emoji) => (
<button
key={emoji || 'none'}
type="button"
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
aria-pressed={(node.icon ?? '') === emoji}
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
(node.icon ?? '') === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji || '-'}
</button>
))}
</div>
</div>
<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 })}
/>
<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>
<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>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={style.bold ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.bold)}
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
className="font-bold"
>
G
</Button>
<Button
size="sm"
variant={style.italic ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.italic)}
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
className="italic"
>
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
@@ -291,9 +255,9 @@ export function PropertiesPanel() {
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{node.url ? (
{single.url ? (
<a
href={node.url}
href={single.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
@@ -308,38 +272,371 @@ export function PropertiesPanel() {
<div>
<label className="field-label" htmlFor="node-note">
Note
Notes
</label>
<textarea
id="node-note"
className="field-input min-h-[80px] resize-y"
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 !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })}
onBlur={() =>
note !== (single.note ?? '') &&
updateNode(single.id, { note: note || undefined })
}
/>
</div>
</>
) : (
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Texte, lien et notes se modifient noeud par noeud. Les reglages d&apos;icone et de style
ci-dessous s&apos;appliquent a toute la selection.
</p>
)}
<div 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
<div>
<span className="field-label">Icone</span>
<div className="mb-2 flex flex-wrap gap-1">
<button
type="button"
aria-label="Aucune icone"
title="Aucune icone"
onClick={() => setNodesIcon(ids, undefined)}
className="h-8 w-8 rounded-md border border-zinc-200 text-xs text-zinc-500 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
-
</button>
{EMOJI_ICONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={`Icone ${emoji}`}
aria-pressed={single?.icon === emoji}
onClick={() => setNodesIcon(ids, emoji, 'emoji')}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
single?.icon === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{BUILTIN_ICON_IDS.map((iconId) => {
const { label, Icon } = BUILTIN_ICONS[iconId] as (typeof BUILTIN_ICONS)[string];
return (
<button
key={iconId}
type="button"
aria-label={`Icone ${label}`}
title={label}
aria-pressed={single?.icon === iconId}
onClick={() => setNodesIcon(ids, iconId, 'builtin')}
className={`flex h-8 w-8 items-center justify-center rounded-md border text-zinc-600 transition-colors dark:text-zinc-300 ${
single?.icon === iconId
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</button>
);
})}
</div>
</div>
<SelectField
id="icon-position"
label="Position de l'icone"
value={styleOf(nodes, (node) => node.iconStyle?.position).value ?? 'left'}
mixed={styleOf(nodes, (node) => node.iconStyle?.position).mixed}
options={ICON_POSITIONS}
onChange={(position) => updateNodesIconStyle(ids, { position })}
/>
<SegmentedControl
legend="Taille de l'icone"
mixed={styleOf(nodes, (node) => node.iconStyle?.size).mixed}
selected={String(
styleOf(nodes, (node) => node.iconStyle?.size).value ?? metrics.iconSize,
)}
options={ICON_SIZES.map((size) => ({ value: String(size), label: `${size} px` }))}
onSelect={(value) => updateNodesIconStyle(ids, { size: Number(value) })}
/>
<NumberField
id="icon-gap"
label="Espacement icone / texte"
value={styleOf(nodes, (node) => node.iconStyle?.gap).value ?? metrics.iconGap}
mixed={styleOf(nodes, (node) => node.iconStyle?.gap).mixed}
min={LAYOUT_LIMITS.iconGap.min}
max={LAYOUT_LIMITS.iconGap.max}
onChange={(gap) => updateNodesIconStyle(ids, { gap })}
/>
</PanelSection>
<PanelSection id="typography" title="Typographie">
<SelectField
id="font-family"
label="Police"
value={styleOf(nodes, (node) => node.style?.fontFamily).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.fontFamily).mixed}
options={FONT_FAMILY_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(fontFamily) =>
updateNodesStyle(ids, {
fontFamily: fontFamily === 'theme' ? undefined : (fontFamily as FontFamilyId),
})
}
/>
<NumberField
id="font-size"
label="Taille du texte"
value={
styleOf(nodes, (node) => node.style?.fontSize).value ?? (reference?.fontSize ?? 14)
}
mixed={styleOf(nodes, (node) => node.style?.fontSize).mixed}
min={LAYOUT_LIMITS.fontSize.min}
max={LAYOUT_LIMITS.fontSize.max}
onChange={(fontSize) => updateNodesStyle(ids, { fontSize })}
/>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant={styleOf(nodes, (node) => node.style?.bold).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.bold).value)}
aria-label="Texte gras"
title="Gras"
className="font-bold"
onClick={() =>
updateNodesStyle(ids, {
bold: !styleOf(nodes, (node) => node.style?.bold).value,
})
}
>
G
</Button>
{node.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}>
<Button
size="sm"
variant={styleOf(nodes, (node) => node.style?.italic).value ? 'primary' : 'secondary'}
aria-pressed={Boolean(styleOf(nodes, (node) => node.style?.italic).value)}
aria-label="Texte italique"
title="Italique"
className="italic"
onClick={() =>
updateNodesStyle(ids, {
italic: !styleOf(nodes, (node) => node.style?.italic).value,
})
}
>
I
</Button>
</div>
<SegmentedControl
legend="Alignement horizontal"
options={HORIZONTAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.textAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.textAlign).value ?? 'left'}
onSelect={(textAlign) => updateNodesStyle(ids, { textAlign })}
/>
<SegmentedControl
legend="Alignement vertical"
options={VERTICAL_ALIGN}
mixed={styleOf(nodes, (node) => node.style?.verticalAlign).mixed}
selected={styleOf(nodes, (node) => node.style?.verticalAlign).value ?? 'top'}
onSelect={(verticalAlign) => updateNodesStyle(ids, { verticalAlign })}
/>
<NumberField
id="line-height"
label="Interligne"
unit=""
step={0.1}
value={styleOf(nodes, (node) => node.style?.lineHeight).value ?? metrics.lineHeight}
mixed={styleOf(nodes, (node) => node.style?.lineHeight).mixed}
min={LAYOUT_LIMITS.lineHeight.min}
max={LAYOUT_LIMITS.lineHeight.max}
onChange={(lineHeight) => updateNodesStyle(ids, { lineHeight })}
/>
</PanelSection>
<PanelSection id="appearance" title="Apparence">
<ColorField
id="node-bg"
label="Fond"
shared={styleOf(nodes, (node) => node.style?.backgroundColor)}
fallback={palette.background}
onChange={(backgroundColor) => updateNodesStyle(ids, { backgroundColor })}
/>
<ColorField
id="node-text-color"
label="Couleur du texte"
shared={styleOf(nodes, (node) => node.style?.textColor)}
fallback={palette.text}
onChange={(textColor) => updateNodesStyle(ids, { textColor })}
/>
<ColorField
id="node-border"
label="Bordure"
shared={styleOf(nodes, (node) => node.style?.borderColor)}
fallback={palette.border}
onChange={(borderColor) => updateNodesStyle(ids, { borderColor })}
/>
<SelectField
id="node-shape"
label="Forme"
value={styleOf(nodes, (node) => node.style?.shape).value ?? theme.nodeShape}
mixed={styleOf(nodes, (node) => node.style?.shape).mixed}
options={SHAPES}
onChange={(shape) => updateNodesStyle(ids, { shape })}
/>
<SelectField
id="node-shadow"
label="Ombre"
value={styleOf(nodes, (node) => node.style?.shadow).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.style?.shadow).mixed}
options={SHADOW_OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
onChange={(shadow) =>
updateNodesStyle(ids, { shadow: shadow === 'theme' ? undefined : (shadow as ShadowStyle) })
}
/>
</PanelSection>
<PanelSection id="layout" title="Mise en page">
<SegmentedControl
legend="Taille du noeud"
mixed={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').mixed}
selected={styleOf(nodes, (node) => node.layout?.widthMode ?? 'auto').value ?? 'auto'}
options={[
{ value: 'auto', label: 'Automatique' },
{ value: 'fixed', label: 'Personnalisee' },
]}
onSelect={(widthMode) => {
if (widthMode === 'auto') {
fitNodesToContent(ids);
return;
}
updateNodesLayout(ids, {
widthMode: 'fixed',
width: reference?.width ?? Math.round(reference?.minWidth ?? 160),
});
}}
/>
<NumberField
id="node-width"
label="Largeur"
value={
styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).value ??
(reference?.width ?? 160)
}
mixed={styleOf(nodes, (node) => node.layout?.width ?? node.style?.width).mixed}
min={LAYOUT_LIMITS.width.min}
max={LAYOUT_LIMITS.width.max}
onChange={(width) => updateNodesLayout(ids, { widthMode: 'fixed', width })}
/>
<NumberField
id="node-height"
label="Hauteur"
value={styleOf(nodes, (node) => node.layout?.height).value ?? (reference?.height ?? 48)}
mixed={styleOf(nodes, (node) => node.layout?.height).mixed}
min={LAYOUT_LIMITS.height.min}
max={LAYOUT_LIMITS.height.max}
onChange={(height) => updateNodesLayout(ids, { heightMode: 'fixed', height })}
/>
<NumberField
id="padding-x"
label="Padding horizontal"
value={styleOf(nodes, (node) => node.layout?.paddingX).value ?? metrics.paddingX}
mixed={styleOf(nodes, (node) => node.layout?.paddingX).mixed}
min={LAYOUT_LIMITS.paddingX.min}
max={LAYOUT_LIMITS.paddingX.max}
onChange={(paddingX) => updateNodesLayout(ids, { paddingX })}
/>
<NumberField
id="padding-y"
label="Padding vertical"
value={styleOf(nodes, (node) => node.layout?.paddingY).value ?? metrics.paddingY}
mixed={styleOf(nodes, (node) => node.layout?.paddingY).mixed}
min={LAYOUT_LIMITS.paddingY.min}
max={LAYOUT_LIMITS.paddingY.max}
onChange={(paddingY) => updateNodesLayout(ids, { paddingY })}
/>
<ToggleRow
id="node-wrap"
label="Retour a la ligne automatique"
checked={styleOf(nodes, (node) => node.layout?.wrap ?? true).value ?? true}
mixed={styleOf(nodes, (node) => node.layout?.wrap ?? true).mixed}
onChange={(wrap) => updateNodesLayout(ids, { wrap })}
/>
<NumberField
id="max-text-width"
label="Largeur maximale du texte"
value={styleOf(nodes, (node) => node.layout?.maxTextWidth).value ?? metrics.maxTextWidth}
mixed={styleOf(nodes, (node) => node.layout?.maxTextWidth).mixed}
min={LAYOUT_LIMITS.maxTextWidth.min}
max={LAYOUT_LIMITS.maxTextWidth.max}
disabled={styleOf(nodes, (node) => node.layout?.wrap ?? true).value === false}
onChange={(maxTextWidth) => updateNodesLayout(ids, { maxTextWidth })}
/>
</PanelSection>
<PanelSection id="branch" title="Branche">
<SelectField
id="branch-style"
label="Style"
value={styleOf(nodes, (node) => node.branch?.style).value ?? 'theme'}
mixed={styleOf(nodes, (node) => node.branch?.style).mixed}
options={BRANCH_STYLES}
onChange={(style) =>
updateNodesBranch(ids, {
style: style === 'theme' ? undefined : (style as BranchStyle),
})
}
/>
<NumberField
id="branch-width"
label="Epaisseur"
value={styleOf(nodes, (node) => node.branch?.width).value ?? theme.branchWidth}
mixed={styleOf(nodes, (node) => node.branch?.width).mixed}
min={LAYOUT_LIMITS.branchWidth.min}
max={LAYOUT_LIMITS.branchWidth.max}
onChange={(width) => updateNodesBranch(ids, { width })}
/>
<ColorField
id="branch-color"
label="Couleur"
shared={styleOf(nodes, (node) => node.branch?.color)}
fallback={theme.branchColor}
onChange={(color) => updateNodesBranch(ids, { color })}
/>
</PanelSection>
<div className="space-y-2 px-4 py-4">
{single?.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(single.id)}>
Detacher de son parent
</Button>
) : null}
<Button
size="sm"
variant="danger"
className="w-full"
onClick={() => deleteNodes([node.id])}
>
<Button size="sm" variant="danger" className="w-full" onClick={() => deleteNodes(ids)}>
<TrashIcon className="h-4 w-4" />
Supprimer ce noeud
{nodes.length > 1 ? `Supprimer ${nodes.length} noeuds` : 'Supprimer ce noeud'}
</Button>
</div>
</div>
</aside>
);
}

View 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>
);
}

View 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);
});
});

View 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;
}

View File

@@ -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) => ({
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: edgeType,
type,
hidden: hidden.has(edge.source) || hidden.has(edge.target),
deletable: !options.readOnly,
style: {
stroke: edge.color ?? options.theme.branchColor,
strokeWidth: options.theme.branchWidth,
stroke: branch.color ?? edge.color ?? options.theme.branchColor,
strokeWidth: branch.width ?? options.theme.branchWidth,
},
}));
};
});
}

View File

@@ -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,
);
});
});

View File

@@ -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 });
}

View 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 = [
'💡',
'✅',
'📌',
'⚠️',
'🔒',
'🌐',
'🖥️',
'📦',
'📄',
'🔗',
'⭐',
'🐳',
'🗄️',
'🚀',
];

View 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');
});
});

View 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,
};
}

View 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>
);
}

View 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>
);
}

View 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';

View File

@@ -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',

View File

@@ -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':

View File

@@ -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);

View 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();
});
});

View File

@@ -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');
});
});

View File

@@ -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;

View 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);
});
});

View File

@@ -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;

View 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());
});
});

View File

@@ -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. */

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);
}

View 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);
}