feat: add group minimize/maximize with floating edges and React Flow v12

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<string, unknown> 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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-20 11:52:44 +02:00
parent f5adbc8ead
commit b1e634d3c4
21 changed files with 614 additions and 395 deletions

342
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -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<RelationData>) => {
}: EdgeProps<Relation>) => {
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"
>

View file

@ -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<string, string>();
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 ? <MaximizeIcon fontSize="small" /> : <MinimizeIcon fontSize="small" />,
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: <UngroupIcon fontSize="small" />,

View file

@ -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<ActorData>) => {
const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
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);

View file

@ -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<GroupData>) => {
const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
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<GroupData>) => {
}
};
// 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 (
<div
className="group-minimized"
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: solidColor,
borderRadius: '8px',
// Use separate border properties to override .react-flow__node-group dashed border
borderWidth: '2px',
borderStyle: 'solid',
borderColor: selected ? '#3b82f6' : 'rgba(0, 0, 0, 0.2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
position: 'relative',
}}
>
{/* Connection handles for minimized groups - hidden but necessary for edge routing */}
<Handle
type="target"
position={Position.Top}
id="top"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="source"
position={Position.Top}
id="top"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="target"
position={Position.Right}
id="right"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="source"
position={Position.Right}
id="right"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="target"
position={Position.Bottom}
id="bottom"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="target"
position={Position.Left}
id="left"
isConnectable={false}
style={{ opacity: 0 }}
/>
<Handle
type="source"
position={Position.Left}
id="left"
isConnectable={false}
style={{ opacity: 0 }}
/>
<div
style={{
padding: '12px 20px',
textAlign: 'center',
}}
>
<div className="text-sm font-semibold text-gray-800 leading-tight">
{data.label}
</div>
<div className="text-xs text-gray-600 mt-1.5">
{data.actorIds.length} actor{data.actorIds.length !== 1 ? 's' : ''}
</div>
</div>
</div>
);
}
// Normal (maximized) state
return (
<div
style={{

View file

@ -2,6 +2,9 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import CloseIcon from '@mui/icons-material/Close';
import MinimizeIcon from '@mui/icons-material/UnfoldLess';
import MaximizeIcon from '@mui/icons-material/UnfoldMore';
import { useNodes } from '@xyflow/react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useConfirm } from '../../hooks/useConfirm';
import type { Group } from '../../types';
@ -32,8 +35,9 @@ const DEFAULT_GROUP_COLORS = [
];
const GroupEditorPanel = ({ selectedGroup, onClose }: Props) => {
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 */}
<div className="pt-4 border-t border-gray-200 space-y-2">
<button
onClick={() => {
// Sync current React Flow dimensions before toggling
if (!selectedGroup.data.minimized) {
// When minimizing, update the store with current dimensions first
const currentNode = reactFlowNodes.find((n) => n.id === selectedGroup.id);
if (currentNode && currentNode.width && currentNode.height) {
setGroups(groups.map((g) =>
g.id === selectedGroup.id
? { ...g, width: currentNode.width, height: currentNode.height }
: g
));
}
}
// Use setTimeout to ensure store update completes before toggle
setTimeout(() => {
toggleGroupMinimized(selectedGroup.id);
}, 0);
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 rounded hover:bg-gray-100 transition-colors flex items-center justify-center space-x-2"
>
{selectedGroup.data.minimized ? (
<>
<MaximizeIcon fontSize="small" />
<span>Maximize Group</span>
</>
) : (
<>
<MinimizeIcon fontSize="small" />
<span>Minimize Group</span>
</>
)}
</button>
<button
onClick={handleUngroup}
className="w-full px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded hover:bg-blue-100 transition-colors"

View file

@ -1,17 +1,19 @@
import React from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import type { ConstellationState } from "../../types/timeline";
interface StateNodeData {
interface StateNodeData extends Record<string, unknown> {
state: ConstellationState;
isCurrent: boolean;
onRename?: (stateId: string) => void;
}
type StateNode = Node<StateNodeData>;
/**
* StateNode - Custom node for timeline visualization
*/
const StateNode: React.FC<NodeProps<StateNodeData>> = ({ data, selected }) => {
const StateNodeComponent: React.FC<NodeProps<StateNode>> = ({ data, selected }) => {
const { state, isCurrent } = data;
// Format date if present
@ -93,4 +95,4 @@ const StateNode: React.FC<NodeProps<StateNodeData>> = ({ data, selected }) => {
);
};
export default StateNode;
export default StateNodeComponent;

View file

@ -1,5 +1,6 @@
import React, { useMemo, useCallback, useState } from "react";
import ReactFlow, {
import {
ReactFlow,
Background,
Controls,
NodeTypes,
@ -9,8 +10,8 @@ import ReactFlow, {
useEdgesState,
BackgroundVariant,
ReactFlowProvider,
} from "reactflow";
import "reactflow/dist/style.css";
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useTimelineStore } from "../../stores/timelineStore";
import { useWorkspaceStore } from "../../stores/workspaceStore";
import StateNode from "./StateNode";

View file

@ -68,9 +68,9 @@ export function useDocumentHistory() {
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as any,
edges: graphStore.edges as any,
groups: graphStore.groups as any,
nodes: graphStore.nodes as unknown as typeof currentState.graph.nodes,
edges: graphStore.edges as unknown as typeof currentState.graph.edges,
groups: graphStore.groups as unknown as typeof currentState.graph.groups,
};
}
@ -128,9 +128,9 @@ export function useDocumentHistory() {
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as any,
edges: graphStore.edges as any,
groups: graphStore.groups as any,
nodes: graphStore.nodes as unknown as typeof currentState.graph.nodes,
edges: graphStore.edges as unknown as typeof currentState.graph.edges,
groups: graphStore.groups as unknown as typeof currentState.graph.groups,
};
}
@ -219,9 +219,9 @@ export function useDocumentHistory() {
if (currentState) {
const graphStore = useGraphStore.getState();
currentState.graph = {
nodes: graphStore.nodes as any,
edges: graphStore.edges as any,
groups: graphStore.groups as any,
nodes: graphStore.nodes as unknown as typeof currentState.graph.nodes,
edges: graphStore.edges as unknown as typeof currentState.graph.edges,
groups: graphStore.groups as unknown as typeof currentState.graph.groups,
};
}

View file

@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useReactFlow } from 'reactflow';
import { useReactFlow } from '@xyflow/react';
import { exportGraphAsPNG, exportGraphAsSVG } from '../utils/graphExport';
import type { ExportOptions } from '../utils/graphExport';

View file

@ -411,6 +411,20 @@ export function useGraphWithHistory() {
[graphStore, pushToHistory]
);
const toggleGroupMinimized = useCallback(
(groupId: string) => {
if (isRestoringRef.current) {
graphStore.toggleGroupMinimized(groupId);
return;
}
const group = graphStore.groups.find((g) => g.id === groupId);
const action = group?.data.minimized ? 'Maximize' : 'Minimize';
pushToHistory(`${action} Group: ${group?.data.label}`);
graphStore.toggleGroupMinimized(groupId);
},
[graphStore, pushToHistory]
);
/**
* createGroupWithActors - Atomic operation to create a group and add actors to it
*
@ -470,6 +484,7 @@ export function useGraphWithHistory() {
deleteGroup,
addActorToGroup,
removeActorFromGroup,
toggleGroupMinimized,
createGroupWithActors,
addNodeType,
updateNodeType,

View file

@ -6,7 +6,6 @@ import "@citation-js/plugin-csl";
import "@citation-js/plugin-doi";
import "@citation-js/plugin-bibtex";
import "@citation-js/plugin-ris";
import "@citation-js/plugin-pubmed";
import "@citation-js/plugin-software-formats";
import type {

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { addEdge as rfAddEdge } from 'reactflow';
import { addEdge as rfAddEdge } from '@xyflow/react';
import type {
Actor,
Relation,
@ -363,6 +363,68 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
};
}),
toggleGroupMinimized: (groupId: string) =>
set((state) => {
const group = state.groups.find((g) => g.id === groupId);
if (!group) return state;
const isMinimized = !group.data.minimized;
// Update group's minimized state
const updatedGroups = state.groups.map((g) => {
if (g.id !== groupId) return g;
if (isMinimized) {
// Minimizing: store original dimensions in metadata
return {
...g,
data: {
...g.data,
minimized: true,
metadata: {
...g.data.metadata,
originalWidth: g.width,
originalHeight: g.height,
},
},
width: 220,
height: 80,
// Override wrapper styles to remove padding and border
style: {
padding: 0,
border: 'none',
backgroundColor: 'white', // Solid background (inner div will cover with its own color)
},
};
} else {
// Maximizing: restore original dimensions from metadata
const originalWidth = (g.data.metadata?.originalWidth as number) || 300;
const originalHeight = (g.data.metadata?.originalHeight as number) || 200;
// Remove the stored dimensions from metadata
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { originalWidth: _, originalHeight: __, ...restMetadata } = g.data.metadata || {};
return {
...g,
data: {
...g.data,
minimized: false,
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined,
},
width: originalWidth,
height: originalHeight,
// Remove wrapper style overrides for maximized state
style: undefined,
};
}
});
return {
groups: updatedGroups,
};
}),
// Utility operations
clearGraph: () =>
set({
@ -415,6 +477,7 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
if (nodeWithParent.parentId && !validGroupIds.has(nodeWithParent.parentId)) {
// Remove orphaned parent reference
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { parentId, extent, ...cleanNode } = nodeWithParent;
return cleanNode as Actor;
}

View file

@ -1,5 +1,5 @@
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import type { ConstellationDocument, SerializedActor, SerializedRelation, SerializedGroup } from './types';
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
import { safeStringify } from '../../utils/safeStringify';
@ -9,12 +9,17 @@ import { safeStringify } from '../../utils/safeStringify';
// Serialize actors for storage (strip React Flow internals)
export function serializeActors(actors: Actor[]): SerializedActor[] {
return actors.map(actor => ({
return actors.map(actor => {
const actorWithParent = actor as Actor & { parentId?: string; extent?: 'parent' };
return {
id: actor.id,
type: actor.type || 'custom', // Default to 'custom' if undefined
position: actor.position,
data: actor.data,
}));
...(actorWithParent.parentId && { parentNode: actorWithParent.parentId }),
...(actorWithParent.extent && { extent: actorWithParent.extent }),
};
});
}
// Serialize relations for storage (strip React Flow internals)
@ -30,6 +35,18 @@ export function serializeRelations(relations: Relation[]): SerializedRelation[]
}));
}
// Serialize groups for storage (strip React Flow internals)
export function serializeGroups(groups: Group[]): SerializedGroup[] {
return groups.map(group => ({
id: group.id,
type: 'group' as const,
position: group.position,
data: group.data,
width: group.width ?? undefined,
height: group.height ?? undefined,
}));
}
// Generate unique state ID
function generateStateId(): string {
return `state_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import { useWorkspaceStore } from '../workspaceStore';
import { useGraphStore } from '../graphStore';
import { useTimelineStore } from '../timelineStore';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import { getCurrentGraphFromDocument } from '../persistence/loader';
/**
@ -27,11 +27,13 @@ export function useActiveDocument() {
const setNodes = useGraphStore((state) => state.setNodes);
const setEdges = useGraphStore((state) => state.setEdges);
const setGroups = useGraphStore((state) => state.setGroups);
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
const setLabels = useGraphStore((state) => state.setLabels);
const graphNodes = useGraphStore((state) => state.nodes);
const graphEdges = useGraphStore((state) => state.edges);
const graphGroups = useGraphStore((state) => state.groups);
const graphNodeTypes = useGraphStore((state) => state.nodeTypes);
const graphEdgeTypes = useGraphStore((state) => state.edgeTypes);
const graphLabels = useGraphStore((state) => state.labels);
@ -48,6 +50,7 @@ export function useActiveDocument() {
documentId: string | null;
nodes: Actor[];
edges: Relation[];
groups: Group[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
labels: LabelConfig[];
@ -55,6 +58,7 @@ export function useActiveDocument() {
documentId: null,
nodes: [],
edges: [],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
@ -78,6 +82,7 @@ export function useActiveDocument() {
setNodes(currentGraph.nodes as never[]);
setEdges(currentGraph.edges as never[]);
setGroups(currentGraph.groups as never[]);
setNodeTypes(currentGraph.nodeTypes as never[]);
setEdgeTypes(currentGraph.edgeTypes as never[]);
setLabels(activeDocument.labels || []);
@ -87,6 +92,7 @@ export function useActiveDocument() {
documentId: activeDocumentId,
nodes: currentGraph.nodes as Actor[],
edges: currentGraph.edges as Relation[],
groups: currentGraph.groups as Group[],
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],
labels: activeDocument.labels || [],
@ -106,6 +112,7 @@ export function useActiveDocument() {
setNodes([]);
setEdges([]);
setGroups([]);
setLabels([]);
// Note: We keep nodeTypes and edgeTypes so they're available for new documents
@ -114,6 +121,7 @@ export function useActiveDocument() {
documentId: null,
nodes: [],
edges: [],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
@ -124,7 +132,7 @@ export function useActiveDocument() {
isLoadingRef.current = false;
}, 100);
}
}, [activeDocumentId, activeDocument, documents, setNodes, setEdges, setNodeTypes, setEdgeTypes, setLabels]);
}, [activeDocumentId, activeDocument, documents, setNodes, setEdges, setGroups, setNodeTypes, setEdgeTypes, setLabels]);
// Save graphStore changes back to workspace (debounced via workspace)
useEffect(() => {
@ -151,11 +159,12 @@ export function useActiveDocument() {
}
// Mark document as dirty when graph changes
// NOTE: We only track nodes/edges here. Type changes are handled by workspaceStore's
// NOTE: We only track nodes/edges/groups here. Type changes are handled by workspaceStore's
// type management actions, which directly mark the document as dirty.
const hasChanges =
JSON.stringify(graphNodes) !== JSON.stringify(lastSyncedStateRef.current.nodes) ||
JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges);
JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges) ||
JSON.stringify(graphGroups) !== JSON.stringify(lastSyncedStateRef.current.groups);
if (hasChanges) {
console.log(`Document ${activeDocumentId} has changes, marking as dirty`);
@ -166,15 +175,17 @@ export function useActiveDocument() {
documentId: activeDocumentId,
nodes: graphNodes as Actor[],
edges: graphEdges as Relation[],
groups: graphGroups as Group[],
nodeTypes: graphNodeTypes as NodeTypeConfig[],
edgeTypes: graphEdgeTypes as EdgeTypeConfig[],
labels: graphLabels as LabelConfig[],
};
// Update the timeline's current state with the new graph data (nodes and edges only)
// Update the timeline's current state with the new graph data (nodes, edges, and groups)
useTimelineStore.getState().saveCurrentGraph({
nodes: graphNodes as never[],
edges: graphEdges as never[],
groups: graphGroups as never[],
});
// Debounced save
@ -184,7 +195,7 @@ export function useActiveDocument() {
return () => clearTimeout(timeoutId);
}
}, [graphNodes, graphEdges, graphNodeTypes, graphEdgeTypes, graphLabels, activeDocumentId, activeDocument, documents, markDocumentDirty, saveDocument]);
}, [graphNodes, graphEdges, graphGroups, graphNodeTypes, graphEdgeTypes, graphLabels, activeDocumentId, activeDocument, documents, markDocumentDirty, saveDocument]);
// Memory management: Unload inactive documents after timeout
useEffect(() => {

View file

@ -49,6 +49,7 @@ code {
background-color: rgba(240, 242, 245, 0.5);
border: 2px dashed rgba(100, 116, 139, 0.4);
border-radius: 8px;
padding: 10px; /* Default padding for maximized groups */
}
.react-flow__node-group.selected {
@ -56,6 +57,13 @@ code {
border: 2px solid rgba(59, 130, 246, 0.6);
}
/* Hide outer border/background/padding for minimized groups (inner div handles styling) */
.react-flow__node-group:has(.group-minimized) {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
/* Smooth transitions for interactive elements */
button {
transition: all 0.2s ease-in-out;

View file

@ -1,7 +1,7 @@
import { Node, Edge } from 'reactflow';
import { Node, Edge } from '@xyflow/react';
// Node/Actor Types
export interface ActorData {
export interface ActorData extends Record<string, unknown> {
label: string;
type: string;
description?: string;
@ -15,7 +15,7 @@ export type Actor = Node<ActorData>;
// Edge/Relation Types
export type EdgeDirectionality = 'directed' | 'bidirectional' | 'undirected';
export interface RelationData {
export interface RelationData extends Record<string, unknown> {
label?: string;
type: string;
directionality?: EdgeDirectionality;
@ -67,11 +67,12 @@ export interface LabelConfig {
}
// Group Types
export interface GroupData {
export interface GroupData extends Record<string, unknown> {
label: string;
description?: string;
color: string;
actorIds: string[];
minimized?: boolean;
metadata?: Record<string, unknown>;
}
@ -118,6 +119,7 @@ export interface GraphActions {
deleteGroup: (id: string, ungroupActors?: boolean) => void;
addActorToGroup: (actorId: string, groupId: string) => void;
removeActorFromGroup: (actorId: string, groupId: string) => void;
toggleGroupMinimized: (groupId: string) => void;
clearGraph: () => void;
setNodes: (nodes: Actor[]) => void;
setEdges: (edges: Relation[]) => void;

View file

@ -1,4 +1,6 @@
import type { Relation, RelationData } from '../types';
import type { Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
/**
* Generates a unique ID for edges
@ -7,6 +9,87 @@ export const generateEdgeId = (source: string, target: string): string => {
return `edge_${source}_${target}_${Date.now()}`;
};
/**
* Calculate the intersection point between a line and a rectangle
* Used for floating edges to connect at the closest point on the node
*/
function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
const {
width: intersectionNodeWidth,
height: intersectionNodeHeight,
position: intersectionNodePosition,
} = intersectionNode;
const targetPosition = targetNode.position;
const w = (intersectionNodeWidth ?? 0) / 2;
const h = (intersectionNodeHeight ?? 0) / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + (targetNode.width ?? 0) / 2;
const y1 = targetPosition.y + (targetNode.height ?? 0) / 2;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;
return { x, y };
}
/**
* Get the position (top, right, bottom, left) of the handle based on the intersection point
*/
function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) {
const n = { ...node.position, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
const width = node.width ?? 0;
const height = node.height ?? 0;
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + width - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= ny + height - 1) {
return Position.Bottom;
}
return Position.Top;
}
/**
* Calculate the parameters for a floating edge between two nodes
* Returns source/target coordinates and positions for dynamic edge routing
*/
export function getFloatingEdgeParams(sourceNode: Node, targetNode: Node) {
const sourceIntersectionPoint = getNodeIntersection(sourceNode, targetNode);
const targetIntersectionPoint = getNodeIntersection(targetNode, sourceNode);
const sourcePos = getEdgePosition(sourceNode, sourceIntersectionPoint);
const targetPos = getEdgePosition(targetNode, targetIntersectionPoint);
return {
sx: sourceIntersectionPoint.x,
sy: sourceIntersectionPoint.y,
tx: targetIntersectionPoint.x,
ty: targetIntersectionPoint.y,
sourcePos,
targetPos,
};
}
/**
* Creates a new relation/edge with default properties
*/

View file

@ -1,6 +1,6 @@
import { toPng, toSvg } from 'html-to-image';
import { getNodesBounds } from 'reactflow';
import type { Node } from 'reactflow';
import { getNodesBounds } from '@xyflow/react';
import type { Node } from '@xyflow/react';
/**
* Graph Export Utilities