feat: Add ComfyUI import, static view mode, and dashboard controls

This commit is contained in:
SysVis AI 2026-01-05 18:39:08 +07:00
parent 5ada3b1fcd
commit 5af1e14750
12 changed files with 1073 additions and 220 deletions

235
package-lock.json generated
View file

@ -34,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@rollup/rollup-darwin-arm64": "^4.55.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
@ -626,9 +627,9 @@
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.7.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -1078,9 +1079,9 @@
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1235,9 +1236,9 @@
} }
}, },
"node_modules/@exodus/bytes": { "node_modules/@exodus/bytes": {
"version": "1.7.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz",
"integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==", "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2089,15 +2090,14 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.54.0", "version": "4.55.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "integrity": "sha512-iiSGJu03Vsi2+Zz9PRbJ18icTAte/Geh/3f5T94DGDwuCa2GBY0MwIyvgZNV6Hur5fBgEBsUUqIZ/cPC8r9B/g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"darwin" "darwin"
] ]
@ -2369,9 +2369,9 @@
] ]
}, },
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.34.45", "version": "0.34.46",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.45.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.46.tgz",
"integrity": "sha512-qJcFVfCa5jxBFSuv7S5WYbA8XdeCPmhnaVVfX/2Y6L8WYg8sk3XY2+6W0zH+3mq1Cz+YC7Ki66HfqX6IHAwnkg==", "integrity": "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -3226,20 +3226,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz",
"integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==", "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/type-utils": "8.51.0",
"@typescript-eslint/utils": "8.50.1", "@typescript-eslint/utils": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.1", "@typescript-eslint/visitor-keys": "8.51.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3249,7 +3249,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.50.1", "@typescript-eslint/parser": "^8.51.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
@ -3265,16 +3265,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz",
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.1", "@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -3290,14 +3290,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz",
"integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.50.1", "@typescript-eslint/tsconfig-utils": "^8.51.0",
"@typescript-eslint/types": "^8.50.1", "@typescript-eslint/types": "^8.51.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -3312,14 +3312,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz",
"integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.1" "@typescript-eslint/visitor-keys": "8.51.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3330,9 +3330,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz",
"integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3347,17 +3347,17 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz",
"integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==", "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/utils": "8.50.1", "@typescript-eslint/utils": "8.51.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3372,9 +3372,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
"integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==", "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3386,21 +3386,21 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz",
"integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.50.1", "@typescript-eslint/project-service": "8.51.0",
"@typescript-eslint/tsconfig-utils": "8.50.1", "@typescript-eslint/tsconfig-utils": "8.51.0",
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.1", "@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"minimatch": "^9.0.4", "minimatch": "^9.0.4",
"semver": "^7.6.0", "semver": "^7.6.0",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3453,16 +3453,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz",
"integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.1" "@typescript-eslint/typescript-estree": "8.51.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3477,13 +3477,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz",
"integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.51.0",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@ -3936,9 +3936,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001761", "version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4163,20 +4163,31 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cssstyle": { "node_modules/cssstyle": {
"version": "5.3.5", "version": "5.3.6",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz",
"integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/css-color": "^4.1.1", "@asamuzakjp/css-color": "^4.1.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.21", "@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": { "engines": {
"node": ">=20" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -5103,9 +5114,9 @@
} }
}, },
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.6.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@ -6081,9 +6092,9 @@
} }
}, },
"node_modules/lib0": { "node_modules/lib0": {
"version": "0.2.116", "version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.116.tgz", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"isomorphic.js": "^0.2.4" "isomorphic.js": "^0.2.4"
@ -7332,6 +7343,20 @@
"fsevents": "~2.3.2" "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": { "node_modules/roughjs": {
"version": "4.6.6", "version": "4.6.6",
"resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
@ -7826,9 +7851,9 @@
} }
}, },
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.3.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -7894,16 +7919,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.50.1", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz",
"integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.50.1", "@typescript-eslint/eslint-plugin": "8.51.0",
"@typescript-eslint/parser": "8.50.1", "@typescript-eslint/parser": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/utils": "8.50.1" "@typescript-eslint/utils": "8.51.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -8214,9 +8239,9 @@
} }
}, },
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "8.0.0", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
@ -8404,9 +8429,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/yjs": { "node_modules/yjs": {
"version": "13.6.28", "version": "13.6.29",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lib0": "^0.2.99" "lib0": "^0.2.99"
@ -8434,9 +8459,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.2.1", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {

View file

@ -52,6 +52,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@rollup/rollup-darwin-arm64": "^4.55.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",

View file

