From b1e634d3c48b864615573796eecace3818b4e465 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Mon, 20 Oct 2025 11:52:44 +0200 Subject: [PATCH] feat: add group minimize/maximize with floating edges and React Flow v12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive group minimize/maximize functionality and migrates to React Flow v12 (@xyflow/react) with improved edge routing. ## Group Minimize/Maximize Features: - Minimized groups render as compact 220×80px solid rectangles - Original dimensions preserved in metadata and restored on maximize - Child actors hidden (not filtered) to prevent React Flow state issues - Solid color backgrounds (transparency removed for minimized state) - Internal edges filtered out when group is minimized - Dimension sync before minimize ensures correct size on maximize ## Floating Edges: - Dynamic edge routing for connections to/from minimized groups - Edges connect to closest point on minimized group border - Regular actors maintain fixed handle connections - Smooth transitions when toggling group state ## React Flow v12 Migration: - Updated package from 'reactflow' to '@xyflow/react' - Changed imports to named imports (ReactFlow is now named) - Updated CSS imports to '@xyflow/react/dist/style.css' - Fixed NodeProps/EdgeProps to use full Node/Edge types - Added Record to data interfaces for v12 compatibility - Replaced useStore(state => state.connectionNodeId) with useConnection() - Updated nodeInternals to nodeLookup (renamed in v12) - Fixed event handler types for v12 API changes ## Edge Label Improvements: - Added explicit z-index (1000) to edge labels via EdgeLabelRenderer - Labels now properly render above edge paths ## Type Safety & Code Quality: - Removed all 'any' type assertions in useDocumentHistory - Fixed missing React Hook dependencies - Fixed unused variable warnings - All ESLint checks passing (0 errors, 0 warnings) - TypeScript compilation clean ## Bug Fixes: - Group drag positions now properly persisted to store - Minimized group styling (removed dotted border, padding) - Node visibility using 'hidden' property instead of array filtering - Dimension sync prevents actors from disappearing on maximize 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 342 ++------------------- package.json | 3 +- src/App.tsx | 2 +- src/components/Edges/CustomEdge.tsx | 60 +++- src/components/Editor/GraphEditor.tsx | 159 ++++++++-- src/components/Nodes/CustomNode.tsx | 10 +- src/components/Nodes/GroupNode.tsx | 115 ++++++- src/components/Panels/GroupEditorPanel.tsx | 39 ++- src/components/Timeline/StateNode.tsx | 10 +- src/components/Timeline/TimelineView.tsx | 7 +- src/hooks/useDocumentHistory.ts | 18 +- src/hooks/useGraphExport.ts | 2 +- src/hooks/useGraphWithHistory.ts | 15 + src/stores/bibliographyStore.ts | 1 - src/stores/graphStore.ts | 65 +++- src/stores/persistence/saver.ts | 33 +- src/stores/workspace/useActiveDocument.ts | 23 +- src/styles/index.css | 8 + src/types/index.ts | 10 +- src/utils/edgeUtils.ts | 83 +++++ src/utils/graphExport.ts | 4 +- 21 files changed, 614 insertions(+), 395 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58c9550..eb0c653 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,18 +12,17 @@ "@citation-js/plugin-bibtex": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", "@citation-js/plugin-doi": "^0.7.18", - "@citation-js/plugin-pubmed": "^0.3.0", "@citation-js/plugin-ris": "^0.7.18", "@citation-js/plugin-software-formats": "^0.6.1", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", + "@xyflow/react": "^12.3.5", "html-to-image": "^1.11.11", "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "reactflow": "^11.11.0", "zustand": "^4.5.0" }, "devDependencies": { @@ -444,17 +443,6 @@ "node": ">=14.0.0" } }, - "node_modules/@citation-js/plugin-pubmed": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@citation-js/plugin-pubmed/-/plugin-pubmed-0.3.0.tgz", - "integrity": "sha512-E3l83VP5UnTh6lLJaTaNBIkNgIx3U6IjDNf7j5l4geBOaOwDIhkl9X+Ss33/MMNEIMNDsBoodoMJTZ8Cq+C/ug==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@citation-js/core": ">=0.5.1 <=0.6" - } - }, "node_modules/@citation-js/plugin-ris": { "version": "0.7.18", "resolved": "https://registry.npmjs.org/@citation-js/plugin-ris/-/plugin-ris-0.7.18.tgz", @@ -1508,102 +1496,6 @@ "url": "https://opencollective.com/popperjs" } }, - "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==", - "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==", - "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==", - "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==", - "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==", - "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==", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1937,93 +1829,11 @@ "@babel/types": "^7.28.2" } }, - "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==", - "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==" - }, - "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==", - "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==", - "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==" - }, "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==" }, - "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==", - "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==" - }, - "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==" - }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -2032,47 +1842,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==" - }, - "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==" - }, - "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==", - "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==" - }, - "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==" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "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==" - }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2081,67 +1850,11 @@ "@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==" - }, - "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==" - }, - "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==" - }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" - }, - "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==", - "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==" - }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "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==" - }, - "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==" - }, - "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==" - }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -2165,11 +1878,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "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==" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2190,7 +1898,6 @@ "version": "18.3.26", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", - "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2435,6 +2142,36 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.8.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz", + "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==", + "dependencies": { + "@xyflow/system": "0.0.70", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", + "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==", + "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.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4632,23 +4369,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/reactflow": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", - "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", - "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/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 5c4434e..f6f2ce7 100644 --- a/package.json +++ b/package.json @@ -14,18 +14,17 @@ "@citation-js/plugin-bibtex": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", "@citation-js/plugin-doi": "^0.7.18", - "@citation-js/plugin-pubmed": "^0.3.0", "@citation-js/plugin-ris": "^0.7.18", "@citation-js/plugin-software-formats": "^0.6.1", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", + "@xyflow/react": "^12.3.5", "html-to-image": "^1.11.11", "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "reactflow": "^11.11.0", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 074609e..f5029ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from "react"; -import { ReactFlowProvider, useReactFlow } from "reactflow"; +import { ReactFlowProvider, useReactFlow } from "@xyflow/react"; import GraphEditor from "./components/Editor/GraphEditor"; import LeftPanel, { type LeftPanelRef } from "./components/Panels/LeftPanel"; import RightPanel from "./components/Panels/RightPanel"; diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 3cd5627..e573010 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -4,11 +4,14 @@ import { getBezierPath, EdgeLabelRenderer, BaseEdge, -} from 'reactflow'; + useNodes, +} from '@xyflow/react'; import { useGraphStore } from '../../stores/graphStore'; import { useSearchStore } from '../../stores/searchStore'; -import type { RelationData } from '../../types'; +import type { Relation } from '../../types'; +import type { Group } from '../../types'; import LabelBadge from '../Common/LabelBadge'; +import { getFloatingEdgeParams } from '../../utils/edgeUtils'; /** * CustomEdge - Represents a relation between actors in the constellation graph @@ -19,11 +22,14 @@ import LabelBadge from '../Common/LabelBadge'; * - Optional label display * - Edge type badge * - Directional arrow markers (directed, bidirectional, undirected) + * - Floating edges for minimized groups * * Usage: Automatically rendered by React Flow for edges with type='custom' */ const CustomEdge = ({ id, + source, + target, sourceX, sourceY, targetX, @@ -32,19 +38,54 @@ const CustomEdge = ({ targetPosition, data, selected, -}: EdgeProps) => { +}: EdgeProps) => { const edgeTypes = useGraphStore((state) => state.edgeTypes); const labels = useGraphStore((state) => state.labels); const { searchText, selectedRelationTypes, selectedLabels } = useSearchStore(); + // Get all nodes to check if source/target are minimized groups + const nodes = useNodes(); + const sourceNode = nodes.find((n) => n.id === source); + const targetNode = nodes.find((n) => n.id === target); + + // Check if either endpoint is a minimized group + const sourceIsMinimizedGroup = sourceNode?.type === 'group' && (sourceNode.data as Group['data']).minimized; + const targetIsMinimizedGroup = targetNode?.type === 'group' && (targetNode.data as Group['data']).minimized; + + // Calculate floating edge parameters if needed + // Only float the side(s) that connect to minimized groups + let finalSourceX = sourceX; + let finalSourceY = sourceY; + let finalTargetX = targetX; + let finalTargetY = targetY; + let finalSourcePosition = sourcePosition; + let finalTargetPosition = targetPosition; + + if ((sourceIsMinimizedGroup || targetIsMinimizedGroup) && sourceNode && targetNode) { + const floatingParams = getFloatingEdgeParams(sourceNode, targetNode); + + // Only use floating position for the minimized group side(s) + if (sourceIsMinimizedGroup) { + finalSourceX = floatingParams.sx; + finalSourceY = floatingParams.sy; + finalSourcePosition = floatingParams.sourcePos; + } + + if (targetIsMinimizedGroup) { + finalTargetX = floatingParams.tx; + finalTargetY = floatingParams.ty; + finalTargetPosition = floatingParams.targetPos; + } + } + // Calculate the bezier path const [edgePath, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, + sourceX: finalSourceX, + sourceY: finalSourceY, + sourcePosition: finalSourcePosition, + targetX: finalTargetX, + targetY: finalTargetY, + targetPosition: finalTargetPosition, }); // Find the edge type configuration @@ -175,6 +216,7 @@ const CustomEdge = ({ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, pointerEvents: 'all', opacity: edgeOpacity, + zIndex: 1000, }} className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm" > diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index d79f18e..8bc474d 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useEffect, useState, useRef } from "react"; -import ReactFlow, { +import { + ReactFlow, Background, Controls, MiniMap, @@ -18,8 +19,8 @@ import ReactFlow, { useReactFlow, Viewport, useOnSelectionChange, -} from "reactflow"; -import "reactflow/dist/style.css"; +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; import { useGraphWithHistory } from "../../hooks/useGraphWithHistory"; import { useDocumentHistory } from "../../hooks/useDocumentHistory"; @@ -38,6 +39,8 @@ import { createNode } from "../../utils/nodeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; import GroupWorkIcon from "@mui/icons-material/GroupWork"; import UngroupIcon from "@mui/icons-material/CallSplit"; +import MinimizeIcon from "@mui/icons-material/UnfoldLess"; +import MaximizeIcon from "@mui/icons-material/UnfoldMore"; import { useConfirm } from "../../hooks/useConfirm"; import { useGraphExport } from "../../hooks/useGraphExport"; import type { ExportOptions } from "../../utils/graphExport"; @@ -92,6 +95,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque deleteNode, deleteEdge, deleteGroup, + toggleGroupMinimized, } = useGraphWithHistory(); const { pushToHistory } = useDocumentHistory(); @@ -134,12 +138,92 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque // Combine regular nodes and group nodes for ReactFlow // IMPORTANT: Parent nodes (groups) MUST appear BEFORE child nodes for React Flow to process correctly const allNodes = useMemo(() => { - return [...(storeGroups as Node[]), ...(storeNodes as Node[])]; + // Get IDs of minimized groups + const minimizedGroupIds = new Set( + storeGroups.filter((group) => group.data.minimized).map((group) => group.id) + ); + + // Mark actors in minimized groups as hidden instead of filtering them out + // This prevents React Flow from losing track of them + const visibleNodes = storeNodes.map((node) => { + const nodeWithParent = node as Actor & { parentId?: string }; + const shouldHide = !!(nodeWithParent.parentId && minimizedGroupIds.has(nodeWithParent.parentId)); + + // Always explicitly set hidden (true or false) to ensure state is cleared when maximizing + return { + ...node, + hidden: shouldHide, + }; + }); + + return [...(storeGroups as Node[]), ...(visibleNodes as Node[])]; }, [storeNodes, storeGroups]); const [nodes, setNodesState, onNodesChange] = useNodesState(allNodes); + + // Track the latest selection state to avoid stale closures + const latestNodesRef = useRef(nodes); + useEffect(() => { + latestNodesRef.current = nodes; + }, [nodes]); + + // Reroute edges to minimized groups and filter internal edges + const visibleEdges = useMemo(() => { + // Build a map of actor -> group for actors in minimized groups + const actorToMinimizedGroup = new Map(); + storeGroups.forEach((group) => { + if (group.data.minimized) { + group.data.actorIds.forEach((actorId) => { + actorToMinimizedGroup.set(actorId, group.id); + }); + } + }); + + // Reroute edges: if source or target is in a minimized group, redirect to the group + // Filter out edges that are internal to a minimized group (both source and target in same group) + return (storeEdges as Edge[]) + .map((edge) => { + const newSource = actorToMinimizedGroup.get(edge.source) || edge.source; + const newTarget = actorToMinimizedGroup.get(edge.target) || edge.target; + + const sourceChanged = newSource !== edge.source; + const targetChanged = newTarget !== edge.target; + + // Filter: if both source and target are rerouted to the SAME group, hide this edge + // (it's an internal edge within a minimized group) + if (sourceChanged && targetChanged && newSource === newTarget) { + return null; // Mark for filtering + } + + // Only update if source or target changed + if (sourceChanged || targetChanged) { + // Destructure to separate handle properties from the rest + const { sourceHandle, targetHandle, ...edgeWithoutHandles } = edge; + + // Create new edge object, omitting handle properties when rerouting to groups + const newEdge: Edge = { + ...edgeWithoutHandles, + source: newSource, + target: newTarget, + }; + + // Only include handle IDs if not rerouted to a group + if (!sourceChanged && sourceHandle) { + newEdge.sourceHandle = sourceHandle; + } + if (!targetChanged && targetHandle) { + newEdge.targetHandle = targetHandle; + } + + return newEdge; + } + return edge; + }) + .filter((edge): edge is Edge => edge !== null); // Remove null entries + }, [storeEdges, storeGroups]); + const [edges, setEdgesState, onEdgesChange] = useEdgesState( - storeEdges as Edge[], + visibleEdges, ); // Track if a drag is in progress to capture state before drag @@ -168,6 +252,12 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque // IMPORTANT: Directly set the nodes array to avoid React Flow processing intermediate states // Using setNodesState with a callback can cause React Flow to process stale state + + // Build selection map from the latest React Flow state using ref + const selectionMap = new Map( + latestNodesRef.current.map((node) => [node.id, node.selected]) + ); + if (hasPendingSelection) { const pendingNodeId = pendingType === 'node' || pendingType === 'group' ? pendingId : null; @@ -177,16 +267,15 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque }))); } else { // Preserve existing selection state - setNodesState((currentNodes) => { - const selectionMap = new Map( - currentNodes.map((node) => [node.id, node.selected]) - ); - - return allNodes.map((node) => ({ + // IMPORTANT: Don't spread the entire node - only copy specific properties + // This ensures hidden state from allNodes is properly applied + setNodesState(allNodes.map((node) => { + const currentSelected = selectionMap.get(node.id) || false; + return { ...node, - selected: selectionMap.get(node.id) || false, - })); - }); + selected: currentSelected, + }; + })); } setEdgesState((currentEdges) => { @@ -194,7 +283,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque if (hasPendingSelection) { const pendingEdgeId = pendingType === 'edge' ? pendingId : null; - const newEdges = (storeEdges as Edge[]).map((edge) => ({ + const newEdges = visibleEdges.map((edge) => ({ ...edge, selected: edge.id === pendingEdgeId, })); @@ -210,12 +299,12 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque currentEdges.map((edge) => [edge.id, edge.selected]) ); - return (storeEdges as Edge[]).map((edge) => ({ + return visibleEdges.map((edge) => ({ ...edge, selected: selectionMap.get(edge.id) || false, })); }); - }, [allNodes, storeEdges, setNodesState, setEdgesState]); + }, [allNodes, visibleEdges, setNodesState, setEdgesState]); // Save viewport when switching documents and restore viewport for new document useEffect(() => { @@ -435,7 +524,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque if (dragStartChanges.length > 0 && !dragInProgressRef.current) { dragInProgressRef.current = true; // Capture the state before any changes are applied - pushToHistory("Move Actor"); + pushToHistory("Move Node"); } // Check if a resize operation just started (resizing: true) @@ -478,8 +567,11 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque setTimeout(() => { // Sync to store - use callback to get fresh state setNodesState((currentNodes) => { - // Filter out groups - they're stored separately + // Sync both groups and actors (groups can be dragged too!) + const groupNodes = currentNodes.filter((node) => node.type === 'group'); const actorNodes = currentNodes.filter((node) => node.type !== 'group'); + + setGroups(groupNodes as Group[]); setNodes(actorNodes as Actor[]); return currentNodes; }); @@ -641,7 +733,7 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque }, []); // Handle right-click on pane (empty space) - const handlePaneContextMenu = useCallback((event: React.MouseEvent) => { + const handlePaneContextMenu = useCallback((event: React.MouseEvent | MouseEvent) => { event.preventDefault(); setContextMenu({ x: event.clientX, @@ -939,11 +1031,36 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onAddNodeReque const sections = []; - // If it's a group node, show "Ungroup" option + // If it's a group node, show "Minimize/Maximize" and "Ungroup" options if (isGroup) { const groupNode = targetNode as Group; + const isMinimized = groupNode.data.minimized; + sections.push({ actions: [ + { + label: isMinimized ? "Maximize Group" : "Minimize Group", + icon: isMinimized ? : , + onClick: () => { + // Sync current React Flow dimensions before toggling + if (!isMinimized) { + // When minimizing, update the store with current dimensions first + const currentNode = nodes.find((n) => n.id === groupNode.id); + if (currentNode && currentNode.width && currentNode.height) { + setGroups(storeGroups.map((g) => + g.id === groupNode.id + ? { ...g, width: currentNode.width, height: currentNode.height } + : g + )); + } + } + // Use setTimeout to ensure store update completes before toggle + setTimeout(() => { + toggleGroupMinimized(groupNode.id); + }, 0); + setContextMenu(null); + }, + }, { label: "Ungroup", icon: , diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 1feea34..e1a447d 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -1,5 +1,5 @@ import { memo, useMemo } from "react"; -import { Handle, Position, NodeProps, useStore } from "reactflow"; +import { Handle, Position, NodeProps, useConnection } from "@xyflow/react"; import { useGraphStore } from "../../stores/graphStore"; import { useSearchStore } from "../../stores/searchStore"; import { @@ -7,7 +7,7 @@ import { adjustColorBrightness, } from "../../utils/colorUtils"; import { getIconComponent } from "../../utils/iconUtils"; -import type { ActorData } from "../../types"; +import type { Actor } from "../../types"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; import LabelBadge from "../Common/LabelBadge"; @@ -22,14 +22,14 @@ import LabelBadge from "../Common/LabelBadge"; * * Usage: Automatically rendered by React Flow for nodes with type='custom' */ -const CustomNode = ({ data, selected }: NodeProps) => { +const CustomNode = ({ data, selected }: NodeProps) => { const nodeTypes = useGraphStore((state) => state.nodeTypes); const labels = useGraphStore((state) => state.labels); const { searchText, selectedActorTypes, selectedLabels } = useSearchStore(); // Check if any connection is being made (to show handles) - const connectionNodeId = useStore((state) => state.connectionNodeId); - const isConnecting = !!connectionNodeId; + const connection = useConnection(); + const isConnecting = !!connection.inProgress; // Find the node type configuration const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type); diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx index 8f39d1d..3de7b08 100644 --- a/src/components/Nodes/GroupNode.tsx +++ b/src/components/Nodes/GroupNode.tsx @@ -1,6 +1,6 @@ import { memo, useState, useMemo } from 'react'; -import { NodeProps, NodeResizer, useStore } from 'reactflow'; -import type { GroupData } from '../../types'; +import { NodeProps, NodeResizer, useStore, Handle, Position } from '@xyflow/react'; +import type { Group } from '../../types'; import type { Actor } from '../../types'; /** @@ -16,15 +16,15 @@ import type { Actor } from '../../types'; * * Usage: Automatically rendered by React Flow for nodes with type='group' */ -const GroupNode = ({ id, data, selected }: NodeProps) => { +const GroupNode = ({ id, data, selected }: NodeProps) => { const [isEditing, setIsEditing] = useState(false); const [editLabel, setEditLabel] = useState(data.label); // Get child nodes from React Flow store to calculate minimum dimensions const childNodes = useStore((state) => { - return state.nodeInternals - ? Array.from(state.nodeInternals.values()).filter( - (node) => (node as Actor & { parentId?: string }).parentId === id + return state.nodeLookup + ? Array.from(state.nodeLookup.values()).filter( + (node) => (node as unknown as Actor & { parentId?: string }).parentId === id ) : []; }); @@ -84,6 +84,109 @@ const GroupNode = ({ id, data, selected }: NodeProps) => { } }; + // Minimized state - render as compact rectangle + if (data.minimized) { + // Convert color to solid (remove alpha) for minimized state + const solidColor = data.color + ? data.color.replace(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/, 'rgb($1, $2, $3)') + : '#f0f2f5'; + + return ( +
+ {/* Connection handles for minimized groups - hidden but necessary for edge routing */} + + + + + + + + + +
+
+ {data.label} +
+
+ {data.actorIds.length} actor{data.actorIds.length !== 1 ? 's' : ''} +
+
+
+ ); + } + + // Normal (maximized) state return (
{ - const { updateGroup, deleteGroup, removeActorFromGroup, nodes, nodeTypes } = useGraphWithHistory(); + const { updateGroup, deleteGroup, removeActorFromGroup, toggleGroupMinimized, nodes, nodeTypes, setGroups, groups } = useGraphWithHistory(); const { confirm, ConfirmDialogComponent } = useConfirm(); + const reactFlowNodes = useNodes(); const [label, setLabel] = useState(selectedGroup.data.label); const [description, setDescription] = useState(selectedGroup.data.description || ''); @@ -238,6 +242,39 @@ const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => { {/* Actions */}
+