From 6ea615fb8c0f58d9b6926655498ee9ac9c83b409 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 17 Aug 2025 21:23:36 -0400 Subject: [PATCH] graph mutation service implementation --- package-lock.json | 568 -------- src/components/graph/TitleEditor.vue | 22 +- src/lib/litegraph/src/LGraphGroup.ts | 2 + .../litegraph/src/subgraph/SubgraphNode.ts | 2 + src/services/GRAPH_MUTATION_SERVICE_DESIGN.md | 462 +++++++ src/services/IGraphMutationService.ts | 161 +++ src/services/graphMutationService.ts | 1152 +++++++++++++++++ .../services/graphMutationService.test.ts | 1049 +++++++++++++++ 8 files changed, 2841 insertions(+), 577 deletions(-) create mode 100644 src/services/GRAPH_MUTATION_SERVICE_DESIGN.md create mode 100644 src/services/IGraphMutationService.ts create mode 100644 src/services/graphMutationService.ts create mode 100644 tests-ui/tests/services/graphMutationService.test.ts diff --git a/package-lock.json b/package-lock.json index 528306e4ad..768a794e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/components/graph/TitleEditor.vue b/src/components/graph/TitleEditor.vue index 220b549fb8..3e0756031a 100644 --- a/src/components/graph/TitleEditor.vue +++ b/src/components/graph/TitleEditor.vue @@ -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(() => ({ 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 diff --git a/src/lib/litegraph/src/LGraphGroup.ts b/src/lib/litegraph/src/LGraphGroup.ts index f00f302e6a..9f8cc6952f 100644 --- a/src/lib/litegraph/src/LGraphGroup.ts +++ b/src/lib/litegraph/src/LGraphGroup.ts @@ -24,6 +24,8 @@ import { } from './measure' import type { ISerialisedGroup } from './types/serialisation' +export type GroupId = number + export interface IGraphGroupFlags extends Record { pinned?: true } diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 8ee6dcdc6d..0b50b8bebf 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -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. */ diff --git a/src/services/GRAPH_MUTATION_SERVICE_DESIGN.md b/src/services/GRAPH_MUTATION_SERVICE_DESIGN.md new file mode 100644 index 0000000000..bfe08d570e --- /dev/null +++ b/src/services/GRAPH_MUTATION_SERVICE_DESIGN.md @@ -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 + removeNode(nodeId: NodeId): Promise + updateNodeProperty(nodeId: NodeId, property: string, value: any): Promise + // ... 50+ total operations + + // Transaction support + transaction(fn: () => Promise): Promise + + // Undo/Redo + undo(): Promise + redo(): Promise +} + +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 \ No newline at end of file diff --git a/src/services/IGraphMutationService.ts b/src/services/IGraphMutationService.ts new file mode 100644 index 0000000000..8bcf010ae1 --- /dev/null +++ b/src/services/IGraphMutationService.ts @@ -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 + 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 +} + +export interface AddNodeOutputParams { + nodeId: NodeId + name: string + type: string + extra_info?: Record +} + +export interface CreateSubgraphParams { + selectedItems: Set +} + +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 + removeNode(nodeId: NodeId): Promise + updateNodeProperty( + nodeId: NodeId, + property: string, + value: any + ): Promise + updateNodeTitle(nodeId: NodeId, title: string): Promise + changeNodeMode(nodeId: NodeId, mode: number): Promise + cloneNode(nodeId: NodeId, pos?: [number, number]): Promise + + connect(params: ConnectParams): Promise + disconnect( + nodeId: NodeId, + slot: number | string, + slotType: 'input' | 'output', + targetNodeId?: NodeId + ): Promise + disconnectInput(nodeId: NodeId, slot: number | string): Promise + disconnectOutput(nodeId: NodeId, slot: number | string): Promise + disconnectOutputTo( + nodeId: NodeId, + slot: number | string, + targetNodeId: NodeId + ): Promise + disconnectLink(linkId: LinkId): Promise + + createGroup(params: CreateGroupParams): Promise + removeGroup(groupId: GroupId): Promise + updateGroupTitle(groupId: GroupId, title: string): Promise + moveGroup(groupId: GroupId, deltaX: number, deltaY: number): Promise + addNodesToGroup(groupId: GroupId, nodeIds: NodeId[]): Promise + recomputeGroupNodes(groupId: GroupId): Promise + + addReroute(params: AddRerouteParams): Promise + removeReroute(rerouteId: RerouteId): Promise + + addNodes(nodes: AddNodeParams[]): Promise + removeNodes(nodeIds: NodeId[]): Promise + duplicateNodes( + nodeIds: NodeId[], + offset?: [number, number] + ): Promise + + copyNodes(nodeIds: NodeId[]): Promise + cutNodes(nodeIds: NodeId[]): Promise + pasteNodes(position?: [number, number]): Promise + getClipboard(): ClipboardData | null + clearClipboard(): void + hasClipboardContent(): boolean + + addSubgraphNodeInput(params: AddNodeInputParams): Promise + addSubgraphNodeOutput(params: AddNodeOutputParams): Promise + removeSubgraphNodeInput(nodeId: NodeId, slot: number): Promise + removeSubgraphNodeOutput(nodeId: NodeId, slot: number): Promise + + createSubgraph(params: CreateSubgraphParams): Promise<{ + subgraph: any + node: any + }> + unpackSubgraph(subgraphNodeId: NodeId): Promise + addSubgraphInput( + subgraphId: SubgraphId, + name: string, + type: string + ): Promise + addSubgraphOutput( + subgraphId: SubgraphId, + name: string, + type: string + ): Promise + removeSubgraphInput(subgraphId: SubgraphId, index: number): Promise + removeSubgraphOutput(subgraphId: SubgraphId, index: number): Promise + + clearGraph(): Promise + + bypassNode(nodeId: NodeId): Promise + unbypassNode(nodeId: NodeId): Promise + + transaction(fn: () => Promise): Promise + + undo(): Promise + redo(): Promise +} diff --git a/src/services/graphMutationService.ts b/src/services/graphMutationService.ts new file mode 100644 index 0000000000..99a4ba9202 --- /dev/null +++ b/src/services/graphMutationService.ts @@ -0,0 +1,1152 @@ +import { Subgraph } from '@/lib/litegraph/src/LGraph' +import { GroupId, LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' +import { LinkId } from '@/lib/litegraph/src/LLink' +import type { RerouteId } from '@/lib/litegraph/src/Reroute' +import { + LGraphEventMode, + LGraphNode, + LiteGraph +} from '@/lib/litegraph/src/litegraph' +import { + SubgraphId, + SubgraphNode +} from '@/lib/litegraph/src/subgraph/SubgraphNode' +import { NodeId } from '@/schemas/comfyWorkflowSchema' +import { app } from '@/scripts/app' +import { useWorkflowStore } from '@/stores/workflowStore' + +import { + AddNodeInputParams, + AddNodeOutputParams, + AddNodeParams, + AddRerouteParams, + ClipboardData, + ConnectParams, + CreateGroupParams, + CreateSubgraphParams, + IGraphMutationService, + ValidationError, + ValidationResult +} from './IGraphMutationService' + +export class ValidationException extends Error { + constructor(public errors: ValidationError[]) { + super(errors.map((e) => e.message).join(', ')) + this.name = 'ValidationException' + } +} + +export class GraphMutationService implements IGraphMutationService { + private workflowStore = useWorkflowStore() + + private transactionDepth = 0 + + private static readonly CLIPBOARD_KEY = 'litegrapheditor_clipboard' + + private getGraph() { + return app.graph + } + + private getChangeTracker() { + return this.workflowStore.activeWorkflow?.changeTracker + } + + private validateAddNode(params: AddNodeParams): ValidationResult { + // TODO: Implement actual validation logic + console.log(params) + return { valid: true } + } + + private validateRemoveNode(nodeId: NodeId): ValidationResult { + // TODO: Implement actual validation logic + console.log(nodeId) + return { valid: true } + } + + private validateConnect(params: ConnectParams): ValidationResult { + // TODO: Implement actual validation logic + console.log(params) + return { valid: true } + } + + private processValidation(validation: ValidationResult): void { + if (!validation.valid && validation.errors) { + throw new ValidationException(validation.errors) + } + + if (validation.warnings?.length) { + // TODO: Implement warning handling + console.warn('Validation warnings:', validation.warnings) + } + } + + async addNode(params: AddNodeParams): Promise { + const validation = this.validateAddNode(params) + this.processValidation(validation) + + const { type, pos, properties, title } = params + const graph = this.getGraph() + + const node = LiteGraph.createNode(type) + + if (!node) { + throw new Error(`Failed to create node of type: ${type}`) + } + + if (pos) { + node.pos = pos + } + + if (title) { + node.title = title + } + + if (properties) { + Object.assign(node.properties || {}, properties) + } + + graph.beforeChange() + + const addedNode = graph.add(node) + if (!addedNode) { + throw new Error('Failed to add node to graph') + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return addedNode.id as NodeId + } + + async removeNode(nodeId: NodeId): Promise { + const validation = this.validateRemoveNode(nodeId) + this.processValidation(validation) + + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + // Note: We don't need to call beforeChange/afterChange here because + // graph.remove() already handles these internally (see LGraph.ts:927 and :982). + // The remove method includes proper transaction boundaries and calls + // beforeChange at the start and afterChange at the end of the operation. + graph.remove(node) + this.getChangeTracker()?.checkState() + } + + async updateNodeProperty( + nodeId: NodeId, + property: string, + value: any + ): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.setProperty(property, value) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async updateNodeTitle(nodeId: NodeId, title: string): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.title = title + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async changeNodeMode(nodeId: NodeId, mode: number): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + const success = node.changeMode(mode) + if (!success) { + throw new Error(`Failed to change node mode to ${mode}`) + } + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async cloneNode(nodeId: NodeId, pos?: [number, number]): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + + const clonedNode = node.clone() + if (!clonedNode) { + throw new Error('Failed to clone node') + } + + if (pos) { + clonedNode.pos = pos + } else { + clonedNode.pos = [node.pos[0] + 50, node.pos[1] + 50] + } + + const addedNode = graph.add(clonedNode) + if (!addedNode) { + throw new Error('Failed to add cloned node to graph') + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return addedNode.id as NodeId + } + + async connect(params: ConnectParams): Promise { + const validation = this.validateConnect(params) + this.processValidation(validation) + + const { sourceNodeId, sourceSlot, targetNodeId, targetSlot } = params + const graph = this.getGraph() + + const sourceNode = graph.getNodeById(sourceNodeId) + const targetNode = graph.getNodeById(targetNodeId) + + if (!sourceNode) { + throw new Error(`Source node with id ${sourceNodeId} not found`) + } + if (!targetNode) { + throw new Error(`Target node with id ${targetNodeId} not found`) + } + + // Note: We wrap the connect call with beforeChange/afterChange even though + // node.connect() may call beforeChange internally in some cases (e.g., when + // disconnecting EVENT type outputs). This ensures consistent transaction + // boundaries for all connection operations. The nested beforeChange calls + // are handled properly by the graph's transaction system. + graph.beforeChange() + + const link = sourceNode.connect( + sourceSlot, + targetNode as LGraphNode, + targetSlot + ) + + if (!link) { + throw new Error('Failed to create connection') + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return link.id as LinkId + } + + async disconnect( + nodeId: NodeId, + slot: number | string, + slotType: 'input' | 'output', + targetNodeId?: NodeId + ): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + + let result: boolean + if (slotType === 'input') { + result = node.disconnectInput(slot) + } else { + if (targetNodeId) { + const targetNode = graph.getNodeById(targetNodeId) + if (!targetNode) { + throw new Error(`Target node with id ${targetNodeId} not found`) + } + result = node.disconnectOutput(slot, targetNode as LGraphNode) + } else { + result = node.disconnectOutput(slot) + } + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return result + } + + async disconnectInput( + nodeId: NodeId, + slot: number | string + ): Promise { + return this.disconnect(nodeId, slot, 'input') + } + + async disconnectOutput( + nodeId: NodeId, + slot: number | string + ): Promise { + return this.disconnect(nodeId, slot, 'output') + } + + async disconnectOutputTo( + nodeId: NodeId, + slot: number | string, + targetNodeId: NodeId + ): Promise { + return this.disconnect(nodeId, slot, 'output', targetNodeId) + } + + async disconnectLink(linkId: LinkId): Promise { + const graph = this.getGraph() + + graph.beforeChange() + graph.removeLink(linkId) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async createGroup(params: CreateGroupParams): Promise { + const { title, pos, size, color, fontSize } = params + const graph = this.getGraph() + + const group = new LGraphGroup(title || 'Group') + + if (pos) { + group.pos[0] = pos[0] + group.pos[1] = pos[1] + } + + if (size) { + group.size[0] = size[0] + group.size[1] = size[1] + } + + if (color) { + group.color = color + } + + if (fontSize) { + group.font_size = fontSize + } + + graph.beforeChange() + graph.add(group) + graph.afterChange() + this.getChangeTracker()?.checkState() + + return group.id as GroupId + } + + async removeGroup(groupId: GroupId): Promise { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + graph.beforeChange() + graph.remove(group) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async updateGroupTitle(groupId: GroupId, title: string): Promise { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + graph.beforeChange() + group.title = title + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + } + + async moveGroup( + groupId: GroupId, + deltaX: number, + deltaY: number + ): Promise { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + graph.beforeChange() + group.move(deltaX, deltaY, false) + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + } + + async addNodesToGroup(groupId: GroupId, nodeIds: NodeId[]): Promise { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + const nodes: LGraphNode[] = [] + for (const nodeId of nodeIds) { + const node = graph.getNodeById(nodeId) + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + nodes.push(node) + } + + graph.beforeChange() + group.addNodes(nodes) + group.recomputeInsideNodes() + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + } + + async recomputeGroupNodes(groupId: GroupId): Promise { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + graph.beforeChange() + group.recomputeInsideNodes() + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async addReroute(params: AddRerouteParams): Promise { + const { pos, linkId, parentRerouteId } = params + const graph = this.getGraph() + + let beforeSegment: any = null + + if (linkId) { + beforeSegment = graph._links.get(linkId) + if (!beforeSegment) { + throw new Error(`Link with id ${linkId} not found`) + } + } else if (parentRerouteId) { + beforeSegment = graph.reroutes.get(parentRerouteId) + if (!beforeSegment) { + throw new Error(`Reroute with id ${parentRerouteId} not found`) + } + } else { + throw new Error('Either linkId or parentRerouteId must be provided') + } + + graph.beforeChange() + const reroute = graph.createReroute(pos, beforeSegment) + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + + return reroute.id as RerouteId + } + + async removeReroute(rerouteId: RerouteId): Promise { + const graph = this.getGraph() + + if (!graph.reroutes.has(rerouteId)) { + throw new Error(`Reroute with id ${rerouteId} not found`) + } + + graph.beforeChange() + graph.removeReroute(rerouteId) + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + } + + async addNodes(nodes: AddNodeParams[]): Promise { + const graph = this.getGraph() + const nodeIds: NodeId[] = [] + + graph.beforeChange() + + try { + for (const nodeParams of nodes) { + const { type, pos, properties, title } = nodeParams + + const node = LiteGraph.createNode(type) + if (!node) { + throw new Error(`Failed to create node of type: ${type}`) + } + + if (pos) node.pos = pos + if (title) node.title = title + if (properties) { + Object.assign(node.properties || {}, properties) + } + + const addedNode = graph.add(node) + if (!addedNode) { + throw new Error('Failed to add node to graph') + } + + nodeIds.push(addedNode.id as NodeId) + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return nodeIds + } catch (error) { + graph.afterChange() + throw error + } + } + + async removeNodes(nodeIds: NodeId[]): Promise { + const graph = this.getGraph() + + const nodes = nodeIds.map((id) => { + const node = graph.getNodeById(id) + if (!node) { + throw new Error(`Node with id ${id} not found`) + } + return node + }) + + // Note: We wrap all remove operations in a single beforeChange/afterChange + // even though graph.remove() for nodes calls these internally. This ensures + // the entire batch operation is treated as a single transaction for undo/redo. + // The nested beforeChange/afterChange calls are handled properly by the graph. + graph.beforeChange() + + for (const node of nodes) { + graph.remove(node) + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async duplicateNodes( + nodeIds: NodeId[], + offset: [number, number] = [50, 50] + ): Promise { + const graph = this.getGraph() + const newNodeIds: NodeId[] = [] + const nodeMap = new Map() // Map old IDs to new IDs + + // Validate all nodes exist + const nodes = nodeIds.map((id) => { + const node = graph.getNodeById(id) + if (!node) { + throw new Error(`Node with id ${id} not found`) + } + return node + }) + + graph.beforeChange() + + try { + for (const node of nodes) { + const clonedNode = node.clone() + if (!clonedNode) { + throw new Error(`Failed to clone node ${node.id}`) + } + + clonedNode.pos = [node.pos[0] + offset[0], node.pos[1] + offset[1]] + + const addedNode = graph.add(clonedNode) + if (!addedNode) { + throw new Error('Failed to add cloned node to graph') + } + + const newNodeId = addedNode.id as NodeId + newNodeIds.push(newNodeId) + nodeMap.set(node.id as NodeId, newNodeId) + } + + for (const node of nodes) { + const sourceNewId = nodeMap.get(node.id as NodeId) + if (!sourceNewId) continue + + const newSourceNode = graph.getNodeById(sourceNewId) + if (!newSourceNode) continue + + if (node.outputs) { + for ( + let outputIndex = 0; + outputIndex < node.outputs.length; + outputIndex++ + ) { + const output = node.outputs[outputIndex] + if (!output.links || output.links.length === 0) continue + + for (const linkId of output.links) { + const link = graph._links.get(linkId) + if (!link) continue + + const targetOldId = link.target_id as NodeId + const targetNewId = nodeMap.get(targetOldId) + + if (targetNewId && nodeIds.includes(targetOldId)) { + const newTargetNode = graph.getNodeById(targetNewId) + if (newTargetNode) { + newSourceNode.connect( + outputIndex, + newTargetNode as LGraphNode, + link.target_slot + ) + } + } + } + } + } + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return newNodeIds + } catch (error) { + graph.afterChange() + throw error + } + } + + async copyNodes(nodeIds: NodeId[]): Promise { + if (!nodeIds.length) { + throw new Error('No nodes to copy') + } + + const graph = this.getGraph() + const clipboardData: any = { + nodes: [], + links: [] + } + + for (const nodeId of nodeIds) { + const node = graph.getNodeById(nodeId) + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + if (node.clonable === false) continue + + const cloned = node.clone() + if (!cloned) { + console.warn('Failed to clone node:', node.type) + continue + } + + const serialized = cloned.serialize() + serialized.id = node.id + clipboardData.nodes.push(serialized) + } + + for (const nodeId of nodeIds) { + const node = graph.getNodeById(nodeId) + if (!node || !node.inputs) continue + + for (const input of node.inputs) { + if (input.link == null) continue + + const link = graph._links.get(input.link) + if (!link) continue + + if (nodeIds.includes(link.origin_id as NodeId)) { + clipboardData.links.push({ + id: link.id, + origin_id: link.origin_id, + origin_slot: link.origin_slot, + target_id: link.target_id, + target_slot: link.target_slot, + type: link.type + }) + } + } + } + + localStorage.setItem( + GraphMutationService.CLIPBOARD_KEY, + JSON.stringify(clipboardData) + ) + } + + async cutNodes(nodeIds: NodeId[]): Promise { + if (!nodeIds.length) { + throw new Error('No nodes to cut') + } + + await this.copyNodes(nodeIds) + + const data = localStorage.getItem(GraphMutationService.CLIPBOARD_KEY) + if (data) { + const clipboardData = JSON.parse(data) + clipboardData.isCut = true + clipboardData.originalIds = nodeIds + localStorage.setItem( + GraphMutationService.CLIPBOARD_KEY, + JSON.stringify(clipboardData) + ) + } + } + + async pasteNodes(position?: [number, number]): Promise { + const data = localStorage.getItem(GraphMutationService.CLIPBOARD_KEY) + if (!data) { + throw new Error('Clipboard is empty') + } + + const clipboardData = JSON.parse(data) + if (!clipboardData.nodes || clipboardData.nodes.length === 0) { + throw new Error('Clipboard is empty') + } + + const graph = this.getGraph() + const newNodeIds: NodeId[] = [] + const nodeIdMap = new Map() + + graph.beforeChange() + + try { + let minX = Infinity + let minY = Infinity + for (const nodeData of clipboardData.nodes) { + if (nodeData.pos) { + minX = Math.min(minX, nodeData.pos[0]) + minY = Math.min(minY, nodeData.pos[1]) + } + } + + if (!Number.isFinite(minX) || !Number.isFinite(minY)) { + minX = 0 + minY = 0 + } + + const offsetX = position ? position[0] - minX : 50 + const offsetY = position ? position[1] - minY : 50 + + for (const nodeData of clipboardData.nodes) { + const node = LiteGraph.createNode(nodeData.type) + if (!node) { + console.warn(`Failed to create node of type: ${nodeData.type}`) + continue + } + + const oldId = nodeData.id + node.configure(nodeData) + + if (nodeData.pos) { + node.pos = [nodeData.pos[0] + offsetX, nodeData.pos[1] + offsetY] + } + + const addedNode = graph.add(node) + if (!addedNode) { + console.warn('Failed to add node to graph') + continue + } + + const newNodeId = addedNode.id as NodeId + newNodeIds.push(newNodeId) + nodeIdMap.set(oldId, newNodeId) + } + + if (clipboardData.links) { + for (const linkData of clipboardData.links) { + const sourceNewId = nodeIdMap.get(linkData.origin_id) + const targetNewId = nodeIdMap.get(linkData.target_id) + + if (sourceNewId && targetNewId) { + const sourceNode = graph.getNodeById(sourceNewId) + const targetNode = graph.getNodeById(targetNewId) + + if (sourceNode && targetNode) { + sourceNode.connect( + linkData.origin_slot, + targetNode as LGraphNode, + linkData.target_slot + ) + } + } + } + } + + if (clipboardData.isCut && clipboardData.originalIds) { + for (const nodeId of clipboardData.originalIds) { + const node = graph.getNodeById(nodeId) + if (node) { + graph.remove(node) + } + } + localStorage.removeItem(GraphMutationService.CLIPBOARD_KEY) + } + + graph.afterChange() + this.getChangeTracker()?.checkState() + + return newNodeIds + } catch (error) { + graph.afterChange() + throw error + } + } + + getClipboard(): ClipboardData | null { + const data = localStorage.getItem(GraphMutationService.CLIPBOARD_KEY) + if (!data) return null + + try { + const clipboardData = JSON.parse(data) + return { + nodes: clipboardData.nodes || [], + connections: clipboardData.links || [], + isCut: clipboardData.isCut || false + } + } catch { + return null + } + } + + clearClipboard(): void { + localStorage.removeItem(GraphMutationService.CLIPBOARD_KEY) + } + + hasClipboardContent(): boolean { + const data = localStorage.getItem(GraphMutationService.CLIPBOARD_KEY) + if (!data) return false + + try { + const clipboardData = JSON.parse(data) + return clipboardData.nodes && clipboardData.nodes.length > 0 + } catch { + return false + } + } + + async addSubgraphNodeInput(params: AddNodeInputParams): Promise { + const { nodeId, name, type, extra_info } = params + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.addInput(name, type, extra_info) + const slotIndex = node.inputs ? node.inputs.length - 1 : 0 + graph.afterChange() + this.getChangeTracker()?.checkState() + + return slotIndex + } + + async addSubgraphNodeOutput(params: AddNodeOutputParams): Promise { + const { nodeId, name, type, extra_info } = params + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.addOutput(name, type, extra_info) + const slotIndex = node.outputs ? node.outputs.length - 1 : 0 + graph.afterChange() + this.getChangeTracker()?.checkState() + + return slotIndex + } + + async removeSubgraphNodeInput(nodeId: NodeId, slot: number): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + if (!node.inputs || slot >= node.inputs.length) { + throw new Error(`Input slot ${slot} not found on node`) + } + + graph.beforeChange() + node.removeInput(slot) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async removeSubgraphNodeOutput(nodeId: NodeId, slot: number): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + if (!node.outputs || slot >= node.outputs.length) { + throw new Error(`Output slot ${slot} not found on node`) + } + + graph.beforeChange() + node.removeOutput(slot) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async createSubgraph(params: CreateSubgraphParams): Promise<{ + subgraph: any + node: any + }> { + const { selectedItems } = params + const graph = this.getGraph() + + if (!selectedItems || selectedItems.size === 0) { + throw new Error('Cannot create subgraph: no items selected') + } + + graph.beforeChange() + try { + const result = graph.convertToSubgraph(selectedItems) + if (!result) { + throw new Error('Failed to create subgraph') + } + graph.afterChange() + this.getChangeTracker()?.checkState() + return result + } catch (error) { + graph.afterChange() + throw error + } + } + + async unpackSubgraph(subgraphNodeId: NodeId): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(subgraphNodeId) + + if (!node) { + throw new Error(`Node with id ${subgraphNodeId} not found`) + } + + if (!node.isSubgraphNode?.() && !(node as any).subgraph) { + throw new Error('Node is not a subgraph node') + } + + graph.beforeChange() + try { + graph.unpackSubgraph(node as any) + graph.afterChange() + this.getChangeTracker()?.checkState() + } catch (error) { + graph.afterChange() + throw error + } + } + + private getSubgraph(subgraphId: SubgraphId): Subgraph | undefined { + const graph = this.getGraph() + + for (const node of graph._nodes) { + if (node instanceof SubgraphNode && node.subgraph.id === subgraphId) { + return node.subgraph + } + } + + return undefined + } + + async addSubgraphInput( + subgraphId: SubgraphId, + name: string, + type: string + ): Promise { + const subgraph = this.getSubgraph(subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${subgraphId} not found`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.addInput(name, type) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async addSubgraphOutput( + subgraphId: SubgraphId, + name: string, + type: string + ): Promise { + const subgraph = this.getSubgraph(subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${subgraphId} not found`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.addOutput(name, type) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async removeSubgraphInput( + subgraphId: SubgraphId, + index: number + ): Promise { + const subgraph = this.getSubgraph(subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${subgraphId} not found`) + } + + if (!subgraph.inputs[index]) { + throw new Error(`Input at index ${index} not found in subgraph`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.removeInput(subgraph.inputs[index]) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async removeSubgraphOutput( + subgraphId: SubgraphId, + index: number + ): Promise { + const subgraph = this.getSubgraph(subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${subgraphId} not found`) + } + + if (!subgraph.outputs[index]) { + throw new Error(`Output at index ${index} not found in subgraph`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.removeOutput(subgraph.outputs[index]) + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async clearGraph(): Promise { + const graph = this.getGraph() + + graph.beforeChange() + graph.clear() + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + async bypassNode(nodeId: NodeId): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.mode = LGraphEventMode.BYPASS + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + } + + async unbypassNode(nodeId: NodeId): Promise { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.mode = LGraphEventMode.ALWAYS + graph.afterChange() + this.getChangeTracker()?.checkState() + graph.setDirtyCanvas(true, false) + } + + async transaction(fn: () => Promise): Promise { + const graph = this.getGraph() + + this.transactionDepth++ + const isRootTransaction = this.transactionDepth === 1 + + if (isRootTransaction) { + graph.beforeChange() + } + + try { + const result = await fn() + + if (isRootTransaction) { + graph.afterChange() + this.getChangeTracker()?.checkState() + } + + return result + } catch (error) { + if (isRootTransaction) { + graph.afterChange() + } + throw error + } finally { + this.transactionDepth-- + } + } + + async undo(): Promise { + const tracker = this.getChangeTracker() + if (!tracker) { + throw new Error('No active workflow or change tracker') + } + + await tracker.undo() + } + + async redo(): Promise { + const tracker = this.getChangeTracker() + if (!tracker) { + throw new Error('No active workflow or change tracker') + } + + await tracker.redo() + } +} + +let graphMutationServiceInstance: GraphMutationService | null = null + +export const useGraphMutationService = (): IGraphMutationService => { + if (!graphMutationServiceInstance) { + graphMutationServiceInstance = new GraphMutationService() + } + + return graphMutationServiceInstance +} diff --git a/tests-ui/tests/services/graphMutationService.test.ts b/tests-ui/tests/services/graphMutationService.test.ts new file mode 100644 index 0000000000..909421e9f2 --- /dev/null +++ b/tests-ui/tests/services/graphMutationService.test.ts @@ -0,0 +1,1049 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { IGraphMutationService } from '@/services/IGraphMutationService' +import { + GraphMutationService, + ValidationException, + useGraphMutationService +} from '@/services/graphMutationService' + +const mockGraph = vi.hoisted(() => ({ + beforeChange: vi.fn(), + afterChange: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + getNodeById: vi.fn(), + removeLink: vi.fn(), + clear: vi.fn(), + setDirtyCanvas: vi.fn(), + _links: new Map(), + _groups: [], + _nodes: [], + reroutes: new Map(), + createReroute: vi.fn(), + removeReroute: vi.fn(), + convertToSubgraph: vi.fn(), + unpackSubgraph: vi.fn(), + version: '1.0.0', + config: {} +})) + +const mockApp = vi.hoisted(() => ({ + graph: mockGraph +})) + +const mockWorkflowStore = vi.hoisted(() => ({ + activeWorkflow: { + changeTracker: { + checkState: vi.fn(), + undo: vi.fn(), + redo: vi.fn() + } + } +})) + +const mockLiteGraph = vi.hoisted(() => ({ + createNode: vi.fn(), + uuidv4: vi.fn(() => 'mock-uuid-' + Math.random()) +})) + +const mockLGraphNode = vi.hoisted(() => ({ + connect: vi.fn(), + disconnectInput: vi.fn(), + disconnectOutput: vi.fn(), + setProperty: vi.fn(), + changeMode: vi.fn(), + clone: vi.fn(), + serialize: vi.fn(), + configure: vi.fn(), + addInput: vi.fn(), + addOutput: vi.fn(), + removeInput: vi.fn(), + removeOutput: vi.fn() +})) + +const mockLGraphGroup = vi.hoisted(() => { + let idCounter = 1000 + return class MockLGraphGroup { + id = idCounter++ + title = 'Group' + pos = [0, 0] + size = [200, 200] + color = '#335577' + font_size = 14 + + constructor(title?: string) { + if (title) this.title = title + } + + move = vi.fn() + resize = vi.fn(() => true) + addNodes = vi.fn() + recomputeInsideNodes = vi.fn() + } +}) + +vi.mock('@/scripts/app', () => ({ + app: mockApp +})) + +vi.mock('@/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => mockWorkflowStore) +})) + +vi.mock('@/lib/litegraph/src/litegraph', () => ({ + LiteGraph: mockLiteGraph, + LGraphNode: mockLGraphNode, + LGraphEventMode: { + ALWAYS: 0, + BYPASS: 4 + } +})) + +vi.mock('@/lib/litegraph/src/LGraphGroup', () => ({ + LGraphGroup: mockLGraphGroup +})) + +vi.mock('@/lib/litegraph/src/LGraph', () => ({ + Subgraph: vi.fn().mockImplementation((graph, data) => ({ + id: data.id, + addInput: vi.fn(), + addOutput: vi.fn(), + removeInput: vi.fn(), + removeOutput: vi.fn(), + inputs: [], + outputs: [], + graph: graph + })) +})) + +vi.mock('@/lib/litegraph/src/subgraph/SubgraphNode', () => ({ + SubgraphNode: vi.fn().mockImplementation((graph, subgraph, data) => ({ + id: data.id, + subgraph, + graph: graph + })) +})) + +describe('GraphMutationService', () => { + let service: IGraphMutationService + let mockNode: any + let mockLink: any + + beforeEach(() => { + vi.clearAllMocks() + + service = new GraphMutationService() + + mockNode = { + id: 'node-1', + pos: [100, 100], + title: 'Test Node', + properties: {}, + outputs: [], + inputs: [], + ...mockLGraphNode + } + + mockLink = { + id: '123', + origin_id: 'node-1', + origin_slot: 0, + target_id: 'node-2', + target_slot: 0 + } + + mockGraph.getNodeById.mockImplementation((id: string) => { + if (id === 'node-1' || id === 'node-2') return mockNode + return null + }) + + mockGraph.add.mockReturnValue(mockNode) + mockGraph._links.set(123, mockLink) + + mockLiteGraph.createNode.mockReturnValue(mockNode) + mockNode.connect.mockReturnValue(mockLink) + mockNode.clone.mockReturnValue({ ...mockNode, id: 'cloned-node' }) + mockNode.serialize.mockReturnValue({ + type: 'TestNode', + id: 'node-1', + pos: [100, 100] + }) + }) + + describe('initialization', () => { + it('should implement IGraphMutationService interface', () => { + expect(service).toHaveProperty('addNode') + expect(service).toHaveProperty('removeNode') + expect(service).toHaveProperty('connect') + expect(service).toHaveProperty('transaction') + expect(service).toHaveProperty('undo') + expect(service).toHaveProperty('redo') + expect(typeof service.addNode).toBe('function') + expect(typeof service.removeNode).toBe('function') + expect(typeof service.connect).toBe('function') + }) + + it('should have singleton behavior through useGraphMutationService', () => { + const instance1 = useGraphMutationService() + const instance2 = useGraphMutationService() + + expect(instance1).toBe(instance2) + }) + }) + + describe('node operations', () => { + describe('addNode', () => { + it('should add a node successfully', async () => { + const params = { + type: 'LoadImage', + pos: [100, 200] as [number, number], + title: 'My Image Loader', + properties: { seed: 12345 } + } + + const nodeId = await service.addNode(params) + + expect(mockLiteGraph.createNode).toHaveBeenCalledWith('LoadImage') + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.add).toHaveBeenCalledWith(mockNode) + expect(mockGraph.afterChange).toHaveBeenCalled() + expect( + mockWorkflowStore.activeWorkflow.changeTracker.checkState + ).toHaveBeenCalled() + expect(nodeId).toBe('node-1') + }) + + it('should handle node creation failure', async () => { + mockLiteGraph.createNode.mockReturnValue(null) + + await expect(service.addNode({ type: 'InvalidType' })).rejects.toThrow( + 'Failed to create node of type: InvalidType' + ) + }) + + it('should handle graph add failure', async () => { + mockGraph.add.mockReturnValue(null) + + await expect(service.addNode({ type: 'TestNode' })).rejects.toThrow( + 'Failed to add node to graph' + ) + }) + + it('should set node properties correctly', async () => { + const params = { + type: 'TestNode', + pos: [50, 75] as [number, number], + title: 'Custom Title', + properties: { prop1: 'value1', prop2: 42 } + } + + await service.addNode(params) + + expect(mockNode.pos).toEqual([50, 75]) + expect(mockNode.title).toBe('Custom Title') + expect(mockNode.properties).toBeDefined() + }) + }) + + describe('removeNode', () => { + it('should remove a node successfully', async () => { + await service.removeNode('node-1') + + expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-1') + expect(mockGraph.remove).toHaveBeenCalledWith(mockNode) + expect( + mockWorkflowStore.activeWorkflow.changeTracker.checkState + ).toHaveBeenCalled() + }) + + it('should handle node not found', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + await expect(service.removeNode('nonexistent')).rejects.toThrow( + 'Node with id nonexistent not found' + ) + }) + }) + + describe('updateNodeProperty', () => { + it('should update node property successfully', async () => { + await service.updateNodeProperty('node-1', 'seed', 54321) + + expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-1') + expect(mockNode.setProperty).toHaveBeenCalledWith('seed', 54321) + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + + it('should handle node not found', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + await expect( + service.updateNodeProperty('nonexistent', 'prop', 'value') + ).rejects.toThrow('Node with id nonexistent not found') + }) + }) + + describe('updateNodeTitle', () => { + it('should update node title successfully', async () => { + await service.updateNodeTitle('node-1', 'New Title') + + expect(mockNode.title).toBe('New Title') + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + }) + + describe('changeNodeMode', () => { + it('should change node mode successfully', async () => { + mockNode.changeMode.mockReturnValue(true) + + await service.changeNodeMode('node-1', 4) + + expect(mockNode.changeMode).toHaveBeenCalledWith(4) + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + + it('should handle mode change failure', async () => { + mockNode.changeMode.mockReturnValue(false) + + await expect(service.changeNodeMode('node-1', 999)).rejects.toThrow( + 'Failed to change node mode to 999' + ) + }) + }) + + describe('cloneNode', () => { + it('should clone node with custom position', async () => { + const clonedId = await service.cloneNode('node-1', [300, 400]) + + expect(mockNode.clone).toHaveBeenCalled() + expect(mockGraph.add).toHaveBeenCalled() + expect(clonedId).toBe('node-1') // mockNode.id is returned + }) + + it('should clone node with offset position', async () => { + mockNode.pos = [100, 200] + + await service.cloneNode('node-1') + + const clonedNode = mockNode.clone() + expect(clonedNode.pos).toEqual([150, 250]) // Original + 50 offset + }) + + it('should handle clone failure', async () => { + mockNode.clone.mockReturnValue(null) + + await expect(service.cloneNode('node-1')).rejects.toThrow( + 'Failed to clone node' + ) + }) + }) + }) + + describe('connection operations', () => { + describe('connect', () => { + it('should create connection successfully', async () => { + const params = { + sourceNodeId: 'node-1' as any, + sourceSlot: 0, + targetNodeId: 'node-2' as any, + targetSlot: 1 + } + + const linkId = await service.connect(params) + + expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-1') + expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-2') + expect(mockNode.connect).toHaveBeenCalledWith(0, mockNode, 1) + expect(linkId).toBe('123') + }) + + it('should handle source node not found', async () => { + mockGraph.getNodeById.mockImplementation((id: string) => + id === 'node-1' ? null : mockNode + ) + + const params = { + sourceNodeId: 'node-1' as any, + sourceSlot: 0, + targetNodeId: 'node-2' as any, + targetSlot: 1 + } + + await expect(service.connect(params)).rejects.toThrow( + 'Source node with id node-1 not found' + ) + }) + + it('should handle connection failure', async () => { + mockNode.connect.mockReturnValue(null) + + const params = { + sourceNodeId: 'node-1' as any, + sourceSlot: 0, + targetNodeId: 'node-2' as any, + targetSlot: 1 + } + + await expect(service.connect(params)).rejects.toThrow( + 'Failed to create connection' + ) + }) + }) + + describe('disconnect operations', () => { + it('should disconnect input successfully', async () => { + mockNode.disconnectInput.mockReturnValue(true) + + const result = await service.disconnectInput('node-1', 0) + + expect(mockNode.disconnectInput).toHaveBeenCalledWith(0) + expect(result).toBe(true) + }) + + it('should disconnect output successfully', async () => { + mockNode.disconnectOutput.mockReturnValue(true) + + const result = await service.disconnectOutput('node-1', 0) + + expect(mockNode.disconnectOutput).toHaveBeenCalledWith(0) + expect(result).toBe(true) + }) + + it('should disconnect output to specific target', async () => { + mockNode.disconnectOutput.mockReturnValue(true) + + const result = await service.disconnectOutputTo('node-1', 0, 'node-2') + + expect(mockNode.disconnectOutput).toHaveBeenCalledWith(0, mockNode) + expect(result).toBe(true) + }) + }) + + describe('disconnectLink', () => { + it('should disconnect link successfully', async () => { + await service.disconnectLink(123) + + expect(mockGraph.removeLink).toHaveBeenCalledWith(123) + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + }) + }) + + describe('group operations', () => { + let mockGroup: any + + beforeEach(() => { + mockGroup = new mockLGraphGroup('Test Group') + mockGroup.id = 999 + mockGraph._groups = [mockGroup] as any + }) + + describe('createGroup', () => { + it('should create group with all parameters', async () => { + const params = { + title: 'My Group', + pos: [50, 60] as [number, number], + size: [300, 250] as [number, number], + color: '#ff0000', + fontSize: 16 + } + + const groupId = await service.createGroup(params) + + expect(mockGraph.add).toHaveBeenCalled() + expect(typeof groupId).toBe('number') + expect(groupId).toBeGreaterThanOrEqual(1000) + }) + + it('should create group with default values', async () => { + const groupId = await service.createGroup({}) + + expect(mockGraph.add).toHaveBeenCalled() + expect(typeof groupId).toBe('number') + expect(groupId).toBeGreaterThanOrEqual(1000) + }) + }) + + describe('removeGroup', () => { + it('should remove group successfully', async () => { + await service.removeGroup(mockGroup.id) + + expect(mockGraph.remove).toHaveBeenCalledWith(mockGroup) + }) + + it('should handle group not found', async () => { + await expect(service.removeGroup(123456)).rejects.toThrow( + 'Group with id 123456 not found' + ) + }) + }) + + describe('updateGroupTitle', () => { + it('should update group title successfully', async () => { + await service.updateGroupTitle(mockGroup.id, 'New Title') + + expect(mockGroup.title).toBe('New Title') + expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false) + }) + }) + + describe('moveGroup', () => { + it('should move group successfully', async () => { + await service.moveGroup(mockGroup.id, 25, 30) + + expect(mockGroup.move).toHaveBeenCalledWith(25, 30, false) + expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false) + }) + }) + + describe('addNodesToGroup', () => { + it('should add nodes to group successfully', async () => { + await service.addNodesToGroup(mockGroup.id, ['node-1', 'node-2']) + + expect(mockGroup.addNodes).toHaveBeenCalledWith([mockNode, mockNode]) + expect(mockGroup.recomputeInsideNodes).toHaveBeenCalled() + }) + + it('should handle node not found', async () => { + mockGraph.getNodeById.mockImplementation((id: string) => + id === 'node-1' ? mockNode : null + ) + + await expect( + service.addNodesToGroup(mockGroup.id, ['node-1', 'nonexistent']) + ).rejects.toThrow('Node with id nonexistent not found') + }) + }) + }) + + describe('batch operations', () => { + describe('addNodes', () => { + it('should add multiple nodes successfully', async () => { + const nodes = [ + { type: 'Node1', pos: [0, 0] as [number, number] }, + { type: 'Node2', pos: [100, 100] as [number, number] } + ] + + const nodeIds = await service.addNodes(nodes) + + expect(mockLiteGraph.createNode).toHaveBeenCalledTimes(2) + expect(mockGraph.add).toHaveBeenCalledTimes(2) + expect(nodeIds).toHaveLength(2) + }) + + it('should handle partial failure and rollback', async () => { + mockLiteGraph.createNode + .mockReturnValueOnce(mockNode) + .mockReturnValueOnce(null) + + const nodes = [{ type: 'Node1' }, { type: 'InvalidNode' }] + + await expect(service.addNodes(nodes)).rejects.toThrow( + 'Failed to create node of type: InvalidNode' + ) + + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + }) + + describe('removeNodes', () => { + it('should remove multiple nodes successfully', async () => { + await service.removeNodes(['node-1', 'node-2']) + + expect(mockGraph.remove).toHaveBeenCalledTimes(2) + }) + + it('should validate all nodes exist first', async () => { + mockGraph.getNodeById.mockImplementation((id: string) => + id === 'node-1' ? mockNode : null + ) + + await expect( + service.removeNodes(['node-1', 'nonexistent']) + ).rejects.toThrow('Node with id nonexistent not found') + + expect(mockGraph.remove).not.toHaveBeenCalled() + }) + }) + + describe('duplicateNodes', () => { + beforeEach(() => { + const node1 = { + ...mockNode, + id: 'node-1', + outputs: [{ links: [123] }] + } + const node2 = { ...mockNode, id: 'node-2' } + + mockGraph.getNodeById.mockImplementation((id: string) => { + if (id === 'node-1') return node1 + if (id === 'node-2') return node2 + if (id === 'cloned-node') return { ...mockNode, id: 'cloned-node' } + return null + }) + + mockGraph.add.mockImplementation((node: any) => ({ + ...node, + id: 'cloned-node' + })) + }) + + it('should duplicate nodes with connections', async () => { + const newNodeIds = await service.duplicateNodes( + ['node-1', 'node-2'], + [100, 50] + ) + + expect(mockNode.clone).toHaveBeenCalledTimes(2) + expect(mockGraph.add).toHaveBeenCalledTimes(2) + expect(newNodeIds).toHaveLength(2) + }) + + it('should use default offset when not provided', async () => { + const newNodeIds = await service.duplicateNodes(['node-1']) + + expect(newNodeIds).toHaveLength(1) + }) + }) + }) + + describe('clipboard operations', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear() + }) + + describe('copyNodes', () => { + it('should copy nodes to clipboard', async () => { + // Mock node.clone() to return a clonable node + const clonedNode = { + serialize: vi.fn(() => ({ + id: 'node-1', + type: 'TestNode', + pos: [0, 0] + })) + } + mockNode.clone = vi.fn(() => clonedNode) + + await service.copyNodes(['node-1']) + + const storedData = localStorage.getItem('litegrapheditor_clipboard') + expect(storedData).not.toBeNull() + + const clipboard = JSON.parse(storedData!) + expect(clipboard.nodes).toHaveLength(1) + expect(clipboard.nodes[0].id).toBe('node-1') + expect(mockNode.clone).toHaveBeenCalled() + + const clipboardData = service.getClipboard() + expect(clipboardData).not.toBeNull() + expect(clipboardData!.nodes).toHaveLength(1) + expect(clipboardData!.isCut).toBe(false) + }) + + it('should handle empty node list', async () => { + await expect(service.copyNodes([])).rejects.toThrow('No nodes to copy') + }) + + it('should handle node not found', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + await expect(service.copyNodes(['nonexistent'])).rejects.toThrow( + 'Node with id nonexistent not found' + ) + }) + }) + + describe('cutNodes', () => { + it('should cut nodes to clipboard', async () => { + // Mock node.clone() to return a clonable node + const clonedNode = { + serialize: vi.fn(() => ({ + id: 'node-1', + type: 'TestNode', + pos: [0, 0] + })) + } + mockNode.clone = vi.fn(() => clonedNode) + + await service.cutNodes(['node-1']) + + const clipboard = service.getClipboard() + expect(clipboard!.isCut).toBe(true) + + const storedData = localStorage.getItem('litegrapheditor_clipboard') + const clipboardData = JSON.parse(storedData!) + expect(clipboardData.isCut).toBe(true) + expect(clipboardData.originalIds).toEqual(['node-1']) + }) + }) + + describe('pasteNodes', () => { + beforeEach(async () => { + const clipboardData = { + nodes: [ + { + id: 'node-1', + type: 'TestNode', + pos: [100, 100] + } + ], + links: [], + isCut: false + } + localStorage.setItem( + 'litegrapheditor_clipboard', + JSON.stringify(clipboardData) + ) + + const newNode = { ...mockNode, id: 'new-node-1', configure: vi.fn() } + mockLiteGraph.createNode.mockReturnValue(newNode) + mockGraph.add.mockReturnValue(newNode) + }) + + it('should paste nodes from clipboard', async () => { + const pastedIds = await service.pasteNodes([200, 300]) + + expect(mockLiteGraph.createNode).toHaveBeenCalledWith('TestNode') + expect(mockGraph.add).toHaveBeenCalled() + expect(pastedIds).toHaveLength(1) + expect(pastedIds[0]).toBe('new-node-1') + }) + + it('should handle empty clipboard', async () => { + localStorage.clear() + + await expect(service.pasteNodes()).rejects.toThrow('Clipboard is empty') + }) + + it('should use default offset when position not provided', async () => { + const pastedIds = await service.pasteNodes() + + expect(pastedIds).toHaveLength(1) + }) + }) + + describe('clipboard utilities', () => { + it('should check clipboard content correctly', async () => { + expect(service.hasClipboardContent()).toBe(false) + + const clonedNode = { + serialize: vi.fn(() => ({ + id: 'node-1', + type: 'TestNode', + pos: [0, 0] + })) + } + mockNode.clone = vi.fn(() => clonedNode) + + await service.copyNodes(['node-1']) + + expect(service.hasClipboardContent()).toBe(true) + }) + + it('should clear clipboard', async () => { + // Setup clipboard data + localStorage.setItem( + 'litegrapheditor_clipboard', + JSON.stringify({ + nodes: [{ id: 'node-1', type: 'TestNode' }], + links: [] + }) + ) + expect(service.hasClipboardContent()).toBe(true) + + service.clearClipboard() + expect(service.hasClipboardContent()).toBe(false) + expect(localStorage.getItem('litegrapheditor_clipboard')).toBeNull() + }) + }) + }) + + describe('transaction support', () => { + it('should execute transaction successfully', async () => { + let executionCount = 0 + + const result = await service.transaction(async () => { + executionCount++ + await service.addNode({ type: 'TestNode' }) + return 'success' + }) + + expect(result).toBe('success') + expect(executionCount).toBe(1) + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + + it('should handle transaction errors', async () => { + await expect( + service.transaction(async () => { + throw new Error('Transaction failed') + }) + ).rejects.toThrow('Transaction failed') + + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + + it('should support nested transactions', async () => { + await service.transaction(async () => { + await service.transaction(async () => { + await service.addNode({ type: 'NestedNode' }) + }) + }) + + expect(mockGraph.beforeChange).toHaveBeenCalledTimes(2) + expect(mockGraph.afterChange).toHaveBeenCalledTimes(2) + }) + }) + + describe('undo/redo operations', () => { + it('should call undo on change tracker', async () => { + await service.undo() + + expect( + mockWorkflowStore.activeWorkflow.changeTracker.undo + ).toHaveBeenCalled() + }) + + it('should call redo on change tracker', async () => { + await service.redo() + + expect( + mockWorkflowStore.activeWorkflow.changeTracker.redo + ).toHaveBeenCalled() + }) + + it('should handle missing change tracker', async () => { + const localService = new GraphMutationService() + const originalActiveWorkflow = mockWorkflowStore.activeWorkflow + mockWorkflowStore.activeWorkflow = null as any + + await expect(localService.undo()).rejects.toThrow( + 'No active workflow or change tracker' + ) + + mockWorkflowStore.activeWorkflow = originalActiveWorkflow + }) + }) + + describe('graph-level operations', () => { + describe('clearGraph', () => { + it('should clear graph successfully', async () => { + await service.clearGraph() + + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.clear).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + + if (mockWorkflowStore.activeWorkflow?.changeTracker) { + expect( + mockWorkflowStore.activeWorkflow.changeTracker.checkState + ).toHaveBeenCalled() + } + }) + }) + + describe('execution control', () => { + it('should bypass node successfully', async () => { + await service.bypassNode('node-1') + + expect(mockNode.mode).toBe(4) // LGraphEventMode.BYPASS + expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false) + }) + + it('should unbypass node successfully', async () => { + await service.unbypassNode('node-1') + + expect(mockNode.mode).toBe(0) // LGraphEventMode.ALWAYS + expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false) + }) + }) + }) + + describe('validation system', () => { + it('should handle ValidationException correctly', () => { + const errors = [ + { code: 'INVALID_TYPE', message: 'Invalid node type' }, + { code: 'MISSING_PARAM', message: 'Missing parameter' } + ] + + const exception = new ValidationException(errors) + + expect(exception.name).toBe('ValidationException') + expect(exception.errors).toEqual(errors) + expect(exception.message).toBe('Invalid node type, Missing parameter') + }) + }) + + describe('error handling', () => { + it('should maintain graph state on errors', async () => { + mockLiteGraph.createNode.mockImplementation(() => { + throw new Error('Node creation failed') + }) + + await expect(service.addNode({ type: 'FailNode' })).rejects.toThrow() + + expect(mockLiteGraph.createNode).toHaveBeenCalled() + }) + + it('should handle change tracker unavailable', async () => { + const originalChangeTracker = + mockWorkflowStore.activeWorkflow?.changeTracker + + if (mockWorkflowStore.activeWorkflow) { + mockWorkflowStore.activeWorkflow.changeTracker = null as any + } + + await service.addNode({ type: 'TestNode' }) + + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + + if (mockWorkflowStore.activeWorkflow) { + mockWorkflowStore.activeWorkflow.changeTracker = originalChangeTracker + } + }) + }) + + describe('subgraph operations', () => { + let mockSubgraphNode: any + let mockSubgraph: any + + beforeEach(() => { + mockSubgraph = { + id: 'subgraph-1', + nodes: [], + groups: [], + reroutes: new Map() + } + + mockSubgraphNode = { + id: 'subgraph-node-1', + type: 'subgraph', + subgraph: mockSubgraph, + isSubgraphNode: vi.fn(() => true) + } + }) + + describe('createSubgraph', () => { + it('should create subgraph from selected items', async () => { + const selectedItems = new Set([mockNode]) + const expectedResult = { + subgraph: mockSubgraph, + node: mockSubgraphNode + } + + mockGraph.convertToSubgraph.mockReturnValue(expectedResult) + + const result = await service.createSubgraph({ selectedItems }) + + expect(mockGraph.convertToSubgraph).toHaveBeenCalledWith(selectedItems) + expect(result).toBe(expectedResult) + }) + + it('should throw error when no items selected', async () => { + const selectedItems = new Set() + + await expect(service.createSubgraph({ selectedItems })).rejects.toThrow( + 'Cannot create subgraph: no items selected' + ) + }) + + it('should handle convertToSubgraph failure', async () => { + const selectedItems = new Set([mockNode]) + mockGraph.convertToSubgraph.mockReturnValue(null) + + await expect(service.createSubgraph({ selectedItems })).rejects.toThrow( + 'Failed to create subgraph' + ) + }) + }) + + describe('unpackSubgraph', () => { + it('should unpack subgraph node successfully', async () => { + mockGraph.getNodeById.mockReturnValue(mockSubgraphNode) + mockGraph.unpackSubgraph.mockImplementation(() => {}) + + await service.unpackSubgraph('subgraph-node-1') + + expect(mockGraph.unpackSubgraph).toHaveBeenCalledWith(mockSubgraphNode) + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + + it('should throw error for non-existent node', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + await expect(service.unpackSubgraph('non-existent')).rejects.toThrow( + 'Node with id non-existent not found' + ) + }) + + it('should throw error for non-subgraph node', async () => { + const regularNode = { + ...mockNode, + isSubgraphNode: undefined, + subgraph: undefined + } + mockGraph.getNodeById.mockReturnValue(regularNode) + + await expect(service.unpackSubgraph('node-1')).rejects.toThrow( + 'Node is not a subgraph node' + ) + }) + + it('should handle unpack errors gracefully', async () => { + mockGraph.getNodeById.mockReturnValue(mockSubgraphNode) + mockGraph.unpackSubgraph + .mockImplementation(() => {}) + .mockImplementation(() => { + throw new Error('Unpack failed') + }) + + await expect(service.unpackSubgraph('subgraph-node-1')).rejects.toThrow( + 'Unpack failed' + ) + + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + }) + }) + + describe('edge cases', () => { + it('should handle nodes without outputs in duplication', async () => { + const nodeWithoutOutputs = { ...mockNode, outputs: null } + mockGraph.getNodeById.mockReturnValue(nodeWithoutOutputs) + + const newNodeIds = await service.duplicateNodes(['node-1']) + + expect(newNodeIds).toHaveLength(1) + }) + + it('should handle empty connections in clipboard', async () => { + const nodeWithoutConnections = { ...mockNode, outputs: [], inputs: [] } + mockGraph.getNodeById.mockReturnValue(nodeWithoutConnections) + + // Mock node.clone() + const clonedNode = { + serialize: vi.fn(() => ({ + id: 'node-1', + type: 'TestNode', + pos: [0, 0] + })) + } + nodeWithoutConnections.clone = vi.fn(() => clonedNode) + + await service.copyNodes(['node-1']) + + const clipboard = service.getClipboard() + expect(clipboard!.connections).toHaveLength(0) + }) + }) +})