mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-01 12:07:17 +00:00
Compare commits
1 Commits
ft/filtere
...
graphMutat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea615fb8c |
568
package-lock.json
generated
568
package-lock.json
generated
@@ -975,32 +975,6 @@
|
||||
"integrity": "sha512-o6WFbYn9yAkGbkOwvhPF7pbKDvN0occZ21Tfyhya8CIsIqKpTHLft0aOqo4yhSh+kTxN16FYjsfrTH5Olk4WuA==",
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
|
||||
@@ -4717,17 +4691,6 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@trivago/prettier-plugin-sort-imports": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.0.tgz",
|
||||
@@ -4763,38 +4726,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
@@ -5720,15 +5651,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
|
||||
"deprecated": "Use your platform's native atob() and btoa() methods instead",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
@@ -5798,18 +5720,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-globals": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
|
||||
"integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.1.0",
|
||||
"acorn-walk": "^8.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
||||
@@ -5820,34 +5730,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
|
||||
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
|
||||
@@ -6058,14 +5940,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
@@ -7227,14 +7101,6 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
@@ -7305,58 +7171,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/cssom": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
|
||||
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
|
||||
"integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssom": "~0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/cssom": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
|
||||
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
||||
"integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"abab": "^2.0.6",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"whatwg-url": "^11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
@@ -7420,14 +7240,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
|
||||
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
|
||||
@@ -7583,17 +7395,6 @@
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
@@ -7689,21 +7490,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/domexception": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||
"integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
|
||||
@@ -8065,29 +7851,6 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^5.2.0",
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.12.0.tgz",
|
||||
@@ -9638,20 +9401,6 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
@@ -9703,37 +9452,6 @@
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "2",
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
|
||||
@@ -10353,14 +10071,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -10592,53 +10302,6 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "20.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
|
||||
"integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"abab": "^2.0.6",
|
||||
"acorn": "^8.8.1",
|
||||
"acorn-globals": "^7.0.0",
|
||||
"cssom": "^0.5.0",
|
||||
"cssstyle": "^2.3.0",
|
||||
"data-urls": "^3.0.2",
|
||||
"decimal.js": "^10.4.2",
|
||||
"domexception": "^4.0.0",
|
||||
"escodegen": "^2.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"html-encoding-sniffer": "^3.0.0",
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.2",
|
||||
"parse5": "^7.1.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^4.1.2",
|
||||
"w3c-xmlserializer": "^4.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^2.0.0",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"whatwg-url": "^11.0.0",
|
||||
"ws": "^8.11.0",
|
||||
"xml-name-validator": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^2.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -11651,14 +11314,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
@@ -13048,14 +12703,6 @@
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
|
||||
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -14215,14 +13862,6 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -14288,14 +13927,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -14801,14 +14432,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||
@@ -15016,20 +14639,6 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -15681,14 +15290,6 @@
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz",
|
||||
@@ -15943,37 +15544,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
|
||||
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/trough": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
|
||||
@@ -16004,51 +15574,6 @@
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -16762,17 +16287,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -17014,18 +16528,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
|
||||
@@ -17102,14 +16604,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -17911,20 +17405,6 @@
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
"integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/walk-up-path": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
|
||||
@@ -17986,20 +17466,6 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
|
||||
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
@@ -18009,21 +17475,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
|
||||
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tr46": "^3.0.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/when-exit": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.3.tgz",
|
||||
@@ -18320,14 +17771,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -18433,17 +17876,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
import Button from 'primevue/button'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
|
||||
import MiniMapPanel from './MiniMapPanel.vue'
|
||||
|
||||
@@ -94,9 +94,7 @@ const toggleOptionsPanel = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (minimapRef.value) {
|
||||
setMinimapRef(minimapRef.value)
|
||||
}
|
||||
setMinimapRef(minimapRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
|
||||
import type { MinimapSettingsKey } from '@/renderer/extensions/minimap/types'
|
||||
import { MinimapOptionKey } from '@/composables/useMinimap'
|
||||
|
||||
defineProps<{
|
||||
panelStyles: any
|
||||
@@ -92,6 +92,6 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
updateOption: [key: MinimapSettingsKey, value: boolean]
|
||||
updateOption: [key: MinimapOptionKey, value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useGraphMutationService } from '@/services/graphMutationService'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
@@ -42,19 +42,23 @@ const inputStyle = computed<CSSProperties>(() => ({
|
||||
const titleEditorStore = useTitleEditorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const previousCanvasDraggable = ref(true)
|
||||
const graphMutationService = useGraphMutationService()
|
||||
|
||||
const onEdit = (newValue: string) => {
|
||||
const onEdit = async (newValue: string) => {
|
||||
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
|
||||
const trimmedTitle = newValue.trim()
|
||||
titleEditorStore.titleEditorTarget.title = trimmedTitle
|
||||
|
||||
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
|
||||
const target = titleEditorStore.titleEditorTarget
|
||||
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
|
||||
target.subgraph.name = trimmedTitle
|
||||
}
|
||||
|
||||
app.graph.setDirtyCanvas(true, true)
|
||||
if (target instanceof LGraphNode) {
|
||||
await graphMutationService.updateNodeTitle(target.id, trimmedTitle)
|
||||
|
||||
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
|
||||
if (target.isSubgraphNode?.()) {
|
||||
target.subgraph.name = trimmedTitle
|
||||
}
|
||||
} else if (target instanceof LGraphGroup) {
|
||||
await graphMutationService.updateGroupTitle(target.id, trimmedTitle)
|
||||
}
|
||||
}
|
||||
showInput.value = false
|
||||
titleEditorStore.titleEditorTarget = null
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
|
||||
849
src/composables/useMinimap.ts
Normal file
849
src/composables/useMinimap.ts
Normal file
@@ -0,0 +1,849 @@
|
||||
import { useRafFn, useThrottleFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
interface GraphCallbacks {
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
onNodeRemoved?: (node: LGraphNode) => void
|
||||
onConnectionChange?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
export type MinimapOptionKey =
|
||||
| 'Comfy.Minimap.NodeColors'
|
||||
| 'Comfy.Minimap.ShowLinks'
|
||||
| 'Comfy.Minimap.ShowGroups'
|
||||
| 'Comfy.Minimap.RenderBypassState'
|
||||
| 'Comfy.Minimap.RenderErrorState'
|
||||
|
||||
export function useMinimap() {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const minimapRef = ref<any>(null)
|
||||
|
||||
const visible = ref(true)
|
||||
|
||||
const nodeColors = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.NodeColors')
|
||||
)
|
||||
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
|
||||
const showGroups = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.ShowGroups')
|
||||
)
|
||||
const renderBypass = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.RenderBypassState')
|
||||
)
|
||||
const renderError = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.RenderErrorState')
|
||||
)
|
||||
|
||||
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
|
||||
await settingStore.set(key, value)
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateMinimap()
|
||||
}
|
||||
|
||||
const initialized = ref(false)
|
||||
const bounds = ref({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
const scale = ref(1)
|
||||
const isDragging = ref(false)
|
||||
const viewportTransform = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
|
||||
const needsFullRedraw = ref(true)
|
||||
const needsBoundsUpdate = ref(true)
|
||||
const lastNodeCount = ref(0)
|
||||
const nodeStatesCache = new Map<NodeId, string>()
|
||||
const linksCache = ref<string>('')
|
||||
|
||||
const updateFlags = ref({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
|
||||
const width = 250
|
||||
const height = 200
|
||||
|
||||
// Theme-aware colors for canvas drawing
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
const nodeColor = computed(
|
||||
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
|
||||
)
|
||||
const nodeColorDefault = computed(
|
||||
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
|
||||
)
|
||||
const linkColor = computed(
|
||||
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
|
||||
)
|
||||
const slotColor = computed(() => linkColor.value)
|
||||
const groupColor = computed(() =>
|
||||
isLightTheme.value ? '#A2D3EC' : '#1F547A'
|
||||
)
|
||||
const groupColorDefault = computed(
|
||||
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
|
||||
)
|
||||
const bypassColor = computed(() =>
|
||||
isLightTheme.value ? '#DBDBDB' : '#4B184B'
|
||||
)
|
||||
|
||||
const containerRect = ref({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: width,
|
||||
height: height
|
||||
})
|
||||
|
||||
const canvasDimensions = ref({
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
|
||||
const updateContainerRect = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
containerRect.value = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
|
||||
const updateCanvasDimensions = () => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
const canvasEl = c.canvas
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvasDimensions.value = {
|
||||
width: canvasEl.clientWidth || canvasEl.width / dpr,
|
||||
height: canvasEl.clientHeight || canvasEl.height / dpr
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
const graph = computed(() => {
|
||||
// If we're in a subgraph, use that; otherwise use the canvas graph
|
||||
const activeSubgraph = workflowStore.activeSubgraph
|
||||
return activeSubgraph || canvas.value?.graph
|
||||
})
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
|
||||
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
const panelStyles = computed(() => ({
|
||||
width: `210px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
|
||||
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
const viewportStyles = computed(() => ({
|
||||
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
|
||||
width: `${viewportTransform.value.width}px`,
|
||||
height: `${viewportTransform.value.height}px`,
|
||||
border: `2px solid ${isLightTheme.value ? '#E0E0E0' : '#FFF'}`,
|
||||
backgroundColor: `#FFF33`,
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden' as const,
|
||||
perspective: '1000px',
|
||||
pointerEvents: 'none' as const
|
||||
}))
|
||||
|
||||
const calculateGraphBounds = () => {
|
||||
const g = graph.value
|
||||
if (!g || !g._nodes || g._nodes.length === 0) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const node of g._nodes) {
|
||||
minX = Math.min(minX, node.pos[0])
|
||||
minY = Math.min(minY, node.pos[1])
|
||||
maxX = Math.max(maxX, node.pos[0] + node.size[0])
|
||||
maxY = Math.max(maxY, node.pos[1] + node.size[1])
|
||||
}
|
||||
|
||||
let currentWidth = maxX - minX
|
||||
let currentHeight = maxY - minY
|
||||
|
||||
// Enforce minimum viewport dimensions for better visualization
|
||||
const minViewportWidth = 2500
|
||||
const minViewportHeight = 2000
|
||||
|
||||
if (currentWidth < minViewportWidth) {
|
||||
const padding = (minViewportWidth - currentWidth) / 2
|
||||
minX -= padding
|
||||
maxX += padding
|
||||
currentWidth = minViewportWidth
|
||||
}
|
||||
|
||||
if (currentHeight < minViewportHeight) {
|
||||
const padding = (minViewportHeight - currentHeight) / 2
|
||||
minY -= padding
|
||||
maxY += padding
|
||||
currentHeight = minViewportHeight
|
||||
}
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: currentWidth,
|
||||
height: currentHeight
|
||||
}
|
||||
}
|
||||
|
||||
const calculateScale = () => {
|
||||
if (bounds.value.width === 0 || bounds.value.height === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const scaleX = width / bounds.value.width
|
||||
const scaleY = height / bounds.value.height
|
||||
|
||||
// Apply 0.9 factor to provide padding/gap between nodes and minimap borders
|
||||
return Math.min(scaleX, scaleY) * 0.9
|
||||
}
|
||||
|
||||
const renderGroups = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
) => {
|
||||
const g = graph.value
|
||||
if (!g || !g._groups || g._groups.length === 0) return
|
||||
|
||||
for (const group of g._groups) {
|
||||
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
const w = group.size[0] * scale.value
|
||||
const h = group.size[1] * scale.value
|
||||
|
||||
let color = groupColor.value
|
||||
|
||||
if (nodeColors.value) {
|
||||
color = group.color ?? groupColorDefault.value
|
||||
|
||||
if (isLightTheme.value) {
|
||||
color = adjustColor(color, { opacity: 0.5 })
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
const renderNodes = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
) => {
|
||||
const g = graph.value
|
||||
if (!g || !g._nodes || g._nodes.length === 0) return
|
||||
|
||||
for (const node of g._nodes) {
|
||||
const x = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
const w = node.size[0] * scale.value
|
||||
const h = node.size[1] * scale.value
|
||||
|
||||
let color = nodeColor.value
|
||||
|
||||
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
|
||||
color = bypassColor.value
|
||||
} else if (nodeColors.value) {
|
||||
color = nodeColorDefault.value
|
||||
|
||||
if (node.bgcolor) {
|
||||
color = isLightTheme.value
|
||||
? adjustColor(node.bgcolor, { lightness: 0.5 })
|
||||
: node.bgcolor
|
||||
}
|
||||
}
|
||||
|
||||
// Render solid node blocks
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, w, h)
|
||||
|
||||
if (renderError.value && node.has_errors) {
|
||||
ctx.strokeStyle = '#FF0000'
|
||||
ctx.lineWidth = 0.3
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderConnections = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
) => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
ctx.strokeStyle = linkColor.value
|
||||
ctx.lineWidth = 0.3
|
||||
|
||||
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
|
||||
const connections: Array<{
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
}> = []
|
||||
|
||||
for (const node of g._nodes) {
|
||||
if (!node.outputs) continue
|
||||
|
||||
const x1 = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y1 = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (!output.links) continue
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = g.links[linkId]
|
||||
if (!link) continue
|
||||
|
||||
const targetNode = g.getNodeById(link.target_id)
|
||||
if (!targetNode) continue
|
||||
|
||||
const x2 =
|
||||
(targetNode.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y2 =
|
||||
(targetNode.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
|
||||
const outputX = x1 + node.size[0] * scale.value
|
||||
const outputY = y1 + node.size[1] * scale.value * 0.2
|
||||
const inputX = x2
|
||||
const inputY = y2 + targetNode.size[1] * scale.value * 0.2
|
||||
|
||||
// Draw connection line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(outputX, outputY)
|
||||
ctx.lineTo(inputX, inputY)
|
||||
ctx.stroke()
|
||||
|
||||
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render connection slots on top
|
||||
ctx.fillStyle = slotColor.value
|
||||
for (const conn of connections) {
|
||||
// Output slot
|
||||
ctx.beginPath()
|
||||
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Input slot
|
||||
ctx.beginPath()
|
||||
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
const renderMinimap = () => {
|
||||
const g = graph.value
|
||||
if (!canvasRef.value || !g) return
|
||||
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Fast path for 0 nodes - just show background
|
||||
if (!g._nodes || g._nodes.length === 0) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
return
|
||||
}
|
||||
|
||||
const needsRedraw =
|
||||
needsFullRedraw.value ||
|
||||
updateFlags.value.nodes ||
|
||||
updateFlags.value.connections
|
||||
|
||||
if (needsRedraw) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
if (showGroups.value) {
|
||||
renderGroups(ctx, offsetX, offsetY)
|
||||
}
|
||||
|
||||
if (showLinks.value) {
|
||||
renderConnections(ctx, offsetX, offsetY)
|
||||
}
|
||||
|
||||
renderNodes(ctx, offsetX, offsetY)
|
||||
|
||||
needsFullRedraw.value = false
|
||||
updateFlags.value.nodes = false
|
||||
updateFlags.value.connections = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateViewport = () => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
canvasDimensions.value.width === 0 ||
|
||||
canvasDimensions.value.height === 0
|
||||
) {
|
||||
updateCanvasDimensions()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
|
||||
const viewportWidth = canvasDimensions.value.width / ds.scale
|
||||
const viewportHeight = canvasDimensions.value.height / ds.scale
|
||||
|
||||
const worldX = -ds.offset[0]
|
||||
const worldY = -ds.offset[1]
|
||||
|
||||
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
viewportTransform.value = {
|
||||
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
|
||||
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
|
||||
width: viewportWidth * scale.value,
|
||||
height: viewportHeight * scale.value
|
||||
}
|
||||
|
||||
updateFlags.value.viewport = false
|
||||
}
|
||||
|
||||
const updateMinimap = () => {
|
||||
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
|
||||
bounds.value = calculateGraphBounds()
|
||||
scale.value = calculateScale()
|
||||
needsBoundsUpdate.value = false
|
||||
updateFlags.value.bounds = false
|
||||
needsFullRedraw.value = true
|
||||
// When bounds change, we need to update the viewport position
|
||||
updateFlags.value.viewport = true
|
||||
}
|
||||
|
||||
if (
|
||||
needsFullRedraw.value ||
|
||||
updateFlags.value.nodes ||
|
||||
updateFlags.value.connections
|
||||
) {
|
||||
renderMinimap()
|
||||
}
|
||||
|
||||
// Update viewport if needed (e.g., after bounds change)
|
||||
if (updateFlags.value.viewport) {
|
||||
updateViewport()
|
||||
}
|
||||
}
|
||||
|
||||
const checkForChanges = useThrottleFn(() => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
let structureChanged = false
|
||||
let positionChanged = false
|
||||
let connectionChanged = false
|
||||
|
||||
if (g._nodes.length !== lastNodeCount.value) {
|
||||
structureChanged = true
|
||||
lastNodeCount.value = g._nodes.length
|
||||
}
|
||||
|
||||
for (const node of g._nodes) {
|
||||
const key = node.id
|
||||
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
|
||||
|
||||
if (nodeStatesCache.get(key) !== currentState) {
|
||||
positionChanged = true
|
||||
nodeStatesCache.set(key, currentState)
|
||||
}
|
||||
}
|
||||
|
||||
const currentLinks = JSON.stringify(g.links || {})
|
||||
if (currentLinks !== linksCache.value) {
|
||||
connectionChanged = true
|
||||
linksCache.value = currentLinks
|
||||
}
|
||||
|
||||
const currentNodeIds = new Set(g._nodes.map((n) => n.id))
|
||||
for (const [nodeId] of nodeStatesCache) {
|
||||
if (!currentNodeIds.has(nodeId)) {
|
||||
nodeStatesCache.delete(nodeId)
|
||||
structureChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if (structureChanged || positionChanged) {
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
}
|
||||
|
||||
if (connectionChanged) {
|
||||
updateFlags.value.connections = true
|
||||
}
|
||||
|
||||
if (structureChanged || positionChanged || connectionChanged) {
|
||||
updateMinimap()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
|
||||
useRafFn(
|
||||
async () => {
|
||||
if (visible.value) {
|
||||
await checkForChanges()
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const { startSync: startViewportSync, stopSync: stopViewportSync } =
|
||||
useCanvasTransformSync(updateViewport, { autoStart: false })
|
||||
|
||||
// Pointer event handlers for touch screen support
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
isDragging.value = true
|
||||
updateContainerRect()
|
||||
handlePointerMove(e)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging.value || !canvasRef.value || !canvas.value) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
const y = e.clientY - containerRect.value.top
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const worldX = (x - offsetX) / scale.value + bounds.value.minX
|
||||
const worldY = (y - offsetY) / scale.value + bounds.value.minY
|
||||
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
containerRect.value.left === 0 &&
|
||||
containerRect.value.top === 0 &&
|
||||
containerRef.value
|
||||
) {
|
||||
updateContainerRect()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
|
||||
const newScale = ds.scale * delta
|
||||
|
||||
const MIN_SCALE = 0.1
|
||||
const MAX_SCALE = 10
|
||||
|
||||
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
const y = e.clientY - containerRect.value.top
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const worldX = (x - offsetX) / scale.value + bounds.value.minX
|
||||
const worldY = (y - offsetY) / scale.value + bounds.value.minY
|
||||
|
||||
ds.scale = newScale
|
||||
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
const centerViewOn = (worldX: number, worldY: number) => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
canvasDimensions.value.width === 0 ||
|
||||
canvasDimensions.value.height === 0
|
||||
) {
|
||||
updateCanvasDimensions()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
|
||||
const viewportWidth = canvasDimensions.value.width / ds.scale
|
||||
const viewportHeight = canvasDimensions.value.height / ds.scale
|
||||
|
||||
ds.offset[0] = -(worldX - viewportWidth / 2)
|
||||
ds.offset[1] = -(worldY - viewportHeight / 2)
|
||||
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
c.setDirty(true, true)
|
||||
}
|
||||
|
||||
// Map to store original callbacks per graph ID
|
||||
const originalCallbacksMap = new Map<string, GraphCallbacks>()
|
||||
|
||||
const handleGraphChanged = useThrottleFn(() => {
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateMinimap()
|
||||
}, 500)
|
||||
|
||||
const setupEventListeners = () => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
// Check if we've already wrapped this graph's callbacks
|
||||
if (originalCallbacksMap.has(g.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store the original callbacks for this graph
|
||||
const originalCallbacks: GraphCallbacks = {
|
||||
onNodeAdded: g.onNodeAdded,
|
||||
onNodeRemoved: g.onNodeRemoved,
|
||||
onConnectionChange: g.onConnectionChange
|
||||
}
|
||||
originalCallbacksMap.set(g.id, originalCallbacks)
|
||||
|
||||
g.onNodeAdded = function (node) {
|
||||
originalCallbacks.onNodeAdded?.call(this, node)
|
||||
|
||||
void handleGraphChanged()
|
||||
}
|
||||
|
||||
g.onNodeRemoved = function (node) {
|
||||
originalCallbacks.onNodeRemoved?.call(this, node)
|
||||
nodeStatesCache.delete(node.id)
|
||||
void handleGraphChanged()
|
||||
}
|
||||
|
||||
g.onConnectionChange = function (node) {
|
||||
originalCallbacks.onConnectionChange?.call(this, node)
|
||||
|
||||
void handleGraphChanged()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupEventListeners = () => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
const originalCallbacks = originalCallbacksMap.get(g.id)
|
||||
if (!originalCallbacks) {
|
||||
console.error(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
|
||||
originalCallbacksMap.delete(g.id)
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
if (initialized.value) return
|
||||
|
||||
visible.value = settingStore.get('Comfy.Minimap.Visible')
|
||||
|
||||
if (canvas.value && graph.value) {
|
||||
setupEventListeners()
|
||||
|
||||
api.addEventListener('graphChanged', handleGraphChanged)
|
||||
|
||||
if (containerRef.value) {
|
||||
updateContainerRect()
|
||||
}
|
||||
updateCanvasDimensions()
|
||||
|
||||
window.addEventListener('resize', updateContainerRect)
|
||||
window.addEventListener('scroll', updateContainerRect)
|
||||
window.addEventListener('resize', updateCanvasDimensions)
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
updateMinimap()
|
||||
updateViewport()
|
||||
|
||||
if (visible.value) {
|
||||
resumeChangeDetection()
|
||||
startViewportSync()
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
pauseChangeDetection()
|
||||
stopViewportSync()
|
||||
cleanupEventListeners()
|
||||
|
||||
api.removeEventListener('graphChanged', handleGraphChanged)
|
||||
|
||||
window.removeEventListener('resize', updateContainerRect)
|
||||
window.removeEventListener('scroll', updateContainerRect)
|
||||
window.removeEventListener('resize', updateCanvasDimensions)
|
||||
|
||||
nodeStatesCache.clear()
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
canvas,
|
||||
async (newCanvas, oldCanvas) => {
|
||||
if (oldCanvas) {
|
||||
cleanupEventListeners()
|
||||
pauseChangeDetection()
|
||||
stopViewportSync()
|
||||
api.removeEventListener('graphChanged', handleGraphChanged)
|
||||
window.removeEventListener('resize', updateContainerRect)
|
||||
window.removeEventListener('scroll', updateContainerRect)
|
||||
window.removeEventListener('resize', updateCanvasDimensions)
|
||||
}
|
||||
if (newCanvas && !initialized.value) {
|
||||
await init()
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
|
||||
// Watch for graph changes (e.g., when navigating to/from subgraphs)
|
||||
watch(graph, (newGraph, oldGraph) => {
|
||||
if (newGraph && newGraph !== oldGraph) {
|
||||
cleanupEventListeners()
|
||||
setupEventListeners()
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateMinimap()
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
if (containerRef.value) {
|
||||
updateContainerRect()
|
||||
}
|
||||
updateCanvasDimensions()
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
await nextTick()
|
||||
|
||||
updateMinimap()
|
||||
updateViewport()
|
||||
resumeChangeDetection()
|
||||
startViewportSync()
|
||||
} else {
|
||||
pauseChangeDetection()
|
||||
stopViewportSync()
|
||||
}
|
||||
})
|
||||
|
||||
const toggle = async () => {
|
||||
visible.value = !visible.value
|
||||
await settingStore.set('Comfy.Minimap.Visible', visible.value)
|
||||
}
|
||||
|
||||
const setMinimapRef = (ref: any) => {
|
||||
minimapRef.value = ref
|
||||
}
|
||||
|
||||
return {
|
||||
visible: computed(() => visible.value),
|
||||
initialized: computed(() => initialized.value),
|
||||
|
||||
containerRef,
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
panelStyles,
|
||||
width,
|
||||
height,
|
||||
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
|
||||
init,
|
||||
destroy,
|
||||
toggle,
|
||||
renderMinimap,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel,
|
||||
setMinimapRef,
|
||||
updateOption
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,38 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { createGraphThumbnail } from '@/renderer/thumbnail/graphThumbnailRenderer'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
|
||||
import { useMinimap } from './useMinimap'
|
||||
|
||||
// Store thumbnails for each workflow
|
||||
const workflowThumbnails = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Shared minimap instance
|
||||
let minimap: ReturnType<typeof useMinimap> | null = null
|
||||
|
||||
export const useWorkflowThumbnail = () => {
|
||||
/**
|
||||
* Capture a thumbnail of the canvas
|
||||
*/
|
||||
const createMinimapPreview = (): Promise<string | null> => {
|
||||
try {
|
||||
const thumbnailDataUrl = createGraphThumbnail()
|
||||
return Promise.resolve(thumbnailDataUrl)
|
||||
if (!minimap) {
|
||||
minimap = useMinimap()
|
||||
minimap.canvasRef.value = document.createElement('canvas')
|
||||
minimap.canvasRef.value.width = minimap.width
|
||||
minimap.canvasRef.value.height = minimap.height
|
||||
}
|
||||
minimap.renderMinimap()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
minimap!.canvasRef.value!.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to capture canvas thumbnail:', error)
|
||||
return Promise.resolve(null)
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from './measure'
|
||||
import type { ISerialisedGroup } from './types/serialisation'
|
||||
|
||||
export type GroupId = number
|
||||
|
||||
export interface IGraphGroupFlags extends Record<string, unknown> {
|
||||
pinned?: true
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from './ExecutableNodeDTO'
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
|
||||
export type SubgraphId = string
|
||||
|
||||
/**
|
||||
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
|
||||
*/
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Spatial bounds calculations for node layouts
|
||||
*/
|
||||
|
||||
export interface SpatialBounds {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface PositionedNode {
|
||||
pos: ArrayLike<number>
|
||||
size: ArrayLike<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the spatial bounding box of positioned nodes
|
||||
*/
|
||||
export function calculateNodeBounds(
|
||||
nodes: PositionedNode[]
|
||||
): SpatialBounds | null {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const node of nodes) {
|
||||
const x = node.pos[0]
|
||||
const y = node.pos[1]
|
||||
const width = node.size[0]
|
||||
const height = node.size[1]
|
||||
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x + width)
|
||||
maxY = Math.max(maxY, y + height)
|
||||
}
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce minimum viewport dimensions for better visualization
|
||||
*/
|
||||
export function enforceMinimumBounds(
|
||||
bounds: SpatialBounds,
|
||||
minWidth: number = 2500,
|
||||
minHeight: number = 2000
|
||||
): SpatialBounds {
|
||||
let { minX, minY, maxX, maxY, width, height } = bounds
|
||||
|
||||
if (width < minWidth) {
|
||||
const padding = (minWidth - width) / 2
|
||||
minX -= padding
|
||||
maxX += padding
|
||||
width = minWidth
|
||||
}
|
||||
|
||||
if (height < minHeight) {
|
||||
const padding = (minHeight - height) / 2
|
||||
minY -= padding
|
||||
maxY += padding
|
||||
height = minHeight
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY, width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the scale factor to fit bounds within a viewport
|
||||
*/
|
||||
export function calculateMinimapScale(
|
||||
bounds: SpatialBounds,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
padding: number = 0.9
|
||||
): number {
|
||||
if (bounds.width === 0 || bounds.height === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const scaleX = viewportWidth / bounds.width
|
||||
const scaleY = viewportHeight / bounds.height
|
||||
|
||||
return Math.min(scaleX, scaleY) * padding
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import type { MinimapCanvas, MinimapSettingsKey } from '../types'
|
||||
import { useMinimapGraph } from './useMinimapGraph'
|
||||
import { useMinimapInteraction } from './useMinimapInteraction'
|
||||
import { useMinimapRenderer } from './useMinimapRenderer'
|
||||
import { useMinimapSettings } from './useMinimapSettings'
|
||||
import { useMinimapViewport } from './useMinimapViewport'
|
||||
|
||||
export function useMinimap() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const minimapRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const visible = ref(true)
|
||||
const initialized = ref(false)
|
||||
|
||||
const width = 250
|
||||
const height = 200
|
||||
|
||||
const canvas = computed(() => canvasStore.canvas as MinimapCanvas | null)
|
||||
const graph = computed(() => {
|
||||
// If we're in a subgraph, use that; otherwise use the canvas graph
|
||||
const activeSubgraph = workflowStore.activeSubgraph
|
||||
return (activeSubgraph || canvas.value?.graph) as LGraph | null
|
||||
})
|
||||
|
||||
// Settings
|
||||
const settings = useMinimapSettings()
|
||||
const {
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
containerStyles,
|
||||
panelStyles
|
||||
} = settings
|
||||
|
||||
const updateOption = async (key: MinimapSettingsKey, value: boolean) => {
|
||||
await settingStore.set(key, value)
|
||||
renderer.forceFullRedraw()
|
||||
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
|
||||
}
|
||||
|
||||
// Viewport management
|
||||
const viewport = useMinimapViewport(canvas, graph, width, height)
|
||||
|
||||
// Interaction handling
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
viewport.bounds,
|
||||
viewport.scale,
|
||||
width,
|
||||
height,
|
||||
viewport.centerViewOn,
|
||||
canvas
|
||||
)
|
||||
|
||||
// Graph event management
|
||||
const graphManager = useMinimapGraph(graph, () => {
|
||||
renderer.forceFullRedraw()
|
||||
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
|
||||
})
|
||||
|
||||
// Rendering
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graph,
|
||||
viewport.bounds,
|
||||
viewport.scale,
|
||||
graphManager.updateFlags,
|
||||
settings,
|
||||
width,
|
||||
height
|
||||
)
|
||||
|
||||
// RAF loop for continuous updates
|
||||
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
|
||||
useRafFn(
|
||||
async () => {
|
||||
if (visible.value) {
|
||||
const hasChanges = await graphManager.checkForChanges()
|
||||
if (hasChanges) {
|
||||
renderer.updateMinimap(
|
||||
viewport.updateBounds,
|
||||
viewport.updateViewport
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const init = async () => {
|
||||
if (initialized.value) return
|
||||
|
||||
visible.value = settingStore.get('Comfy.Minimap.Visible')
|
||||
|
||||
if (canvas.value && graph.value) {
|
||||
graphManager.init()
|
||||
|
||||
if (containerRef.value) {
|
||||
interaction.updateContainerRect()
|
||||
}
|
||||
viewport.updateCanvasDimensions()
|
||||
|
||||
window.addEventListener('resize', interaction.updateContainerRect)
|
||||
window.addEventListener('scroll', interaction.updateContainerRect)
|
||||
window.addEventListener('resize', viewport.updateCanvasDimensions)
|
||||
|
||||
renderer.forceFullRedraw()
|
||||
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
|
||||
viewport.updateViewport()
|
||||
|
||||
if (visible.value) {
|
||||
resumeChangeDetection()
|
||||
viewport.startViewportSync()
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
pauseChangeDetection()
|
||||
viewport.stopViewportSync()
|
||||
graphManager.destroy()
|
||||
|
||||
window.removeEventListener('resize', interaction.updateContainerRect)
|
||||
window.removeEventListener('scroll', interaction.updateContainerRect)
|
||||
window.removeEventListener('resize', viewport.updateCanvasDimensions)
|
||||
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
canvas,
|
||||
async (newCanvas, oldCanvas) => {
|
||||
if (oldCanvas) {
|
||||
graphManager.cleanupEventListeners()
|
||||
pauseChangeDetection()
|
||||
viewport.stopViewportSync()
|
||||
graphManager.destroy()
|
||||
window.removeEventListener('resize', interaction.updateContainerRect)
|
||||
window.removeEventListener('scroll', interaction.updateContainerRect)
|
||||
window.removeEventListener('resize', viewport.updateCanvasDimensions)
|
||||
}
|
||||
if (newCanvas && !initialized.value) {
|
||||
await init()
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
|
||||
// Watch for graph changes (e.g., when navigating to/from subgraphs)
|
||||
watch(graph, (newGraph, oldGraph) => {
|
||||
if (newGraph && newGraph !== oldGraph) {
|
||||
graphManager.cleanupEventListeners(oldGraph || undefined)
|
||||
graphManager.setupEventListeners()
|
||||
renderer.forceFullRedraw()
|
||||
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
if (containerRef.value) {
|
||||
interaction.updateContainerRect()
|
||||
}
|
||||
viewport.updateCanvasDimensions()
|
||||
|
||||
renderer.forceFullRedraw()
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
|
||||
viewport.updateViewport()
|
||||
resumeChangeDetection()
|
||||
viewport.startViewportSync()
|
||||
} else {
|
||||
pauseChangeDetection()
|
||||
viewport.stopViewportSync()
|
||||
}
|
||||
})
|
||||
|
||||
const toggle = async () => {
|
||||
visible.value = !visible.value
|
||||
await settingStore.set('Comfy.Minimap.Visible', visible.value)
|
||||
}
|
||||
|
||||
const setMinimapRef = (ref: HTMLElement | null) => {
|
||||
minimapRef.value = ref
|
||||
}
|
||||
|
||||
// Dynamic viewport styles based on actual viewport transform
|
||||
const viewportStyles = computed(() => {
|
||||
const transform = viewport.viewportTransform.value
|
||||
return {
|
||||
transform: `translate(${transform.x}px, ${transform.y}px)`,
|
||||
width: `${transform.width}px`,
|
||||
height: `${transform.height}px`,
|
||||
border: `2px solid ${settings.isLightTheme.value ? '#E0E0E0' : '#FFF'}`,
|
||||
backgroundColor: `rgba(255, 255, 255, 0.2)`,
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden' as const,
|
||||
perspective: '1000px',
|
||||
pointerEvents: 'none' as const
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
visible: computed(() => visible.value),
|
||||
initialized: computed(() => initialized.value),
|
||||
|
||||
containerRef,
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
panelStyles,
|
||||
width,
|
||||
height,
|
||||
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
|
||||
init,
|
||||
destroy,
|
||||
toggle,
|
||||
renderMinimap: renderer.renderMinimap,
|
||||
handlePointerDown: interaction.handlePointerDown,
|
||||
handlePointerMove: interaction.handlePointerMove,
|
||||
handlePointerUp: interaction.handlePointerUp,
|
||||
handleWheel: interaction.handleWheel,
|
||||
setMinimapRef,
|
||||
updateOption
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { UpdateFlags } from '../types'
|
||||
|
||||
interface GraphCallbacks {
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
onNodeRemoved?: (node: LGraphNode) => void
|
||||
onConnectionChange?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
export function useMinimapGraph(
|
||||
graph: Ref<LGraph | null>,
|
||||
onGraphChanged: () => void
|
||||
) {
|
||||
const nodeStatesCache = new Map<NodeId, string>()
|
||||
const linksCache = ref<string>('')
|
||||
const lastNodeCount = ref(0)
|
||||
const updateFlags = ref<UpdateFlags>({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
|
||||
// Map to store original callbacks per graph ID
|
||||
const originalCallbacksMap = new Map<string, GraphCallbacks>()
|
||||
|
||||
const handleGraphChangedThrottled = useThrottleFn(() => {
|
||||
onGraphChanged()
|
||||
}, 500)
|
||||
|
||||
const setupEventListeners = () => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
// Check if we've already wrapped this graph's callbacks
|
||||
if (originalCallbacksMap.has(g.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store the original callbacks for this graph
|
||||
const originalCallbacks: GraphCallbacks = {
|
||||
onNodeAdded: g.onNodeAdded,
|
||||
onNodeRemoved: g.onNodeRemoved,
|
||||
onConnectionChange: g.onConnectionChange
|
||||
}
|
||||
originalCallbacksMap.set(g.id, originalCallbacks)
|
||||
|
||||
g.onNodeAdded = function (node: LGraphNode) {
|
||||
originalCallbacks.onNodeAdded?.call(this, node)
|
||||
void handleGraphChangedThrottled()
|
||||
}
|
||||
|
||||
g.onNodeRemoved = function (node: LGraphNode) {
|
||||
originalCallbacks.onNodeRemoved?.call(this, node)
|
||||
nodeStatesCache.delete(node.id)
|
||||
void handleGraphChangedThrottled()
|
||||
}
|
||||
|
||||
g.onConnectionChange = function (node: LGraphNode) {
|
||||
originalCallbacks.onConnectionChange?.call(this, node)
|
||||
void handleGraphChangedThrottled()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupEventListeners = (oldGraph?: LGraph) => {
|
||||
const g = oldGraph || graph.value
|
||||
if (!g) return
|
||||
|
||||
const originalCallbacks = originalCallbacksMap.get(g.id)
|
||||
if (!originalCallbacks) {
|
||||
console.error(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
|
||||
originalCallbacksMap.delete(g.id)
|
||||
}
|
||||
|
||||
const checkForChangesInternal = () => {
|
||||
const g = graph.value
|
||||
if (!g) return false
|
||||
|
||||
let structureChanged = false
|
||||
let positionChanged = false
|
||||
let connectionChanged = false
|
||||
|
||||
if (g._nodes.length !== lastNodeCount.value) {
|
||||
structureChanged = true
|
||||
lastNodeCount.value = g._nodes.length
|
||||
}
|
||||
|
||||
for (const node of g._nodes) {
|
||||
const key = node.id
|
||||
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
|
||||
|
||||
if (nodeStatesCache.get(key) !== currentState) {
|
||||
positionChanged = true
|
||||
nodeStatesCache.set(key, currentState)
|
||||
}
|
||||
}
|
||||
|
||||
const currentLinks = JSON.stringify(g.links || {})
|
||||
if (currentLinks !== linksCache.value) {
|
||||
connectionChanged = true
|
||||
linksCache.value = currentLinks
|
||||
}
|
||||
|
||||
const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id))
|
||||
for (const [nodeId] of nodeStatesCache) {
|
||||
if (!currentNodeIds.has(nodeId)) {
|
||||
nodeStatesCache.delete(nodeId)
|
||||
structureChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if (structureChanged || positionChanged) {
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
}
|
||||
|
||||
if (connectionChanged) {
|
||||
updateFlags.value.connections = true
|
||||
}
|
||||
|
||||
return structureChanged || positionChanged || connectionChanged
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
setupEventListeners()
|
||||
api.addEventListener('graphChanged', handleGraphChangedThrottled)
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
cleanupEventListeners()
|
||||
api.removeEventListener('graphChanged', handleGraphChangedThrottled)
|
||||
nodeStatesCache.clear()
|
||||
}
|
||||
|
||||
const clearCache = () => {
|
||||
nodeStatesCache.clear()
|
||||
linksCache.value = ''
|
||||
lastNodeCount.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
updateFlags,
|
||||
setupEventListeners,
|
||||
cleanupEventListeners,
|
||||
checkForChanges: checkForChangesInternal,
|
||||
init,
|
||||
destroy,
|
||||
clearCache
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { MinimapCanvas } from '../types'
|
||||
|
||||
export function useMinimapInteraction(
|
||||
containerRef: Ref<HTMLDivElement | undefined>,
|
||||
bounds: Ref<{ minX: number; minY: number; width: number; height: number }>,
|
||||
scale: Ref<number>,
|
||||
width: number,
|
||||
height: number,
|
||||
centerViewOn: (worldX: number, worldY: number) => void,
|
||||
canvas: Ref<MinimapCanvas | null>
|
||||
) {
|
||||
const isDragging = ref(false)
|
||||
const containerRect = ref({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: width,
|
||||
height: height
|
||||
})
|
||||
|
||||
const updateContainerRect = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
containerRect.value = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
isDragging.value = true
|
||||
updateContainerRect()
|
||||
handlePointerMove(e)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging.value || !canvas.value) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
const y = e.clientY - containerRect.value.top
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const worldX = (x - offsetX) / scale.value + bounds.value.minX
|
||||
const worldY = (y - offsetY) / scale.value + bounds.value.minY
|
||||
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
containerRect.value.left === 0 &&
|
||||
containerRect.value.top === 0 &&
|
||||
containerRef.value
|
||||
) {
|
||||
updateContainerRect()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
|
||||
const newScale = ds.scale * delta
|
||||
|
||||
const MIN_SCALE = 0.1
|
||||
const MAX_SCALE = 10
|
||||
|
||||
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
const y = e.clientY - containerRect.value.top
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const worldX = (x - offsetX) / scale.value + bounds.value.minX
|
||||
const worldY = (y - offsetY) / scale.value + bounds.value.minY
|
||||
|
||||
ds.scale = newScale
|
||||
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
containerRect,
|
||||
updateContainerRect,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { renderMinimapToCanvas } from '../minimapCanvasRenderer'
|
||||
import type { UpdateFlags } from '../types'
|
||||
|
||||
export function useMinimapRenderer(
|
||||
canvasRef: Ref<HTMLCanvasElement | undefined>,
|
||||
graph: Ref<LGraph | null>,
|
||||
bounds: Ref<{ minX: number; minY: number; width: number; height: number }>,
|
||||
scale: Ref<number>,
|
||||
updateFlags: Ref<UpdateFlags>,
|
||||
settings: {
|
||||
nodeColors: Ref<boolean>
|
||||
showLinks: Ref<boolean>
|
||||
showGroups: Ref<boolean>
|
||||
renderBypass: Ref<boolean>
|
||||
renderError: Ref<boolean>
|
||||
},
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const needsFullRedraw = ref(true)
|
||||
const needsBoundsUpdate = ref(true)
|
||||
|
||||
const renderMinimap = () => {
|
||||
const g = graph.value
|
||||
if (!canvasRef.value || !g) return
|
||||
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Fast path for 0 nodes - just show background
|
||||
if (!g._nodes || g._nodes.length === 0) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
return
|
||||
}
|
||||
|
||||
const needsRedraw =
|
||||
needsFullRedraw.value ||
|
||||
updateFlags.value.nodes ||
|
||||
updateFlags.value.connections
|
||||
|
||||
if (needsRedraw) {
|
||||
renderMinimapToCanvas(canvasRef.value, g, {
|
||||
bounds: bounds.value,
|
||||
scale: scale.value,
|
||||
settings: {
|
||||
nodeColors: settings.nodeColors.value,
|
||||
showLinks: settings.showLinks.value,
|
||||
showGroups: settings.showGroups.value,
|
||||
renderBypass: settings.renderBypass.value,
|
||||
renderError: settings.renderError.value
|
||||
},
|
||||
width,
|
||||
height
|
||||
})
|
||||
|
||||
needsFullRedraw.value = false
|
||||
updateFlags.value.nodes = false
|
||||
updateFlags.value.connections = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMinimap = (
|
||||
updateBounds: () => void,
|
||||
updateViewport: () => void
|
||||
) => {
|
||||
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
|
||||
updateBounds()
|
||||
needsBoundsUpdate.value = false
|
||||
updateFlags.value.bounds = false
|
||||
needsFullRedraw.value = true
|
||||
// When bounds change, we need to update the viewport position
|
||||
updateFlags.value.viewport = true
|
||||
}
|
||||
|
||||
if (
|
||||
needsFullRedraw.value ||
|
||||
updateFlags.value.nodes ||
|
||||
updateFlags.value.connections
|
||||
) {
|
||||
renderMinimap()
|
||||
}
|
||||
|
||||
// Update viewport if needed (e.g., after bounds change)
|
||||
if (updateFlags.value.viewport) {
|
||||
updateViewport()
|
||||
updateFlags.value.viewport = false
|
||||
}
|
||||
}
|
||||
|
||||
const forceFullRedraw = () => {
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateFlags.value.viewport = true
|
||||
}
|
||||
|
||||
return {
|
||||
needsFullRedraw,
|
||||
needsBoundsUpdate,
|
||||
renderMinimap,
|
||||
updateMinimap,
|
||||
forceFullRedraw
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
/**
|
||||
* Composable for minimap configuration options that are set by the user in the
|
||||
* settings. Provides reactive computed properties for the settings.
|
||||
*/
|
||||
export function useMinimapSettings() {
|
||||
const settingStore = useSettingStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const nodeColors = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.NodeColors')
|
||||
)
|
||||
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
|
||||
const showGroups = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.ShowGroups')
|
||||
)
|
||||
const renderBypass = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.RenderBypassState')
|
||||
)
|
||||
const renderError = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.RenderErrorState')
|
||||
)
|
||||
|
||||
const width = 250
|
||||
const height = 200
|
||||
|
||||
// Theme-aware colors
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
|
||||
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
const panelStyles = computed(() => ({
|
||||
width: `210px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
|
||||
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
return {
|
||||
nodeColors,
|
||||
showLinks,
|
||||
showGroups,
|
||||
renderBypass,
|
||||
renderError,
|
||||
containerStyles,
|
||||
panelStyles,
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
calculateMinimapScale,
|
||||
calculateNodeBounds,
|
||||
enforceMinimumBounds
|
||||
} from '@/renderer/core/spatial/boundsCalculator'
|
||||
|
||||
import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types'
|
||||
|
||||
export function useMinimapViewport(
|
||||
canvas: Ref<MinimapCanvas | null>,
|
||||
graph: Ref<LGraph | null>,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const bounds = ref<MinimapBounds>({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
|
||||
const scale = ref(1)
|
||||
const viewportTransform = ref<ViewportTransform>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
|
||||
const canvasDimensions = ref({
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
|
||||
const updateCanvasDimensions = () => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
const canvasEl = c.canvas
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvasDimensions.value = {
|
||||
width: canvasEl.clientWidth || canvasEl.width / dpr,
|
||||
height: canvasEl.clientHeight || canvasEl.height / dpr
|
||||
}
|
||||
}
|
||||
|
||||
const calculateGraphBounds = (): MinimapBounds => {
|
||||
const g = graph.value
|
||||
if (!g || !g._nodes || g._nodes.length === 0) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
const bounds = calculateNodeBounds(g._nodes)
|
||||
if (!bounds) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
return enforceMinimumBounds(bounds)
|
||||
}
|
||||
|
||||
const calculateScale = () => {
|
||||
return calculateMinimapScale(bounds.value, width, height)
|
||||
}
|
||||
|
||||
const updateViewport = () => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
canvasDimensions.value.width === 0 ||
|
||||
canvasDimensions.value.height === 0
|
||||
) {
|
||||
updateCanvasDimensions()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
|
||||
const viewportWidth = canvasDimensions.value.width / ds.scale
|
||||
const viewportHeight = canvasDimensions.value.height / ds.scale
|
||||
|
||||
const worldX = -ds.offset[0]
|
||||
const worldY = -ds.offset[1]
|
||||
|
||||
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
viewportTransform.value = {
|
||||
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
|
||||
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
|
||||
width: viewportWidth * scale.value,
|
||||
height: viewportHeight * scale.value
|
||||
}
|
||||
}
|
||||
|
||||
const updateBounds = () => {
|
||||
bounds.value = calculateGraphBounds()
|
||||
scale.value = calculateScale()
|
||||
}
|
||||
|
||||
const centerViewOn = (worldX: number, worldY: number) => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
canvasDimensions.value.width === 0 ||
|
||||
canvasDimensions.value.height === 0
|
||||
) {
|
||||
updateCanvasDimensions()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
|
||||
const viewportWidth = canvasDimensions.value.width / ds.scale
|
||||
const viewportHeight = canvasDimensions.value.height / ds.scale
|
||||
|
||||
ds.offset[0] = -(worldX - viewportWidth / 2)
|
||||
ds.offset[1] = -(worldY - viewportHeight / 2)
|
||||
|
||||
c.setDirty(true, true)
|
||||
}
|
||||
|
||||
const { startSync: startViewportSync, stopSync: stopViewportSync } =
|
||||
useCanvasTransformSync(updateViewport, { autoStart: false })
|
||||
|
||||
return {
|
||||
bounds: computed(() => bounds.value),
|
||||
scale: computed(() => scale.value),
|
||||
viewportTransform: computed(() => viewportTransform.value),
|
||||
canvasDimensions: computed(() => canvasDimensions.value),
|
||||
updateCanvasDimensions,
|
||||
updateViewport,
|
||||
updateBounds,
|
||||
centerViewOn,
|
||||
startViewportSync,
|
||||
stopViewportSync
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { LGraph, LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
import type { MinimapRenderContext } from './types'
|
||||
|
||||
/**
|
||||
* Get theme-aware colors for the minimap
|
||||
*/
|
||||
function getMinimapColors() {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
|
||||
|
||||
return {
|
||||
nodeColor: isLightTheme ? '#3DA8E099' : '#0B8CE999',
|
||||
nodeColorDefault: isLightTheme ? '#D9D9D9' : '#353535',
|
||||
linkColor: isLightTheme ? '#616161' : '#B3B3B3',
|
||||
slotColor: isLightTheme ? '#616161' : '#B3B3B3',
|
||||
groupColor: isLightTheme ? '#A2D3EC' : '#1F547A',
|
||||
groupColorDefault: isLightTheme ? '#283640' : '#B3C1CB',
|
||||
bypassColor: isLightTheme ? '#DBDBDB' : '#4B184B',
|
||||
errorColor: '#FF0000',
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render groups on the minimap
|
||||
*/
|
||||
function renderGroups(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
context: MinimapRenderContext,
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
) {
|
||||
if (!graph._groups || graph._groups.length === 0) return
|
||||
|
||||
for (const group of graph._groups) {
|
||||
const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
const w = group.size[0] * context.scale
|
||||
const h = group.size[1] * context.scale
|
||||
|
||||
let color = colors.groupColor
|
||||
|
||||
if (context.settings.nodeColors) {
|
||||
color = group.color ?? colors.groupColorDefault
|
||||
|
||||
if (colors.isLightTheme) {
|
||||
color = adjustColor(color, { opacity: 0.5 })
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render nodes on the minimap with performance optimizations
|
||||
*/
|
||||
function renderNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
context: MinimapRenderContext,
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
) {
|
||||
if (!graph._nodes || graph._nodes.length === 0) return
|
||||
|
||||
// Group nodes by color for batch rendering
|
||||
const nodesByColor = new Map<
|
||||
string,
|
||||
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
|
||||
>()
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
const w = node.size[0] * context.scale
|
||||
const h = node.size[1] * context.scale
|
||||
|
||||
let color = colors.nodeColor
|
||||
|
||||
if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
|
||||
color = colors.bypassColor
|
||||
} else if (context.settings.nodeColors) {
|
||||
color = colors.nodeColorDefault
|
||||
|
||||
if (node.bgcolor) {
|
||||
color = colors.isLightTheme
|
||||
? adjustColor(node.bgcolor, { lightness: 0.5 })
|
||||
: node.bgcolor
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodesByColor.has(color)) {
|
||||
nodesByColor.set(color, [])
|
||||
}
|
||||
|
||||
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors })
|
||||
}
|
||||
|
||||
// Batch render nodes by color
|
||||
for (const [color, nodes] of nodesByColor) {
|
||||
ctx.fillStyle = color
|
||||
for (const node of nodes) {
|
||||
ctx.fillRect(node.x, node.y, node.w, node.h)
|
||||
}
|
||||
}
|
||||
|
||||
// Render error outlines if needed
|
||||
if (context.settings.renderError) {
|
||||
ctx.strokeStyle = colors.errorColor
|
||||
ctx.lineWidth = 0.3
|
||||
for (const nodes of nodesByColor.values()) {
|
||||
for (const node of nodes) {
|
||||
if (node.hasErrors) {
|
||||
ctx.strokeRect(node.x, node.y, node.w, node.h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render connections on the minimap
|
||||
*/
|
||||
function renderConnections(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
graph: LGraph,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
context: MinimapRenderContext,
|
||||
colors: ReturnType<typeof getMinimapColors>
|
||||
) {
|
||||
if (!graph || !graph._nodes) return
|
||||
|
||||
ctx.strokeStyle = colors.linkColor
|
||||
ctx.lineWidth = 0.3
|
||||
|
||||
const slotRadius = Math.max(context.scale, 0.5)
|
||||
const connections: Array<{
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
}> = []
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (!node.outputs) continue
|
||||
|
||||
const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (!output.links) continue
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = graph.links[linkId]
|
||||
if (!link) continue
|
||||
|
||||
const targetNode = graph.getNodeById(link.target_id)
|
||||
if (!targetNode) continue
|
||||
|
||||
const x2 =
|
||||
(targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX
|
||||
const y2 =
|
||||
(targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY
|
||||
|
||||
const outputX = x1 + node.size[0] * context.scale
|
||||
const outputY = y1 + node.size[1] * context.scale * 0.2
|
||||
const inputX = x2
|
||||
const inputY = y2 + targetNode.size[1] * context.scale * 0.2
|
||||
|
||||
// Draw connection line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(outputX, outputY)
|
||||
ctx.lineTo(inputX, inputY)
|
||||
ctx.stroke()
|
||||
|
||||
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render connection slots on top
|
||||
ctx.fillStyle = colors.slotColor
|
||||
for (const conn of connections) {
|
||||
// Output slot
|
||||
ctx.beginPath()
|
||||
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Input slot
|
||||
ctx.beginPath()
|
||||
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a graph to a minimap canvas
|
||||
*/
|
||||
export function renderMinimapToCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
graph: LGraph,
|
||||
context: MinimapRenderContext
|
||||
) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, context.width, context.height)
|
||||
|
||||
// Fast path for empty graph
|
||||
if (!graph || !graph._nodes || graph._nodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const colors = getMinimapColors()
|
||||
const offsetX = (context.width - context.bounds.width * context.scale) / 2
|
||||
const offsetY = (context.height - context.bounds.height * context.scale) / 2
|
||||
|
||||
// Render in correct order: groups -> links -> nodes
|
||||
if (context.settings.showGroups) {
|
||||
renderGroups(ctx, graph, offsetX, offsetY, context, colors)
|
||||
}
|
||||
|
||||
if (context.settings.showLinks) {
|
||||
renderConnections(ctx, graph, offsetX, offsetY, context, colors)
|
||||
}
|
||||
|
||||
renderNodes(ctx, graph, offsetX, offsetY, context, colors)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Minimap-specific type definitions
|
||||
*/
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
* Minimal interface for what the minimap needs from the canvas
|
||||
*/
|
||||
export interface MinimapCanvas {
|
||||
canvas: HTMLCanvasElement
|
||||
ds: {
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
graph?: LGraph | null
|
||||
setDirty: (fg?: boolean, bg?: boolean) => void
|
||||
}
|
||||
|
||||
export interface MinimapRenderContext {
|
||||
bounds: {
|
||||
minX: number
|
||||
minY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
scale: number
|
||||
settings: MinimapRenderSettings
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface MinimapRenderSettings {
|
||||
nodeColors: boolean
|
||||
showLinks: boolean
|
||||
showGroups: boolean
|
||||
renderBypass: boolean
|
||||
renderError: boolean
|
||||
}
|
||||
|
||||
export interface MinimapBounds {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ViewportTransform {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface UpdateFlags {
|
||||
bounds: boolean
|
||||
nodes: boolean
|
||||
connections: boolean
|
||||
viewport: boolean
|
||||
}
|
||||
|
||||
export type MinimapSettingsKey =
|
||||
| 'Comfy.Minimap.NodeColors'
|
||||
| 'Comfy.Minimap.ShowLinks'
|
||||
| 'Comfy.Minimap.ShowGroups'
|
||||
| 'Comfy.Minimap.RenderBypassState'
|
||||
| 'Comfy.Minimap.RenderErrorState'
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
calculateMinimapScale,
|
||||
calculateNodeBounds
|
||||
} from '@/renderer/core/spatial/boundsCalculator'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import { renderMinimapToCanvas } from '../extensions/minimap/minimapCanvasRenderer'
|
||||
|
||||
/**
|
||||
* Create a thumbnail of the current canvas's active graph.
|
||||
* Used by workflow thumbnail generation.
|
||||
*/
|
||||
export function createGraphThumbnail(): string | null {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const graph = workflowStore.activeSubgraph || canvasStore.canvas?.graph
|
||||
if (!graph || !graph._nodes || graph._nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const width = 250
|
||||
const height = 200
|
||||
|
||||
// Calculate bounds using spatial calculator
|
||||
const bounds = calculateNodeBounds(graph._nodes)
|
||||
if (!bounds) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scale = calculateMinimapScale(bounds, width, height)
|
||||
|
||||
// Create detached canvas
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// Render the minimap
|
||||
renderMinimapToCanvas(canvas, graph as LGraph, {
|
||||
bounds,
|
||||
scale,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: true,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width,
|
||||
height
|
||||
})
|
||||
|
||||
const dataUrl = canvas.toDataURL()
|
||||
|
||||
// Explicit cleanup (optional but good practice)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
return dataUrl
|
||||
}
|
||||
462
src/services/GRAPH_MUTATION_SERVICE_DESIGN.md
Normal file
462
src/services/GRAPH_MUTATION_SERVICE_DESIGN.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# GraphMutationService Design and Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
GraphMutationService is the centralized service layer for all graph modification operations in ComfyUI Frontend. It provides a unified and validated API for graph mutations, serving as the single entry point for all graph modification operations.
|
||||
|
||||
## Project Background
|
||||
|
||||
### Current System Analysis
|
||||
|
||||
ComfyUI Frontend uses the LiteGraph library for graph operations, with main components including:
|
||||
|
||||
1. **LGraph** (`src/lib/litegraph/src/LGraph.ts`)
|
||||
- Core graph management class
|
||||
- Provides basic operations like `add()`, `remove()`
|
||||
- Supports `beforeChange()`/`afterChange()` transaction mechanism
|
||||
|
||||
2. **LGraphNode** (`src/lib/litegraph/src/LGraphNode.ts`)
|
||||
- Node class containing position, connections, and other properties
|
||||
- Provides methods like `connect()`, `disconnectInput()`, `disconnectOutput()`
|
||||
|
||||
3. **ChangeTracker** (`src/scripts/changeTracker.ts`)
|
||||
- Existing undo/redo system
|
||||
- Snapshot-based history tracking
|
||||
- Supports up to 50 history states
|
||||
|
||||
**Primary Goals:**
|
||||
- Single entry point for all graph modifications
|
||||
- Built-in validation and error handling
|
||||
- Transaction support for atomic operations
|
||||
- Natural undo/redo through existing ChangeTracker
|
||||
- Clean architecture for future extensibility
|
||||
|
||||
### Interface-Based Architecture
|
||||
|
||||
The GraphMutationService follows an **interface-based design pattern** with singleton state management:
|
||||
|
||||
- **IGraphMutationService Interface**: Defines the complete contract for all graph operations
|
||||
- **GraphMutationService Class**: Implements the interface with LiteGraph integration
|
||||
- **Singleton State**: Shared clipboard and transaction state across components
|
||||
|
||||
```typescript
|
||||
interface IGraphMutationService {
|
||||
// Node operations
|
||||
addNode(params: AddNodeParams): Promise<NodeId>
|
||||
removeNode(nodeId: NodeId): Promise<void>
|
||||
updateNodeProperty(nodeId: NodeId, property: string, value: any): Promise<void>
|
||||
// ... 50+ total operations
|
||||
|
||||
// Transaction support
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
|
||||
// Undo/Redo
|
||||
undo(): Promise<void>
|
||||
redo(): Promise<void>
|
||||
}
|
||||
|
||||
class GraphMutationService implements IGraphMutationService {
|
||||
// Implementation details...
|
||||
}
|
||||
```
|
||||
|
||||
The `useGraphMutationService()` hook returns the interface type, maintaining backward compatibility while enabling new architectural benefits.
|
||||
|
||||
### Core Components
|
||||
|
||||
```typescript
|
||||
// Interface Definition
|
||||
interface IGraphMutationService {
|
||||
// Complete method signatures for all 50+ operations
|
||||
// Organized by functional categories
|
||||
}
|
||||
|
||||
// Implementation Class
|
||||
class GraphMutationService implements IGraphMutationService {
|
||||
private workflowStore = useWorkflowStore()
|
||||
private transactionDepth = 0
|
||||
private clipboard: ClipboardData | null = null
|
||||
|
||||
// All interface methods implemented
|
||||
}
|
||||
|
||||
// Singleton Hook
|
||||
export const useGraphMutationService = (): IGraphMutationService => {
|
||||
if (!graphMutationServiceInstance) {
|
||||
graphMutationServiceInstance = new GraphMutationService()
|
||||
}
|
||||
return graphMutationServiceInstance
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Validation Framework
|
||||
|
||||
Each operation includes validation to ensure data integrity:
|
||||
|
||||
```typescript
|
||||
interface ValidationResult {
|
||||
isValid: boolean
|
||||
errors: ValidationError[]
|
||||
warnings: ValidationWarning[]
|
||||
}
|
||||
```
|
||||
|
||||
## Implemented Operations
|
||||
|
||||
### Node Operations (6 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `addNode` | Add a new node to the graph | src/scripts/app.ts:1589-1593, src/lib/litegraph/src/LGraph.ts:823-893 |
|
||||
| `removeNode` | Remove a node from the graph | src/lib/litegraph/src/LGraph.ts:899-986 |
|
||||
| `updateNodeProperty` | Update a custom node property | src/lib/litegraph/src/LGraphNode.ts:974-984 |
|
||||
| `updateNodeTitle` | Change the node's title | src/services/litegraphService.ts:369 (direct assignment) |
|
||||
| `changeNodeMode` | Change execution mode (ALWAYS/ON_TRIGGER/NEVER/ON_REQUEST/ON_EVENT) | src/lib/litegraph/src/LGraphNode.ts:1295-1320 |
|
||||
| `cloneNode` | Create a copy of a node | src/lib/litegraph/src/LGraphNode.ts:923-950 |
|
||||
|
||||
### Connection Operations (6 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `connect` | Create a connection between nodes | src/lib/litegraph/src/LGraphNode.ts:2641-2743, :2753-2870 (connectSlots) |
|
||||
| `disconnect` | Generic disconnect (auto-detects input/output) | Wrapper combining disconnectInput/disconnectOutput |
|
||||
| `disconnectInput` | Disconnect a specific input slot | src/lib/litegraph/src/LGraphNode.ts:3050-3144 |
|
||||
| `disconnectOutput` | Disconnect all connections from an output slot | src/lib/litegraph/src/LGraphNode.ts:2931-3043 |
|
||||
| `disconnectOutputTo` | Disconnect output to a specific target node | src/lib/litegraph/src/LGraphNode.ts:2931 (with target_node param) |
|
||||
| `disconnectLink` | Disconnect by link ID | src/lib/litegraph/src/LGraph.ts:1433-1441 |
|
||||
|
||||
### Group Operations (6 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `createGroup` | Create a new node group | src/composables/useCoreCommands.ts:425-430, src/lib/litegraph/src/LGraph.ts:823-848 (add method for groups) |
|
||||
| `removeGroup` | Delete a group (nodes remain) | src/lib/litegraph/src/LGraph.ts:899-913 (remove method for groups) |
|
||||
| `updateGroupTitle` | Change group title | Direct assignment (group.title = value) |
|
||||
| `moveGroup` | Move group and its contents | src/lib/litegraph/src/LGraphGroup.ts:230-240 |
|
||||
| `addNodesToGroup` | Add nodes to group and auto-resize | src/lib/litegraph/src/LGraphGroup.ts:303-306 |
|
||||
| `recomputeGroupNodes` | Recalculate which nodes are in group | src/lib/litegraph/src/LGraphGroup.ts:247-273 |
|
||||
|
||||
### Subgraph Node Slot Operations (4 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `addSubgraphNodeInput` | Add an input slot to a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1606-1627 |
|
||||
| `addSubgraphNodeOutput` | Add an output slot to a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1551-1571 |
|
||||
| `removeSubgraphNodeInput` | Remove an input slot from a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1632-1652 |
|
||||
| `removeSubgraphNodeOutput` | Remove an output slot from a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1576-1599 |
|
||||
|
||||
### Batch Operations (3 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `addNodes` | Add multiple nodes in one operation | Custom implementation based on single addNode logic |
|
||||
| `removeNodes` | Remove multiple nodes in one operation | src/composables/useCoreCommands.ts:180 (forEach pattern) |
|
||||
| `duplicateNodes` | Duplicate selected nodes with their connections | src/utils/vintageClipboard.ts:32 (node.clone pattern) |
|
||||
|
||||
### Clipboard Operations (6 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `copyNodes` | Copy nodes to clipboard | src/lib/litegraph/src/LGraphCanvas.ts:3602-3687 |
|
||||
| `cutNodes` | Cut nodes to clipboard | Custom implementation (copy + mark for deletion) |
|
||||
| `pasteNodes` | Paste nodes from clipboard | src/lib/litegraph/src/LGraphCanvas.ts:3693-3871 |
|
||||
| `getClipboard` | Get current clipboard content | Custom implementation (returns internal clipboard) |
|
||||
| `clearClipboard` | Clear clipboard content | Custom implementation (sets clipboard to null) |
|
||||
| `hasClipboardContent` | Check if clipboard has content | Custom implementation (checks clipboard state) |
|
||||
|
||||
### Reroute Operations (2 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `addReroute` | Add a reroute point on a connection | src/lib/litegraph/src/LGraph.ts:1338-1361 (createReroute) |
|
||||
| `removeReroute` | Remove a reroute point | src/lib/litegraph/src/LGraph.ts:1381-1407 |
|
||||
|
||||
### Subgraph Operations (6 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `createSubgraph` | Create a subgraph from selected nodes | src/lib/litegraph/src/LGraph.ts:1459-1566 (convertToSubgraph) |
|
||||
| `unpackSubgraph` | Unpack a subgraph node back into regular nodes | src/lib/litegraph/src/LGraph.ts:1672-1841 |
|
||||
| `addSubgraphInput` | Add an input to a subgraph | src/lib/litegraph/src/LGraph.ts:2440-2456 |
|
||||
| `addSubgraphOutput` | Add an output to a subgraph | src/lib/litegraph/src/LGraph.ts:2458-2474 |
|
||||
| `removeSubgraphInput` | Remove a subgraph input | src/lib/litegraph/src/LGraph.ts:2520-2535 |
|
||||
| `removeSubgraphOutput` | Remove a subgraph output | src/lib/litegraph/src/LGraph.ts:2541-2559 |
|
||||
|
||||
### Graph-level Operations (1 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `clearGraph` | Clear all nodes and connections | src/lib/litegraph/src/LGraph.ts:293-362 |
|
||||
|
||||
### Execution Control Operations (2 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `bypassNode` | Set node to bypass mode (never execute) | Direct mode assignment (node.mode = LGraphEventMode.BYPASS) |
|
||||
| `unbypassNode` | Set node to normal mode (always execute) | Direct mode assignment (node.mode = LGraphEventMode.ALWAYS) |
|
||||
|
||||
### Transaction and History Operations (3 operations)
|
||||
|
||||
| Operation | Description | Original Implementation Reference |
|
||||
|-----------|-------------|-----------------------------------|
|
||||
| `transaction` | Execute multiple operations atomically | Custom implementation using beforeChange/afterChange |
|
||||
| `undo` | Undo the last operation | src/scripts/changeTracker.ts (uses changeTracker.undo) |
|
||||
| `redo` | Redo the previously undone operation | src/scripts/changeTracker.ts (uses changeTracker.redo) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Node Operations
|
||||
|
||||
```typescript
|
||||
import { useGraphMutationService } from '@/services/graphMutationService'
|
||||
|
||||
const service = useGraphMutationService()
|
||||
|
||||
// Add a node
|
||||
const nodeId = await service.addNode({
|
||||
type: 'LoadImage',
|
||||
pos: [100, 100],
|
||||
title: 'Image Loader'
|
||||
})
|
||||
|
||||
// Update node properties
|
||||
await service.updateNodeTitle(nodeId, 'My Image')
|
||||
await service.updateNodeProperty(nodeId, 'seed', 12345)
|
||||
|
||||
// Clone a node
|
||||
const clonedId = await service.cloneNode(nodeId, [300, 200])
|
||||
```
|
||||
|
||||
### Connection Management
|
||||
|
||||
```typescript
|
||||
// Create a connection
|
||||
const linkId = await service.connect({
|
||||
sourceNodeId: node1Id,
|
||||
sourceSlot: 0,
|
||||
targetNodeId: node2Id,
|
||||
targetSlot: 0
|
||||
})
|
||||
|
||||
// Various disconnect methods
|
||||
await service.disconnectInput(node2Id, 0)
|
||||
await service.disconnectOutput(node1Id, 0)
|
||||
await service.disconnectLink(linkId)
|
||||
```
|
||||
|
||||
### Group Management
|
||||
|
||||
```typescript
|
||||
// Create a group
|
||||
const groupId = await service.createGroup({
|
||||
title: 'Image Processing',
|
||||
pos: [100, 100],
|
||||
size: [400, 300],
|
||||
color: '#335577'
|
||||
})
|
||||
|
||||
// Manage group content
|
||||
await service.addNodesToGroup(groupId, [node1Id, node2Id])
|
||||
await service.moveGroup(groupId, 50, 100) // deltaX, deltaY
|
||||
await service.resizeGroup(groupId, [500, 400])
|
||||
```
|
||||
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```typescript
|
||||
// Add multiple nodes
|
||||
const nodeIds = await service.addNodes([
|
||||
{ type: 'LoadImage', pos: [100, 100] },
|
||||
{ type: 'VAEEncode', pos: [300, 100] },
|
||||
{ type: 'KSampler', pos: [500, 100] }
|
||||
])
|
||||
|
||||
// Duplicate with connections preserved
|
||||
const duplicatedIds = await service.duplicateNodes(
|
||||
[node1Id, node2Id, node3Id],
|
||||
[100, 100] // offset
|
||||
)
|
||||
|
||||
// Batch delete
|
||||
await service.removeNodes([node1Id, node2Id, node3Id])
|
||||
```
|
||||
|
||||
### Clipboard Operations
|
||||
|
||||
```typescript
|
||||
// Copy/Cut/Paste workflow
|
||||
await service.copyNodes([node1Id, node2Id])
|
||||
await service.cutNodes([node3Id, node4Id])
|
||||
|
||||
const pastedNodes = await service.pasteNodes([200, 200])
|
||||
|
||||
// Check clipboard
|
||||
if (service.hasClipboardContent()) {
|
||||
const clipboard = service.getClipboard()
|
||||
console.log(`${clipboard.nodes.length} nodes in clipboard`)
|
||||
}
|
||||
```
|
||||
|
||||
### Transactions
|
||||
|
||||
```typescript
|
||||
// Atomic operations
|
||||
await service.transaction(async () => {
|
||||
const node1 = await service.addNode({ type: 'LoadImage' })
|
||||
const node2 = await service.addNode({ type: 'SaveImage' })
|
||||
await service.connect({
|
||||
sourceNodeId: node1,
|
||||
sourceSlot: 0,
|
||||
targetNodeId: node2,
|
||||
targetSlot: 0
|
||||
})
|
||||
})
|
||||
|
||||
// Entire transaction can be undone as one operation
|
||||
await service.undo()
|
||||
```
|
||||
|
||||
### Graph-level Operations
|
||||
|
||||
```typescript
|
||||
// Clear entire graph
|
||||
await service.clearGraph()
|
||||
|
||||
// Distribute nodes evenly
|
||||
await service.distributeNodes([node1Id, node2Id, node3Id], 'horizontal')
|
||||
```
|
||||
|
||||
### Execution Control
|
||||
|
||||
```typescript
|
||||
// Bypass node (set to never execute)
|
||||
await service.bypassNode(nodeId)
|
||||
|
||||
// Re-enable node execution
|
||||
await service.unbypassNode(nodeId)
|
||||
```
|
||||
|
||||
### Subgraph Operations
|
||||
|
||||
```typescript
|
||||
// Create subgraph from selected nodes
|
||||
const subgraphId = await service.createSubgraph({
|
||||
name: 'Image Processing',
|
||||
nodeIds: [node1Id, node2Id, node3Id]
|
||||
})
|
||||
|
||||
// Configure subgraph I/O
|
||||
await service.addSubgraphInput(subgraphId, 'image', 'IMAGE')
|
||||
await service.addSubgraphOutput(subgraphId, 'result', 'IMAGE')
|
||||
|
||||
// Add dynamic slots to subgraph nodes
|
||||
await service.addSubgraphNodeInput({
|
||||
nodeId: subgraphNodeId,
|
||||
name: 'extra_input',
|
||||
type: 'LATENT'
|
||||
})
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **LiteGraph Integration**
|
||||
- Uses `app.graph` for graph access
|
||||
- Calls `beforeChange()`/`afterChange()` for transactions
|
||||
- Integrates with existing LiteGraph node/connection APIs
|
||||
|
||||
2. **ChangeTracker Integration**
|
||||
- Maintains compatibility with existing undo/redo system
|
||||
- Calls `checkState()` after operations
|
||||
- Provides undo/redo through existing tracker
|
||||
|
||||
## Validation System
|
||||
|
||||
### Current Validations (Placeholder)
|
||||
|
||||
- `validateAddNode()` - Check node type exists
|
||||
- `validateRemoveNode()` - Check node can be removed
|
||||
- `validateConnect()` - Check connection compatibility
|
||||
- `validateUpdateNodePosition()` - Check position bounds
|
||||
|
||||
### Future Validations
|
||||
|
||||
- Type compatibility checking
|
||||
- Circular dependency detection
|
||||
- Resource limit enforcement
|
||||
- Permission validation
|
||||
- Business rule enforcement
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### Why Validation Layer?
|
||||
- **Data Integrity**: Prevent invalid graph states
|
||||
- **User Experience**: Early error detection
|
||||
- **Security**: Prevent malicious operations
|
||||
- **Extensibility**: Easy to add new rules
|
||||
|
||||
### Why Transaction Support?
|
||||
- **Atomicity**: Multiple operations succeed or fail together
|
||||
- **Consistency**: Graph remains valid throughout
|
||||
- **User Experience**: Natural undo/redo boundaries
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Interface Definition**: `src/services/IGraphMutationService.ts`
|
||||
- **Implementation**: `src/services/GraphMutationService.ts`
|
||||
- **LiteGraph Core**: `src/lib/litegraph/src/LGraph.ts`
|
||||
- **Node Implementation**: `src/lib/litegraph/src/LGraphNode.ts`
|
||||
- **Change Tracking**: `src/scripts/changeTracker.ts`
|
||||
|
||||
## Implementation Compatibility Notes
|
||||
|
||||
### Critical Implementation Details to Maintain:
|
||||
|
||||
1. **beforeChange/afterChange Pattern**
|
||||
- All mutations MUST be wrapped with `graph.beforeChange()` and `graph.afterChange()`
|
||||
- This enables undo/redo functionality through ChangeTracker
|
||||
- Reference: `src/scripts/changeTracker.ts:200-208`
|
||||
|
||||
2. **Node ID Management**
|
||||
- Node IDs can be numbers or strings (for API compatibility)
|
||||
- Reference: `src/scripts/app.ts:1591` - `node.id = isNaN(+id) ? id : +id`
|
||||
|
||||
3. **Clipboard Implementation**
|
||||
- Current implementation uses localStorage for persistence
|
||||
- Must maintain compatibility with existing clipboard format
|
||||
- Reference: `src/lib/litegraph/src/LGraphCanvas.ts:3602-3857`
|
||||
|
||||
4. **Group Resizing**
|
||||
- Groups should auto-resize when adding nodes using `recomputeInsideNodes()`
|
||||
- Reference: `src/composables/useCoreCommands.ts:430` - `group.resizeTo()`
|
||||
|
||||
5. **Canvas Dirty Flag**
|
||||
- Visual operations (groups, reroutes) must call `graph.setDirtyCanvas(true, false)`
|
||||
- This triggers canvas redraw
|
||||
|
||||
6. **Error Handling**
|
||||
- Node creation can return null (for invalid types)
|
||||
- Connection operations return null/false on failure
|
||||
- Must validate before operations
|
||||
|
||||
7. **Subgraph Support**
|
||||
- Subgraph operations use specialized Subgraph and SubgraphNode classes
|
||||
- Reference: `src/lib/litegraph/src/subgraph/`
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Start by replacing direct `app.graph.add()` calls with `graphMutationService.addNode()`
|
||||
2. Replace `graph.remove()` calls with `graphMutationService.removeNode()`
|
||||
3. Update connection operations to use service methods
|
||||
4. Migrate clipboard operations to use centralized service
|
||||
5. Ensure all operations maintain existing beforeChange/afterChange patterns
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Always use GraphMutationService** - Never call graph methods directly
|
||||
2. **Backward Compatibility** - Service maintains compatibility with existing code
|
||||
3. **Gradual Migration** - Existing code can be migrated incrementally
|
||||
4. **Performance** - Command recording has minimal overhead
|
||||
161
src/services/IGraphMutationService.ts
Normal file
161
src/services/IGraphMutationService.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { GroupId } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LinkId } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { SubgraphId } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
|
||||
export interface AddNodeParams {
|
||||
type: string
|
||||
pos?: [number, number]
|
||||
properties?: Record<string, any>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface ConnectParams {
|
||||
sourceNodeId: NodeId
|
||||
sourceSlot: number | string
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number | string
|
||||
}
|
||||
|
||||
export interface CreateGroupParams {
|
||||
title?: string
|
||||
pos?: [number, number]
|
||||
size?: [number, number]
|
||||
color?: string
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
export interface AddRerouteParams {
|
||||
pos: [number, number]
|
||||
linkId?: LinkId
|
||||
parentRerouteId?: RerouteId
|
||||
}
|
||||
|
||||
export interface AddNodeInputParams {
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
type: string
|
||||
extra_info?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AddNodeOutputParams {
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
type: string
|
||||
extra_info?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface CreateSubgraphParams {
|
||||
selectedItems: Set<any>
|
||||
}
|
||||
|
||||
export interface ClipboardData {
|
||||
nodes: any[]
|
||||
connections: any[]
|
||||
isCut: boolean
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
errors?: ValidationError[]
|
||||
warnings?: ValidationWarning[]
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
code: string
|
||||
message: string
|
||||
field?: string
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface IGraphMutationService {
|
||||
// Node operations
|
||||
addNode(params: AddNodeParams): Promise<NodeId>
|
||||
removeNode(nodeId: NodeId): Promise<void>
|
||||
updateNodeProperty(
|
||||
nodeId: NodeId,
|
||||
property: string,
|
||||
value: any
|
||||
): Promise<void>
|
||||
updateNodeTitle(nodeId: NodeId, title: string): Promise<void>
|
||||
changeNodeMode(nodeId: NodeId, mode: number): Promise<void>
|
||||
cloneNode(nodeId: NodeId, pos?: [number, number]): Promise<NodeId>
|
||||
|
||||
connect(params: ConnectParams): Promise<LinkId>
|
||||
disconnect(
|
||||
nodeId: NodeId,
|
||||
slot: number | string,
|
||||
slotType: 'input' | 'output',
|
||||
targetNodeId?: NodeId
|
||||
): Promise<boolean>
|
||||
disconnectInput(nodeId: NodeId, slot: number | string): Promise<boolean>
|
||||
disconnectOutput(nodeId: NodeId, slot: number | string): Promise<boolean>
|
||||
disconnectOutputTo(
|
||||
nodeId: NodeId,
|
||||
slot: number | string,
|
||||
targetNodeId: NodeId
|
||||
): Promise<boolean>
|
||||
disconnectLink(linkId: LinkId): Promise<void>
|
||||
|
||||
createGroup(params: CreateGroupParams): Promise<GroupId>
|
||||
removeGroup(groupId: GroupId): Promise<void>
|
||||
updateGroupTitle(groupId: GroupId, title: string): Promise<void>
|
||||
moveGroup(groupId: GroupId, deltaX: number, deltaY: number): Promise<void>
|
||||
addNodesToGroup(groupId: GroupId, nodeIds: NodeId[]): Promise<void>
|
||||
recomputeGroupNodes(groupId: GroupId): Promise<void>
|
||||
|
||||
addReroute(params: AddRerouteParams): Promise<RerouteId>
|
||||
removeReroute(rerouteId: RerouteId): Promise<void>
|
||||
|
||||
addNodes(nodes: AddNodeParams[]): Promise<NodeId[]>
|
||||
removeNodes(nodeIds: NodeId[]): Promise<void>
|
||||
duplicateNodes(
|
||||
nodeIds: NodeId[],
|
||||
offset?: [number, number]
|
||||
): Promise<NodeId[]>
|
||||
|
||||
copyNodes(nodeIds: NodeId[]): Promise<void>
|
||||
cutNodes(nodeIds: NodeId[]): Promise<void>
|
||||
pasteNodes(position?: [number, number]): Promise<NodeId[]>
|
||||
getClipboard(): ClipboardData | null
|
||||
clearClipboard(): void
|
||||
hasClipboardContent(): boolean
|
||||
|
||||
addSubgraphNodeInput(params: AddNodeInputParams): Promise<number>
|
||||
addSubgraphNodeOutput(params: AddNodeOutputParams): Promise<number>
|
||||
removeSubgraphNodeInput(nodeId: NodeId, slot: number): Promise<void>
|
||||
removeSubgraphNodeOutput(nodeId: NodeId, slot: number): Promise<void>
|
||||
|
||||
createSubgraph(params: CreateSubgraphParams): Promise<{
|
||||
subgraph: any
|
||||
node: any
|
||||
}>
|
||||
unpackSubgraph(subgraphNodeId: NodeId): Promise<void>
|
||||
addSubgraphInput(
|
||||
subgraphId: SubgraphId,
|
||||
name: string,
|
||||
type: string
|
||||
): Promise<void>
|
||||
addSubgraphOutput(
|
||||
subgraphId: SubgraphId,
|
||||
name: string,
|
||||
type: string
|
||||
): Promise<void>
|
||||
removeSubgraphInput(subgraphId: SubgraphId, index: number): Promise<void>
|
||||
removeSubgraphOutput(subgraphId: SubgraphId, index: number): Promise<void>
|
||||
|
||||
clearGraph(): Promise<void>
|
||||
|
||||
bypassNode(nodeId: NodeId): Promise<void>
|
||||
unbypassNode(nodeId: NodeId): Promise<void>
|
||||
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
|
||||
undo(): Promise<void>
|
||||
redo(): Promise<void>
|
||||
}
|
||||
1152
src/services/graphMutationService.ts
Normal file
1152
src/services/graphMutationService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import { t } from '@/i18n'
|
||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
|
||||
@@ -2,8 +2,8 @@ import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -3,39 +3,28 @@ import { nextTick } from 'vue'
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const triggerRAF = async () => {
|
||||
// Trigger all RAF callbacks
|
||||
Object.values(rafCallbacks).forEach((cb) => cb?.())
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const mockPause = vi.fn()
|
||||
const mockResume = vi.fn()
|
||||
|
||||
const rafCallbacks: Record<string, () => void> = {}
|
||||
let rafCallbackId = 0
|
||||
|
||||
vi.mock('@vueuse/core', () => {
|
||||
const callbacks: Record<string, () => void> = {}
|
||||
let callbackId = 0
|
||||
|
||||
return {
|
||||
useRafFn: vi.fn((callback, options) => {
|
||||
const id = rafCallbackId++
|
||||
rafCallbacks[id] = callback
|
||||
const id = callbackId++
|
||||
callbacks[id] = callback
|
||||
|
||||
if (options?.immediate !== false) {
|
||||
void Promise.resolve().then(() => callback())
|
||||
}
|
||||
|
||||
const resumeFn = vi.fn(() => {
|
||||
mockResume()
|
||||
// Execute the RAF callback immediately when resumed
|
||||
if (rafCallbacks[id]) {
|
||||
rafCallbacks[id]()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
pause: mockPause,
|
||||
resume: resumeFn
|
||||
resume: vi.fn(() => {
|
||||
mockResume()
|
||||
void Promise.resolve().then(() => callbacks[id]?.())
|
||||
})
|
||||
}
|
||||
}),
|
||||
useThrottleFn: vi.fn((callback) => {
|
||||
@@ -153,9 +142,7 @@ vi.mock('@/stores/workflowStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const { useMinimap } = await import(
|
||||
'@/renderer/extensions/minimap/composables/useMinimap'
|
||||
)
|
||||
const { useMinimap } = await import('@/composables/useMinimap')
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useMinimap', () => {
|
||||
@@ -438,19 +425,7 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
// Force initial render
|
||||
minimap.renderMinimap()
|
||||
|
||||
// Force a render by triggering a graph change
|
||||
mockGraph._nodes.push({
|
||||
id: 'new-node',
|
||||
pos: [150, 150],
|
||||
size: [100, 50]
|
||||
})
|
||||
|
||||
// Trigger RAF to process changes
|
||||
await triggerRAF()
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(getContextSpy).toHaveBeenCalled()
|
||||
expect(getContextSpy).toHaveBeenCalledWith('2d')
|
||||
@@ -463,15 +438,7 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
// Force initial render
|
||||
minimap.renderMinimap()
|
||||
|
||||
// Force a render by modifying a node position
|
||||
mockGraph._nodes[0].pos = [50, 50]
|
||||
|
||||
// Trigger RAF to process changes
|
||||
await triggerRAF()
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const renderingOccurred =
|
||||
mockContext2D.clearRect.mock.calls.length > 0 ||
|
||||
@@ -482,15 +449,6 @@ describe('useMinimap', () => {
|
||||
console.log('Minimap initialized:', minimap.initialized.value)
|
||||
console.log('Canvas exists:', !!defaultCanvasStore.canvas)
|
||||
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
|
||||
console.log(
|
||||
'clearRect calls:',
|
||||
mockContext2D.clearRect.mock.calls.length
|
||||
)
|
||||
console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length)
|
||||
console.log(
|
||||
'getContext calls:',
|
||||
mockCanvasElement.getContext.mock.calls.length
|
||||
)
|
||||
}
|
||||
|
||||
expect(renderingOccurred).toBe(true)
|
||||
@@ -520,10 +478,6 @@ describe('useMinimap', () => {
|
||||
minimap.canvasRef.value = mockCanvasElement
|
||||
|
||||
await minimap.init()
|
||||
|
||||
// The renderer has a fast path for empty graphs, force it to execute
|
||||
minimap.renderMinimap()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(minimap.initialized.value).toBe(true)
|
||||
@@ -963,7 +917,7 @@ describe('useMinimap', () => {
|
||||
describe('setMinimapRef', () => {
|
||||
it('should set minimap reference', () => {
|
||||
const minimap = useMinimap()
|
||||
const ref = document.createElement('div')
|
||||
const ref = { value: 'test-ref' }
|
||||
|
||||
minimap.setMinimapRef(ref)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
vi.mock('@/renderer/thumbnail/graphThumbnailRenderer', () => ({
|
||||
createGraphThumbnail: vi.fn()
|
||||
vi.mock('@/composables/useMinimap', () => ({
|
||||
useMinimap: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -19,14 +19,13 @@ vi.mock('@/scripts/api', () => ({
|
||||
}))
|
||||
|
||||
const { useWorkflowThumbnail } = await import(
|
||||
'@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
||||
)
|
||||
const { createGraphThumbnail } = await import(
|
||||
'@/renderer/thumbnail/graphThumbnailRenderer'
|
||||
'@/composables/useWorkflowThumbnail'
|
||||
)
|
||||
const { useMinimap } = await import('@/composables/useMinimap')
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useWorkflowThumbnail', () => {
|
||||
let mockMinimapInstance: any
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,23 +39,35 @@ describe('useWorkflowThumbnail', () => {
|
||||
// Now set up mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
const blob = new Blob()
|
||||
|
||||
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Mock API responses
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
|
||||
|
||||
// Default createGraphThumbnail to return test value
|
||||
vi.mocked(createGraphThumbnail).mockReturnValue(
|
||||
'data:image/png;base64,test'
|
||||
)
|
||||
mockMinimapInstance = {
|
||||
renderMinimap: vi.fn(),
|
||||
canvasRef: {
|
||||
value: {
|
||||
toBlob: vi.fn((cb) => cb(blob))
|
||||
}
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance)
|
||||
})
|
||||
|
||||
it('should capture minimap thumbnail', async () => {
|
||||
const { createMinimapPreview } = useWorkflowThumbnail()
|
||||
const thumbnail = await createMinimapPreview()
|
||||
|
||||
expect(createGraphThumbnail).toHaveBeenCalledOnce()
|
||||
expect(useMinimap).toHaveBeenCalledOnce()
|
||||
expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce()
|
||||
|
||||
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||
})
|
||||
|
||||
@@ -150,9 +161,6 @@ describe('useWorkflowThumbnail', () => {
|
||||
// Reset the mock to track new calls and create different URL
|
||||
vi.clearAllMocks()
|
||||
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
|
||||
vi.mocked(createGraphThumbnail).mockReturnValue(
|
||||
'data:image/png;base64,test2'
|
||||
)
|
||||
|
||||
// Store second thumbnail for same workflow - should revoke the first URL
|
||||
await storeThumbnail(mockWorkflow)
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useThrottleFn: vi.fn((fn) => fn)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useMinimapGraph', () => {
|
||||
let mockGraph: LGraph
|
||||
let onGraphChangedMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockGraph = {
|
||||
id: 'test-graph-123',
|
||||
_nodes: [
|
||||
{ id: '1', pos: [100, 100], size: [150, 80] },
|
||||
{ id: '2', pos: [300, 200], size: [120, 60] }
|
||||
],
|
||||
links: { link1: { id: 'link1' } },
|
||||
onNodeAdded: vi.fn(),
|
||||
onNodeRemoved: vi.fn(),
|
||||
onConnectionChange: vi.fn()
|
||||
} as any
|
||||
|
||||
onGraphChangedMock = vi.fn()
|
||||
})
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
expect(graphManager.updateFlags.value).toEqual({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
})
|
||||
|
||||
it('should setup event listeners on init', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.init()
|
||||
|
||||
expect(api.addEventListener).toHaveBeenCalledWith(
|
||||
'graphChanged',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should wrap graph callbacks on setup', () => {
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
const originalOnConnectionChange = vi.fn()
|
||||
|
||||
mockGraph.onNodeAdded = originalOnNodeAdded
|
||||
mockGraph.onNodeRemoved = originalOnNodeRemoved
|
||||
mockGraph.onConnectionChange = originalOnConnectionChange
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
|
||||
// Should wrap the callbacks
|
||||
expect(mockGraph.onNodeAdded).not.toBe(originalOnNodeAdded)
|
||||
expect(mockGraph.onNodeRemoved).not.toBe(originalOnNodeRemoved)
|
||||
expect(mockGraph.onConnectionChange).not.toBe(originalOnConnectionChange)
|
||||
|
||||
// Test wrapped callbacks
|
||||
const testNode = { id: '3' } as LGraphNode
|
||||
mockGraph.onNodeAdded!(testNode)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
|
||||
expect(onGraphChangedMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prevent duplicate event listener setup', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Store original callbacks for comparison
|
||||
// const originalCallbacks = {
|
||||
// onNodeAdded: mockGraph.onNodeAdded,
|
||||
// onNodeRemoved: mockGraph.onNodeRemoved,
|
||||
// onConnectionChange: mockGraph.onConnectionChange
|
||||
// }
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
const wrappedCallbacks = {
|
||||
onNodeAdded: mockGraph.onNodeAdded,
|
||||
onNodeRemoved: mockGraph.onNodeRemoved,
|
||||
onConnectionChange: mockGraph.onConnectionChange
|
||||
}
|
||||
|
||||
// Setup again - should not re-wrap
|
||||
graphManager.setupEventListeners()
|
||||
|
||||
expect(mockGraph.onNodeAdded).toBe(wrappedCallbacks.onNodeAdded)
|
||||
expect(mockGraph.onNodeRemoved).toBe(wrappedCallbacks.onNodeRemoved)
|
||||
expect(mockGraph.onConnectionChange).toBe(
|
||||
wrappedCallbacks.onConnectionChange
|
||||
)
|
||||
})
|
||||
|
||||
it('should cleanup event listeners properly', () => {
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
const originalOnConnectionChange = vi.fn()
|
||||
|
||||
mockGraph.onNodeAdded = originalOnNodeAdded
|
||||
mockGraph.onNodeRemoved = originalOnNodeRemoved
|
||||
mockGraph.onConnectionChange = originalOnConnectionChange
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
graphManager.cleanupEventListeners()
|
||||
|
||||
// Should restore original callbacks
|
||||
expect(mockGraph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(mockGraph.onNodeRemoved).toBe(originalOnNodeRemoved)
|
||||
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
|
||||
})
|
||||
|
||||
it('should handle cleanup for never-setup graph', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.cleanupEventListeners()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should detect node position changes', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// First check - cache initial state
|
||||
let hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true) // Initial cache population
|
||||
|
||||
// No changes
|
||||
hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(false)
|
||||
|
||||
// Change node position
|
||||
mockGraph._nodes[0].pos = [200, 150]
|
||||
hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
expect(graphManager.updateFlags.value.bounds).toBe(true)
|
||||
expect(graphManager.updateFlags.value.nodes).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect node count changes', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Cache initial state
|
||||
graphManager.checkForChanges()
|
||||
|
||||
// Add a node
|
||||
mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any)
|
||||
|
||||
const hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
expect(graphManager.updateFlags.value.bounds).toBe(true)
|
||||
expect(graphManager.updateFlags.value.nodes).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect connection changes', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Cache initial state
|
||||
graphManager.checkForChanges()
|
||||
|
||||
// Change connections
|
||||
mockGraph.links = new Map([
|
||||
[1, { id: 1 }],
|
||||
[2, { id: 2 }]
|
||||
]) as any
|
||||
|
||||
const hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
expect(graphManager.updateFlags.value.connections).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle node removal in callbacks', () => {
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
mockGraph.onNodeRemoved = originalOnNodeRemoved
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
|
||||
const removedNode = { id: '2' } as LGraphNode
|
||||
mockGraph.onNodeRemoved!(removedNode)
|
||||
|
||||
expect(originalOnNodeRemoved).toHaveBeenCalledWith(removedNode)
|
||||
expect(onGraphChangedMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should destroy properly', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.init()
|
||||
graphManager.setupEventListeners()
|
||||
graphManager.destroy()
|
||||
|
||||
expect(api.removeEventListener).toHaveBeenCalledWith(
|
||||
'graphChanged',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear cache', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Populate cache
|
||||
graphManager.checkForChanges()
|
||||
|
||||
// Clear cache
|
||||
graphManager.clearCache()
|
||||
|
||||
// Should detect changes again after clear
|
||||
const hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null graph gracefully', () => {
|
||||
const graphRef = ref(null as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
expect(() => graphManager.setupEventListeners()).not.toThrow()
|
||||
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
|
||||
expect(graphManager.checkForChanges()).toBe(false)
|
||||
})
|
||||
|
||||
it('should clean up removed nodes from cache', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Cache initial state
|
||||
graphManager.checkForChanges()
|
||||
|
||||
// Remove a node
|
||||
mockGraph._nodes = mockGraph._nodes.filter((n) => n.id !== '2')
|
||||
|
||||
const hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
expect(graphManager.updateFlags.value.bounds).toBe(true)
|
||||
})
|
||||
|
||||
it('should throttle graph changed callback', () => {
|
||||
const throttledFn = vi.fn()
|
||||
vi.mocked(useThrottleFn).mockReturnValue(throttledFn)
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
|
||||
// Trigger multiple changes rapidly
|
||||
mockGraph.onNodeAdded!({ id: '3' } as LGraphNode)
|
||||
mockGraph.onNodeAdded!({ id: '4' } as LGraphNode)
|
||||
mockGraph.onNodeAdded!({ id: '5' } as LGraphNode)
|
||||
|
||||
// Should be throttled
|
||||
expect(throttledFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -1,328 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction'
|
||||
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
|
||||
|
||||
describe('useMinimapInteraction', () => {
|
||||
let mockContainer: HTMLDivElement
|
||||
let mockCanvas: MinimapCanvas
|
||||
let centerViewOnMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContainer = {
|
||||
getBoundingClientRect: vi.fn().mockReturnValue({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 250,
|
||||
height: 200
|
||||
})
|
||||
} as any
|
||||
|
||||
mockCanvas = {
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0]
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
} as any
|
||||
|
||||
centerViewOnMock = vi.fn()
|
||||
})
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
expect(interaction.isDragging.value).toBe(false)
|
||||
expect(interaction.containerRect.value).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 250,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('should update container rect', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
interaction.updateContainerRect()
|
||||
|
||||
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
|
||||
|
||||
expect(interaction.containerRect.value).toEqual({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 250,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle pointer down and start dragging', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
clientX: 150,
|
||||
clientY: 100
|
||||
})
|
||||
|
||||
interaction.handlePointerDown(event)
|
||||
|
||||
expect(interaction.isDragging.value).toBe(true)
|
||||
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
|
||||
expect(centerViewOnMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle pointer move when dragging', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
// Start dragging
|
||||
interaction.handlePointerDown(
|
||||
new PointerEvent('pointerdown', {
|
||||
clientX: 150,
|
||||
clientY: 100
|
||||
})
|
||||
)
|
||||
|
||||
// Move pointer
|
||||
const moveEvent = new PointerEvent('pointermove', {
|
||||
clientX: 200,
|
||||
clientY: 150
|
||||
})
|
||||
|
||||
interaction.handlePointerMove(moveEvent)
|
||||
|
||||
// Should calculate world coordinates and center view
|
||||
expect(centerViewOnMock).toHaveBeenCalledTimes(2) // Once on down, once on move
|
||||
|
||||
// Calculate expected world coordinates
|
||||
const x = 200 - 100 // clientX - containerLeft
|
||||
const y = 150 - 50 // clientY - containerTop
|
||||
const offsetX = (250 - 500 * 0.5) / 2 // (width - bounds.width * scale) / 2
|
||||
const offsetY = (200 - 400 * 0.5) / 2 // (height - bounds.height * scale) / 2
|
||||
const worldX = (x - offsetX) / 0.5 + 0 // (x - offsetX) / scale + bounds.minX
|
||||
const worldY = (y - offsetY) / 0.5 + 0 // (y - offsetY) / scale + bounds.minY
|
||||
|
||||
expect(centerViewOnMock).toHaveBeenLastCalledWith(worldX, worldY)
|
||||
})
|
||||
|
||||
it('should not move when not dragging', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
const moveEvent = new PointerEvent('pointermove', {
|
||||
clientX: 200,
|
||||
clientY: 150
|
||||
})
|
||||
|
||||
interaction.handlePointerMove(moveEvent)
|
||||
|
||||
expect(centerViewOnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle pointer up to stop dragging', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
// Start dragging
|
||||
interaction.handlePointerDown(
|
||||
new PointerEvent('pointerdown', {
|
||||
clientX: 150,
|
||||
clientY: 100
|
||||
})
|
||||
)
|
||||
|
||||
expect(interaction.isDragging.value).toBe(true)
|
||||
|
||||
interaction.handlePointerUp()
|
||||
|
||||
expect(interaction.isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle wheel events for zooming', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
clientX: 200,
|
||||
clientY: 150
|
||||
})
|
||||
wheelEvent.preventDefault = vi.fn()
|
||||
|
||||
interaction.handleWheel(wheelEvent)
|
||||
|
||||
// Should update canvas scale (zoom in)
|
||||
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
|
||||
expect(centerViewOnMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect zoom limits', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
// Set scale close to minimum
|
||||
mockCanvas.ds.scale = 0.11
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: 100, // Zoom out
|
||||
clientX: 200,
|
||||
clientY: 150
|
||||
})
|
||||
wheelEvent.preventDefault = vi.fn()
|
||||
|
||||
interaction.handleWheel(wheelEvent)
|
||||
|
||||
// Should not go below minimum scale
|
||||
expect(mockCanvas.ds.scale).toBe(0.11)
|
||||
expect(centerViewOnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle null container gracefully', () => {
|
||||
const containerRef = ref<HTMLDivElement | undefined>(undefined)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
// Should not throw
|
||||
expect(() => interaction.updateContainerRect()).not.toThrow()
|
||||
expect(() =>
|
||||
interaction.handlePointerDown(new PointerEvent('pointerdown'))
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(null as any)
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
250,
|
||||
200,
|
||||
centerViewOnMock,
|
||||
canvasRef
|
||||
)
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
interaction.handlePointerMove(new PointerEvent('pointermove'))
|
||||
).not.toThrow()
|
||||
expect(() => interaction.handleWheel(new WheelEvent('wheel'))).not.toThrow()
|
||||
expect(centerViewOnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,267 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer'
|
||||
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
|
||||
import type { UpdateFlags } from '@/renderer/extensions/minimap/types'
|
||||
|
||||
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
|
||||
renderMinimapToCanvas: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useMinimapRenderer', () => {
|
||||
let mockCanvas: HTMLCanvasElement
|
||||
let mockContext: CanvasRenderingContext2D
|
||||
let mockGraph: LGraph
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContext = {
|
||||
clearRect: vi.fn()
|
||||
} as any
|
||||
|
||||
mockCanvas = {
|
||||
getContext: vi.fn().mockReturnValue(mockContext)
|
||||
} as any
|
||||
|
||||
mockGraph = {
|
||||
_nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }]
|
||||
} as any
|
||||
})
|
||||
|
||||
it('should initialize with full redraw needed', () => {
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
const settings = {
|
||||
nodeColors: ref(true),
|
||||
showLinks: ref(true),
|
||||
showGroups: ref(true),
|
||||
renderBypass: ref(false),
|
||||
renderError: ref(false)
|
||||
}
|
||||
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graphRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
updateFlagsRef,
|
||||
settings,
|
||||
250,
|
||||
200
|
||||
)
|
||||
|
||||
expect(renderer.needsFullRedraw.value).toBe(true)
|
||||
expect(renderer.needsBoundsUpdate.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty graph with fast path', () => {
|
||||
const emptyGraph = { _nodes: [] } as any
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(emptyGraph)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
const settings = {
|
||||
nodeColors: ref(true),
|
||||
showLinks: ref(true),
|
||||
showGroups: ref(true),
|
||||
renderBypass: ref(false),
|
||||
renderError: ref(false)
|
||||
}
|
||||
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graphRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
updateFlagsRef,
|
||||
settings,
|
||||
250,
|
||||
200
|
||||
)
|
||||
|
||||
renderer.renderMinimap()
|
||||
|
||||
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
|
||||
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only render when redraw is needed', async () => {
|
||||
const { renderMinimapToCanvas } = await import(
|
||||
'@/renderer/extensions/minimap/minimapCanvasRenderer'
|
||||
)
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
const settings = {
|
||||
nodeColors: ref(true),
|
||||
showLinks: ref(true),
|
||||
showGroups: ref(true),
|
||||
renderBypass: ref(false),
|
||||
renderError: ref(false)
|
||||
}
|
||||
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graphRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
updateFlagsRef,
|
||||
settings,
|
||||
250,
|
||||
200
|
||||
)
|
||||
|
||||
// First render (needsFullRedraw is true by default)
|
||||
renderer.renderMinimap()
|
||||
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second render without changes (should not render)
|
||||
renderer.renderMinimap()
|
||||
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Set update flag and render again
|
||||
updateFlagsRef.value.nodes = true
|
||||
renderer.renderMinimap()
|
||||
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should update minimap with bounds and viewport callbacks', () => {
|
||||
const updateBounds = vi.fn()
|
||||
const updateViewport = vi.fn()
|
||||
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
bounds: true,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
const settings = {
|
||||
nodeColors: ref(true),
|
||||
showLinks: ref(true),
|
||||
showGroups: ref(true),
|
||||
renderBypass: ref(false),
|
||||
renderError: ref(false)
|
||||
}
|
||||
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graphRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
updateFlagsRef,
|
||||
settings,
|
||||
250,
|
||||
200
|
||||
)
|
||||
|
||||
renderer.updateMinimap(updateBounds, updateViewport)
|
||||
|
||||
expect(updateBounds).toHaveBeenCalled()
|
||||
expect(updateViewport).toHaveBeenCalled()
|
||||
expect(updateFlagsRef.value.bounds).toBe(false)
|
||||
expect(renderer.needsFullRedraw.value).toBe(false) // After rendering, needsFullRedraw is reset to false
|
||||
expect(updateFlagsRef.value.viewport).toBe(false) // After updating viewport, this is reset to false
|
||||
})
|
||||
|
||||
it('should force full redraw when requested', () => {
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
const settings = {
|
||||
nodeColors: ref(true),
|
||||
showLinks: ref(true),
|
||||
showGroups: ref(true),
|
||||
renderBypass: ref(false),
|
||||
renderError: ref(false)
|
||||
}
|
||||
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graphRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
updateFlagsRef,
|
||||
settings,
|
||||
250,
|
||||
200
|
||||
)
|
||||
|
||||
renderer.forceFullRedraw()
|
||||
|
||||
expect(renderer.needsFullRedraw.value).toBe(true)
|
||||
expect(updateFlagsRef.value.bounds).toBe(true)
|
||||
expect(updateFlagsRef.value.nodes).toBe(true)
|
||||
expect(updateFlagsRef.value.connections).toBe(true)
|
||||
expect(updateFlagsRef.value.viewport).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const canvasRef = ref<HTMLCanvasElement | undefined>(undefined)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
const settings = {
|
||||
nodeColors: ref(true),
|
||||
showLinks: ref(true),
|
||||
showGroups: ref(true),
|
||||
renderBypass: ref(false),
|
||||
renderError: ref(false)
|
||||
}
|
||||
|
||||
const renderer = useMinimapRenderer(
|
||||
canvasRef,
|
||||
graphRef,
|
||||
boundsRef,
|
||||
scaleRef,
|
||||
updateFlagsRef,
|
||||
settings,
|
||||
250,
|
||||
200
|
||||
)
|
||||
|
||||
// Should not throw
|
||||
expect(() => renderer.renderMinimap()).not.toThrow()
|
||||
expect(mockCanvas.getContext).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
vi.mock('@/stores/settingStore')
|
||||
vi.mock('@/stores/workspace/colorPaletteStore')
|
||||
|
||||
describe('useMinimapSettings', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return all minimap settings as computed refs', () => {
|
||||
const mockSettingStore = {
|
||||
get: vi.fn((key: string) => {
|
||||
const settings: Record<string, any> = {
|
||||
'Comfy.Minimap.NodeColors': true,
|
||||
'Comfy.Minimap.ShowLinks': false,
|
||||
'Comfy.Minimap.ShowGroups': true,
|
||||
'Comfy.Minimap.RenderBypassState': false,
|
||||
'Comfy.Minimap.RenderErrorState': true
|
||||
}
|
||||
return settings[key]
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: false }
|
||||
} as any)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
|
||||
expect(settings.nodeColors.value).toBe(true)
|
||||
expect(settings.showLinks.value).toBe(false)
|
||||
expect(settings.showGroups.value).toBe(true)
|
||||
expect(settings.renderBypass.value).toBe(false)
|
||||
expect(settings.renderError.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should generate container styles based on theme', () => {
|
||||
const mockColorPaletteStore = {
|
||||
completedActivePalette: { light_theme: false }
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||
mockColorPaletteStore as any
|
||||
)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
const styles = settings.containerStyles.value
|
||||
|
||||
expect(styles.width).toBe('250px')
|
||||
expect(styles.height).toBe('200px')
|
||||
expect(styles.backgroundColor).toBe('#15161C') // dark theme color
|
||||
expect(styles.border).toBe('1px solid #333')
|
||||
})
|
||||
|
||||
it('should generate light theme container styles', () => {
|
||||
const mockColorPaletteStore = {
|
||||
completedActivePalette: { light_theme: true }
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||
mockColorPaletteStore as any
|
||||
)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
const styles = settings.containerStyles.value
|
||||
|
||||
expect(styles.backgroundColor).toBe('#FAF9F5') // light theme color
|
||||
expect(styles.border).toBe('1px solid #ccc')
|
||||
})
|
||||
|
||||
it('should generate panel styles based on theme', () => {
|
||||
const mockColorPaletteStore = {
|
||||
completedActivePalette: { light_theme: false }
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||
mockColorPaletteStore as any
|
||||
)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
const styles = settings.panelStyles.value
|
||||
|
||||
expect(styles.backgroundColor).toBe('#15161C')
|
||||
expect(styles.border).toBe('1px solid #333')
|
||||
expect(styles.borderRadius).toBe('8px')
|
||||
})
|
||||
|
||||
it('should create computed properties that call the store getter', () => {
|
||||
const mockGet = vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Minimap.NodeColors') return true
|
||||
if (key === 'Comfy.Minimap.ShowLinks') return false
|
||||
return true
|
||||
})
|
||||
const mockSettingStore = { get: mockGet }
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: false }
|
||||
} as any)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
|
||||
// Access the computed properties
|
||||
expect(settings.nodeColors.value).toBe(true)
|
||||
expect(settings.showLinks.value).toBe(false)
|
||||
|
||||
// Verify the store getter was called with the correct keys
|
||||
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.NodeColors')
|
||||
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.ShowLinks')
|
||||
})
|
||||
})
|
||||
@@ -1,289 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
|
||||
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
|
||||
|
||||
vi.mock('@/composables/canvas/useCanvasTransformSync')
|
||||
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
|
||||
calculateNodeBounds: vi.fn(),
|
||||
calculateMinimapScale: vi.fn(),
|
||||
enforceMinimumBounds: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useMinimapViewport', () => {
|
||||
let mockCanvas: MinimapCanvas
|
||||
let mockGraph: LGraph
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCanvas = {
|
||||
canvas: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
width: 1600,
|
||||
height: 1200
|
||||
} as HTMLCanvasElement,
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0]
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
mockGraph = {
|
||||
_nodes: [
|
||||
{ pos: [100, 100], size: [150, 80] },
|
||||
{ pos: [300, 200], size: [120, 60] }
|
||||
]
|
||||
} as any
|
||||
|
||||
vi.mocked(useCanvasTransformSync).mockReturnValue({
|
||||
startSync: vi.fn(),
|
||||
stopSync: vi.fn()
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should initialize with default bounds', () => {
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
expect(viewport.bounds.value).toEqual({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
|
||||
expect(viewport.scale.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should calculate graph bounds from nodes', async () => {
|
||||
const { calculateNodeBounds, enforceMinimumBounds } = await import(
|
||||
'@/renderer/core/spatial/boundsCalculator'
|
||||
)
|
||||
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 100,
|
||||
minY: 100,
|
||||
maxX: 420,
|
||||
maxY: 260,
|
||||
width: 320,
|
||||
height: 160
|
||||
})
|
||||
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.updateBounds()
|
||||
|
||||
expect(calculateNodeBounds).toHaveBeenCalledWith(mockGraph._nodes)
|
||||
expect(enforceMinimumBounds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty graph', async () => {
|
||||
const { calculateNodeBounds } = await import(
|
||||
'@/renderer/core/spatial/boundsCalculator'
|
||||
)
|
||||
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue(null)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref({ _nodes: [] } as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.updateBounds()
|
||||
|
||||
expect(viewport.bounds.value).toEqual({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 100,
|
||||
maxY: 100,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('should update canvas dimensions', () => {
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.updateCanvasDimensions()
|
||||
|
||||
expect(viewport.canvasDimensions.value).toEqual({
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate viewport transform', async () => {
|
||||
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
|
||||
await import('@/renderer/core/spatial/boundsCalculator')
|
||||
|
||||
// Mock the bounds calculation
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 500,
|
||||
maxY: 400,
|
||||
width: 500,
|
||||
height: 400
|
||||
})
|
||||
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
// Set canvas transform
|
||||
mockCanvas.ds.scale = 2
|
||||
mockCanvas.ds.offset = [-100, -50]
|
||||
|
||||
// Update bounds and viewport
|
||||
viewport.updateBounds()
|
||||
viewport.updateCanvasDimensions()
|
||||
viewport.updateViewport()
|
||||
|
||||
const transform = viewport.viewportTransform.value
|
||||
|
||||
// World coordinates
|
||||
const worldX = -(-100) // -offset[0] = 100
|
||||
const worldY = -(-50) // -offset[1] = 50
|
||||
|
||||
// Viewport size in world coordinates
|
||||
const viewportWidth = 800 / 2 // canvasWidth / scale = 400
|
||||
const viewportHeight = 600 / 2 // canvasHeight / scale = 300
|
||||
|
||||
// Center offsets
|
||||
const centerOffsetX = (250 - 500 * 0.5) / 2 // (250 - 250) / 2 = 0
|
||||
const centerOffsetY = (200 - 400 * 0.5) / 2 // (200 - 200) / 2 = 0
|
||||
|
||||
// Expected values based on implementation: (worldX - bounds.minX) * scale + centerOffsetX
|
||||
expect(transform.x).toBeCloseTo((worldX - 0) * 0.5 + centerOffsetX) // (100 - 0) * 0.5 + 0 = 50
|
||||
expect(transform.y).toBeCloseTo((worldY - 0) * 0.5 + centerOffsetY) // (50 - 0) * 0.5 + 0 = 25
|
||||
expect(transform.width).toBeCloseTo(viewportWidth * 0.5) // 400 * 0.5 = 200
|
||||
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
|
||||
})
|
||||
|
||||
it('should center view on world coordinates', () => {
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.updateCanvasDimensions()
|
||||
mockCanvas.ds.scale = 2
|
||||
|
||||
viewport.centerViewOn(300, 200)
|
||||
|
||||
// Should update canvas offset to center on the given world coordinates
|
||||
const expectedOffsetX = -(300 - 800 / 2 / 2) // -(worldX - viewportWidth/2)
|
||||
const expectedOffsetY = -(200 - 600 / 2 / 2) // -(worldY - viewportHeight/2)
|
||||
|
||||
expect(mockCanvas.ds.offset[0]).toBe(expectedOffsetX)
|
||||
expect(mockCanvas.ds.offset[1]).toBe(expectedOffsetY)
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should start and stop viewport sync', () => {
|
||||
const startSyncMock = vi.fn()
|
||||
const stopSyncMock = vi.fn()
|
||||
|
||||
vi.mocked(useCanvasTransformSync).mockReturnValue({
|
||||
startSync: startSyncMock,
|
||||
stopSync: stopSyncMock
|
||||
} as any)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.startViewportSync()
|
||||
expect(startSyncMock).toHaveBeenCalled()
|
||||
|
||||
viewport.stopViewportSync()
|
||||
expect(stopSyncMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const canvasRef = ref(null as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
// Should not throw
|
||||
expect(() => viewport.updateCanvasDimensions()).not.toThrow()
|
||||
expect(() => viewport.updateViewport()).not.toThrow()
|
||||
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should calculate scale correctly', async () => {
|
||||
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
|
||||
await import('@/renderer/core/spatial/boundsCalculator')
|
||||
|
||||
const testBounds = {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 500,
|
||||
maxY: 400,
|
||||
width: 500,
|
||||
height: 400
|
||||
}
|
||||
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue(testBounds)
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.4)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.updateBounds()
|
||||
|
||||
expect(calculateMinimapScale).toHaveBeenCalledWith(testBounds, 250, 200)
|
||||
expect(viewport.scale.value).toBe(0.4)
|
||||
})
|
||||
|
||||
it('should handle device pixel ratio', () => {
|
||||
const originalDPR = window.devicePixelRatio
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
value: 2,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
viewport.updateCanvasDimensions()
|
||||
|
||||
// Should use client dimensions or calculate from canvas dimensions / dpr
|
||||
expect(viewport.canvasDimensions.value.width).toBe(800)
|
||||
expect(viewport.canvasDimensions.value.height).toBe(600)
|
||||
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
value: originalDPR,
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,324 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
|
||||
import type { MinimapRenderContext } from '@/renderer/extensions/minimap/types'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore')
|
||||
vi.mock('@/utils/colorUtil', () => ({
|
||||
adjustColor: vi.fn((color: string) => color + '_adjusted')
|
||||
}))
|
||||
|
||||
describe('minimapCanvasRenderer', () => {
|
||||
let mockCanvas: HTMLCanvasElement
|
||||
let mockContext: CanvasRenderingContext2D
|
||||
let mockGraph: LGraph
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContext = {
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1
|
||||
} as any
|
||||
|
||||
mockCanvas = {
|
||||
getContext: vi.fn().mockReturnValue(mockContext)
|
||||
} as any
|
||||
|
||||
mockGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: '1',
|
||||
pos: [100, 100],
|
||||
size: [150, 80],
|
||||
bgcolor: '#FF0000',
|
||||
mode: LGraphEventMode.ALWAYS,
|
||||
has_errors: false,
|
||||
outputs: []
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
pos: [300, 200],
|
||||
size: [120, 60],
|
||||
bgcolor: '#00FF00',
|
||||
mode: LGraphEventMode.BYPASS,
|
||||
has_errors: true,
|
||||
outputs: []
|
||||
}
|
||||
] as unknown as LGraphNode[],
|
||||
_groups: [],
|
||||
links: {},
|
||||
getNodeById: vi.fn()
|
||||
} as any
|
||||
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: false }
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should clear canvas and render nodes', () => {
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: true,
|
||||
renderError: true
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Should clear the canvas first
|
||||
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
|
||||
|
||||
// Should render nodes (batch by color)
|
||||
expect(mockContext.fillRect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty graph', () => {
|
||||
mockGraph._nodes = []
|
||||
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
|
||||
expect(mockContext.fillRect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should batch render nodes by color', () => {
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Should set fill style for each color group
|
||||
const fillStyleCalls = []
|
||||
let currentStyle = ''
|
||||
|
||||
mockContext.fillStyle = ''
|
||||
Object.defineProperty(mockContext, 'fillStyle', {
|
||||
get: () => currentStyle,
|
||||
set: (value) => {
|
||||
currentStyle = value
|
||||
fillStyleCalls.push(value)
|
||||
}
|
||||
})
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Different colors for different nodes
|
||||
expect(fillStyleCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render bypass nodes with special color', () => {
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: true,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Node 2 is in bypass mode, should be rendered
|
||||
expect(mockContext.fillRect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render error outlines when enabled', () => {
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: true
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Should set stroke style for errors
|
||||
expect(mockContext.strokeStyle).toBe('#FF0000')
|
||||
expect(mockContext.strokeRect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render groups when enabled', () => {
|
||||
mockGraph._groups = [
|
||||
{
|
||||
pos: [50, 50],
|
||||
size: [400, 300],
|
||||
color: '#0000FF'
|
||||
}
|
||||
] as any
|
||||
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: true,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Groups should be rendered before nodes
|
||||
expect(mockContext.fillRect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render connections when enabled', () => {
|
||||
const targetNode = {
|
||||
id: '2',
|
||||
pos: [300, 200],
|
||||
size: [120, 60]
|
||||
}
|
||||
|
||||
mockGraph._nodes[0].outputs = [
|
||||
{
|
||||
links: [1]
|
||||
}
|
||||
] as any
|
||||
|
||||
// Create a hybrid Map/Object for links as LiteGraph expects
|
||||
const linksMap = new Map([[1, { id: 1, target_id: 2 }]])
|
||||
const links = Object.assign(linksMap, {
|
||||
1: { id: 1, target_id: 2 }
|
||||
})
|
||||
mockGraph.links = links as any
|
||||
|
||||
mockGraph.getNodeById = vi.fn().mockReturnValue(targetNode)
|
||||
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: false,
|
||||
showLinks: true,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Should draw connection lines
|
||||
expect(mockContext.beginPath).toHaveBeenCalled()
|
||||
expect(mockContext.moveTo).toHaveBeenCalled()
|
||||
expect(mockContext.lineTo).toHaveBeenCalled()
|
||||
expect(mockContext.stroke).toHaveBeenCalled()
|
||||
|
||||
// Should draw connection slots
|
||||
expect(mockContext.arc).toHaveBeenCalled()
|
||||
expect(mockContext.fill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle light theme colors', () => {
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: true }
|
||||
} as any)
|
||||
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: true,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// Color adjustment should be called for light theme
|
||||
expect(adjustColor).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should calculate correct offsets for centering', () => {
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 200, height: 100 },
|
||||
scale: 0.5,
|
||||
settings: {
|
||||
nodeColors: false,
|
||||
showLinks: false,
|
||||
showGroups: false,
|
||||
renderBypass: false,
|
||||
renderError: false
|
||||
},
|
||||
width: 250,
|
||||
height: 200
|
||||
}
|
||||
|
||||
renderMinimapToCanvas(mockCanvas, mockGraph, context)
|
||||
|
||||
// With bounds 200x100 at scale 0.5 = 100x50
|
||||
// Canvas is 250x200, so offset should be (250-100)/2 = 75, (200-50)/2 = 75
|
||||
// This affects node positioning
|
||||
expect(mockContext.fillRect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
1049
tests-ui/tests/services/graphMutationService.test.ts
Normal file
1049
tests-ui/tests/services/graphMutationService.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user