@ -14,7 +14,7 @@ import { nodeTypes } from './nodes/CustomNodes';
import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge'; import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge';
import { import {
Spline, Minus, Plus, Maximize, Map, Wand2, Spline, Minus, Plus, Maximize, Map, Wand2,
Hand, MousePointer2, Settings2, ChevronDown Hand, MousePointer2, Settings2, ChevronDown, FileImage, Trash2
} from 'lucide-react'; } from 'lucide-react';
import { getLayoutedElements } from '../lib/layoutEngine'; import { getLayoutedElements } from '../lib/layoutEngine';
@ -25,7 +25,7 @@ export function FlowCanvas() {
const { const {
nodes, edges, onNodesChange, onEdgesChange, onConnect, nodes, edges, onNodesChange, onEdgesChange, onConnect,
setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters, setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters,
setNodes, setEdges, focusMode setNodes, setEdges, focusMode, viewMode, setViewMode
} = useFlowStore(); } = useFlowStore();
const { isMobile } = useMobileDetect(); const { isMobile } = useMobileDetect();
const { zoomIn, zoomOut, fitView } = useReactFlow(); 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 { return {
...node, ...node,
hidden: !activeFilters.includes(category) hidden: !isVisible
}; };
}); });
}, [nodes, activeFilters]); }, [nodes, activeFilters]);
@ -146,14 +241,18 @@ export function FlowCanvas() {
}); });
}, [edges, edgeStyle]); }, [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(() => { 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)); const visibleNodeIds = new Set(filteredNodes.filter(n => !n.hidden).map(n => n.id));
return styledEdges.map(edge => ({ return styledEdges.map(edge => ({
...edge, ...edge,
hidden: !visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target) hidden: !visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)
})); }));
}, [styledEdges, filteredNodes]); }, [styledEdges, filteredNodes, activeFilters]);
// Node click handler - bidirectional highlighting // Node click handler - bidirectional highlighting
const onNodeClick = useCallback((_event: React.MouseEvent, node: any) => { const onNodeClick = useCallback((_event: React.MouseEvent, node: any) => {
@ -234,96 +333,121 @@ export function FlowCanvas() {
{/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */} {/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */}
{!isMobile && ( {!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' : ''}`}> <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"> <div className="flex items-center gap-2">
<button {/* Clear Dashboard Button */}
onClick={() => setShowToolkit(!showToolkit)} {nodes.length > 0 && (
className={` <button
h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none onClick={() => {
${showToolkit setNodes([]);
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20' setEdges([]);
: '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'} useFlowStore.getState().setMermaidCode('');
`} useFlowStore.getState().setDescription('');
> useFlowStore.getState().setSourceCode('');
<Settings2 className="w-4 h-4" /> }}
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span> 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"
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} /> title="Clear Dashboard"
</button> >
<Trash2 className="w-5 h-5" />
{/* Dropdown Menu */} </button>
{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>
<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> </Panel>
)} )}

View file

@ -1,13 +1,14 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { ImageUpload } from './ImageUpload'; import { ImageUpload } from './ImageUpload';
import { CodeEditor } from './CodeEditor'; 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 { useFlowStore } from '../store';
import { interpretText } from '../lib/aiService'; import { interpretText } from '../lib/aiService';
import { parseMermaid } from '../lib/mermaidParser'; import { parseMermaid } from '../lib/mermaidParser';
import { getLayoutedElements } from '../lib/layoutEngine'; import { getLayoutedElements } from '../lib/layoutEngine';
type Tab = 'image' | 'code' | 'describe'; type Tab = 'image' | 'code' | 'describe' | 'comfy'; // Add 'comfy' tab type
export function InputPanel() { export function InputPanel() {
const { const {
@ -95,16 +96,17 @@ export function InputPanel() {
}, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode, setMermaidCode, saveDiagram]); }, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode, setMermaidCode, saveDiagram]);
const tabs = [ 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: '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 ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Floating Tabs */} {/* Floating Tabs */}
<div className="px-4 pt-6 pb-2"> <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 => { {tabs.map(tab => {
const Icon = tab.icon; const Icon = tab.icon;
const isActive = activeTab === tab.id; const isActive = activeTab === tab.id;
@ -121,7 +123,7 @@ export function InputPanel() {
)} )}
<div className="relative flex items-center gap-2 z-10"> <div className="relative flex items-center gap-2 z-10">
<Icon className="w-3.5 h-3.5" /> <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> </div>
</button> </button>
); );
@ -129,7 +131,7 @@ export function InputPanel() {
</div> </div>
</div> </div>
{/* Complexity Toggle */} {/* Complexity Toggle - Only for text intent flow */}
{(activeTab === 'image' || activeTab === 'describe') && ( {(activeTab === 'image' || activeTab === 'describe') && (
<div className="px-4 py-3"> <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> <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"> <div className="flex-1 px-4 pb-6 overflow-y-auto hide-scrollbar">
{activeTab === 'image' && <ImageUpload />} {activeTab === 'image' && <ImageUpload />}
{activeTab === 'code' && <CodeEditor />} {activeTab === 'code' && <CodeEditor />}
{activeTab === 'comfy' && <ComfyImportPanel />}
{activeTab === 'describe' && ( {activeTab === 'describe' && (
<div className="h-full flex flex-col animate-slide-up"> <div className="h-full flex flex-col animate-slide-up">
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Describe your diagram in natural language... placeholder="Describe your diagram in natural language...
Example: Create a user registration flow with login, verification, and dashboard access" 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" 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"
/> />

View file

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Eye, Server, Database, Smartphone, Layers, BoxSelect } from 'lucide-react'; import { Eye, Server, Database, Smartphone, Layers, BoxSelect, Share2 } from 'lucide-react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
const filters = [ const filters = [
@ -8,25 +8,98 @@ const filters = [
{ id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' }, { id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' },
{ id: 'filter-group', label: 'Groups', icon: BoxSelect, color: '#94a3b8' }, { id: 'filter-group', label: 'Groups', icon: BoxSelect, color: '#94a3b8' },
{ id: 'filter-other', label: 'Flow / Other', icon: Layers, color: '#f59e0b' }, { id: 'filter-other', label: 'Flow / Other', icon: Layers, color: '#f59e0b' },
{ id: 'filter-edge', label: 'Connections', icon: Share2, color: '#64748b' },
]; ];
export default function InteractiveLegend() { export default function InteractiveLegend() {
const [isOpen, setIsOpen] = useState(false); 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; if (focusMode) return null;
// Calculate available categories from current nodes // 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 // Always include 'filter-group' if there are groups
if (nodes.some(n => n.type === 'group')) { if ((nodes || []).some(n => n.type === 'group')) {
availableCategories.add('filter-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 ( return (
<div className="absolute bottom-20 left-4 z-50"> <div className="absolute bottom-20 left-4 z-50">
@ -44,12 +117,12 @@ export default function InteractiveLegend() {
{/* Panel */} {/* Panel */}
{isOpen && ( {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"> <div className="text-[10px] font-bold uppercase tracking-widest text-tertiary mb-3 border-b titanium-border pb-2">
Legend Filters Legend Filters
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{visibleFilters.map(f => { {allVisibleFilters.map(f => {
const isActive = activeFilters.includes(f.id); const isActive = activeFilters.includes(f.id);
return ( return (
<button <button
@ -61,7 +134,7 @@ export default function InteractiveLegend() {
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: f.color }} 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> </button>
); );
})} })}

View 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>
);
}

View 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>
);
}

View 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
View 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');
}

View file

@ -1,6 +1,7 @@
import { ReactFlowProvider } from '@xyflow/react'; import { ReactFlowProvider } from '@xyflow/react';
import { InputPanel } from '../components/InputPanel'; import { InputPanel } from '../components/InputPanel';
import { FlowCanvas } from '../components/FlowCanvas'; import { FlowCanvas } from '../components/FlowCanvas';
import { MermaidStaticViewer } from '../components/MermaidStaticViewer';
import { NodeDetailsPanel } from '../components/NodeDetailsPanel'; import { NodeDetailsPanel } from '../components/NodeDetailsPanel';
import InteractiveLegend from '../components/InteractiveLegend'; import InteractiveLegend from '../components/InteractiveLegend';
import OnboardingTour from '../components/OnboardingTour'; import OnboardingTour from '../components/OnboardingTour';
@ -17,7 +18,7 @@ export function Editor() {
// Enable Programmatic API // Enable Programmatic API
useDiagramAPI(); 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 { isMobile } = useMobileDetect();
const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96 const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96
const isResizing = useRef(false); const isResizing = useRef(false);
@ -130,7 +131,11 @@ export function Editor() {
{/* Panoramic Canvas */} {/* Panoramic Canvas */}
<main className="flex-1 relative overflow-hidden"> <main className="flex-1 relative overflow-hidden">
<FlowCanvas /> {viewMode === 'static' ? (
<MermaidStaticViewer />
) : (
<FlowCanvas />
)}
<InteractiveLegend /> <InteractiveLegend />
<OnboardingTour /> <OnboardingTour />

View file

@ -100,6 +100,10 @@ export function useFlowStore() {
setLoading: ui.setLoading, setLoading: ui.setLoading,
setError: ui.setError, setError: ui.setError,
// View Mode
viewMode: ui.viewMode,
setViewMode: ui.setViewMode,
// Combined reset // Combined reset
reset: () => { reset: () => {
diagram.reset(); diagram.reset();

View file

@ -51,9 +51,13 @@ interface UIState {
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearError: () => 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) => ({ export const useUIStore = create<UIState>()((set, get) => ({
// Initial state // Initial state
@ -108,4 +112,8 @@ export const useUIStore = create<UIState>()((set, get) => ({
setLoading: (isLoading) => set({ isLoading }), setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
clearError: () => set({ error: null }), clearError: () => set({ error: null }),
// View Mode actions
viewMode: 'interactive',
setViewMode: (viewMode) => set({ viewMode }),
})); }));