From f88f7bcb766506bf074778dad60f5a5c1217cd20 Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Thu, 22 Oct 2026 16:13:24 +0200 Subject: [PATCH 1/3] chore: migrate to React Flow 12 Upgrade to @xyflow/react 12.12.0, adapt internal-node geometry and dimensions, preserve model format 2, and keep measurement updates out of dirty tracking and resize history. Initialize PNG capture from locally measured export nodes and await viewport updates. Preserve Modelizer's dark canvas palette. Validation: lint, 527 tests in 34 files (514 retained plus 13 regressions), and production build pass. Existing MySQL parser chunk-size warning remains. Browser comparison against main covered a 20-class model, light/dark rendering, all three views, and matching conceptual PNG previews at 2396 x 1595. Verified pen gesture undo/redo and annotation-only preview on localhost. Downloaded-file inspection remains unverified after the browser download wait timed out; exhaustive manual tool/gesture checks remain for review. --- package-lock.json | 376 ++---------------- package.json | 2 +- src/App.jsx | 6 +- .../annotations/AnnotationLayer.jsx | 2 +- .../annotations/AnnotationToolbox.jsx | 2 +- .../__tests__/AnnotationLayer.test.jsx | 2 +- .../__tests__/AnnotationToolbox.test.jsx | 2 +- src/components/flow/edges/Association.jsx | 6 +- .../flow/edges/AssociativeAssociation.jsx | 2 +- src/components/flow/edges/Composition.jsx | 6 +- .../flow/edges/EdgeControlPoints.jsx | 12 +- .../flow/edges/ReflexiveAssociation.jsx | 4 +- .../flow/edges/ReflexiveResizeHandles.jsx | 12 +- src/components/flow/edges/Relationship.jsx | 2 +- .../flow/handles/AttributeHandle.jsx | 2 +- src/components/flow/handles/ClassHandle.jsx | 2 +- .../flow/handles/CompositionHandle.jsx | 2 +- src/components/flow/nodes/Area.jsx | 2 +- .../flow/nodes/AssociationHelperNode.jsx | 2 +- src/components/flow/nodes/Attribute.jsx | 2 +- src/components/flow/nodes/Class.jsx | 2 +- .../flow/nodes/__tests__/Class.test.jsx | 2 +- .../utils/__tests__/associationUtils.test.js | 2 +- .../utils/__tests__/relationshipUtils.test.js | 2 +- src/components/flow/utils/associationUtils.js | 2 +- .../flow/utils/relationshipUtils.js | 2 +- .../modelOpeningIntegration.test.jsx | 41 +- src/hooks/__tests__/useModelState.test.jsx | 41 ++ src/hooks/useFileActions.js | 14 +- src/hooks/useModelState.js | 56 +-- src/index.css | 2 +- src/model/PngExportCanvas.jsx | 25 +- src/model/__tests__/pngExport.test.jsx | 38 +- src/model/__tests__/pngExportUtils.test.js | 6 + .../__tests__/pngExportViewport.test.jsx | 35 +- src/model/pngExport.jsx | 4 +- src/model/pngExportUtils.js | 4 +- src/xy-theme.css | 5 + vite.config.js | 2 +- 39 files changed, 296 insertions(+), 437 deletions(-) diff --git a/package-lock.json b/package-lock.json index dcd898e..94bcbca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,13 +24,13 @@ "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-toast": "^1.2.23", "@radix-ui/react-tooltip": "^1.2.16", + "@xyflow/react": "12.12.0", "html-to-image": "1.11.11", "markdown-it": "^15.0.2", "node-sql-parser": "^5.4.0", "react": "^19.3.0", "react-color": "^2.19.3", - "react-dom": "^19.3.0", - "reactflow": "^11.11.4" + "react-dom": "^19.3.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1929,108 +1929,6 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, - "node_modules/@reactflow/background": { - "version": "11.3.14", - "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", - "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/controls": { - "version": "11.2.14", - "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", - "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/core": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", - "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", - "license": "MIT", - "dependencies": { - "@types/d3": "^7.4.0", - "@types/d3-drag": "^3.0.1", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/minimap": { - "version": "11.7.14", - "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", - "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-resizer": { - "version": "2.2.14", - "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", - "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.4", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-toolbar": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", - "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", @@ -2792,102 +2690,12 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "license": "MIT" - }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "license": "MIT" - }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -2897,54 +2705,6 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "license": "MIT", - "dependencies": { - "@types/d3-dsv": "*" - } - }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "license": "MIT" - }, - "node_modules/@types/d3-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", - "license": "MIT" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" - }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2954,78 +2714,12 @@ "@types/d3-color": "*" } }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "license": "MIT" - }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "license": "MIT" - }, - "node_modules/@types/d3-random": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", - "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, "node_modules/@types/d3-selection": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", "license": "MIT" }, - "node_modules/@types/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-time-format": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -3066,12 +2760,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3232,6 +2920,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -5656,24 +5386,6 @@ "lodash": "^4.0.1" } }, - "node_modules/reactflow": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", - "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", - "license": "MIT", - "dependencies": { - "@reactflow/background": "11.3.14", - "@reactflow/controls": "11.2.14", - "@reactflow/core": "11.11.4", - "@reactflow/minimap": "11.7.14", - "@reactflow/node-resizer": "2.2.14", - "@reactflow/node-toolbar": "1.3.14" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index ab8d209..a283102 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "react": "^19.3.0", "react-color": "^2.19.3", "react-dom": "^19.3.0", - "reactflow": "^11.11.4" + "@xyflow/react": "12.12.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.jsx b/src/App.jsx index d3082df..c166dea 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import ReactFlow, { +import { + ReactFlow, Background, ConnectionMode, ConnectionLineType, Controls, -} from 'reactflow' +} from '@xyflow/react' import * as Toast from '@radix-ui/react-toast' import { edgeTypes, nodeTypes } from './flowTypes.js' import { @@ -1028,6 +1029,7 @@ function App() { ref={reactFlowWrapper} > ({ +vi.mock('@xyflow/react', () => ({ useViewport: () => ({ x: 0, y: 0, zoom: 1 }), })) diff --git a/src/components/annotations/__tests__/AnnotationToolbox.test.jsx b/src/components/annotations/__tests__/AnnotationToolbox.test.jsx index 3191aa3..18a7348 100644 --- a/src/components/annotations/__tests__/AnnotationToolbox.test.jsx +++ b/src/components/annotations/__tests__/AnnotationToolbox.test.jsx @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import AnnotationToolbox from '../AnnotationToolbox.jsx' -vi.mock('reactflow', () => ({ +vi.mock('@xyflow/react', () => ({ Panel: ({ children, className, style }) => (
{children} diff --git a/src/components/flow/edges/Association.jsx b/src/components/flow/edges/Association.jsx index b4e57c5..55b7b1b 100644 --- a/src/components/flow/edges/Association.jsx +++ b/src/components/flow/edges/Association.jsx @@ -1,4 +1,4 @@ -import { EdgeLabelRenderer, useStore } from 'reactflow' +import { EdgeLabelRenderer, useInternalNode } from '@xyflow/react' import { AssociationLabel } from '../labels/AssociationLabel.jsx' import { MultiplicityLabel } from '../labels/MultiplicityLabel.jsx' import { RoleLabel } from '../labels/RoleLabel.jsx' @@ -48,8 +48,8 @@ export function Association({ data, selected, }) { - const sourceNode = useStore((state) => state.nodeInternals.get(source)) - const targetNode = useStore((state) => state.nodeInternals.get(target)) + const sourceNode = useInternalNode(source) + const targetNode = useInternalNode(target) const layout = getAssociationLayout(sourceNode, targetNode, data) if (!layout) { diff --git a/src/components/flow/edges/AssociativeAssociation.jsx b/src/components/flow/edges/AssociativeAssociation.jsx index e8edf2d..8be7aae 100644 --- a/src/components/flow/edges/AssociativeAssociation.jsx +++ b/src/components/flow/edges/AssociativeAssociation.jsx @@ -1,4 +1,4 @@ -import { getStraightPath } from 'reactflow' +import { getStraightPath } from '@xyflow/react' export function AssociativeAssociation({ id, diff --git a/src/components/flow/edges/Composition.jsx b/src/components/flow/edges/Composition.jsx index abfe65c..e288138 100644 --- a/src/components/flow/edges/Composition.jsx +++ b/src/components/flow/edges/Composition.jsx @@ -1,4 +1,4 @@ -import { EdgeLabelRenderer, useStore } from 'reactflow' +import { EdgeLabelRenderer, useInternalNode } from '@xyflow/react' import { AssociationLabel } from '../labels/AssociationLabel.jsx' import { MultiplicityLabel } from '../labels/MultiplicityLabel.jsx' import { getAssociationLayout } from '../utils/associationUtils.js' @@ -35,8 +35,8 @@ export function Composition({ data, selected, }) { - const sourceNode = useStore((state) => state.nodeInternals.get(source)) - const targetNode = useStore((state) => state.nodeInternals.get(target)) + const sourceNode = useInternalNode(source) + const targetNode = useInternalNode(target) const layout = getAssociationLayout(sourceNode, targetNode, data) if (!layout) { diff --git a/src/components/flow/edges/EdgeControlPoints.jsx b/src/components/flow/edges/EdgeControlPoints.jsx index a41ebb6..bf09593 100644 --- a/src/components/flow/edges/EdgeControlPoints.jsx +++ b/src/components/flow/edges/EdgeControlPoints.jsx @@ -1,16 +1,8 @@ import { useEffect, useRef } from 'react' -import { EdgeLabelRenderer, useReactFlow } from 'reactflow' +import { EdgeLabelRenderer, useReactFlow } from '@xyflow/react' function toFlowPosition(reactFlow, clientX, clientY) { - if (reactFlow?.screenToFlowPosition) { - return reactFlow.screenToFlowPosition({ x: clientX, y: clientY }) - } - - if (reactFlow?.project) { - return reactFlow.project({ x: clientX, y: clientY }) - } - - return { x: clientX, y: clientY } + return reactFlow.screenToFlowPosition({ x: clientX, y: clientY }) } export function EdgeControlPoints({ diff --git a/src/components/flow/edges/ReflexiveAssociation.jsx b/src/components/flow/edges/ReflexiveAssociation.jsx index be7a755..2f1aca2 100644 --- a/src/components/flow/edges/ReflexiveAssociation.jsx +++ b/src/components/flow/edges/ReflexiveAssociation.jsx @@ -1,4 +1,4 @@ -import { EdgeLabelRenderer, useStore } from 'reactflow' +import { EdgeLabelRenderer, useInternalNode } from '@xyflow/react' import { AssociationLabel } from '../labels/AssociationLabel.jsx' import { MultiplicityLabel } from '../labels/MultiplicityLabel.jsx' import { RoleLabel } from '../labels/RoleLabel.jsx' @@ -40,7 +40,7 @@ export function ReflexiveAssociation({ data, selected, }) { - const node = useStore((state) => state.nodeInternals.get(source)) + const node = useInternalNode(source) const layout = getReflexiveAssociationLayout(node, data) if (!layout) { return null diff --git a/src/components/flow/edges/ReflexiveResizeHandles.jsx b/src/components/flow/edges/ReflexiveResizeHandles.jsx index d1b8e62..463d7cb 100644 --- a/src/components/flow/edges/ReflexiveResizeHandles.jsx +++ b/src/components/flow/edges/ReflexiveResizeHandles.jsx @@ -1,16 +1,8 @@ import { useEffect, useRef } from 'react' -import { EdgeLabelRenderer, useReactFlow } from 'reactflow' +import { EdgeLabelRenderer, useReactFlow } from '@xyflow/react' function toFlowPosition(reactFlow, clientX, clientY) { - if (reactFlow?.screenToFlowPosition) { - return reactFlow.screenToFlowPosition({ x: clientX, y: clientY }) - } - - if (reactFlow?.project) { - return reactFlow.project({ x: clientX, y: clientY }) - } - - return { x: clientX, y: clientY } + return reactFlow.screenToFlowPosition({ x: clientX, y: clientY }) } export function ReflexiveResizeHandles({ diff --git a/src/components/flow/edges/Relationship.jsx b/src/components/flow/edges/Relationship.jsx index 5cf2b81..629522a 100644 --- a/src/components/flow/edges/Relationship.jsx +++ b/src/components/flow/edges/Relationship.jsx @@ -1,4 +1,4 @@ -import { Position } from 'reactflow' +import { Position } from '@xyflow/react' import { EdgeControlPoints } from './EdgeControlPoints.jsx' import { getRelationshipLayout } from '../utils/relationshipUtils.js' diff --git a/src/components/flow/handles/AttributeHandle.jsx b/src/components/flow/handles/AttributeHandle.jsx index 06b2c74..ce49a8b 100644 --- a/src/components/flow/handles/AttributeHandle.jsx +++ b/src/components/flow/handles/AttributeHandle.jsx @@ -1,4 +1,4 @@ -import { Handle } from 'reactflow' +import { Handle } from '@xyflow/react' import HandleDot from './HandleDot.jsx' const HANDLE_BASE_CLASS = diff --git a/src/components/flow/handles/ClassHandle.jsx b/src/components/flow/handles/ClassHandle.jsx index 90c4598..6f3c6d0 100644 --- a/src/components/flow/handles/ClassHandle.jsx +++ b/src/components/flow/handles/ClassHandle.jsx @@ -1,4 +1,4 @@ -import { Handle } from 'reactflow' +import { Handle } from '@xyflow/react' import HandleDot from './HandleDot.jsx' const HANDLE_BASE_CLASS = diff --git a/src/components/flow/handles/CompositionHandle.jsx b/src/components/flow/handles/CompositionHandle.jsx index 5754e30..1784df0 100644 --- a/src/components/flow/handles/CompositionHandle.jsx +++ b/src/components/flow/handles/CompositionHandle.jsx @@ -1,4 +1,4 @@ -import { Handle } from 'reactflow' +import { Handle } from '@xyflow/react' import HandleDot from './HandleDot.jsx' const HANDLE_BASE_CLASS = diff --git a/src/components/flow/nodes/Area.jsx b/src/components/flow/nodes/Area.jsx index d5cbb24..f0fedd1 100644 --- a/src/components/flow/nodes/Area.jsx +++ b/src/components/flow/nodes/Area.jsx @@ -1,4 +1,4 @@ -import { NodeResizer } from 'reactflow' +import { NodeResizer } from '@xyflow/react' import { CLASS_COLOR_PALETTE } from '../../../classPalette.js' const withAlpha = (hex, alpha) => { diff --git a/src/components/flow/nodes/AssociationHelperNode.jsx b/src/components/flow/nodes/AssociationHelperNode.jsx index e251d9b..b67c344 100644 --- a/src/components/flow/nodes/AssociationHelperNode.jsx +++ b/src/components/flow/nodes/AssociationHelperNode.jsx @@ -1,4 +1,4 @@ -import { Position } from 'reactflow' +import { Position } from '@xyflow/react' import ClassHandle from '../handles/ClassHandle.jsx' export default function AssociationHelperNode() { diff --git a/src/components/flow/nodes/Attribute.jsx b/src/components/flow/nodes/Attribute.jsx index d901897..28c9c92 100644 --- a/src/components/flow/nodes/Attribute.jsx +++ b/src/components/flow/nodes/Attribute.jsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { Position } from 'reactflow' +import { Position } from '@xyflow/react' import { formatAttributeType } from '../../../attributes.js' import AttributeHandle from '../handles/AttributeHandle.jsx' diff --git a/src/components/flow/nodes/Class.jsx b/src/components/flow/nodes/Class.jsx index 346c140..85a46e6 100644 --- a/src/components/flow/nodes/Class.jsx +++ b/src/components/flow/nodes/Class.jsx @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef } from 'react' -import { Position, useUpdateNodeInternals } from 'reactflow' +import { Position, useUpdateNodeInternals } from '@xyflow/react' import { CLASS_COLOR_PALETTE } from '../../../classPalette.js' import { getClassDisplayName, diff --git a/src/components/flow/nodes/__tests__/Class.test.jsx b/src/components/flow/nodes/__tests__/Class.test.jsx index bd33422..d4ef886 100644 --- a/src/components/flow/nodes/__tests__/Class.test.jsx +++ b/src/components/flow/nodes/__tests__/Class.test.jsx @@ -8,7 +8,7 @@ import { } from '../../../../model/constants.js' import { Class } from '../Class.jsx' -vi.mock('reactflow', () => ({ +vi.mock('@xyflow/react', () => ({ Handle: () => null, Position: { Bottom: 'bottom', diff --git a/src/components/flow/utils/__tests__/associationUtils.test.js b/src/components/flow/utils/__tests__/associationUtils.test.js index be97533..b1268cd 100644 --- a/src/components/flow/utils/__tests__/associationUtils.test.js +++ b/src/components/flow/utils/__tests__/associationUtils.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -vi.mock("reactflow", () => ({ +vi.mock("@xyflow/react", () => ({ Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" }, getStraightPath: ({ sourceX, sourceY, targetX, targetY }) => [ `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`, diff --git a/src/components/flow/utils/__tests__/relationshipUtils.test.js b/src/components/flow/utils/__tests__/relationshipUtils.test.js index aa7a16c..9c84ad8 100644 --- a/src/components/flow/utils/__tests__/relationshipUtils.test.js +++ b/src/components/flow/utils/__tests__/relationshipUtils.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -vi.mock("reactflow", () => ({ +vi.mock("@xyflow/react", () => ({ Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" }, getStraightPath: ({ sourceX, sourceY, targetX, targetY }) => [ `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`, diff --git a/src/components/flow/utils/associationUtils.js b/src/components/flow/utils/associationUtils.js index ac0bd8b..f83666a 100644 --- a/src/components/flow/utils/associationUtils.js +++ b/src/components/flow/utils/associationUtils.js @@ -1,4 +1,4 @@ -import { Position, getSmoothStepPath, getStraightPath } from 'reactflow' +import { Position, getSmoothStepPath, getStraightPath } from '@xyflow/react' import { ASSOCIATION_LINE_STYLE_MANUAL, ASSOCIATION_LINE_STYLE_STRAIGHT, diff --git a/src/components/flow/utils/relationshipUtils.js b/src/components/flow/utils/relationshipUtils.js index d10d5f1..e3d6a56 100644 --- a/src/components/flow/utils/relationshipUtils.js +++ b/src/components/flow/utils/relationshipUtils.js @@ -1,4 +1,4 @@ -import { Position, getSmoothStepPath, getStraightPath } from 'reactflow' +import { Position, getSmoothStepPath, getStraightPath } from '@xyflow/react' import { ASSOCIATION_LINE_STYLE_MANUAL, ASSOCIATION_LINE_STYLE_ORTHOGONAL, diff --git a/src/hooks/__tests__/modelOpeningIntegration.test.jsx b/src/hooks/__tests__/modelOpeningIntegration.test.jsx index 489dfea..25d882f 100644 --- a/src/hooks/__tests__/modelOpeningIntegration.test.jsx +++ b/src/hooks/__tests__/modelOpeningIntegration.test.jsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { act, cleanup, renderHook, waitFor } from '@testing-library/react' -import { ReactFlowProvider, useStoreApi } from 'reactflow' +import { ReactFlowProvider, useStoreApi } from '@xyflow/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { useFileActions } from '../useFileActions.js' +import { buildHashPayload, useFileActions } from '../useFileActions.js' import { useModelState } from '../useModelState.js' import { useAnnotations } from '../useAnnotations.js' @@ -346,3 +346,40 @@ describe('shared model opening', () => { expect(replacement.createWritable).toHaveBeenCalledOnce() }) }) + + +describe('React Flow 12 file compatibility', () => { + it.each([undefined, 1, 2])('opens version %s without freezing automatic node dimensions or dirtying the model', async (version) => { + const { result } = setup() + const nodes = ['class', 'note', 'area'].map((type) => ({ id: type, type, + position: { x: 10, y: 20 }, width: 280, height: 180, + measured: { width: 999, height: 999 }, internals: { positionAbsolute: { x: 9, y: 9 } }, data: {}, + })) + await act(async () => result.current.onOpenModelFile(fileFor({ version, nodes, edges: [] }))) + for (const node of result.current.nodes) { + expect(node.measured).toBeUndefined() + expect(node.internals).toBeUndefined() + if (node.type !== 'area') { + expect(node.width).toBeUndefined() + expect(node.height).toBeUndefined() + } else { + expect(node.width).toBe(280) + expect(node.data.viewSizes.conceptual).toEqual({ width: 280, height: 180 }) + } + } + const before = buildHashPayload({ nodes: result.current.nodes, edges: [] }) + for (const height of [250, 350]) { + act(() => result.current.onNodesChange(nodes.map(node => ({ id: node.id, type: 'dimensions', dimensions: { width: 300, height } })))) + } + expect(result.current.isDirty).toBe(false) + expect(result.current.canUndo).toBe(false) + expect(result.current.nodes[0].measured.height).toBe(350) + expect(result.current.nodes[0].height).toBeUndefined() + const saved = buildHashPayload({ nodes: result.current.nodes, edges: [] }) + expect(saved).toEqual(before) + expect(saved.version).toBe(2) + await act(async () => result.current.onOpenModelFile(fileFor(saved))) + expect(result.current.isDirty).toBe(false) + expect(result.current.nodes.find(node => node.type === 'area').width).toBe(280) + }) +}) diff --git a/src/hooks/__tests__/useModelState.test.jsx b/src/hooks/__tests__/useModelState.test.jsx index 610278c..dc344bb 100644 --- a/src/hooks/__tests__/useModelState.test.jsx +++ b/src/hooks/__tests__/useModelState.test.jsx @@ -101,3 +101,44 @@ describe('useModelState class logical names', () => { expect(result.current.nodes[0].data.logicalName).toBe('') }) }) + + +describe('React Flow 12 measurements and resize history', () => { + const area = { id: 'area', type: 'area', position: { x: 0, y: 0 }, width: 280, height: 180, + style: { width: 280, height: 180 }, data: { viewSizes: { + conceptual: { width: 280, height: 180 }, logical: { width: 300, height: 200 }, physical: { width: 320, height: 220 }, + }, viewPositionsMeta: { conceptual: true, logical: true, physical: true } } } + + it('keeps automatic measurements out of area sizes and undo history', async () => { + const { result } = renderModelState() + act(() => result.current.setModel([area], [])) + const data = result.current.nodes[0].data + act(() => result.current.onNodesChange([{ id: 'area', type: 'dimensions', dimensions: { width: 281, height: 181 } }])) + await flushHistoryPush() + expect(result.current.nodes[0].measured).toEqual({ width: 281, height: 181 }) + expect(result.current.nodes[0].data).toBe(data) + expect(result.current.nodes[0].width).toBe(280) + expect(result.current.canUndo).toBe(false) + }) + + it.each(['conceptual', 'logical', 'physical'])('records one undo step for an area resize in %s', async (activeView) => { + const { result } = renderHook(() => useModelState({ activeView })) + act(() => result.current.setModel([area], [])) + const original = result.current.nodes[0] + for (const width of [400, 420, 450]) { + act(() => result.current.onNodesChange([{ id: 'area', type: 'dimensions', dimensions: { width, height: 300 }, resizing: true, setAttributes: true }])) + await flushHistoryPush() + } + act(() => result.current.onNodesChange([{ id: 'area', type: 'dimensions', resizing: false }])) + expect(result.current.nodes[0].data.viewSizes[activeView]).toEqual({ width: 450, height: 300 }) + for (const view of ['conceptual', 'logical', 'physical'].filter(view => view !== activeView)) { + expect(result.current.nodes[0].data.viewSizes[view]).toEqual(original.data.viewSizes[view]) + } + act(() => result.current.onUndo()) + expect(result.current.nodes[0].data.viewSizes).toEqual(original.data.viewSizes) + expect(result.current.canUndo).toBe(false) + act(() => result.current.onRedo()) + expect(result.current.nodes[0].width).toBe(450) + expect(result.current.nodes[0].style.width).toBe(450) + }) +}) diff --git a/src/hooks/useFileActions.js b/src/hooks/useFileActions.js index 3a42ca7..0615068 100644 --- a/src/hooks/useFileActions.js +++ b/src/hooks/useFileActions.js @@ -32,11 +32,12 @@ const normalizeNodeForPayload = (node) => { return node } - // Strip ReactFlow-internal computed properties that must not affect dirty-state - // comparisons: positionAbsolute is added after setNodes(), width/height are - // measured from the DOM for class/note nodes and vary between renders. + // Keep runtime measurements out of saved files and dirty-state comparisons. + // Persist explicit area dimensions below; class/note dimensions are automatic. const { positionAbsolute: _pa, + measured: _measured, + internals: _internals, dragging: _dragging, resizing: _resizing, width: _width, @@ -233,6 +234,13 @@ export const getHiddenContentState = ({ function prepareLoadedModel(payload) { const nextNodes = (payload?.nodes ?? []).map((node, index) => { + // Legacy class/note dimensions were DOM measurements, not fixed sizes. + const { measured: _measured, internals: _internals, positionAbsolute: _pa, ...loadedNode } = node + node = loadedNode + if ([CLASS_NODE_TYPE, NOTE_NODE_TYPE, 'umlClass'].includes(node.type) || !node.type) { + const { width: _width, height: _height, ...autoSizedNode } = node + node = autoSizedNode + } const nodeId = node?.id ?? `class-${Date.now()}-${index}` const data = node?.data ?? {} const nodeType = node?.type === 'umlClass' ? CLASS_NODE_TYPE : node?.type ?? CLASS_NODE_TYPE diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 42eed81..80530cf 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -6,7 +6,7 @@ import { applyNodeChanges, useEdgesState, useNodesState, -} from 'reactflow' +} from '@xyflow/react' import { CLASS_COLOR_PALETTE, getRandomPaletteColor } from '../classPalette.js' import { createAttribute, normalizeAttributes } from '../attributes.js' import { getAssociationLayout } from '../components/flow/utils/associationUtils.js' @@ -453,8 +453,8 @@ export function useModelState({ updatedAreaIds.add(change.id) updatedNoteIds.add(change.id) } - if (change.type === 'dimensions') { - updatedAreaSizeIds.add(change.id) + if (change.type === 'dimensions' && (change.resizing !== undefined || change.setAttributes)) { + if (change.dimensions && change.setAttributes) updatedAreaSizeIds.add(change.id) const node = nextNodes.find((n) => n.id === change.id) if (node?.resizing && !isResizingRef.current.has(change.id)) { isResizingRef.current.add(change.id) @@ -764,7 +764,7 @@ export function useModelState({ } const node = - reactFlowInstance?.getNode(nodeId) ?? + reactFlowInstance?.getInternalNode(nodeId) ?? nodes.find((entry) => entry.id === nodeId) return node?.type === ASSOCIATION_HELPER_NODE_TYPE }, @@ -775,7 +775,7 @@ export function useModelState({ (params) => { const getNodeLabel = (nodeId) => { const node = - reactFlowInstance?.getNode(nodeId) ?? + reactFlowInstance?.getInternalNode(nodeId) ?? nodes.find((entry) => entry.id === nodeId) return node?.data?.label } @@ -1011,13 +1011,7 @@ export function useModelState({ const toFlowPosition = reactFlowInstance?.screenToFlowPosition && wrapperBounds ? (point) => reactFlowInstance.screenToFlowPosition(point) - : reactFlowInstance?.project && wrapperBounds - ? (point) => - reactFlowInstance.project({ - x: point.x - wrapperBounds.left, - y: point.y - wrapperBounds.top, - }) - : null + : null const centerPosition = wrapperBounds && toFlowPosition ? toFlowPosition({ @@ -1222,13 +1216,7 @@ export function useModelState({ const toFlowPosition = reactFlowInstance?.screenToFlowPosition && wrapperBounds ? (point) => reactFlowInstance.screenToFlowPosition(point) - : reactFlowInstance?.project && wrapperBounds - ? (point) => - reactFlowInstance.project({ - x: point.x - wrapperBounds.left, - y: point.y - wrapperBounds.top, - }) - : null + : null const centerPosition = wrapperBounds && toFlowPosition ? toFlowPosition({ @@ -1283,13 +1271,7 @@ export function useModelState({ const toFlowPosition = reactFlowInstance?.screenToFlowPosition && wrapperBounds ? (point) => reactFlowInstance.screenToFlowPosition(point) - : reactFlowInstance?.project && wrapperBounds - ? (point) => - reactFlowInstance.project({ - x: point.x - wrapperBounds.left, - y: point.y - wrapperBounds.top, - }) - : null + : null const centerPosition = wrapperBounds && toFlowPosition ? toFlowPosition({ @@ -1844,10 +1826,10 @@ export function useModelState({ if (!sourceAnchor || !targetAnchor) { const sourceNode = - reactFlowInstance?.getNode(edge.source) ?? + reactFlowInstance?.getInternalNode(edge.source) ?? nodesRef.current.find((node) => node.id === edge.source) const targetNode = - reactFlowInstance?.getNode(edge.target) ?? + reactFlowInstance?.getInternalNode(edge.target) ?? nodesRef.current.find((node) => node.id === edge.target) const layout = getRelationshipLayoutFromNodes( edge, @@ -1911,7 +1893,7 @@ export function useModelState({ } const sourceNode = - reactFlowInstance?.getNode(edge.source) ?? + reactFlowInstance?.getInternalNode(edge.source) ?? nodesRef.current.find((node) => node.id === edge.source) const layout = getReflexiveAssociationLayout(sourceNode, edge.data) if (!layout) { @@ -2081,10 +2063,10 @@ export function useModelState({ } const sourceNode = - reactFlowInstance?.getNode(edge.source) ?? + reactFlowInstance?.getInternalNode(edge.source) ?? nodesRef.current.find((node) => node.id === edge.source) const targetNode = - reactFlowInstance?.getNode(edge.target) ?? + reactFlowInstance?.getInternalNode(edge.target) ?? nodesRef.current.find((node) => node.id === edge.target) const layout = getRelationshipLayoutFromNodes(edge, sourceNode, targetNode) if (!layout) { @@ -2115,10 +2097,10 @@ export function useModelState({ } const sourceNode = - reactFlowInstance?.getNode(edge.source) ?? + reactFlowInstance?.getInternalNode(edge.source) ?? nodesRef.current.find((node) => node.id === edge.source) const targetNode = - reactFlowInstance?.getNode(edge.target) ?? + reactFlowInstance?.getInternalNode(edge.target) ?? nodesRef.current.find((node) => node.id === edge.target) const layout = getAssociationLayout(sourceNode, targetNode, edge.data) if (!layout) { @@ -2508,7 +2490,7 @@ export function useModelState({ ) const node = - reactFlowInstance?.getNode(nodeId) ?? + reactFlowInstance?.getInternalNode(nodeId) ?? nodesRef.current.find((entry) => entry.id === nodeId) if (!node || !reactFlowInstance?.setCenter) { return @@ -2545,7 +2527,7 @@ export function useModelState({ ) const node = - reactFlowInstance?.getNode(noteId) ?? + reactFlowInstance?.getInternalNode(noteId) ?? nodesRef.current.find((entry) => entry.id === noteId) if (!node || !reactFlowInstance?.setCenter) { return @@ -2582,7 +2564,7 @@ export function useModelState({ ) const node = - reactFlowInstance?.getNode(areaId) ?? + reactFlowInstance?.getInternalNode(areaId) ?? nodesRef.current.find((entry) => entry.id === areaId) if (!node || !reactFlowInstance?.setCenter) { return @@ -2667,7 +2649,7 @@ export function useModelState({ const nodeMap = new Map(nodes.map((node) => [node.id, node])) const getNode = (nodeId) => - reactFlowInstance?.getNode(nodeId) ?? nodeMap.get(nodeId) + reactFlowInstance?.getInternalNode(nodeId) ?? nodeMap.get(nodeId) return edges .filter( diff --git a/src/index.css b/src/index.css index 84a82f7..1f9d566 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,6 @@ @config "../tailwind.config.js"; -@import "reactflow/dist/style.css"; @import "tailwindcss"; +@import "@xyflow/react/dist/style.css"; @import "./xy-theme.css"; :root, diff --git a/src/model/PngExportCanvas.jsx b/src/model/PngExportCanvas.jsx index 67e1f47..5f2ff9e 100644 --- a/src/model/PngExportCanvas.jsx +++ b/src/model/PngExportCanvas.jsx @@ -1,13 +1,26 @@ -import ReactFlow, { +import { useEffect } from 'react' +import { + ReactFlow, + useNodesInitialized, + useReactFlow, ConnectionLineType, ConnectionMode, -} from 'reactflow' +} from '@xyflow/react' import AnnotationLayer from '../components/annotations/AnnotationLayer.jsx' import DefaultValuesPanel from '../components/flow/overlays/DefaultValuesPanel.jsx' import { VIEW_PHYSICAL } from './constants.js' const noop = () => {} +function ExportReady({ hasNodes, onReady }) { + const nodesInitialized = useNodesInitialized() + const flow = useReactFlow() + useEffect(() => { + if (flow.viewportInitialized && (!hasNodes || nodesInitialized)) onReady?.(flow) + }, [flow, hasNodes, nodesInitialized, onReady]) + return null +} + export default function PngExportCanvas({ nodes, edges, @@ -23,6 +36,7 @@ export default function PngExportCanvas({ defaultValueEntries, onInit, }) { + // defaultNodes lets React Flow keep measurements local to the export canvas. const viewport = { x: -bounds.x, y: -bounds.y, @@ -36,9 +50,10 @@ export default function PngExportCanvas({ >
+ !node.hidden)} onReady={onInit} /> {showAnnotations ? ( ({ skipInit: false, + nodesInitialized: true, setViewport: vi.fn(), })) @@ -14,11 +15,11 @@ vi.mock('html-to-image', () => ({ toPng: vi.fn(() => Promise.resolve('data:image/png;base64,exported')), })) -vi.mock('reactflow', async () => { +vi.mock('@xyflow/react', async () => { const React = await import('react') return { - default: function MockReactFlow({ children, onInit, nodes, defaultViewport }) { + ReactFlow: function MockReactFlow({ children, onInit, defaultNodes: nodes, defaultViewport }) { React.useEffect(() => { if (reactFlowMockState.skipInit) { return @@ -30,6 +31,8 @@ vi.mock('reactflow', async () => { {children}
}, + useNodesInitialized: () => reactFlowMockState.nodesInitialized, + useReactFlow: () => ({ viewportInitialized: !reactFlowMockState.skipInit, setViewport: reactFlowMockState.setViewport }), ConnectionLineType: { Straight: 'straight' }, ConnectionMode: { Loose: 'loose' }, useViewport: () => ({ x: 0, y: 0, zoom: 1 }), @@ -65,6 +68,7 @@ describe('pngExport', () => { beforeEach(() => { vi.clearAllMocks() reactFlowMockState.skipInit = false + reactFlowMockState.nodesInitialized = true vi.stubGlobal('requestAnimationFrame', (callback) => { callback() return 1 @@ -173,6 +177,36 @@ describe('pngExport', () => { } }) + it('does not capture an initialized viewport before its nodes are measured', async () => { + vi.useFakeTimers() + reactFlowMockState.nodesInitialized = false + const pending = exportFlowPng(getExportArgs()).catch(error => error) + await vi.advanceTimersByTimeAsync(5000) + expect(await pending).toBeInstanceOf(Error) + expect(toPng).not.toHaveBeenCalled() + expect(document.querySelector('[aria-hidden="true"][data-theme="light"]')).toBeNull() + }) + + it('waits for asynchronous viewport adjustments before capturing expanded content', async () => { + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + return this.classList.contains('react-flow__node') + ? { left: 0, top: 0, right: 1000, bottom: 900, width: 1000, height: 900 } + : { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } + }) + let finishViewport + reactFlowMockState.setViewport.mockImplementationOnce(() => new Promise(resolve => { finishViewport = resolve })) + try { + const pending = exportFlowPng(getExportArgs()) + await vi.waitFor(() => expect(finishViewport).toBeTypeOf('function')) + expect(toPng).not.toHaveBeenCalled() + finishViewport(true) + await pending + expect(toPng).toHaveBeenCalledOnce() + } finally { + rect.mockRestore() + } + }) + it('keeps existing export filtering semantics', () => { const root = document.createElement('div') const controls = document.createElement('div') diff --git a/src/model/__tests__/pngExportUtils.test.js b/src/model/__tests__/pngExportUtils.test.js index a3a4bda..0927a0e 100644 --- a/src/model/__tests__/pngExportUtils.test.js +++ b/src/model/__tests__/pngExportUtils.test.js @@ -9,6 +9,12 @@ import { } from '../pngExportUtils.js' describe('pngExportUtils', () => { + it('uses v12 measured bounds before stale fixed dimensions', () => { + expect(getPngExportContentBounds({ nodes: [{ id: 'note', position: { x: -200, y: -100 }, + width: 10, height: 10, measured: { width: 400, height: 300 } }], padding: 32, + })).toEqual({ x: -232, y: -132, width: 464, height: 364 }) + }) + it('computes padded bounds from visible nodes and edge routing hints', () => { const bounds = getPngExportContentBounds({ nodes: [ diff --git a/src/model/__tests__/pngExportViewport.test.jsx b/src/model/__tests__/pngExportViewport.test.jsx index 512df7f..9334297 100644 --- a/src/model/__tests__/pngExportViewport.test.jsx +++ b/src/model/__tests__/pngExportViewport.test.jsx @@ -6,7 +6,7 @@ import { getPngExportContentBounds } from '../pngExportUtils.js' vi.mock('html-to-image', () => ({ toPng: vi.fn() })) -afterEach(() => { vi.clearAllMocks() }) +afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('PNG export with the installed React Flow viewport', () => { it.each([ @@ -41,3 +41,36 @@ describe('PNG export with the installed React Flow viewport', () => { })).resolves.toBe('data:image/png;base64,annotations') }) }) + + +it.each(['conceptual', 'logical', 'physical'])('measures newly visible nodes and exports mixed content in %s', async (activeView) => { + // jsdom has no layout engine: supply browser measurements, keeping React Flow real. + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(240) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(160) + vi.stubGlobal('DOMMatrixReadOnly', class { m22 = 1 }) + vi.stubGlobal('ResizeObserver', class { + constructor(callback) { this.callback = callback; this.elements = new Set() } + observe(element) { + this.elements.add(element) + queueMicrotask(() => { + if (this.elements.has(element)) this.callback([{ target: element, contentRect: { width: 240, height: 160 } }]) + }) + } + unobserve(element) { this.elements.delete(element) } + disconnect() { this.elements.clear() } + }) + const nodes = [{ id: 'new-note', type: 'default', position: { x: -500, y: -300 }, data: { label: 'Newly visible note' } }] + const annotations = { [activeView]: { items: [{ id: 'feedback', kind: 'text', x: 500, y: 500, text: 'Feedback', fontSize: 22, color: '#ef4444' }] } } + toPng.mockImplementationOnce(async (container) => { + const node = container.querySelector('.react-flow__node') + expect(node).toBeVisible() + expect(node.style.visibility).not.toBe('hidden') + expect(container.querySelector('[data-annotation-id="feedback"]')).not.toBeNull() + expect(container.querySelector('.react-flow')).toHaveClass('light') + return 'data:image/png;base64,mixed' + }) + await expect(exportFlowPng({ nodes, edges: [], annotations, activeView, showAnnotations: true, + nodeTypes: {}, edgeTypes: {}, defaultValueEntries: [], fallbackWidth: 800, fallbackHeight: 600, + padding: 32, resolution: 'standard', includeAccentColorsInExport: true, + })).resolves.toBe('data:image/png;base64,mixed') +}) diff --git a/src/model/pngExport.jsx b/src/model/pngExport.jsx index d69ddd5..7effa44 100644 --- a/src/model/pngExport.jsx +++ b/src/model/pngExport.jsx @@ -140,9 +140,9 @@ export async function exportFlowPng({ container.style.width = `${width}px` container.style.height = `${height}px` renderCanvas() - // React Flow 11 only reads defaultViewport on mount. Apply subsequent + // defaultViewport is read on mount. Apply subsequent // bounds adjustments through its instance so annotations move with nodes. - flow.setViewport({ x: -bounds.x, y: -bounds.y, zoom: 1 }) + await waitForExportInitialization(Promise.resolve(flow.setViewport({ x: -bounds.x, y: -bounds.y, zoom: 1 }))) await waitForFrame() await waitForFrame() } diff --git a/src/model/pngExportUtils.js b/src/model/pngExportUtils.js index 4b92350..23208a6 100644 --- a/src/model/pngExportUtils.js +++ b/src/model/pngExportUtils.js @@ -63,10 +63,10 @@ function getNodeSize(node) { const fallback = getFallbackSize(node) return { width: - positiveNumber(node?.width, node?.measured?.width, node?.style?.width) ?? + positiveNumber(node?.measured?.width, node?.width, node?.style?.width) ?? fallback.width, height: - positiveNumber(node?.height, node?.measured?.height, node?.style?.height) ?? + positiveNumber(node?.measured?.height, node?.height, node?.style?.height) ?? fallback.height, } } diff --git a/src/xy-theme.css b/src/xy-theme.css index c362a94..c57d8d3 100644 --- a/src/xy-theme.css +++ b/src/xy-theme.css @@ -1,3 +1,8 @@ +/* Keep the application's canvas color, including its existing dark palette. */ +.react-flow { + --xy-background-color: transparent; +} + /* React Flow's built-in controls use fixed light colors by default. */ .react-flow__controls-button { color: rgb(var(--color-base-content)); diff --git a/vite.config.js b/vite.config.js index 7018fdc..04d0f36 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,7 +33,7 @@ export default defineConfig({ ) { return 'mysql-parser' } - if (id.includes('reactflow')) { + if (id.includes('@xyflow/')) { return 'reactflow' } if (id.includes('@radix-ui')) { From 26a454a64075e762a1ea3f42f2bfe226b34e7d1a Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Thu, 22 Oct 2026 18:50:59 +0200 Subject: [PATCH 2/3] fix: complete React Flow 12 rendering and focus follow-ups Preserve derived association helper measurements so associative links survive controlled-node updates and view switches. Make sidebar focus exclusive across nodes and edges, add focus tooltips, and render composition diamonds without clipping or transparency overlap. Add regression coverage for ordinary/reflexive associative links and focus in all three views. Record migration validation, remaining manual checks, and the deferred class-panel layout ideas in docs/react-flow-12-review.md. Validation: lint passes; all 541 tests in 35 files pass; build:ci passes with the existing MySQL parser size warning. Browser checks confirm composition colors in both themes, associative rendering, and unclipped real PNGs across all three views plus annotations-only output. Native download completion and exhaustive manual interaction checks remain documented for review. --- docs/react-flow-12-review.md | 72 +++++++++++++++++++ src/components/flow/edges/Composition.jsx | 62 ++++++---------- .../panels/areas/AreasPanelItem.jsx | 65 +++++++++-------- .../panels/classes/ClassesPanelItem.jsx | 65 +++++++++-------- .../panels/notes/NotesPanelItem.jsx | 65 +++++++++-------- .../panels/refs/AssociationsPanelItem.jsx | 65 +++++++++-------- .../panels/refs/RelationshipsPanelItem.jsx | 65 +++++++++-------- src/components/ui/ControlTooltip.jsx | 21 ++++++ .../__tests__/associativeRendering.test.jsx | 62 ++++++++++++++++ src/hooks/__tests__/useModelState.test.jsx | 41 ++++++++++- src/hooks/useModelState.js | 40 +++++++---- 11 files changed, 411 insertions(+), 212 deletions(-) create mode 100644 docs/react-flow-12-review.md create mode 100644 src/components/ui/ControlTooltip.jsx create mode 100644 src/hooks/__tests__/associativeRendering.test.jsx diff --git a/docs/react-flow-12-review.md b/docs/react-flow-12-review.md new file mode 100644 index 0000000..ed44fc9 --- /dev/null +++ b/docs/react-flow-12-review.md @@ -0,0 +1,72 @@ +# React Flow 12 migration review + +The migration is implemented on `chore/react-flow-12`, based on `main` at +`9de921a`. React Flow 11.11.4 is replaced by `@xyflow/react` 12.12.0. React, +Node, build tooling, and model-file format version 2 remain unchanged. Version 1 +models remain supported. Merging and releasing are separate steps. + +After switching branches, run `npm install` and restart the development server +so the installed dependencies match the branch. + +## Implementation and follow-up fixes + +- Migrate imports, styles, chunking, internal-node geometry, and viewport APIs. +- Keep runtime measurements out of serialization, dirty tracking, and undo. + Preserve explicit area dimensions and gesture-level resize history. +- Wait for export node measurements and asynchronous viewport changes; support + annotation-only exports without waiting for nonexistent nodes. +- Preserve the fixed measurements of derived association helpers so associative + links remain visible after updates and view changes. +- Make sidebar focus exclusive across nodes and edges in all views, and add + focus-button tooltips with keyboard visibility. +- Render composition diamonds in the edge SVG without clipped tips. Composite + the line and diamond before applying opacity to prevent overlap artifacts. + +## Validation on 22 October 2026 + +- `npm run lint`: passed. +- `npm run test:unit`: 541 tests passed in 35 files. The original 514 tests remain. +- `npm run build:ci`: passed. The existing MySQL parser chunk-size warning remains. +- The initial migration comparison against `main`, recorded in commit `f88f7bc`, + covered a 20-class diagram, light/dark rendering, all three views, matching + conceptual PNG preview dimensions, pen undo/redo, and an annotation-only preview. +- Follow-up browser checks confirmed solid composition diamonds with matching + line colors in light and dark themes. +- A temporary browser fixture using the actual model-state hook, node/edge + components, and PNG exporter confirmed the reported associative link renders. + Real generated PNG bytes were saved locally and visually inspected: + conceptual and logical output at 1919 × 1185, physical output at 1919 × 1275, + and annotation-only output at 1734 × 1185. These fixtures included negative + coordinates, text, shapes, ink, and notes/areas omitted from the live canvas. + Content remained unclipped, and the physical default-values footer was visible. + Dark-mode export retained light export styling and annotation colors. +- Personal model files and local review images are not part of the commits. + +## Outstanding manual review + +Browser download-event automation timed out, so completion of a native PNG +download through the production dialog remains unverified. The PNG rendering +itself was verified independently from its generated image bytes. + +Exhaustive keyboard, pointer, touch, area-resize, relationship-control-point, +and annotation-tool checks across all views and themes remain a release review +task. Automated regressions cover these areas in part; they do not establish +complete visual or interaction parity. Browser file-picker automation also +failed for the supplied model; file parsing/opening has automated coverage, +and the model rendered through the local review fixture. + +## Deferred class panel layout + +Discussed with the user and explicitly deferred until after the migration: + +- Align attribute fields using consistent label and input columns. +- Clarify N/UQ/AI controls, their selected states, and explanatory tooltips. +- Reduce uppercase label noise and excessive vertical spacing. +- Move the class color control into the header instead of a separate Options section. +- Consider compact, accessible delete actions in class and attribute headers, + retaining class deletion confirmation. +- Add muted type/key summaries to collapsed attributes. +- Keep the colored class stripe, expandable attributes, and Add attribute placement. + +Start with alignment, spacing, and the color control. These are recorded design +ideas, not changes included in this migration. diff --git a/src/components/flow/edges/Composition.jsx b/src/components/flow/edges/Composition.jsx index e288138..fbe8d24 100644 --- a/src/components/flow/edges/Composition.jsx +++ b/src/components/flow/edges/Composition.jsx @@ -22,10 +22,6 @@ function getEndpointLabelTransform(position, x, y) { } } -function getDiamondTransform(x, y, rotationDeg = 0) { - return `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${rotationDeg}deg)` -} - export function Composition({ id, source, @@ -51,8 +47,7 @@ export function Composition({ const onDeleteControlPoint = data?.onDeleteControlPoint const onControlPointDragStart = data?.onControlPointDragStart const onControlPointDragEnd = data?.onControlPointDragEnd - const strokeClass = selected ? 'text-primary' : 'text-base-content/70' - const diamondClass = selected ? 'text-primary' : 'text-base-content' + const strokeClass = selected ? 'text-primary' : 'text-base-content' const { edgePath, labelX, @@ -137,14 +132,26 @@ export function Composition({ return ( <> - + {/* Composite opaque geometry first, so the line cannot show through the diamond. */} + + + ) : null} -
- -
) diff --git a/src/components/panels/areas/AreasPanelItem.jsx b/src/components/panels/areas/AreasPanelItem.jsx index 4c431ff..17d5808 100644 --- a/src/components/panels/areas/AreasPanelItem.jsx +++ b/src/components/panels/areas/AreasPanelItem.jsx @@ -1,3 +1,4 @@ +import ControlTooltip from '../../ui/ControlTooltip.jsx' import { useEffect, useRef, useState } from 'react' import * as Accordion from '@radix-ui/react-accordion' import { CirclePicker } from 'react-color' @@ -126,38 +127,40 @@ export default function AreasPanelItem({ )}
- + + + + + + + + + + + + {canRename ? (
- + + +
diff --git a/src/components/ui/ControlTooltip.jsx b/src/components/ui/ControlTooltip.jsx new file mode 100644 index 0000000..61667b5 --- /dev/null +++ b/src/components/ui/ControlTooltip.jsx @@ -0,0 +1,21 @@ +import * as Tooltip from '@radix-ui/react-tooltip' + +export default function ControlTooltip({ label, children }) { + return ( + + + {children} + + + {label} + + + + + + ) +} diff --git a/src/hooks/__tests__/associativeRendering.test.jsx b/src/hooks/__tests__/associativeRendering.test.jsx new file mode 100644 index 0000000..3cab425 --- /dev/null +++ b/src/hooks/__tests__/associativeRendering.test.jsx @@ -0,0 +1,62 @@ +import { useLayoutEffect, useState } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { ReactFlow } from '@xyflow/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useModelState } from '../useModelState.js' +import { nodeTypes, edgeTypes } from '../../flowTypes.js' + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +it.each(['association', 'reflexiveAssociation'])('keeps the associative link to a %s rendered after measurements and updates', async (type) => { + // Supply layout measurements only; use the real controlled React Flow renderer. + const size = (element, dimension) => element.classList.contains('association-helper-node') ? 1 + : element.classList.contains('react-flow__handle') ? 16 : dimension === 'width' ? 240 : 160 + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function () { return size(this, 'width') }) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { return size(this, 'height') }) + vi.stubGlobal('DOMMatrixReadOnly', class { m22 = 1 }) + const measuredElements = new WeakSet() + vi.stubGlobal('ResizeObserver', class { + constructor(callback) { this.callback = callback; this.elements = new Set() } + observe(element) { + if (measuredElements.has(element)) return + measuredElements.add(element) + this.elements.add(element) + queueMicrotask(() => { + if (this.elements.has(element)) this.callback([{ target: element }]) + }) + } + unobserve(element) { this.elements.delete(element) } + disconnect() { this.elements.clear() } + }) + let model + function Diagram({ activeView = 'conceptual' }) { + const [instance, setInstance] = useState(null) + const state = useModelState({ activeView, reactFlowInstance: instance }) + useLayoutEffect(() => { model = state }) + return + } + const { container, rerender } = render() + act(() => model.setModel([ + { id: 'a', type: 'class', position: { x: 100, y: 100 }, data: { name: 'A' } }, + { id: 'b', type: 'class', position: { x: 600, y: 100 }, data: { name: 'B' } }, + { id: 'link-class', type: 'class', position: { x: 350, y: 350 }, data: { name: 'Link' } }, + ], [ + { id: 'base', type, source: 'a', sourceHandle: 'right-source', target: type === 'association' ? 'b' : 'a', targetHandle: 'left-target', data: { lineStyle: 'orthogonal' } }, + { id: 'link', type: 'associativeAssociation', source: 'link-class', sourceHandle: 'top-source', target: 'assoc-edge-base', targetHandle: 'association-target', data: { type: 'associative' } }, + ])) + const expectLink = async () => waitFor(() => { + const path = container.querySelector('path#link') + expect(path).not.toBeNull() + expect(path.getAttribute('d')).not.toMatch(/NaN|undefined/) + }) + await expectLink() + expect(model.canUndo).toBe(false) + act(() => model.onNodesChange([{ id: 'a', type: 'position', position: { x: 150, y: 150 } }])) + await expectLink() + rerender() + await waitFor(() => expect(container.querySelector('path#link')).toBeNull()) + rerender() + await expectLink() + expect(model.nodes.some(node => node.type === 'associationHelper')).toBe(false) +}) diff --git a/src/hooks/__tests__/useModelState.test.jsx b/src/hooks/__tests__/useModelState.test.jsx index dc344bb..9c279f6 100644 --- a/src/hooks/__tests__/useModelState.test.jsx +++ b/src/hooks/__tests__/useModelState.test.jsx @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { useModelState } from '../useModelState.js' @@ -21,6 +21,45 @@ const flushHistoryPush = async () => { }) } +describe.each(['conceptual', 'logical', 'physical'])('exclusive focus in %s view', (activeView) => { + const setup = () => { + const reactFlowInstance = { getInternalNode: vi.fn(), setCenter: vi.fn() } + const hook = renderHook(() => useModelState({ activeView, reactFlowInstance })) + const nodes = ['class', 'note', 'area'].map((type) => ({ + id: type, type, position: { x: 100, y: 200 }, selected: true, data: {}, + })) + const type = activeView === 'conceptual' ? 'association' : 'relationship' + const edges = ['first', 'second'].map((id) => ({ + id, type, source: 'class', target: 'class', selected: true, data: {}, + })) + act(() => hook.result.current.setModel(nodes, edges)) + return { ...hook, reactFlowInstance } + } + + it.each([ + ['class', 'onHighlightClass'], + ['note', 'onHighlightNote'], + ['area', 'onHighlightArea'], + ])('focusing a %s clears every other diagram selection', async (id, handler) => { + const { result, reactFlowInstance } = setup() + act(() => result.current[handler](id)) + await flushHistoryPush() + expect(result.current.nodes.filter((node) => node.selected).map((node) => node.id)).toEqual([id]) + expect(result.current.edges.every((edge) => !edge.selected)).toBe(true) + expect(reactFlowInstance.setCenter).toHaveBeenCalledOnce() + expect(result.current.canUndo).toBe(false) + }) + + it('focusing an edge clears all nodes and other edges', async () => { + const { result } = setup() + act(() => result.current.onHighlightAssociation('first')) + await flushHistoryPush() + expect(result.current.nodes.every((node) => !node.selected)).toBe(true) + expect(result.current.edges.filter((edge) => edge.selected).map((edge) => edge.id)).toEqual(['first']) + expect(result.current.canUndo).toBe(false) + }) +}) + describe('useModelState model name history', () => { it('undoes and redoes model renames', async () => { const { result } = renderModelState() diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 80530cf..5c0456e 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -2240,6 +2240,9 @@ export function useModelState({ (edgeId) => { const edgeToFocus = edgesRef.current.find((edge) => edge.id === edgeId) focusEdge(edgeToFocus) + setNodes((current) => + current.map((node) => node.selected ? { ...node, selected: false } : node), + ) setEdges((current) => current.map((edge) => { return { @@ -2250,7 +2253,7 @@ export function useModelState({ }), ) }, - [focusEdge, setEdges], + [focusEdge, setEdges, setNodes], ) const onReorderClasses = useCallback( @@ -2480,8 +2483,20 @@ export function useModelState({ [updateEdgesAndPanel, updateNodesAndPanel], ) + const clearAssociationHighlight = useCallback(() => { + setEdges((current) => + current.map((edge) => { + if (!edge.selected) { + return edge + } + return { ...edge, selected: false, data: edge.data } + }), + ) + }, [setEdges]) + const onHighlightClass = useCallback( (nodeId) => { + clearAssociationHighlight() setNodes((current) => current.map((node) => ({ ...node, @@ -2514,11 +2529,12 @@ export function useModelState({ duration: 300, }) }, - [reactFlowInstance, setNodes], + [clearAssociationHighlight, reactFlowInstance, setNodes], ) const onHighlightNote = useCallback( (noteId) => { + clearAssociationHighlight() setNodes((current) => current.map((node) => ({ ...node, @@ -2551,11 +2567,12 @@ export function useModelState({ duration: 300, }) }, - [reactFlowInstance, setNodes], + [clearAssociationHighlight, reactFlowInstance, setNodes], ) const onHighlightArea = useCallback( (areaId) => { + clearAssociationHighlight() setNodes((current) => current.map((node) => ({ ...node, @@ -2588,20 +2605,9 @@ export function useModelState({ duration: 300, }) }, - [reactFlowInstance, setNodes], + [clearAssociationHighlight, reactFlowInstance, setNodes], ) - const clearAssociationHighlight = useCallback(() => { - setEdges((current) => - current.map((edge) => { - if (!edge.selected) { - return edge - } - return { ...edge, selected: false, data: edge.data } - }), - ) - }, [setEdges]) - const onPaneClick = useCallback(() => { clearAssociationHighlight() }, [clearAssociationHighlight]) @@ -2687,6 +2693,9 @@ export function useModelState({ }, width: ASSOCIATION_NODE_SIZE, height: ASSOCIATION_NODE_SIZE, + // Fixed-size derived nodes must retain measurements so React Flow + // preserves their handle bounds when rebuilding controlled nodes. + measured: { width: ASSOCIATION_NODE_SIZE, height: ASSOCIATION_NODE_SIZE }, data: { edgeId: edge.id }, className: 'association-helper-node', style: { @@ -2729,6 +2738,7 @@ export function useModelState({ }, width: ASSOCIATION_NODE_SIZE, height: ASSOCIATION_NODE_SIZE, + measured: { width: ASSOCIATION_NODE_SIZE, height: ASSOCIATION_NODE_SIZE }, data: { edgeId: edge.id }, className: 'association-helper-node', style: { From 477b70658f18ea32518c1355535176e677389e85 Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Thu, 22 Oct 2026 18:52:17 +0200 Subject: [PATCH 3/3] chore: ignore local temporary files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 732b52c..293617d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist dist-ssr *.local /import +/tmp # Claude Code .claude