From af6fd519c5555c2f0a73292ebe100bd4d6cb1348 Mon Sep 17 00:00:00 2001 From: snoopy Date: Wed, 9 Sep 2026 17:29:48 +0200 Subject: [PATCH] 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 --- README.md | 68 +- backend/tests/test_layout_payload.py | 120 +++ frontend/package-lock.json | 10 + frontend/package.json | 1 + .../features/editor/AlignmentGuidesLayer.tsx | 50 ++ .../src/features/editor/EditorToolbar.tsx | 86 +- .../src/features/editor/MindMapCanvas.tsx | 68 +- frontend/src/features/editor/MindNodeView.tsx | 235 ++++-- .../src/features/editor/NodeContextMenu.tsx | 108 +++ .../src/features/editor/NodeIconGlyph.tsx | 28 + .../src/features/editor/PropertiesPanel.tsx | 753 ++++++++++++------ .../src/features/editor/SelectionToolbar.tsx | 112 +++ .../features/editor/alignmentGuides.test.ts | 51 ++ .../src/features/editor/alignmentGuides.ts | 119 +++ frontend/src/features/editor/flowAdapters.ts | 110 +-- frontend/src/features/editor/layout.test.ts | 72 +- frontend/src/features/editor/layout.ts | 67 +- frontend/src/features/editor/nodeIcons.ts | 90 +++ .../features/editor/nodeStyleResolver.test.ts | 110 +++ .../src/features/editor/nodeStyleResolver.ts | 187 +++++ .../features/editor/panel/PanelSection.tsx | 56 ++ .../src/features/editor/panel/controls.tsx | 288 +++++++ .../src/features/editor/panel/sharedValue.ts | 35 + frontend/src/features/editor/themes.ts | 49 ++ .../src/features/editor/useEditorShortcuts.ts | 13 + frontend/src/features/export/exportImage.ts | 5 +- .../services/mapLayoutSerialization.test.ts | 171 ++++ frontend/src/services/mapRepository.test.ts | 47 ++ frontend/src/services/mapSerialization.ts | 185 ++++- frontend/src/stores/editorLayout.test.ts | 187 +++++ frontend/src/stores/editorStore.ts | 387 ++++++++- frontend/src/stores/editorStyle.test.ts | 139 ++++ frontend/src/stores/uiStore.ts | 29 + frontend/src/styles/index.css | 15 + frontend/src/types/map.ts | 90 ++- frontend/src/utils/nodeSize.ts | 100 ++- frontend/src/utils/textMeasure.ts | 94 +++ 37 files changed, 3896 insertions(+), 439 deletions(-) create mode 100644 backend/tests/test_layout_payload.py create mode 100644 frontend/src/features/editor/AlignmentGuidesLayer.tsx create mode 100644 frontend/src/features/editor/NodeContextMenu.tsx create mode 100644 frontend/src/features/editor/NodeIconGlyph.tsx create mode 100644 frontend/src/features/editor/SelectionToolbar.tsx create mode 100644 frontend/src/features/editor/alignmentGuides.test.ts create mode 100644 frontend/src/features/editor/alignmentGuides.ts create mode 100644 frontend/src/features/editor/nodeIcons.ts create mode 100644 frontend/src/features/editor/nodeStyleResolver.test.ts create mode 100644 frontend/src/features/editor/nodeStyleResolver.ts create mode 100644 frontend/src/features/editor/panel/PanelSection.tsx create mode 100644 frontend/src/features/editor/panel/controls.tsx create mode 100644 frontend/src/features/editor/panel/sharedValue.ts create mode 100644 frontend/src/services/mapLayoutSerialization.test.ts create mode 100644 frontend/src/stores/editorLayout.test.ts create mode 100644 frontend/src/stores/editorStyle.test.ts create mode 100644 frontend/src/utils/textMeasure.ts diff --git a/README.md b/README.md index 674fe55..8dbfa41 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/backend/tests/test_layout_payload.py b/backend/tests/test_layout_payload.py new file mode 100644 index 0000000..489bcfd --- /dev/null +++ b/backend/tests/test_layout_payload.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22029bc..ee163fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a0ef9d5..c02a055 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/features/editor/AlignmentGuidesLayer.tsx b/frontend/src/features/editor/AlignmentGuidesLayer.tsx new file mode 100644 index 0000000..345a07f --- /dev/null +++ b/frontend/src/features/editor/AlignmentGuidesLayer.tsx @@ -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 ( + + {guides.map((guide) => { + const vertical = guide.orientation === 'vertical'; + const margin = 24; + return ( + ); } diff --git a/frontend/src/features/editor/MindNodeView.tsx b/frontend/src/features/editor/MindNodeView.tsx index f9b1049..2d60c1c 100644 --- a/frontend/src/features/editor/MindNodeView.tsx +++ b/frontend/src/features/editor/MindNodeView.tsx @@ -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) { +function MindNodeViewComponent({ data, id, selected }: NodeProps) { 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(null); const textareaRef = useRef(null); @@ -44,7 +63,7 @@ function MindNodeViewComponent({ data, id }: NodeProps) { 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) { width: Math.round(element.offsetWidth), height: Math.round(element.offsetHeight), }); - }, [id, node.text, node.style, node.icon, setMeasuredSize, isEditing]); + }, [id, node.text, node.style, node.layout, node.icon, node.iconStyle, setMeasuredSize, isEditing]); const commitText = () => { const value = draft.trim(); @@ -68,31 +87,91 @@ function MindNodeViewComponent({ data, id }: NodeProps) { const isRoot = node.type === 'root'; const label = node.text || (isRoot ? 'Racine sans titre' : 'Noeud sans titre'); + const hasIcon = Boolean(node.icon); + const iconKind = node.iconStyle?.kind ?? 'emoji'; + const iconOnTop = hasIcon && appearance.iconPosition === 'top'; + + const decorations = + (hasIcon && !iconOnTop ? appearance.iconSize + appearance.iconGap : 0) + + (node.note ? INDICATOR_WIDTH : 0) + + (node.url ? INDICATOR_WIDTH : 0); + + const containerStyle = useMemo(() => { + const fixedWidth = appearance.widthMode === 'fixed' ? appearance.width : undefined; + const fixedHeight = appearance.heightMode === 'fixed' ? appearance.height : undefined; + + return { + background: appearance.background, + color: appearance.color, + border: `${appearance.borderWidth}px solid ${appearance.borderColor}`, + borderRadius: appearance.borderRadius, + boxShadow: appearance.boxShadow, + fontFamily: appearance.fontFamily, + fontSize: appearance.fontSize, + fontWeight: appearance.fontWeight, + fontStyle: appearance.fontStyle, + lineHeight: appearance.lineHeight, + paddingLeft: appearance.paddingX, + paddingRight: appearance.paddingX, + paddingTop: appearance.paddingY, + paddingBottom: appearance.paddingY, + boxSizing: 'border-box' as const, + display: 'flex', + flexDirection: 'column' as const, + justifyContent: VERTICAL_ALIGNMENT[appearance.verticalAlign], + minWidth: appearance.minWidth, + /* + * En mode automatique, `max-content` plutot que la largeur par defaut : + * le noeud est absolument positionne, sa largeur "auto" se resoudrait + * sinon a la largeur min-content (le mot le plus long), ce qui produirait + * une colonne etroite et haute pour une phrase. Avec `max-content` borne + * par `maxWidth`, une phrase longue occupe la largeur maximale autorisee + * puis passe a la ligne, et un texte court reste compact. + */ + ...(fixedWidth ? { width: fixedWidth } : { width: 'max-content' }), + ...(fixedHeight ? { height: fixedHeight } : { minHeight: appearance.minHeight }), + ...(fixedWidth || !appearance.wrap + ? {} + : { maxWidth: appearance.maxTextWidth + appearance.paddingX * 2 + decorations }), + ...(appearance.circle && !fixedWidth ? { aspectRatio: '1 / 1', alignItems: 'center' } : {}), + }; + }, [appearance, decorations]); + + const iconElement = hasIcon ? ( + + + + ) : 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 (
{ if (readOnly) return; @@ -100,6 +179,27 @@ function MindNodeViewComponent({ data, id }: NodeProps) { setEditingNode(id); }} > + {!readOnly && selected && !isEditing ? ( + beginResize()} + onResize={(_event, params) => + resizeNode(id, { + width: params.width, + height: params.height, + position: { x: params.x, y: params.y }, + }) + } + /> + ) : null} + ) { aria-label="Point de connexion entrant" /> -
- {node.icon ? ( - - ) : null} +
+ {hasIcon && (appearance.iconPosition === 'left' || iconOnTop) ? iconElement : null} - {isEditing ? ( -