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 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/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..fbe8d24 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' @@ -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, @@ -35,8 +31,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) { @@ -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/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/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__/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..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() @@ -101,3 +140,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..5c0456e 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) { @@ -2258,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 { @@ -2268,7 +2253,7 @@ export function useModelState({ }), ) }, - [focusEdge, setEdges], + [focusEdge, setEdges, setNodes], ) const onReorderClasses = useCallback( @@ -2498,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, @@ -2508,7 +2505,7 @@ export function useModelState({ ) const node = - reactFlowInstance?.getNode(nodeId) ?? + reactFlowInstance?.getInternalNode(nodeId) ?? nodesRef.current.find((entry) => entry.id === nodeId) if (!node || !reactFlowInstance?.setCenter) { return @@ -2532,11 +2529,12 @@ export function useModelState({ duration: 300, }) }, - [reactFlowInstance, setNodes], + [clearAssociationHighlight, reactFlowInstance, setNodes], ) const onHighlightNote = useCallback( (noteId) => { + clearAssociationHighlight() setNodes((current) => current.map((node) => ({ ...node, @@ -2545,7 +2543,7 @@ export function useModelState({ ) const node = - reactFlowInstance?.getNode(noteId) ?? + reactFlowInstance?.getInternalNode(noteId) ?? nodesRef.current.find((entry) => entry.id === noteId) if (!node || !reactFlowInstance?.setCenter) { return @@ -2569,11 +2567,12 @@ export function useModelState({ duration: 300, }) }, - [reactFlowInstance, setNodes], + [clearAssociationHighlight, reactFlowInstance, setNodes], ) const onHighlightArea = useCallback( (areaId) => { + clearAssociationHighlight() setNodes((current) => current.map((node) => ({ ...node, @@ -2582,7 +2581,7 @@ export function useModelState({ ) const node = - reactFlowInstance?.getNode(areaId) ?? + reactFlowInstance?.getInternalNode(areaId) ?? nodesRef.current.find((entry) => entry.id === areaId) if (!node || !reactFlowInstance?.setCenter) { return @@ -2606,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]) @@ -2667,7 +2655,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( @@ -2705,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: { @@ -2747,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: { 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')) {