mirror of
https://github.com/vndangkhoa/Sys-Arc-Visl.git
synced 2026-04-04 17:08:17 +07:00
feat: Add ComfyUI import, static view mode, and dashboard controls
This commit is contained in:
parent
5ada3b1fcd
commit
5af1e14750
12 changed files with 1073 additions and 220 deletions
235
package-lock.json
generated
235
package-lock.json
generated
|
|
@ -34,6 +34,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@rollup/rollup-darwin-arm64": "^4.55.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
|
|
@ -626,9 +627,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
|
||||
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1078,9 +1079,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1235,9 +1236,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz",
|
||||
"integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz",
|
||||
"integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2089,15 +2090,14 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
||||
"version": "4.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.0.tgz",
|
||||
"integrity": "sha512-iiSGJu03Vsi2+Zz9PRbJ18icTAte/Geh/3f5T94DGDwuCa2GBY0MwIyvgZNV6Hur5fBgEBsUUqIZ/cPC8r9B/g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
|
|
@ -2369,9 +2369,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.45",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.45.tgz",
|
||||
"integrity": "sha512-qJcFVfCa5jxBFSuv7S5WYbA8XdeCPmhnaVVfX/2Y6L8WYg8sk3XY2+6W0zH+3mq1Cz+YC7Ki66HfqX6IHAwnkg==",
|
||||
"version": "0.34.46",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.46.tgz",
|
||||
"integrity": "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -3226,20 +3226,20 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz",
|
||||
"integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz",
|
||||
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.50.1",
|
||||
"@typescript-eslint/type-utils": "8.50.1",
|
||||
"@typescript-eslint/utils": "8.50.1",
|
||||
"@typescript-eslint/visitor-keys": "8.50.1",
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/type-utils": "8.51.0",
|
||||
"@typescript-eslint/utils": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
"ts-api-utils": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -3249,7 +3249,7 @@
|
|||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.50.1",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
|
|
@ -3265,16 +3265,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz",
|
||||
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz",
|
||||
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.1",
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
"@typescript-eslint/typescript-estree": "8.50.1",
|
||||
"@typescript-eslint/visitor-keys": "8.50.1",
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -3290,14 +3290,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz",
|
||||
"integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz",
|
||||
"integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.50.1",
|
||||
"@typescript-eslint/types": "^8.50.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.51.0",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -3312,14 +3312,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz",
|
||||
"integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz",
|
||||
"integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
"@typescript-eslint/visitor-keys": "8.50.1"
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -3330,9 +3330,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz",
|
||||
"integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz",
|
||||
"integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -3347,17 +3347,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz",
|
||||
"integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz",
|
||||
"integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
"@typescript-eslint/typescript-estree": "8.50.1",
|
||||
"@typescript-eslint/utils": "8.50.1",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0",
|
||||
"@typescript-eslint/utils": "8.51.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
"ts-api-utils": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -3372,9 +3372,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz",
|
||||
"integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
|
||||
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -3386,21 +3386,21 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz",
|
||||
"integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz",
|
||||
"integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.50.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.50.1",
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
"@typescript-eslint/visitor-keys": "8.50.1",
|
||||
"@typescript-eslint/project-service": "8.51.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0",
|
||||
"debug": "^4.3.4",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
"ts-api-utils": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -3453,16 +3453,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz",
|
||||
"integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz",
|
||||
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.50.1",
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
"@typescript-eslint/typescript-estree": "8.50.1"
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -3477,13 +3477,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz",
|
||||
"integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz",
|
||||
"integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -3936,9 +3936,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001761",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
|
||||
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
|
||||
"version": "1.0.30001762",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
||||
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4163,20 +4163,31 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz",
|
||||
"integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==",
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz",
|
||||
"integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^4.1.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
|
||||
"css-tree": "^3.1.0"
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
|
|
@ -5103,9 +5114,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
||||
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
|
@ -6081,9 +6092,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.116.tgz",
|
||||
"integrity": "sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==",
|
||||
"version": "0.2.117",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
|
|
@ -7332,6 +7343,20 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/roughjs": {
|
||||
"version": "4.6.6",
|
||||
"resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
|
||||
|
|
@ -7826,9 +7851,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
|
||||
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -7894,16 +7919,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.50.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz",
|
||||
"integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz",
|
||||
"integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@typescript-eslint/typescript-estree": "8.50.1",
|
||||
"@typescript-eslint/utils": "8.50.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.51.0",
|
||||
"@typescript-eslint/parser": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0",
|
||||
"@typescript-eslint/utils": "8.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -8214,9 +8239,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
|
||||
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
|
|
@ -8404,9 +8429,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.28",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
|
||||
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
|
||||
"version": "13.6.29",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
|
||||
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
|
|
@ -8434,9 +8459,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
|
||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@rollup/rollup-darwin-arm64": "^4.55.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { nodeTypes } from './nodes/CustomNodes';
|
|||
import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge';
|
||||
import {
|
||||
Spline, Minus, Plus, Maximize, Map, Wand2,
|
||||
Hand, MousePointer2, Settings2, ChevronDown
|
||||
Hand, MousePointer2, Settings2, ChevronDown, FileImage, Trash2
|
||||
} from 'lucide-react';
|
||||
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export function FlowCanvas() {
|
|||
const {
|
||||
nodes, edges, onNodesChange, onEdgesChange, onConnect,
|
||||
setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters,
|
||||
setNodes, setEdges, focusMode
|
||||
setNodes, setEdges, focusMode, viewMode, setViewMode
|
||||
} = useFlowStore();
|
||||
const { isMobile } = useMobileDetect();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
|
|
@ -110,9 +110,104 @@ export function FlowCanvas() {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for Dynamic Label Filter
|
||||
const label = (node.data?.label as string || '').trim();
|
||||
const dynamicFilterId = `dyn-${label}`;
|
||||
|
||||
// Logic:
|
||||
// 1. If a dynamic filter active state exists for this label (meaning the label is "known" effectively), check it.
|
||||
// However, activeFilters is a list of IDs.
|
||||
// Wait, we don't know if the filter 'exists' here easily without scanning all nodes or passing `dynamicFilters` prop.
|
||||
// But we know if `dyn-` version is in `activeFilters`, it is explicitly ON.
|
||||
// If `dyn-` version is NOT in `activeFilters`, is it implicitly OFF?
|
||||
// In `InteractiveLegend`, we auto-add new dynamic labels to `activeFilters`.
|
||||
// So if it IS a known dynamic label, it SHOULD be in `activeFilters` to be visible.
|
||||
// But `FlowCanvas` doesn't know if it's a "known" dynamic label or just some random text.
|
||||
// Heuristic: If the label is short enough to trigger a dynamic filter (length < 30),
|
||||
// then we assume it is governed by the dynamic filter system.
|
||||
|
||||
let isVisible = false;
|
||||
|
||||
if (label.length < 30 && label.length > 0) {
|
||||
// It is a dynamic filter candidate.
|
||||
// Visibility is determined by presence in activeFilters.
|
||||
// Note: InteractiveLegend adds them asynchronously. There might be a split second where it's hidden before appearing.
|
||||
// To prevent flickering: maybe default to TRUE if activeFilters doesn't contain ANY dynamic filters yet? No.
|
||||
// We will trust the store.
|
||||
|
||||
// If the user has disabled the category (e.g. 'Server'), should 'KSampler' still show?
|
||||
// User request imply: "specific node that appear".
|
||||
// Usually specific overrides general.
|
||||
|
||||
const isDynamicActive = activeFilters.includes(dynamicFilterId);
|
||||
if (isDynamicActive) {
|
||||
isVisible = true;
|
||||
} else {
|
||||
// CAUTION: If it's NOT in activeFilters, it could mean:
|
||||
// A) InteractiveLegend hasn't added it yet (it's new) -> Should show?
|
||||
// B) User explicitly turned it off -> Should hide.
|
||||
|
||||
// Problem: We can't distinguish A from B easily here.
|
||||
// But we know InteractiveLegend adds them immediately on mount/update.
|
||||
// A flash of invisibility is possible.
|
||||
// BUT, we can fallback to Category visibility if Dynamic visibility is "off" (missing)?
|
||||
// No, if I turn off "KSampler", I want it gone.
|
||||
|
||||
// Let's assume if the label is "Dynamic-able", strict filtering applies.
|
||||
// But if activeFilters doesn't have it, it's hidden.
|
||||
|
||||
// Fallback check:
|
||||
// Is the CATEGORY also required?
|
||||
// If I have "KSampler" (Other), and I turn off "Other", "KSampler" should probably hide too?
|
||||
// Composite logic: Visible if (Category is Active) AND (Dynamic is Active or Not Applicable).
|
||||
|
||||
// BUT, dynamic filters are added to the list.
|
||||
// If I toggle "KSampler", it removes from list.
|
||||
// So we must check dynamic ID.
|
||||
|
||||
// If we enforce BOTH, then unchecking "Other" hides everything.
|
||||
// If we enforce EITHER, then unchecking "Other" keeps KSampler.
|
||||
|
||||
// Let's go with: Dynamic Filter OVERRIDES Category if present?
|
||||
// Or Dynamic Filter is an AND condition?
|
||||
// Usually: Visible = CategoryActive && (DynamicActive if exists).
|
||||
|
||||
// Let's try:
|
||||
// If `activeFilters` has ANY `dyn-`... implied system is active.
|
||||
}
|
||||
|
||||
// REVISED LOGIC:
|
||||
// We check if `dyn-${label}` is in activeFilters.
|
||||
// If we find ANY `dyn-` filters in `activeFilters` at all, we assume system is initialized.
|
||||
// If so, we stick to strict checking.
|
||||
|
||||
const anyDynamicActive = activeFilters.some(f => f.startsWith('dyn-'));
|
||||
|
||||
if (!anyDynamicActive) {
|
||||
// System maybe not ready or no dynamic filters active.
|
||||
// Fallback to category.
|
||||
isVisible = activeFilters.includes(category);
|
||||
} else {
|
||||
// System has dynamic filters.
|
||||
// If this specific dynamic filter is present, show.
|
||||
// Also check category?
|
||||
// Let's prioritize Dynamic Filter visibility.
|
||||
if (activeFilters.includes(dynamicFilterId)) {
|
||||
isVisible = true;
|
||||
} else {
|
||||
// Dynamic filter is OFF.
|
||||
isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Not a dynamic label situation, standard category
|
||||
isVisible = activeFilters.includes(category);
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
hidden: !activeFilters.includes(category)
|
||||
hidden: !isVisible
|
||||
};
|
||||
});
|
||||
}, [nodes, activeFilters]);
|
||||
|
|
@ -146,14 +241,18 @@ export function FlowCanvas() {
|
|||
});
|
||||
}, [edges, edgeStyle]);
|
||||
|
||||
// Filter edges to only show connections between visible nodes
|
||||
// Filter edges to only show connections between visible nodes AND if edges are enabled
|
||||
const filteredEdges = useMemo(() => {
|
||||
// Check if edges are globally enabled via filter
|
||||
const edgesEnabled = activeFilters.includes('filter-edge');
|
||||
if (!edgesEnabled) return [];
|
||||
|
||||
const visibleNodeIds = new Set(filteredNodes.filter(n => !n.hidden).map(n => n.id));
|
||||
return styledEdges.map(edge => ({
|
||||
...edge,
|
||||
hidden: !visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)
|
||||
}));
|
||||
}, [styledEdges, filteredNodes]);
|
||||
}, [styledEdges, filteredNodes, activeFilters]);
|
||||
|
||||
// Node click handler - bidirectional highlighting
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: any) => {
|
||||
|
|
@ -234,96 +333,121 @@ export function FlowCanvas() {
|
|||
{/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */}
|
||||
{!isMobile && (
|
||||
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowToolkit(!showToolkit)}
|
||||
className={`
|
||||
h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none
|
||||
${showToolkit
|
||||
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20'
|
||||
: 'bg-white/90 dark:bg-surface/90 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-white dark:hover:bg-surface'}
|
||||
`}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showToolkit && (
|
||||
<div className="absolute top-full right-0 mt-2 w-56 p-2 rounded-2xl bg-white/95 dark:bg-[#0B1221]/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl flex flex-col gap-2 animate-in fade-in slide-in-from-top-2 duration-200 origin-top-right">
|
||||
|
||||
{/* Section: Interaction Mode */}
|
||||
<div className="p-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Mode</span>
|
||||
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(false)}
|
||||
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode
|
||||
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Hand className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Pan</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode
|
||||
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Select</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
|
||||
|
||||
{/* Section: View Controls */}
|
||||
<div className="p-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span>
|
||||
<div className="flex bg-slate-100 dark:bg-white/5 rounded-lg p-0.5 divide-x divide-slate-200 dark:divide-white/5 border border-slate-200 dark:border-white/5">
|
||||
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" />
|
||||
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" />
|
||||
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
|
||||
|
||||
{/* Section: Layout & Overlays */}
|
||||
<div className="p-1 space-y-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span>
|
||||
|
||||
<MenuButton
|
||||
icon={Wand2}
|
||||
label="Auto Layout"
|
||||
active={false}
|
||||
onClick={handleAutoLayout}
|
||||
/>
|
||||
|
||||
<MenuButton
|
||||
icon={edgeStyle === 'curved' ? Spline : Minus}
|
||||
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'}
|
||||
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''}
|
||||
active={false}
|
||||
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
|
||||
/>
|
||||
|
||||
<MenuButton
|
||||
icon={Map}
|
||||
label="MiniMap Overlay"
|
||||
active={showMiniMap}
|
||||
onClick={() => setShowMiniMap(!showMiniMap)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Clear Dashboard Button */}
|
||||
{nodes.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
useFlowStore.getState().setMermaidCode('');
|
||||
useFlowStore.getState().setDescription('');
|
||||
useFlowStore.getState().setSourceCode('');
|
||||
}}
|
||||
className="h-10 w-10 flex items-center justify-center rounded-xl bg-white/90 dark:bg-surface/90 backdrop-blur-md border border-red-200 dark:border-red-900/30 text-red-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 shadow-sm transition-all"
|
||||
title="Clear Dashboard"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowToolkit(!showToolkit)}
|
||||
className={`
|
||||
h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none
|
||||
${showToolkit
|
||||
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20'
|
||||
: 'bg-white/90 dark:bg-surface/90 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-white dark:hover:bg-surface'}
|
||||
`}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showToolkit && (
|
||||
<div className="absolute top-full right-0 mt-2 w-56 p-2 rounded-2xl bg-white/95 dark:bg-[#0B1221]/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl flex flex-col gap-2 animate-in fade-in slide-in-from-top-2 duration-200 origin-top-right">
|
||||
|
||||
{/* Section: Interaction Mode */}
|
||||
<div className="p-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Mode</span>
|
||||
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(false)}
|
||||
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode
|
||||
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Hand className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Pan</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode
|
||||
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Select</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
|
||||
|
||||
{/* Section: View Controls */}
|
||||
<div className="p-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span>
|
||||
<div className="flex bg-slate-100 dark:bg-white/5 rounded-lg p-0.5 divide-x divide-slate-200 dark:divide-white/5 border border-slate-200 dark:border-white/5">
|
||||
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" />
|
||||
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" />
|
||||
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
|
||||
|
||||
{/* Section: Layout & Overlays */}
|
||||
<div className="p-1 space-y-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span>
|
||||
|
||||
<MenuButton
|
||||
icon={Wand2}
|
||||
label="Auto Layout"
|
||||
active={false}
|
||||
onClick={handleAutoLayout}
|
||||
/>
|
||||
|
||||
<MenuButton
|
||||
icon={edgeStyle === 'curved' ? Spline : Minus}
|
||||
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'}
|
||||
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''}
|
||||
active={false}
|
||||
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
|
||||
/>
|
||||
|
||||
<MenuButton
|
||||
icon={Map}
|
||||
label="MiniMap Overlay"
|
||||
active={showMiniMap}
|
||||
onClick={() => setShowMiniMap(!showMiniMap)}
|
||||
/>
|
||||
|
||||
<MenuButton
|
||||
icon={FileImage}
|
||||
label="Static View"
|
||||
active={false}
|
||||
onClick={() => setViewMode('static')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { useCallback } from 'react';
|
||||
import { ImageUpload } from './ImageUpload';
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
import { Image, Code, MessageSquare, Loader2, Zap } from 'lucide-react';
|
||||
import { ComfyImportPanel } from './editor/ComfyImportPanel'; // Import new panel
|
||||
import { Image, Code, MessageSquare, Loader2, Zap, Workflow } from 'lucide-react'; // Add Workflow icon
|
||||
import { useFlowStore } from '../store';
|
||||
import { interpretText } from '../lib/aiService';
|
||||
import { parseMermaid } from '../lib/mermaidParser';
|
||||
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||
|
||||
type Tab = 'image' | 'code' | 'describe';
|
||||
type Tab = 'image' | 'code' | 'describe' | 'comfy'; // Add 'comfy' tab type
|
||||
|
||||
export function InputPanel() {
|
||||
const {
|
||||
|
|
@ -95,16 +96,17 @@ export function InputPanel() {
|
|||
}, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode, setMermaidCode, saveDiagram]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'image' as Tab, icon: Image, label: 'Upload' },
|
||||
{ id: 'describe' as Tab, icon: MessageSquare, label: 'Describe' }, // Moved Describe first as primary
|
||||
{ id: 'code' as Tab, icon: Code, label: 'Code' },
|
||||
{ id: 'describe' as Tab, icon: MessageSquare, label: 'Describe' },
|
||||
{ id: 'comfy' as Tab, icon: Workflow, label: 'ComfyUI' }, // New Tab
|
||||
{ id: 'image' as Tab, icon: Image, label: 'Upload' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Floating Tabs */}
|
||||
<div className="px-4 pt-6 pb-2">
|
||||
<div className="flex bg-slate-100 dark:bg-black/20 p-1 rounded-full border border-black/5 dark:border-white/5 mx-auto max-w-[280px]">
|
||||
<div className="flex bg-slate-100 dark:bg-black/20 p-1 rounded-full border border-black/5 dark:border-white/5 mx-auto w-full">
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
|
|
@ -121,7 +123,7 @@ export function InputPanel() {
|
|||
)}
|
||||
<div className="relative flex items-center gap-2 z-10">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-black uppercase tracking-wider">{tab.label}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-wider hidden sm:inline">{tab.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -129,7 +131,7 @@ export function InputPanel() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Complexity Toggle */}
|
||||
{/* Complexity Toggle - Only for text intent flow */}
|
||||
{(activeTab === 'image' || activeTab === 'describe') && (
|
||||
<div className="px-4 py-3">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 pl-1 mb-2 block">Diagram Complexity</label>
|
||||
|
|
@ -164,13 +166,14 @@ export function InputPanel() {
|
|||
<div className="flex-1 px-4 pb-6 overflow-y-auto hide-scrollbar">
|
||||
{activeTab === 'image' && <ImageUpload />}
|
||||
{activeTab === 'code' && <CodeEditor />}
|
||||
{activeTab === 'comfy' && <ComfyImportPanel />}
|
||||
|
||||
{activeTab === 'describe' && (
|
||||
<div className="h-full flex flex-col animate-slide-up">
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe your diagram in natural language...
|
||||
|
||||
Example: Create a user registration flow with login, verification, and dashboard access"
|
||||
className="w-full flex-1 p-5 rounded-2xl bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-slate-200 text-sm resize-none outline-none focus:border-blue-500/40 focus:ring-2 focus:ring-blue-500/20 transition-all font-sans leading-relaxed placeholder:text-slate-400 dark:placeholder:text-slate-600"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Eye, Server, Database, Smartphone, Layers, BoxSelect } from 'lucide-react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Eye, Server, Database, Smartphone, Layers, BoxSelect, Share2 } from 'lucide-react';
|
||||
import { useFlowStore } from '../store';
|
||||
|
||||
const filters = [
|
||||
|
|
@ -8,25 +8,98 @@ const filters = [
|
|||
{ id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' },
|
||||
{ id: 'filter-group', label: 'Groups', icon: BoxSelect, color: '#94a3b8' },
|
||||
{ id: 'filter-other', label: 'Flow / Other', icon: Layers, color: '#f59e0b' },
|
||||
{ id: 'filter-edge', label: 'Connections', icon: Share2, color: '#64748b' },
|
||||
];
|
||||
|
||||
export default function InteractiveLegend() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { focusMode, activeFilters, toggleFilter, nodes } = useFlowStore();
|
||||
const { focusMode, activeFilters, toggleFilter, setActiveFilters, nodes, edges } = useFlowStore();
|
||||
|
||||
// EFFECT: Auto-register new dynamic categories (labels) into activeFilters so they show by default
|
||||
const [knownDynamicLabels, setKnownDynamicLabels] = useState<Set<string>>(new Set());
|
||||
|
||||
const dynamicFilters = useMemo(() => {
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) return [];
|
||||
|
||||
const labels = new Set<string>();
|
||||
nodes.forEach(node => {
|
||||
if (node.type !== 'group' && node.data?.label) {
|
||||
// Heuristic: If label looks like a type (AlphaNumeric, no spaces preferably, or short)
|
||||
const label = String(node.data.label).trim();
|
||||
if (label.length < 30) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sortedLabels = Array.from(labels).sort();
|
||||
|
||||
// Generate consistent colors for labels
|
||||
const stringToColor = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
|
||||
return '#' + '00000'.substring(0, 6 - c.length) + c;
|
||||
};
|
||||
|
||||
return sortedLabels.map(label => ({
|
||||
id: `dyn-${label}`,
|
||||
label: label,
|
||||
icon: BoxSelect,
|
||||
color: stringToColor(label),
|
||||
isDynamic: true
|
||||
}));
|
||||
}, [nodes]);
|
||||
|
||||
// Sync effect to auto-enable new filters
|
||||
useEffect(() => {
|
||||
if (!activeFilters) return;
|
||||
|
||||
const newLabels = dynamicFilters.filter(f => !knownDynamicLabels.has(f.id));
|
||||
if (newLabels.length > 0) {
|
||||
const nextKnown = new Set(knownDynamicLabels);
|
||||
const idsToAdd: string[] = [];
|
||||
|
||||
newLabels.forEach(f => {
|
||||
nextKnown.add(f.id);
|
||||
// Only add to active if NOT ALREADY THERE. Default to ON.
|
||||
if (!activeFilters.includes(f.id)) {
|
||||
idsToAdd.push(f.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (idsToAdd.length > 0) {
|
||||
// Use a timeout to avoid render-cycle issues with Zustand updates inside useEffect
|
||||
setTimeout(() => {
|
||||
setActiveFilters([...activeFilters, ...idsToAdd]);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
setKnownDynamicLabels(nextKnown);
|
||||
}
|
||||
}, [dynamicFilters, activeFilters, knownDynamicLabels, setActiveFilters]);
|
||||
|
||||
if (focusMode) return null;
|
||||
|
||||
// Calculate available categories from current nodes
|
||||
const availableCategories = new Set(nodes.map(node => node.data.category));
|
||||
const availableCategories = new Set((nodes || []).map(node => node.data?.category));
|
||||
|
||||
// Always include 'filter-group' if there are groups, or maybe check node type
|
||||
if (nodes.some(n => n.type === 'group')) {
|
||||
// Always include 'filter-group' if there are groups
|
||||
if ((nodes || []).some(n => n.type === 'group')) {
|
||||
availableCategories.add('filter-group');
|
||||
}
|
||||
|
||||
const visibleFilters = filters.filter(f => availableCategories.has(f.id));
|
||||
// Always include 'filter-edge' if there are edges
|
||||
if (edges && Array.isArray(edges) && edges.length > 0) {
|
||||
availableCategories.add('filter-edge');
|
||||
}
|
||||
|
||||
if (visibleFilters.length === 0) return null;
|
||||
const visibleStaticFilters = filters.filter(f => availableCategories.has(f.id));
|
||||
const allVisibleFilters = [...visibleStaticFilters, ...dynamicFilters];
|
||||
|
||||
if (allVisibleFilters.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-20 left-4 z-50">
|
||||
|
|
@ -44,12 +117,12 @@ export default function InteractiveLegend() {
|
|||
|
||||
{/* Panel */}
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-12 left-0 floating-glass border titanium-border rounded-xl p-3 min-w-[140px] shadow-xl animate-fade-in">
|
||||
<div className="absolute bottom-12 left-0 floating-glass border titanium-border rounded-xl p-3 min-w-[140px] max-h-[60vh] overflow-y-auto shadow-xl animate-fade-in hide-scrollbar">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-tertiary mb-3 border-b titanium-border pb-2">
|
||||
Legend Filters
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{visibleFilters.map(f => {
|
||||
{allVisibleFilters.map(f => {
|
||||
const isActive = activeFilters.includes(f.id);
|
||||
return (
|
||||
<button
|
||||
|
|
@ -61,7 +134,7 @@ export default function InteractiveLegend() {
|
|||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: f.color }}
|
||||
/>
|
||||
<span className="text-[11px] font-bold tracking-tight">{f.label}</span>
|
||||
<span className="text-[11px] font-bold tracking-tight text-left truncate">{f.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
168
src/components/MermaidStaticViewer.tsx
Normal file
168
src/components/MermaidStaticViewer.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import { useFlowStore } from '../store';
|
||||
import { ZoomIn, ZoomOut, Maximize, AlertCircle, MousePointer2 } from 'lucide-react';
|
||||
|
||||
export function MermaidStaticViewer() {
|
||||
const { mermaidCode, theme, setViewMode } = useFlowStore();
|
||||
const [svgContent, setSvgContent] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const renderMermaid = async () => {
|
||||
if (!mermaidCode) {
|
||||
setSvgContent('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure mermaid based on theme
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'Instrument Sans, sans-serif',
|
||||
});
|
||||
|
||||
// Generate unique ID for the SVG
|
||||
const id = `mermaid-${Date.now()}`;
|
||||
const { svg } = await mermaid.render(id, mermaidCode);
|
||||
|
||||
setSvgContent(svg);
|
||||
setError(null);
|
||||
// Reset view on new render
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
} catch (err) {
|
||||
console.error("Mermaid Render Error", err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to render Mermaid diagram');
|
||||
}
|
||||
};
|
||||
|
||||
renderMermaid();
|
||||
}, [mermaidCode, theme]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setScale(s => Math.min(Math.max(0.1, s * delta), 8));
|
||||
} else {
|
||||
// Pan
|
||||
setPosition(p => ({ x: p.x - e.deltaX, y: p.y - e.deltaY }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const fitView = () => {
|
||||
setScale(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
if (!mermaidCode) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 dark:text-slate-500 italic relative">
|
||||
<div className="mb-4">No Mermaid code to render</div>
|
||||
<button
|
||||
onClick={() => setViewMode('interactive')}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-500/10 hover:bg-blue-100 dark:hover:bg-blue-500/20 rounded-lg text-blue-600 dark:text-blue-400 transition-colors font-bold text-xs"
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4" />
|
||||
Back to Interactive
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-red-500 p-8 text-center bg-slate-50 dark:bg-slate-900 relative">
|
||||
<AlertCircle className="w-10 h-10 mb-4 opacity-50" />
|
||||
<h3 className="font-bold mb-2">Rendering Error</h3>
|
||||
<p className="text-xs font-mono bg-red-50 dark:bg-red-900/10 p-4 rounded-xl max-w-lg overflow-auto select-text mb-6">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setViewMode('interactive')}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5 rounded-lg text-slate-600 dark:text-slate-300 transition-colors font-bold text-xs"
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4" />
|
||||
Back to Interactive
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-slate-50 dark:bg-[#0B1221] relative overflow-hidden flex flex-col select-none">
|
||||
{/* Controls */}
|
||||
<div className="absolute top-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div className="flex bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm shadow-xl rounded-xl border border-slate-200 dark:border-white/10 p-1">
|
||||
<button onClick={() => setScale(s => Math.min(8, s * 1.2))} className="p-2 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg text-slate-500 dark:text-slate-400" title="Zoom In">
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setScale(s => Math.max(0.1, s / 1.2))} className="p-2 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg text-slate-500 dark:text-slate-400" title="Zoom Out">
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={fitView} className="p-2 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg text-slate-500 dark:text-slate-400" title="Fit View">
|
||||
<Maximize className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200 dark:bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('interactive')}
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg text-blue-600 dark:text-blue-400 transition-colors font-bold text-xs uppercase tracking-wider"
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4" />
|
||||
Interactive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Viewport */}
|
||||
<div
|
||||
className="w-full h-full cursor-move flex items-center justify-center overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
className="mermaid-svg-container"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4 pointer-events-none opacity-50 text-[10px] text-slate-400 font-mono">
|
||||
Static View • {Math.round(scale * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/editor/ComfyImportPanel.tsx
Normal file
116
src/components/editor/ComfyImportPanel.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import React, { useState } from 'react';
|
||||
import { workflowToMermaid } from '../../lib/comfy2mermaid';
|
||||
import { Upload, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { useFlowStore } from '../../store';
|
||||
import { parseMermaid } from '../../lib/mermaidParser';
|
||||
import { getLayoutedElements } from '../../lib/layoutEngine';
|
||||
|
||||
export function ComfyImportPanel() {
|
||||
const {
|
||||
setMermaidCode,
|
||||
setSourceCode,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setInputActiveTab,
|
||||
saveDiagram
|
||||
} = useFlowStore();
|
||||
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const text = e.target?.result as string;
|
||||
setJsonInput(text);
|
||||
convert(text);
|
||||
} catch (err) {
|
||||
setError("Failed to read file");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const convert = async (input: string) => {
|
||||
setError(null);
|
||||
if (!input.trim()) return;
|
||||
|
||||
try {
|
||||
const workflow = JSON.parse(input);
|
||||
const code = workflowToMermaid(workflow);
|
||||
|
||||
// Update Store
|
||||
setMermaidCode(code);
|
||||
setSourceCode(code);
|
||||
|
||||
// Process Diagram for visualization
|
||||
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(code);
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
||||
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(layoutedEdges);
|
||||
|
||||
// Auto-save
|
||||
saveDiagram(`ComfyUI Import ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
// Switch back to Code tab to see result
|
||||
setInputActiveTab('code');
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Invalid JSON or conversion error: " + (err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-slide-up">
|
||||
<div className="p-4 mb-4 bg-slate-50 dark:bg-black/20 rounded-xl border border-dashed border-slate-300 dark:border-white/10">
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 cursor-pointer hover:bg-slate-100 dark:hover:bg-white/5 transition rounded-lg group">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<Upload className="w-8 h-8 mb-3 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">Click to upload</span> or drag workflow.json
|
||||
</p>
|
||||
</div>
|
||||
<input type="file" className="hidden" accept=".json" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 pl-1">
|
||||
Or Paste JSON
|
||||
</label>
|
||||
<textarea
|
||||
className="flex-1 w-full p-4 rounded-xl bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 text-xs font-mono text-slate-600 dark:text-slate-300 resize-none outline-none focus:border-blue-500/40 focus:ring-2 focus:ring-blue-500/20 transition-all placeholder:text-slate-400"
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
placeholder='{ "nodes": [], "links": [] }'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-500/30 text-red-600 dark:text-red-300 p-3 rounded-lg flex items-center gap-2 text-xs">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => convert(jsonInput)}
|
||||
disabled={!jsonInput.trim()}
|
||||
className={`mt-4 w-full py-3.5 rounded-xl font-bold text-sm tracking-wide transition-all duration-300 flex items-center justify-center gap-2
|
||||
${!jsonInput.trim()
|
||||
? 'bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-600/20 active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Convert & Visualize
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/lib/comfy2mermaid.test.ts
Normal file
50
src/lib/comfy2mermaid.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { workflowToMermaid } from './comfy2mermaid';
|
||||
|
||||
describe('comfy2mermaid', () => {
|
||||
it('converts a simple workflow', () => {
|
||||
const workflow = {
|
||||
last_node_id: 2,
|
||||
last_link_id: 1,
|
||||
nodes: [
|
||||
{ id: 1, type: "LoadImage", title: "Load Image" },
|
||||
{ id: 2, type: "PreviewImage", title: "Preview Image" }
|
||||
],
|
||||
links: [
|
||||
[1, 1, 0, 2, 0, "IMAGE"]
|
||||
],
|
||||
version: 0.4
|
||||
};
|
||||
|
||||
const mermaidCode = workflowToMermaid(workflow as any);
|
||||
|
||||
expect(mermaidCode).toContain('graph TD');
|
||||
expect(mermaidCode).toContain('N1["Load Image"]');
|
||||
expect(mermaidCode).toContain('N2["Preview Image"]');
|
||||
expect(mermaidCode).toContain('N1 -- IMAGE --> N2');
|
||||
});
|
||||
|
||||
it('handles groups', () => {
|
||||
const workflow = {
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{ id: 1, type: "A", pos: [100, 100], size: [50, 50] }, // Inside group
|
||||
{ id: 2, type: "B", pos: [500, 500], size: [50, 50] } // Outside group
|
||||
],
|
||||
links: [],
|
||||
groups: [
|
||||
{ title: "My Group", bounding: [0, 0, 200, 200] }
|
||||
]
|
||||
};
|
||||
|
||||
const mermaidCode = workflowToMermaid(workflow as any);
|
||||
expect(mermaidCode).toContain('subgraph G0 ["My Group"]');
|
||||
expect(mermaidCode).toContain('N1');
|
||||
// Should not contain N2 inside the subgraph block, but regex is hard.
|
||||
// Logic check:
|
||||
// subgraph G0 ["My Group"]
|
||||
// N1
|
||||
// end
|
||||
});
|
||||
});
|
||||
276
src/lib/comfy2mermaid.ts
Normal file
276
src/lib/comfy2mermaid.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import mermaid from 'mermaid';
|
||||
|
||||
// --- Types ---
|
||||
export interface ComfyNode {
|
||||
id: number;
|
||||
type: string;
|
||||
pos?: [number, number];
|
||||
size?: { "0": number; "1": number } | [number, number];
|
||||
flags?: Record<string, any>;
|
||||
order?: number;
|
||||
mode?: number;
|
||||
inputs?: any[];
|
||||
outputs?: any[];
|
||||
properties?: Record<string, any>;
|
||||
widgets_values?: any[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ComfyLink {
|
||||
// [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
0: number; // link_id
|
||||
1: number; // origin_id
|
||||
2: number; // origin_slot
|
||||
3: number; // target_id
|
||||
4: number; // target_slot
|
||||
5: string; // type
|
||||
}
|
||||
|
||||
export interface ComfyGroup {
|
||||
title: string;
|
||||
bounding: [number, number, number, number]; // [x, y, w, h]
|
||||
color?: string;
|
||||
font_size?: number;
|
||||
}
|
||||
|
||||
export interface ComfyWorkflow {
|
||||
last_node_id: number;
|
||||
last_link_id: number;
|
||||
nodes: ComfyNode[];
|
||||
links: ComfyLink[];
|
||||
groups?: ComfyGroup[];
|
||||
version?: number;
|
||||
extra?: any;
|
||||
}
|
||||
|
||||
export interface MermaidConfig {
|
||||
Default_Graph_Direction?: string;
|
||||
Default_Connector?: string;
|
||||
Default_Node_Style?: string;
|
||||
Generate_ComfyUI_Subgraphs?: boolean;
|
||||
Style_Definitions?: Record<string, string>;
|
||||
Node_Group?: Array<{ group_name: string; nodes: string[] }>;
|
||||
Default_Node_Shape?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
const CONTRAST_THRESHOLD = 4.5;
|
||||
const DEFAULT_SHAPE_SYNTAX: [string, string] = ['[', ']'];
|
||||
|
||||
const MERMAID_SHAPE_SYNTAX: Record<string, [string, string]> = {
|
||||
rectangle: ['[', ']'],
|
||||
round: ['(', ')'],
|
||||
stadium: ['([', '])'],
|
||||
subroutine: ['[[', ']]'],
|
||||
cylinder: ['[(', ')]'],
|
||||
circle: ['((', '))'],
|
||||
rhombus: ['{', '}'],
|
||||
diamond: ['{', '}'],
|
||||
hexagon: ['{{', '}}'],
|
||||
parallelogram: ['[/', '/]'],
|
||||
parallelogram_alt: ['[\\', '\\]'],
|
||||
trapezoid: ['[/', '\\]'],
|
||||
trapezoid_alt: ['[\\', '/]'],
|
||||
double_circle: ['(((', ')))'],
|
||||
database: ['[(', ')]'],
|
||||
};
|
||||
|
||||
const LINK_LABEL_FORMATS: Record<string, string> = {
|
||||
"-->": "-- {} -->", "---": "-- {} ---", "-.->": "-. {} .->", "-.-": "-. {} .-",
|
||||
"==>": "== {} ==>", "===": "== {} ===", "--o": "-- {} --o", "o--": "o-- {} --",
|
||||
"o--o": "o-- {} --o", "--x": "-- {} --x", "x--": "x-- {} --", "x--x": "x-- {} --x",
|
||||
"<-->": "<-- {} -->", "<-.->": "<-. {} .->", "<==>": "<== {} ==>",
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getMermaidShapeSyntax(shapeName: string): [string, string] {
|
||||
return MERMAID_SHAPE_SYNTAX[shapeName.toLowerCase()] || DEFAULT_SHAPE_SYNTAX;
|
||||
}
|
||||
|
||||
function parseColor(colorString: string): [number, number, number] | null {
|
||||
if (!colorString) return null;
|
||||
const str = colorString.trim().toLowerCase();
|
||||
|
||||
if (str.startsWith('#')) {
|
||||
const hex = str.slice(1);
|
||||
if (hex.length === 3) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
if (!isNaN(r)) return [r, g, b];
|
||||
} else if (hex.length === 6) {
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
if (!isNaN(r)) return [r, g, b];
|
||||
}
|
||||
}
|
||||
|
||||
// Basic colors fallback
|
||||
const basicColors: Record<string, [number, number, number]> = {
|
||||
"white": [255, 255, 255], "black": [0, 0, 0], "red": [255, 0, 0],
|
||||
"green": [0, 128, 0], "blue": [0, 0, 255], "yellow": [255, 255, 0],
|
||||
"cyan": [0, 255, 255], "magenta": [255, 0, 255],
|
||||
"gray": [128, 128, 128], "grey": [128, 128, 128]
|
||||
};
|
||||
|
||||
return basicColors[str] || null;
|
||||
}
|
||||
|
||||
function getRelativeLuminance(rgb: [number, number, number]): number {
|
||||
const [r, g, b] = rgb.map(v => {
|
||||
const val = v / 255.0;
|
||||
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function calculateContrastRatio(rgb1: [number, number, number], rgb2: [number, number, number]): number {
|
||||
const lum1 = getRelativeLuminance(rgb1);
|
||||
const lum2 = getRelativeLuminance(rgb2);
|
||||
return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
|
||||
}
|
||||
|
||||
function adjustTextColorForBackground(styleString: string): string {
|
||||
if (!styleString) return styleString;
|
||||
const parts = styleString.split(',').map(p => p.trim()).filter(Boolean);
|
||||
|
||||
// Check if color is already explicitly set
|
||||
if (parts.some(p => p.toLowerCase().startsWith('color') || p.toLowerCase().startsWith('stroke'))) {
|
||||
// simplified check, effectively what python regex did
|
||||
}
|
||||
|
||||
let fillColorValue = null;
|
||||
for (const part of parts) {
|
||||
if (part.toLowerCase().startsWith('fill:')) {
|
||||
fillColorValue = part.split(':', 2)[1]?.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fillColorValue) return styleString;
|
||||
const bgRgb = parseColor(fillColorValue);
|
||||
if (!bgRgb) return styleString;
|
||||
|
||||
// Assuming default text color is white for dark mode (implied by python script)
|
||||
const contrast = calculateContrastRatio(bgRgb, [255, 255, 255]);
|
||||
if (contrast < CONTRAST_THRESHOLD) {
|
||||
return styleString + ",color:#000";
|
||||
}
|
||||
return styleString;
|
||||
}
|
||||
|
||||
|
||||
// --- Main Conversion Logic ---
|
||||
|
||||
export function workflowToMermaid(workflow: ComfyWorkflow, config: MermaidConfig = {}): string {
|
||||
// Config defaults
|
||||
const graphDirection = config.Default_Graph_Direction || 'LR';
|
||||
const defaultConnector = config.Default_Connector || '-->';
|
||||
const defaultNodeShape = config.Default_Node_Shape || 'rectangle';
|
||||
|
||||
// State maps
|
||||
const nodeIdToDisplayLabel: Record<number, string> = {};
|
||||
const nodeIdToType: Record<number, string> = {};
|
||||
const nodesInGroups = new Set<number>();
|
||||
|
||||
// Pre-process nodes
|
||||
workflow.nodes.forEach(node => {
|
||||
const title = node.title || node.type || 'Unknown';
|
||||
nodeIdToDisplayLabel[node.id] = title;
|
||||
nodeIdToType[node.id] = node.type;
|
||||
});
|
||||
|
||||
const mermaidLines: string[] = [];
|
||||
mermaidLines.push(`graph ${graphDirection}`);
|
||||
|
||||
// 1. Process Groups (Subgraphs)
|
||||
if (workflow.groups) {
|
||||
workflow.groups.forEach((group, index) => {
|
||||
const [gx, gy, gw, gh] = group.bounding;
|
||||
const groupNodes: ComfyNode[] = [];
|
||||
|
||||
workflow.nodes.forEach(node => {
|
||||
if (!node.pos) return;
|
||||
const [nx, ny] = node.pos;
|
||||
// Determine node center (approximate size if missing)
|
||||
const width = (node.size && (Array.isArray(node.size) ? node.size[0] : node.size["0"])) || 100;
|
||||
const height = (node.size && (Array.isArray(node.size) ? node.size[1] : node.size["1"])) || 50;
|
||||
const cx = nx + width / 2;
|
||||
const cy = ny + height / 2;
|
||||
|
||||
// Check inclusion
|
||||
if (cx >= gx && cx <= gx + gw && cy >= gy && cy <= gy + gh) {
|
||||
groupNodes.push(node);
|
||||
nodesInGroups.add(node.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (groupNodes.length > 0) {
|
||||
const groupId = `G${index}`;
|
||||
const sanitizedTitle = group.title.replace(/"/g, '#quot;');
|
||||
|
||||
mermaidLines.push(` subgraph ${groupId} ["${sanitizedTitle}"]`);
|
||||
mermaidLines.push(` direction ${graphDirection}`); // Groups usually flow same direction
|
||||
|
||||
groupNodes.forEach(node => {
|
||||
const nodeId = `N${node.id}`;
|
||||
const label = nodeIdToDisplayLabel[node.id].replace(/"/g, '#quot;');
|
||||
const [open, close] = getMermaidShapeSyntax(defaultNodeShape);
|
||||
mermaidLines.push(` ${nodeId}${open}"${label}"${close}`);
|
||||
// Force basic class
|
||||
mermaidLines.push(` class ${nodeId} defaultNode`);
|
||||
});
|
||||
|
||||
mermaidLines.push(` end`);
|
||||
|
||||
// Apply Group Style
|
||||
if (group.color) {
|
||||
let hex = group.color.toLowerCase();
|
||||
mermaidLines.push(` style ${groupId} fill:${hex},stroke:${hex},color:#fff,stroke-width:2px,fill-opacity:0.6`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Process Ungrouped Nodes
|
||||
workflow.nodes.forEach(node => {
|
||||
if (!nodesInGroups.has(node.id)) {
|
||||
const nodeId = `N${node.id}`;
|
||||
const label = nodeIdToDisplayLabel[node.id].replace(/"/g, '#quot;');
|
||||
const [open, close] = getMermaidShapeSyntax(defaultNodeShape);
|
||||
mermaidLines.push(` ${nodeId}${open}"${label}"${close}`);
|
||||
mermaidLines.push(` class ${nodeId} defaultNode`);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Process Links
|
||||
mermaidLines.push(" %% Connections");
|
||||
workflow.links.forEach((link) => {
|
||||
const startNodeId = `N${link[1]}`;
|
||||
const endNodeId = `N${link[3]}`;
|
||||
const connector = defaultConnector;
|
||||
const type = String(link[5] || '').toUpperCase();
|
||||
|
||||
// Optional: filter out boring link types if needed
|
||||
|
||||
let linkText = ` ${startNodeId} ${connector} ${endNodeId}`;
|
||||
if (type && config.Add_Link_Labels !== false) {
|
||||
const format = LINK_LABEL_FORMATS[connector] || "-- {} -->";
|
||||
const label = type.replace(/"/g, '#quot;');
|
||||
const connectorWithLabel = format.replace('{}', label);
|
||||
linkText = ` ${startNodeId} ${connectorWithLabel} ${endNodeId}`;
|
||||
}
|
||||
mermaidLines.push(linkText);
|
||||
});
|
||||
|
||||
// 4. Global Styles
|
||||
// Dark theme aesthetic: Dark blocks, white text, subtle edges
|
||||
mermaidLines.push(" classDef defaultNode fill:#353535,stroke:#555,stroke-width:1px,color:#eee,rx:5,ry:5;");
|
||||
mermaidLines.push(" classDef default fill:#353535,stroke:#555,stroke-width:1px,color:#eee;");
|
||||
mermaidLines.push(" linkStyle default stroke:#777,stroke-width:1px,fill:none;");
|
||||
|
||||
return mermaidLines.join('\n');
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import { InputPanel } from '../components/InputPanel';
|
||||
import { FlowCanvas } from '../components/FlowCanvas';
|
||||
import { MermaidStaticViewer } from '../components/MermaidStaticViewer';
|
||||
import { NodeDetailsPanel } from '../components/NodeDetailsPanel';
|
||||
import InteractiveLegend from '../components/InteractiveLegend';
|
||||
import OnboardingTour from '../components/OnboardingTour';
|
||||
|
|
@ -17,7 +18,7 @@ export function Editor() {
|
|||
// Enable Programmatic API
|
||||
useDiagramAPI();
|
||||
|
||||
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode, mobileEditorOpen, setMobileEditorOpen } = useFlowStore();
|
||||
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode, mobileEditorOpen, setMobileEditorOpen, viewMode } = useFlowStore();
|
||||
const { isMobile } = useMobileDetect();
|
||||
const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96
|
||||
const isResizing = useRef(false);
|
||||
|
|
@ -130,7 +131,11 @@ export function Editor() {
|
|||
|
||||
{/* Panoramic Canvas */}
|
||||
<main className="flex-1 relative overflow-hidden">
|
||||
<FlowCanvas />
|
||||
{viewMode === 'static' ? (
|
||||
<MermaidStaticViewer />
|
||||
) : (
|
||||
<FlowCanvas />
|
||||
)}
|
||||
|
||||
<InteractiveLegend />
|
||||
<OnboardingTour />
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ export function useFlowStore() {
|
|||
setLoading: ui.setLoading,
|
||||
setError: ui.setError,
|
||||
|
||||
// View Mode
|
||||
viewMode: ui.viewMode,
|
||||
setViewMode: ui.setViewMode,
|
||||
|
||||
// Combined reset
|
||||
reset: () => {
|
||||
diagram.reset();
|
||||
|
|
|
|||
|
|
@ -51,9 +51,13 @@ interface UIState {
|
|||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clearError: () => void;
|
||||
|
||||
// View Mode
|
||||
viewMode: 'interactive' | 'static';
|
||||
setViewMode: (mode: 'interactive' | 'static') => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other', 'filter-group'];
|
||||
const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other', 'filter-group', 'filter-edge'];
|
||||
|
||||
export const useUIStore = create<UIState>()((set, get) => ({
|
||||
// Initial state
|
||||
|
|
@ -108,4 +112,8 @@ export const useUIStore = create<UIState>()((set, get) => ({
|
|||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
// View Mode actions
|
||||
viewMode: 'interactive',
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue